refactor: remove tab bar — single note open at a time

Replace the multi-tab model with single-note navigation. One note is
open at a time; clicking any note (sidebar, wikilink, relationship)
replaces the current note in the editor. Back/Forward history still
works via Cmd+[/].

Removed: TabBar component, useClosedTabHistory, tab reorder/close/
reopen logic, Cmd+W/Cmd+Shift+T shortcuts, Close Tab and Reopen
Closed Tab menu items, tabLayout utility, and all related tests.

Simplified: useTabManagement (single-note), useAppNavigation (no tab
lookup), useKeyboardNavigation (note nav only), useNoteCreation (no
close-tab cleanup), useDeleteActions (deselect instead of close tab),
Editor (no TabBar render), Rust menu (removed 2 menu items).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-24 17:24:49 +01:00
parent 8b723b36d9
commit c4136d69b4
30 changed files with 173 additions and 2018 deletions

View File

@@ -98,8 +98,6 @@ const defaultProps = {
tabs: [] as { entry: VaultEntry; content: string }[],
activeTabPath: null as string | null,
entries: [mockEntry],
onSwitchTab: vi.fn(),
onCloseTab: vi.fn(),
onNavigateWikilink: vi.fn(),
inspectorCollapsed: true,
onToggleInspector: vi.fn(),
@@ -143,62 +141,6 @@ describe('Editor', () => {
expect(screen.getByText(/words/)).toBeInTheDocument()
})
it('calls onCloseTab when close button is clicked', () => {
const onCloseTab = vi.fn()
render(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
onCloseTab={onCloseTab}
/>
)
// Find the close button (X icon) in the tab
const closeButtons = document.querySelectorAll('button')
const tabCloseBtn = Array.from(closeButtons).find(btn => {
const svg = btn.querySelector('svg')
return svg && btn.closest('[class*="group"]')
})
if (tabCloseBtn) {
fireEvent.click(tabCloseBtn)
expect(onCloseTab).toHaveBeenCalledWith(mockEntry.path)
}
})
it('calls onSwitchTab when clicking a tab', () => {
const secondEntry: VaultEntry = {
...mockEntry,
path: '/vault/topic/dev.md',
title: 'Dev Topic',
isA: 'Topic',
}
const onSwitchTab = vi.fn()
render(
<Editor
{...defaultProps}
tabs={[mockTab, { entry: secondEntry, content: '# Dev' }]}
activeTabPath={mockEntry.path}
onSwitchTab={onSwitchTab}
/>
)
fireEvent.click(screen.getByText('Dev Topic'))
expect(onSwitchTab).toHaveBeenCalledWith(secondEntry.path)
})
it('renders new note button in tab bar', () => {
const onCreateNote = vi.fn()
render(
<Editor
{...defaultProps}
onCreateNote={onCreateNote}
/>
)
const newNoteBtn = screen.getByTitle('New note')
expect(newNoteBtn).toBeInTheDocument()
fireEvent.click(newNoteBtn)
expect(onCreateNote).toHaveBeenCalled()
})
it('shows BlockNote editor when a tab is active', () => {
render(
<Editor

View File

@@ -7,7 +7,6 @@ import type { VaultEntry, GitCommit, NoteStatus } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import type { FrontmatterValue } from './Inspector'
import { ResizeHandle } from './ResizeHandle'
import { TabBar } from './TabBar'
import { useDiffMode } from '../hooks/useDiffMode'
import { useRawMode } from '../hooks/useRawMode'
import { useEditorFocus } from '../hooks/useEditorFocus'
@@ -26,9 +25,6 @@ interface EditorProps {
tabs: Tab[]
activeTabPath: string | null
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>
onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise<string>
@@ -55,7 +51,6 @@ interface EditorProps {
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
onRenameTab?: (path: string, newTitle: string) => void
onContentChange?: (path: string, content: string) => void
onSave?: () => void
/** Called when the user edits the title in TitleField. */
@@ -153,20 +148,6 @@ function useRawModeWithFlush(
activeTabPath, onBeforeRawEnd: handleBeforeRawEnd,
})
// Flush raw editor content when switching tabs while raw mode stays active.
const prevTabPathRef = useRef(activeTabPath)
const onContentChangeRef = useRef(onContentChange)
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
useEffect(() => {
const prev = prevTabPathRef.current
prevTabPathRef.current = activeTabPath
const hasUnflushedContent = prev && prev !== activeTabPath && rawMode && rawLatestContentRef.current != null
if (hasUnflushedContent) {
onContentChangeRef.current?.(prev, rawLatestContentRef.current!)
rawLatestContentRef.current = null
}
}, [activeTabPath, rawMode])
return { rawMode, handleToggleRaw, rawLatestContentRef }
}
@@ -217,8 +198,8 @@ function useEditorSetup({
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink,
getNoteStatus, onCreateNote,
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
@@ -226,7 +207,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
vaultPath, noteList, noteListFilter,
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
onFileCreated, onFileModified, onVaultChanged,
onSetNoteIcon, onRemoveNoteIcon,
isConflicted, onKeepMine, onKeepTheirs,
@@ -247,21 +227,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<TabBar
tabs={tabs}
activeTabPath={activeTabPath}
getNoteStatus={getNoteStatus}
onSwitchTab={onSwitchTab}
onCloseTab={onCloseTab}
onCreateNote={onCreateNote}
onReorderTabs={onReorderTabs}
onRenameTab={props.onRenameTab}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={onGoBack}
onGoForward={onGoForward}
leftPanelsCollapsed={leftPanelsCollapsed}
/>
<div className="flex flex-1 min-h-0">
{tabs.length === 0
? <EditorEmptyState />
@@ -311,7 +276,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
entries={entries}
gitHistory={gitHistory}
vaultPath={vaultPath ?? ''}
openTabs={tabs.map(t => t.entry)}
noteList={noteList}
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}

View File

@@ -12,7 +12,6 @@ interface EditorRightPanelProps {
entries: VaultEntry[]
gitHistory: GitCommit[]
vaultPath: string
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
@@ -31,7 +30,7 @@ interface EditorRightPanelProps {
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, openTabs,
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onOpenNote,
@@ -53,7 +52,6 @@ export function EditorRightPanel({
activeEntry={inspectorEntry}
activeNoteContent={inspectorContent}
entries={entries}
openTabs={openTabs}
noteList={noteList}
noteListFilter={noteListFilter}
/>

View File

@@ -1,316 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TabBar } from './TabBar'
import { computeTabMaxWidth } from '../utils/tabLayout'
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,
trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
}
}
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('shows modified indicator dot on modified tabs', () => {
const tabs = makeTabs(['Alpha', 'Beta'])
const getNoteStatus = (path: string) => path === tabs[0].entry.path ? 'modified' as const : 'clean' as const
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} getNoteStatus={getNoteStatus} {...defaultProps} />)
const indicators = screen.getAllByTestId('tab-modified-indicator')
expect(indicators).toHaveLength(1)
})
it('does not show modified indicator when no tabs are modified', () => {
const tabs = makeTabs(['Alpha', 'Beta'])
const getNoteStatus = () => 'clean' as const
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} getNoteStatus={getNoteStatus} {...defaultProps} />)
expect(screen.queryByTestId('tab-modified-indicator')).not.toBeInTheDocument()
expect(screen.queryByTestId('tab-new-indicator')).not.toBeInTheDocument()
})
it('shows modified indicator on multiple tabs', () => {
const tabs = makeTabs(['Alpha', 'Beta', 'Gamma'])
const getNoteStatus = () => 'modified' as const
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} getNoteStatus={getNoteStatus} {...defaultProps} />)
expect(screen.getAllByTestId('tab-modified-indicator')).toHaveLength(3)
})
it('shows green new indicator on new tabs', () => {
const tabs = makeTabs(['Alpha', 'Beta'])
const getNoteStatus = (path: string) => path === tabs[0].entry.path ? 'new' as const : 'clean' as const
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} getNoteStatus={getNoteStatus} {...defaultProps} />)
expect(screen.getAllByTestId('tab-new-indicator')).toHaveLength(1)
expect(screen.queryByTestId('tab-modified-indicator')).not.toBeInTheDocument()
})
it('does not reorder on drag cancel (dragEnd without 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 betaTab = screen.getByText('Beta').closest('[draggable]')!
fireEvent.dragStart(alphaTab, {
dataTransfer: { effectAllowed: 'move', setData: vi.fn() },
})
const rect = betaTab.getBoundingClientRect()
fireEvent.dragOver(betaTab, {
clientX: rect.left + rect.width * 0.75,
dataTransfer: { dropEffect: 'move' },
})
// Cancel via dragEnd (Escape or release outside tab bar)
fireEvent.dragEnd(alphaTab)
expect(onReorderTabs).not.toHaveBeenCalled()
})
it('reorders from last toward first position', () => {
const onReorderTabs = vi.fn()
const tabs = makeTabs(['Alpha', 'Beta', 'Gamma'])
render(
<TabBar
tabs={tabs}
activeTabPath={tabs[2].entry.path}
{...defaultProps}
onReorderTabs={onReorderTabs}
/>
)
const gammaTab = screen.getByText('Gamma').closest('[draggable]')!
const alphaTab = screen.getByText('Alpha').closest('[draggable]')!
fireEvent.dragStart(gammaTab, {
dataTransfer: { effectAllowed: 'move', setData: vi.fn() },
})
// jsdom returns zero-sized rects, so clientX always hits "right half"
// (insert after index 0 → insert index 1). This still validates
// that dragging the last tab toward the front produces a reorder.
const rect = alphaTab.getBoundingClientRect()
fireEvent.dragOver(alphaTab, {
clientX: rect.left + rect.width * 0.75,
dataTransfer: { dropEffect: 'move' },
})
fireEvent.drop(alphaTab, { dataTransfer: {} })
// Gamma (2) dragged onto Alpha (0) → reorder from 2 to 1
expect(onReorderTabs).toHaveBeenCalledWith(2, 1)
})
it('shows pending save indicator (pulsing dot) when getNoteStatus returns pendingSave', () => {
const tabs = makeTabs(['Alpha', 'Beta'])
const getNoteStatus = (path: string) => path === tabs[0].entry.path ? 'pendingSave' as const : 'clean' as const
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} getNoteStatus={getNoteStatus} {...defaultProps} />)
expect(screen.getAllByTestId('tab-pending-save-indicator')).toHaveLength(1)
expect(screen.queryByTestId('tab-modified-indicator')).not.toBeInTheDocument()
expect(screen.queryByTestId('tab-new-indicator')).not.toBeInTheDocument()
})
it('renders nav back/forward buttons', () => {
const tabs = makeTabs(['Alpha'])
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />)
expect(screen.getByTestId('nav-back')).toBeInTheDocument()
expect(screen.getByTestId('nav-forward')).toBeInTheDocument()
})
it('disables nav buttons when canGoBack/canGoForward are false', () => {
const tabs = makeTabs(['Alpha'])
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} canGoBack={false} canGoForward={false} />)
expect(screen.getByTestId('nav-back')).toBeDisabled()
expect(screen.getByTestId('nav-forward')).toBeDisabled()
})
it('enables nav buttons and fires handlers on click', () => {
const onGoBack = vi.fn()
const onGoForward = vi.fn()
const tabs = makeTabs(['Alpha'])
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} canGoBack canGoForward onGoBack={onGoBack} onGoForward={onGoForward} />)
fireEvent.click(screen.getByTestId('nav-back'))
expect(onGoBack).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByTestId('nav-forward'))
expect(onGoForward).toHaveBeenCalledTimes(1)
})
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)
})
describe('responsive tab width', () => {
it('wraps tabs in an overflow-hidden flex container', () => {
const tabs = makeTabs(['Alpha'])
const { container } = render(
<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />
)
const tabArea = container.querySelector('.overflow-hidden')
expect(tabArea).toBeInTheDocument()
expect(tabArea?.classList.contains('flex')).toBe(true)
expect(tabArea?.classList.contains('min-w-0')).toBe(true)
expect(tabArea?.classList.contains('flex-1')).toBe(true)
})
it('tab elements are shrinkable with min-w-0', () => {
const tabs = makeTabs(['Alpha', 'Beta'])
const { container } = render(
<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />
)
const tabEls = container.querySelectorAll('[draggable="true"]')
expect(tabEls).toHaveLength(2)
for (const el of tabEls) {
expect(el.classList.contains('shrink-0')).toBe(false)
expect(el.classList.contains('min-w-0')).toBe(true)
}
})
})
describe('computeTabMaxWidth', () => {
it('caps at 360px when container is wide', () => {
expect(computeTabMaxWidth(1200, 2)).toBe(360)
})
it('divides space equally among tabs', () => {
expect(computeTabMaxWidth(500, 5)).toBe(100)
})
it('enforces minimum of 60px', () => {
expect(computeTabMaxWidth(300, 10)).toBe(60)
})
it('returns 360 for zero tabs', () => {
expect(computeTabMaxWidth(800, 0)).toBe(360)
})
it('floors the result to integer pixels', () => {
// 1000 / 3 = 333.33 → 333
expect(computeTabMaxWidth(1000, 3)).toBe(333)
})
it('handles single tab', () => {
expect(computeTabMaxWidth(200, 1)).toBe(200)
expect(computeTabMaxWidth(500, 1)).toBe(360)
})
})
})

View File

@@ -1,391 +0,0 @@
import { memo, useState, useRef, useCallback, useEffect } from 'react'
import { useDragRegion } from '../hooks/useDragRegion'
import type { VaultEntry, NoteStatus } from '../types'
import { cn } from '@/lib/utils'
import { X } from 'lucide-react'
import { Plus, Columns, ArrowsOutSimple, ArrowLeft, ArrowRight } from '@phosphor-icons/react'
import { computeTabMaxWidth } from '@/utils/tabLayout'
import { isEmoji } from '../utils/emoji'
interface Tab {
entry: VaultEntry
content: string
}
interface TabBarProps {
tabs: Tab[]
activeTabPath: string | null
getNoteStatus?: (path: string) => NoteStatus
onSwitchTab: (path: string) => void
onCloseTab: (path: string) => void
onCreateNote?: () => void
onReorderTabs?: (fromIndex: number, toIndex: number) => void
onRenameTab?: (path: string, newTitle: string) => void
canGoBack?: boolean
canGoForward?: boolean
onGoBack?: () => void
onGoForward?: () => void
leftPanelsCollapsed?: boolean
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
// --- Inline edit ---
/** Inline edit input shown when user double-clicks a tab title. */
function InlineTabEdit({ initialValue, onSave, onCancel }: {
initialValue: string
onSave: (value: string) => void
onCancel: () => void
}) {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
// Guard against double-fire: Enter calls handleSave, then React unmounts
// the input (editingPath → null), which triggers blur → handleSave again.
const committedRef = useRef(false)
useEffect(() => {
inputRef.current?.select()
}, [])
const handleSave = useCallback(() => {
if (committedRef.current) return
committedRef.current = true
const trimmed = value.trim()
if (trimmed && trimmed !== initialValue) {
onSave(trimmed)
} else {
onCancel()
}
}, [value, initialValue, onSave, onCancel])
return (
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') onCancel()
e.stopPropagation()
}}
onBlur={handleSave}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
draggable={false}
onDragStart={(e) => e.preventDefault()}
style={{
width: '100%',
minWidth: 40,
maxWidth: 150,
background: 'var(--background)',
border: '1px solid var(--ring)',
borderRadius: 3,
padding: '2px 6px',
fontSize: 12,
fontWeight: 500,
color: 'var(--foreground)',
outline: 'none',
fontFamily: 'inherit',
}}
/>
)
}
// --- Drag-and-drop helpers ---
function computeDropTarget(dragIdx: number | null, dropIdx: number | null): number | null {
if (dragIdx === null || dropIdx === null || dragIdx === dropIdx) return null
const toIndex = dropIdx > dragIdx ? dropIdx - 1 : dropIdx
return toIndex !== dragIdx ? toIndex : null
}
function computeInsertIndex(e: React.DragEvent<HTMLDivElement>, index: number): number {
const rect = e.currentTarget.getBoundingClientRect()
return e.clientX < rect.left + rect.width / 2 ? index : index + 1
}
function useTabDrag(onReorderTabs?: (from: number, to: number) => void) {
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [dropIndex, setDropIndex] = useState<number | null>(null)
// Refs mirror state so event handlers always read the latest values,
// avoiding stale closures when dragover and drop fire in the same frame.
const dragIndexRef = useRef<number | null>(null)
const dropIndexRef = useRef<number | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null)
const onReorderRef = useRef(onReorderTabs)
useEffect(() => { onReorderRef.current = onReorderTabs })
const resetDrag = useCallback(() => {
if (dragNodeRef.current) dragNodeRef.current.style.opacity = ''
dragNodeRef.current = null
dragIndexRef.current = null
dropIndexRef.current = null
setDragIndex(null)
setDropIndex(null)
}, [])
const handleDragStart = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
dragIndexRef.current = index
setDragIndex(index)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(index))
dragNodeRef.current = e.currentTarget
requestAnimationFrame(() => {
if (dragNodeRef.current) dragNodeRef.current.style.opacity = '0.5'
})
}, [])
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const currentDrag = dragIndexRef.current
if (currentDrag === null || currentDrag === index) {
dropIndexRef.current = null
setDropIndex(null)
return
}
const idx = computeInsertIndex(e, index)
dropIndexRef.current = idx
setDropIndex(idx)
}, [])
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
const toIndex = computeDropTarget(dragIndexRef.current, dropIndexRef.current)
if (toIndex !== null && onReorderRef.current) {
onReorderRef.current(dragIndexRef.current!, toIndex)
}
resetDrag()
}, [resetDrag])
const handleBarDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
const related = e.relatedTarget as HTMLElement | null
if (!e.currentTarget.contains(related)) {
dropIndexRef.current = null
setDropIndex(null)
}
}, [])
return { dragIndex, dropIndex, handleDragStart, handleDragEnd: resetDrag, handleDragOver, handleDrop, handleBarDragLeave }
}
// --- Sub-components ---
function DropIndicator({ side }: { side: 'left' | 'right' }) {
return (
<div style={{
position: 'absolute', [side]: -1, top: 8, bottom: 8,
width: 2, background: 'var(--primary)', borderRadius: 1, zIndex: 10,
}} />
)
}
const STATUS_DOT: Record<string, { color: string; testId: string; title: string; pulse?: boolean }> = {
unsaved: { color: 'var(--accent-blue, #3b82f6)', testId: 'tab-unsaved-indicator', title: 'Auto-saving…', pulse: true },
pendingSave: { color: 'var(--accent-green)', testId: 'tab-pending-save-indicator', title: 'Saving to disk…', pulse: true },
new: { color: 'var(--accent-green)', testId: 'tab-new-indicator', title: 'New (uncommitted)' },
modified: { color: 'var(--accent-orange)', testId: 'tab-modified-indicator', title: 'Modified (uncommitted)' },
}
function StatusDot({ status }: { status: NoteStatus }) {
const cfg = STATUS_DOT[status]
if (!cfg) return null
return (
<span
className={`shrink-0${cfg.pulse ? ' tab-status-pulse' : ''}`}
style={{ width: 6, height: 6, borderRadius: '50%', background: cfg.color }}
data-testid={cfg.testId}
title={cfg.title}
/>
)
}
function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, tabMaxWidth, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
tab: Tab
isActive: boolean
isEditing: boolean
noteStatus: NoteStatus
isDragging: boolean
showDropBefore: boolean
showDropAfter: boolean
tabMaxWidth: number
onSwitch: () => void
onClose: () => void
onDoubleClick: () => void
onRenameSave: (newTitle: string) => void
onRenameCancel: () => void
dragProps: React.HTMLAttributes<HTMLDivElement>
}) {
return (
<div
data-tab-path={tab.entry.path}
draggable={!isEditing}
{...dragProps}
className={cn(
"group flex min-w-0 items-center gap-1.5 whitespace-nowrap transition-all relative",
isActive ? "text-foreground" : "text-muted-foreground hover:text-secondary-foreground"
)}
style={{
maxWidth: tabMaxWidth,
background: isActive ? 'var(--background)' : 'transparent',
borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`,
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
padding: '0 12px', fontSize: 12,
fontWeight: isActive ? 500 : 400,
cursor: isEditing ? 'default' : isDragging ? 'grabbing' : 'grab',
WebkitAppRegion: 'no-drag',
} as React.CSSProperties}
onClick={() => !isEditing && onSwitch()}
>
{showDropBefore && <DropIndicator side="left" />}
{isEditing ? (
<InlineTabEdit initialValue={tab.entry.title} onSave={onRenameSave} onCancel={onRenameCancel} />
) : (
<span className="truncate" onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
{tab.entry.icon && isEmoji(tab.entry.icon) && <span className="mr-1">{tab.entry.icon}</span>}
{tab.entry.title}
</span>
)}
<StatusDot status={noteStatus} />
<button
className={cn(
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
style={{ lineHeight: 0 }}
draggable={false}
onClick={(e) => { e.stopPropagation(); onClose() }}
>
<X size={14} />
</button>
{showDropAfter && <DropIndicator side="right" />}
</div>
)
}
function NavButtons({ canGoBack, canGoForward, onGoBack, onGoForward }: {
canGoBack?: boolean; canGoForward?: boolean; onGoBack?: () => void; onGoForward?: () => void
}) {
return (
<div
className="flex shrink-0 items-center"
style={{
gap: 4, padding: '0 8px',
borderRight: '1px solid var(--sidebar-border)',
borderBottom: '1px solid var(--sidebar-border)',
WebkitAppRegion: 'no-drag',
} as React.CSSProperties}
>
<button
className={cn(
"flex items-center justify-center border-none bg-transparent p-0.5 rounded-sm transition-colors",
canGoBack ? "text-muted-foreground cursor-pointer hover:text-foreground hover:bg-accent" : "text-muted-foreground"
)}
style={canGoBack ? undefined : DISABLED_ICON_STYLE}
disabled={!canGoBack}
onClick={onGoBack}
title="Back (⌘[)"
data-testid="nav-back"
>
<ArrowLeft size={15} />
</button>
<button
className={cn(
"flex items-center justify-center border-none bg-transparent p-0.5 rounded-sm transition-colors",
canGoForward ? "text-muted-foreground cursor-pointer hover:text-foreground hover:bg-accent" : "text-muted-foreground"
)}
style={canGoForward ? undefined : DISABLED_ICON_STYLE}
disabled={!canGoForward}
onClick={onGoForward}
title="Forward (⌘])"
data-testid="nav-forward"
>
<ArrowRight size={15} />
</button>
</div>
)
}
function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
return (
<div
className="flex shrink-0 items-center"
style={{
borderLeft: '1px solid var(--border)', borderBottom: '1px solid var(--border)',
gap: 12, padding: '0 12px', WebkitAppRegion: 'no-drag',
} as React.CSSProperties}
>
<button className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors" onClick={() => onCreateNote?.()} title="New note">
<Plus size={16} />
</button>
<button className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground" style={DISABLED_ICON_STYLE} title="Coming soon" tabIndex={-1}>
<Columns size={16} />
</button>
<button className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground" style={DISABLED_ICON_STYLE} title="Coming soon" tabIndex={-1}>
<ArrowsOutSimple size={16} />
</button>
</div>
)
}
// --- Main TabBar ---
export const TabBar = memo(function TabBar({
tabs, activeTabPath, getNoteStatus, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
}: TabBarProps) {
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
const [editingPath, setEditingPath] = useState<string | null>(null)
const tabAreaRef = useRef<HTMLDivElement>(null)
const [tabMaxWidth, setTabMaxWidth] = useState(360)
const { onMouseDown: onDragMouseDown } = useDragRegion()
useEffect(() => {
const el = tabAreaRef.current
if (!el) return
const recalc = () => setTabMaxWidth(computeTabMaxWidth(el.clientWidth, tabs.length))
recalc()
const observer = new ResizeObserver(recalc)
observer.observe(el)
return () => observer.disconnect()
}, [tabs.length])
return (
<div
className="flex shrink-0 items-stretch"
style={{ height: 52, background: 'var(--sidebar)', paddingLeft: leftPanelsCollapsed ? 80 : 0 } as React.CSSProperties}
onDragLeave={handleBarDragLeave}
>
<NavButtons canGoBack={canGoBack} canGoForward={canGoForward} onGoBack={onGoBack} onGoForward={onGoForward} />
<div ref={tabAreaRef} className="flex flex-1 min-w-0 items-stretch overflow-hidden">
{tabs.map((tab, index) => (
<TabItem
key={tab.entry.path}
tab={tab}
isActive={tab.entry.path === activeTabPath}
isEditing={editingPath === tab.entry.path}
noteStatus={getNoteStatus?.(tab.entry.path) ?? 'clean'}
isDragging={dragIndex !== null}
showDropBefore={dropIndex === index}
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
tabMaxWidth={tabMaxWidth}
onSwitch={() => onSwitchTab(tab.entry.path)}
onClose={() => onCloseTab(tab.entry.path)}
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
onRenameCancel={() => setEditingPath(null)}
dragProps={{
onDragStart: (e) => handleDragStart(e, index),
onDragEnd: handleDragEnd,
onDragOver: (e) => handleDragOver(e, index),
onDrop: handleDrop,
}}
/>
))}
<div className="flex-1 shrink-0" style={{ borderBottom: '1px solid var(--border)', cursor: 'default' }} onMouseDown={onDragMouseDown} />
</div>
<TabBarActions onCreateNote={onCreateNote} />
</div>
)
})