feat: draggable tabs with localStorage persistence
This commit is contained in:
6178
design/draggable-tabs.pen
Normal file
6178
design/draggable-tabs.pen
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
127
src/components/TabBar.test.tsx
Normal file
127
src/components/TabBar.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user