feat: draggable tabs with localStorage persistence

This commit is contained in:
lucaronin
2026-02-21 18:35:13 +01:00
6 changed files with 6477 additions and 6 deletions

6178
design/draggable-tabs.pen Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -191,6 +191,7 @@ function App() {
entries={vault.entries}
onSwitchTab={notes.handleSwitchTab}
onCloseTab={notes.handleCloseTab}
onReorderTabs={notes.handleReorderTabs}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
isModified={vault.isFileModified}

View File

@@ -29,6 +29,7 @@ interface EditorProps {
entries: VaultEntry[]
onSwitchTab: (path: string) => void
onCloseTab: (path: string) => void
onReorderTabs?: (fromIndex: number, toIndex: number) => void
onNavigateWikilink: (target: string) => void
onLoadDiff?: (path: string) => Promise<string>
isModified?: (path: string) => boolean
@@ -147,7 +148,7 @@ function SingleEditorView({ editor, entries, onNavigateWikilink }: { editor: Ret
}
export const Editor = memo(function Editor({
tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onNavigateWikilink, onLoadDiff, isModified, onCreateNote,
tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink, onLoadDiff, isModified, onCreateNote,
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
inspectorEntry, inspectorContent, allContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
@@ -347,6 +348,7 @@ export const Editor = memo(function Editor({
onSwitchTab={onSwitchTab}
onCloseTab={onCloseTab}
onCreateNote={onCreateNote}
onReorderTabs={onReorderTabs}
/>
)

View File

@@ -0,0 +1,127 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TabBar } from './TabBar'
import type { VaultEntry } from '../types'
function makeEntry(path: string, title: string): VaultEntry {
return {
path, filename: `${title}.md`, title, isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null, archived: false,
modifiedAt: null, createdAt: null, fileSize: 0,
snippet: '', relationships: {}, icon: null, color: null,
}
}
function makeTabs(titles: string[]) {
return titles.map((t) => ({
entry: makeEntry(`/vault/${t.toLowerCase()}.md`, t),
content: `# ${t}`,
}))
}
describe('TabBar', () => {
const defaultProps = {
onSwitchTab: vi.fn(),
onCloseTab: vi.fn(),
onCreateNote: vi.fn(),
onReorderTabs: vi.fn(),
}
it('renders all tabs', () => {
const tabs = makeTabs(['Alpha', 'Beta', 'Gamma'])
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />)
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('marks tabs as draggable', () => {
const tabs = makeTabs(['Alpha', 'Beta'])
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />)
const alphaTab = screen.getByText('Alpha').closest('[draggable]')
expect(alphaTab).toHaveAttribute('draggable', 'true')
})
it('calls onReorderTabs on drag and drop', () => {
const onReorderTabs = vi.fn()
const tabs = makeTabs(['Alpha', 'Beta', 'Gamma'])
render(
<TabBar
tabs={tabs}
activeTabPath={tabs[0].entry.path}
{...defaultProps}
onReorderTabs={onReorderTabs}
/>
)
const alphaTab = screen.getByText('Alpha').closest('[draggable]')!
const gammaTab = screen.getByText('Gamma').closest('[draggable]')!
// Simulate drag start on Alpha (index 0)
fireEvent.dragStart(alphaTab, {
dataTransfer: { effectAllowed: 'move', setData: vi.fn() },
})
// Simulate drag over Gamma (index 2) - cursor past midpoint
const rect = gammaTab.getBoundingClientRect()
fireEvent.dragOver(gammaTab, {
clientX: rect.left + rect.width * 0.75,
dataTransfer: { dropEffect: 'move' },
})
// Drop
fireEvent.drop(gammaTab, {
dataTransfer: {},
})
// Alpha (0) dragged past Gamma (2) → should reorder from 0 to 2
expect(onReorderTabs).toHaveBeenCalledWith(0, 2)
})
it('does not call onReorderTabs when dropping in same position', () => {
const onReorderTabs = vi.fn()
const tabs = makeTabs(['Alpha', 'Beta'])
render(
<TabBar
tabs={tabs}
activeTabPath={tabs[0].entry.path}
{...defaultProps}
onReorderTabs={onReorderTabs}
/>
)
const alphaTab = screen.getByText('Alpha').closest('[draggable]')!
fireEvent.dragStart(alphaTab, {
dataTransfer: { effectAllowed: 'move', setData: vi.fn() },
})
// Drag over same tab
const rect = alphaTab.getBoundingClientRect()
fireEvent.dragOver(alphaTab, {
clientX: rect.left + rect.width / 2,
dataTransfer: { dropEffect: 'move' },
})
fireEvent.drop(alphaTab, { dataTransfer: {} })
expect(onReorderTabs).not.toHaveBeenCalled()
})
it('switches tab on click', () => {
const onSwitchTab = vi.fn()
const tabs = makeTabs(['Alpha', 'Beta'])
render(
<TabBar
tabs={tabs}
activeTabPath={tabs[0].entry.path}
{...defaultProps}
onSwitchTab={onSwitchTab}
/>
)
fireEvent.click(screen.getByText('Beta'))
expect(onSwitchTab).toHaveBeenCalledWith(tabs[1].entry.path)
})
})

View File

@@ -1,4 +1,4 @@
import { memo } from 'react'
import { memo, useState, useRef, useCallback } from 'react'
import type { VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { X } from 'lucide-react'
@@ -15,26 +15,97 @@ interface TabBarProps {
onSwitchTab: (path: string) => void
onCloseTab: (path: string) => void
onCreateNote?: () => void
onReorderTabs?: (fromIndex: number, toIndex: number) => void
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
export const TabBar = memo(function TabBar({
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote,
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs,
}: TabBarProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [dropIndex, setDropIndex] = useState<number | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null)
const handleDragStart = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
setDragIndex(index)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(index))
// Make the drag image slightly transparent
if (e.currentTarget) {
dragNodeRef.current = e.currentTarget
requestAnimationFrame(() => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = '0.5'
}
})
}
}, [])
const handleDragEnd = useCallback(() => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = ''
}
dragNodeRef.current = null
setDragIndex(null)
setDropIndex(null)
}, [])
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragIndex === null || dragIndex === index) {
setDropIndex(null)
return
}
// Determine drop position based on cursor within the tab
const rect = e.currentTarget.getBoundingClientRect()
const midpoint = rect.left + rect.width / 2
const insertIndex = e.clientX < midpoint ? index : index + 1
setDropIndex(insertIndex)
}, [dragIndex])
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
if (dragIndex !== null && dropIndex !== null && dragIndex !== dropIndex && onReorderTabs) {
// Adjust target index: if dropping after the dragged item, account for removal
const toIndex = dropIndex > dragIndex ? dropIndex - 1 : dropIndex
if (toIndex !== dragIndex) {
onReorderTabs(dragIndex, toIndex)
}
}
handleDragEnd()
}, [dragIndex, dropIndex, onReorderTabs, handleDragEnd])
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
// Only clear if we're leaving the tab bar entirely
const relatedTarget = e.relatedTarget as HTMLElement | null
if (!e.currentTarget.contains(relatedTarget)) {
setDropIndex(null)
}
}, [])
return (
<div
className="flex shrink-0 items-stretch"
style={{ height: 45, background: 'var(--sidebar)', WebkitAppRegion: 'drag' } as React.CSSProperties}
data-tauri-drag-region
onDragLeave={handleDragLeave}
>
{tabs.map((tab) => {
{tabs.map((tab, index) => {
const isActive = tab.entry.path === activeTabPath
const showDropBefore = dropIndex === index
const showDropAfter = dropIndex === index + 1 && index === tabs.length - 1
return (
<div
key={tab.entry.path}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
className={cn(
"group flex shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap max-w-[180px] transition-all",
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[180px] transition-all relative",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-secondary-foreground"
@@ -46,10 +117,25 @@ export const TabBar = memo(function TabBar({
padding: '0 12px',
fontSize: 12,
fontWeight: isActive ? 500 : 400,
cursor: dragIndex !== null ? 'grabbing' : 'grab',
WebkitAppRegion: 'no-drag',
} as React.CSSProperties}
onClick={() => onSwitchTab(tab.entry.path)}
>
{showDropBefore && (
<div
style={{
position: 'absolute',
left: -1,
top: 8,
bottom: 8,
width: 2,
background: 'var(--primary)',
borderRadius: 1,
zIndex: 10,
}}
/>
)}
<span className="truncate">{tab.entry.title}</span>
<button
className={cn(
@@ -57,6 +143,7 @@ export const TabBar = memo(function TabBar({
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
style={{ lineHeight: 0 }}
draggable={false}
onClick={(e) => {
e.stopPropagation()
onCloseTab(tab.entry.path)
@@ -64,6 +151,20 @@ export const TabBar = memo(function TabBar({
>
<X size={14} />
</button>
{showDropAfter && (
<div
style={{
position: 'absolute',
right: -1,
top: 8,
bottom: 8,
width: 2,
background: 'var(--primary)',
borderRadius: 1,
zIndex: 10,
}}
/>
)}
</div>
)
})}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
import type { VaultEntry } from '../types'
@@ -89,6 +89,23 @@ function deleteMockFrontmatterProperty(path: string, key: string): string {
return `---\n${newLines.join('\n')}\n---${rest}`
}
const TAB_ORDER_KEY = 'laputa-tab-order'
function saveTabOrder(tabs: Tab[]) {
try {
localStorage.setItem(TAB_ORDER_KEY, JSON.stringify(tabs.map(t => t.entry.path)))
} catch { /* localStorage may be unavailable */ }
}
function loadTabOrder(): string[] {
try {
const stored = localStorage.getItem(TAB_ORDER_KEY)
return stored ? JSON.parse(stored) : []
} catch {
return []
}
}
async function loadNoteContent(path: string): Promise<string> {
return isTauri()
? invoke<string>('get_note_content', { path })
@@ -313,6 +330,50 @@ export function useNoteActions(
replaceTabWithEntry(entry, currentPath, setTabs, setActiveTabPath)
}, [handleSelectNote])
const handleReorderTabs = useCallback((fromIndex: number, toIndex: number) => {
setTabs((prev) => {
const next = [...prev]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
saveTabOrder(next)
return next
})
}, [])
// Persist tab order to localStorage whenever tabs change
useEffect(() => {
if (tabs.length > 0) {
saveTabOrder(tabs)
} else {
try { localStorage.removeItem(TAB_ORDER_KEY) } catch { /* noop */ }
}
}, [tabs])
// Restore tab order from localStorage on mount
useEffect(() => {
const savedOrder = loadTabOrder()
if (savedOrder.length === 0) return
setTabs((prev) => {
if (prev.length <= 1) return prev
const pathToTab = new Map(prev.map(t => [t.entry.path, t]))
const ordered: Tab[] = []
for (const path of savedOrder) {
const tab = pathToTab.get(path)
if (tab) {
ordered.push(tab)
pathToTab.delete(path)
}
}
// Append any tabs not in saved order (newly opened)
for (const tab of pathToTab.values()) {
ordered.push(tab)
}
return ordered
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const closeAllTabs = useCallback(() => {
setTabs([])
setActiveTabPath(null)
@@ -326,6 +387,7 @@ export function useNoteActions(
handleSelectNote,
handleCloseTab,
handleSwitchTab,
handleReorderTabs,
handleNavigateWikilink,
handleCreateNote,
handleCreateType,