{tabs.length === 0
?
@@ -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}
diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx
index 33936454..68153b13 100644
--- a/src/components/EditorRightPanel.tsx
+++ b/src/components/EditorRightPanel.tsx
@@ -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}
/>
diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx
deleted file mode 100644
index 7b162536..00000000
--- a/src/components/TabBar.test.tsx
+++ /dev/null
@@ -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(
)
- 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(
)
- 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(
-
- )
-
- 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(
-
- )
-
- 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(
)
- 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(
)
- 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(
)
- 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(
)
- 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(
-
- )
-
- 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(
-
- )
-
- 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(
)
- 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(
)
- 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(
)
- 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(
)
-
- 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(
-
- )
-
- 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(
-
- )
- 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(
-
- )
- 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)
- })
- })
-})
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
deleted file mode 100644
index db15ea61..00000000
--- a/src/components/TabBar.tsx
+++ /dev/null
@@ -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
(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 (
- 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, 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(null)
- const [dropIndex, setDropIndex] = useState(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(null)
- const dropIndexRef = useRef(null)
- const dragNodeRef = useRef(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, 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, 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) => {
- 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) => {
- 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 (
-
- )
-}
-
-const STATUS_DOT: Record = {
- 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 (
-
- )
-}
-
-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
-}) {
- return (
- !isEditing && onSwitch()}
- >
- {showDropBefore && }
- {isEditing ? (
-
- ) : (
- { e.stopPropagation(); onDoubleClick() }}>
- {tab.entry.icon && isEmoji(tab.entry.icon) && {tab.entry.icon}}
- {tab.entry.title}
-
- )}
-
-
- {showDropAfter && }
-
- )
-}
-
-function NavButtons({ canGoBack, canGoForward, onGoBack, onGoForward }: {
- canGoBack?: boolean; canGoForward?: boolean; onGoBack?: () => void; onGoForward?: () => void
-}) {
- return (
-
- )
-}
-
-function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
- return (
-
-
-
-
-
- )
-}
-
-// --- 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(null)
- const tabAreaRef = useRef(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 (
-
-
-
- {tabs.map((tab, index) => (
-
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,
- }}
- />
- ))}
-
-
-
-
- )
-})
diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts
index 622a7cd5..62a1b49c 100644
--- a/src/hooks/useAppCommands.ts
+++ b/src/hooks/useAppCommands.ts
@@ -8,13 +8,9 @@ import type { SidebarSelection, SidebarFilter, VaultEntry } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
-interface Tab { entry: VaultEntry; content: string }
-
interface AppCommandsConfig {
activeTabPath: string | null
activeTabPathRef: React.MutableRefObject
- handleCloseTabRef: React.MutableRefObject<(path: string) => void>
- tabs: Tab[]
entries: VaultEntry[]
modifiedCount: number
selection: SidebarSelection
@@ -43,8 +39,6 @@ interface AppCommandsConfig {
onZoomReset: () => void
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
- onCloseTab: (path: string) => void
- onSwitchTab: (path: string) => void
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
onGoBack?: () => void
@@ -63,7 +57,6 @@ interface AppCommandsConfig {
onInstallMcp?: () => void
onEmptyTrash?: () => void
trashedCount?: number
- onReopenClosedTab?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onSetNoteIcon?: () => void
@@ -118,10 +111,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onGoForward: config.onGoForward,
onToggleAIChat: config.onToggleAIChat,
onToggleRawEditor: config.onToggleRawEditor,
- onReopenClosedTab: config.onReopenClosedTab,
onOpenInNewWindow: config.onOpenInNewWindow,
activeTabPathRef: config.activeTabPathRef,
- handleCloseTabRef: config.handleCloseTabRef,
})
useMenuEvents({
@@ -158,10 +149,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
onEmptyTrash: config.onEmptyTrash,
- onReopenClosedTab: config.onReopenClosedTab,
onOpenInNewWindow: config.onOpenInNewWindow,
activeTabPathRef: config.activeTabPathRef,
- handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,
modifiedCount: config.modifiedCount,
})
@@ -195,7 +184,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
zoomLevel: config.zoomLevel,
onSelect: config.onSelect,
onOpenDailyNote: config.onOpenDailyNote,
- onCloseTab: config.onCloseTab,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
canGoBack: config.canGoBack,
@@ -222,11 +210,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
})
useKeyboardNavigation({
- tabs: config.tabs,
activeTabPath: config.activeTabPath,
entries: config.entries,
selection: config.selection,
- onSwitchTab: config.onSwitchTab,
onReplaceActiveTab: config.onReplaceActiveTab,
onSelectNote: config.onSelectNote,
})
diff --git a/src/hooks/useAppKeyboard.test.ts b/src/hooks/useAppKeyboard.test.ts
index 77ff54bc..1ca5127a 100644
--- a/src/hooks/useAppKeyboard.test.ts
+++ b/src/hooks/useAppKeyboard.test.ts
@@ -31,7 +31,6 @@ function makeActions() {
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject,
- handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
}
}
@@ -87,13 +86,6 @@ describe('useAppKeyboard', () => {
expect(actions.onOpenDailyNote).toHaveBeenCalled()
})
- it('Cmd+W closes the active tab', () => {
- const actions = makeActions()
- renderHook(() => useAppKeyboard(actions))
- fireKey('w', { metaKey: true })
- expect(actions.handleCloseTabRef.current).toHaveBeenCalledWith('/vault/test.md')
- })
-
it('Alt+4 does not trigger any view mode', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
@@ -183,23 +175,6 @@ describe('useAppKeyboard', () => {
expect(actions.onZoomReset).toHaveBeenCalled()
})
- it('Cmd+Shift+T triggers reopen closed tab', () => {
- const actions = makeActions()
- const onReopenClosedTab = vi.fn()
- renderHook(() => useAppKeyboard({ ...actions, onReopenClosedTab }))
- fireKey('t', { metaKey: true, shiftKey: true })
- expect(onReopenClosedTab).toHaveBeenCalled()
- })
-
- it('Cmd+Shift+T does not trigger other shortcuts', () => {
- const actions = makeActions()
- const onReopenClosedTab = vi.fn()
- renderHook(() => useAppKeyboard({ ...actions, onReopenClosedTab }))
- fireKey('t', { metaKey: true, shiftKey: true })
- expect(actions.onQuickOpen).not.toHaveBeenCalled()
- expect(actions.onCreateNote).not.toHaveBeenCalled()
- })
-
it('Cmd+I triggers toggle AI chat', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts
index 2f650b67..052d9c47 100644
--- a/src/hooks/useAppKeyboard.ts
+++ b/src/hooks/useAppKeyboard.ts
@@ -19,10 +19,8 @@ interface KeyboardActions {
onGoForward?: () => void
onToggleAIChat?: () => void
onToggleRawEditor?: () => void
- onReopenClosedTab?: () => void
onOpenInNewWindow?: () => void
activeTabPathRef: React.MutableRefObject
- handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
type ShortcutHandler = () => void
@@ -66,7 +64,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record)
export function useAppKeyboard({
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
- onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, onOpenInNewWindow, activeTabPathRef, handleCloseTabRef,
+ onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onOpenInNewWindow, activeTabPathRef,
}: KeyboardActions) {
useEffect(() => {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
@@ -82,7 +80,6 @@ export function useAppKeyboard({
s: onSave,
',': onOpenSettings,
e: withActiveTab(onArchiveNote),
- w: withActiveTab((path) => handleCloseTabRef.current(path)),
Backspace: withActiveTab(onTrashNote),
Delete: withActiveTab(onTrashNote),
'[': () => onGoBack?.(),
@@ -102,12 +99,6 @@ export function useAppKeyboard({
onSearch()
return
}
- // Cmd+Shift+T: reopen last closed tab
- if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 't' || e.key === 'T')) {
- e.preventDefault()
- onReopenClosedTab?.()
- return
- }
// Cmd+Shift+O: open active note in new window
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'o' || e.key === 'O')) {
e.preventDefault()
@@ -120,5 +111,5 @@ export function useAppKeyboard({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
- }, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, onOpenInNewWindow])
+ }, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onOpenInNewWindow])
}
diff --git a/src/hooks/useAppNavigation.test.ts b/src/hooks/useAppNavigation.test.ts
index d9fa4a5d..d3c83c89 100644
--- a/src/hooks/useAppNavigation.test.ts
+++ b/src/hooks/useAppNavigation.test.ts
@@ -7,29 +7,21 @@ function makeEntry(path: string): VaultEntry {
return { path, filename: path.split('/').pop()!, title: path, isA: null, aliases: [] } as VaultEntry
}
-function makeTab(entry: VaultEntry) {
- return { entry, content: '' }
-}
-
describe('useAppNavigation', () => {
let onSelectNote: ReturnType
- let onSwitchTab: ReturnType
beforeEach(() => {
onSelectNote = vi.fn()
- onSwitchTab = vi.fn()
})
function renderNav(overrides: {
entries?: VaultEntry[]
- tabs?: Array<{ entry: VaultEntry; content: string }>
activeTabPath?: string | null
} = {}) {
const entries = overrides.entries ?? [makeEntry('/a.md'), makeEntry('/b.md'), makeEntry('/c.md')]
- const tabs = overrides.tabs ?? []
const activeTabPath = overrides.activeTabPath ?? null
return renderHook(() =>
- useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
+ useAppNavigation({ entries, activeTabPath, onSelectNote }),
)
}
@@ -60,51 +52,30 @@ describe('useAppNavigation', () => {
describe('navigation via activeTabPath changes', () => {
it('pushes to history when activeTabPath changes, enabling goBack', () => {
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
- const tabA = makeTab(entries[0])
- const tabB = makeTab(entries[1])
const { result, rerender } = renderHook(
- ({ activeTabPath, tabs }) =>
- useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
- { initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabA] } },
+ ({ activeTabPath }) =>
+ useAppNavigation({ entries, activeTabPath, onSelectNote }),
+ { initialProps: { activeTabPath: '/a.md' as string | null } },
)
// Navigate to /b.md
- rerender({ activeTabPath: '/b.md', tabs: [tabA, tabB] })
+ rerender({ activeTabPath: '/b.md' })
expect(result.current.canGoBack).toBe(true)
expect(result.current.canGoForward).toBe(false)
})
- it('handleGoBack switches to the tab if it is open', () => {
+ it('handleGoBack calls onSelectNote with the previous entry', () => {
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
- const tabA = makeTab(entries[0])
- const tabB = makeTab(entries[1])
const { result, rerender } = renderHook(
- ({ activeTabPath, tabs }) =>
- useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
- { initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabA, tabB] } },
+ ({ activeTabPath }) =>
+ useAppNavigation({ entries, activeTabPath, onSelectNote }),
+ { initialProps: { activeTabPath: '/a.md' as string | null } },
)
- rerender({ activeTabPath: '/b.md', tabs: [tabA, tabB] })
-
- act(() => { result.current.handleGoBack() })
-
- expect(onSwitchTab).toHaveBeenCalledWith('/a.md')
- })
-
- it('handleGoBack opens entry via onSelectNote if not in tabs', () => {
- const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
- const tabB = makeTab(entries[1])
-
- const { result, rerender } = renderHook(
- ({ activeTabPath, tabs }) =>
- useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
- { initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabB] } },
- )
-
- rerender({ activeTabPath: '/b.md', tabs: [tabB] })
+ rerender({ activeTabPath: '/b.md' })
act(() => { result.current.handleGoBack() })
@@ -113,22 +84,20 @@ describe('useAppNavigation', () => {
it('handleGoForward works after going back', () => {
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
- const tabA = makeTab(entries[0])
- const tabB = makeTab(entries[1])
const { result, rerender } = renderHook(
- ({ activeTabPath, tabs }) =>
- useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
- { initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabA, tabB] } },
+ ({ activeTabPath }) =>
+ useAppNavigation({ entries, activeTabPath, onSelectNote }),
+ { initialProps: { activeTabPath: '/a.md' as string | null } },
)
- rerender({ activeTabPath: '/b.md', tabs: [tabA, tabB] })
+ rerender({ activeTabPath: '/b.md' })
act(() => { result.current.handleGoBack() })
expect(result.current.canGoForward).toBe(true)
act(() => { result.current.handleGoForward() })
- expect(onSwitchTab).toHaveBeenCalledWith('/b.md')
+ expect(onSelectNote).toHaveBeenCalledWith(entries[1])
})
})
})
diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/useAppNavigation.ts
index 84c438fd..96654f50 100644
--- a/src/hooks/useAppNavigation.ts
+++ b/src/hooks/useAppNavigation.ts
@@ -3,34 +3,26 @@ import { useNavigationHistory } from './useNavigationHistory'
import { useNavigationGestures } from './useNavigationGestures'
import type { VaultEntry } from '../types'
-interface TabLike {
- entry: { path: string }
-}
-
interface UseAppNavigationParams {
entries: VaultEntry[]
- tabs: TabLike[]
activeTabPath: string | null
onSelectNote: (entry: VaultEntry) => void
- onSwitchTab: (path: string) => void
}
/**
* Encapsulates browser-style back/forward navigation for the app:
- * - Navigation history (push on tab change, back/forward traversal)
+ * - Navigation history (push on note change, back/forward traversal)
* - Mouse button & trackpad gesture bindings
- * - O(1) path→entry lookup map
+ * - O(1) path->entry lookup map
*/
export function useAppNavigation({
entries,
- tabs,
activeTabPath,
onSelectNote,
- onSwitchTab,
}: UseAppNavigationParams) {
const navHistory = useNavigationHistory()
- // Push to navigation history whenever the active tab changes (user-initiated)
+ // Push to navigation history whenever the active note changes (user-initiated)
const navFromHistoryRef = useRef(false)
useEffect(() => {
if (activeTabPath && !navFromHistoryRef.current) {
@@ -48,27 +40,19 @@ export function useAppNavigation({
const target = navHistory.goBack(isEntryExists)
if (target) {
navFromHistoryRef.current = true
- if (tabs.some(t => t.entry.path === target)) {
- onSwitchTab(target)
- } else {
- const entry = entries.find(e => e.path === target)
- if (entry) onSelectNote(entry)
- }
+ const entry = entries.find(e => e.path === target)
+ if (entry) onSelectNote(entry)
}
- }, [navHistory, isEntryExists, entries, tabs, onSelectNote, onSwitchTab])
+ }, [navHistory, isEntryExists, entries, onSelectNote])
const handleGoForward = useCallback(() => {
const target = navHistory.goForward(isEntryExists)
if (target) {
navFromHistoryRef.current = true
- if (tabs.some(t => t.entry.path === target)) {
- onSwitchTab(target)
- } else {
- const entry = entries.find(e => e.path === target)
- if (entry) onSelectNote(entry)
- }
+ const entry = entries.find(e => e.path === target)
+ if (entry) onSelectNote(entry)
}
- }, [navHistory, isEntryExists, entries, tabs, onSelectNote, onSwitchTab])
+ }, [navHistory, isEntryExists, entries, onSelectNote])
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
diff --git a/src/hooks/useClosedTabHistory.test.ts b/src/hooks/useClosedTabHistory.test.ts
deleted file mode 100644
index ca760e46..00000000
--- a/src/hooks/useClosedTabHistory.test.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { renderHook, act } from '@testing-library/react'
-import { useClosedTabHistory } from './useClosedTabHistory'
-import type { VaultEntry } from '../types'
-
-const stubEntry = (path: string): VaultEntry => ({
- path, filename: path.split('/').pop() ?? '', title: path.split('/').pop()?.replace(/\.md$/, '') ?? '',
- isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: 'Active',
- archived: false, trashed: false, trashedAt: null, modifiedAt: 0, createdAt: 0, fileSize: 0,
- snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
-})
-
-describe('useClosedTabHistory', () => {
- it('starts with empty history', () => {
- const { result } = renderHook(() => useClosedTabHistory())
- expect(result.current.canReopen).toBe(false)
- })
-
- it('records a closed tab and allows reopening', () => {
- const { result } = renderHook(() => useClosedTabHistory())
-
- act(() => { result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md')) })
-
- expect(result.current.canReopen).toBe(true)
- const entry = result.current.pop()
- expect(entry?.path).toBe('/vault/a.md')
- expect(entry?.index).toBe(0)
- expect(result.current.canReopen).toBe(false)
- })
-
- it('pops in LIFO order', () => {
- const { result } = renderHook(() => useClosedTabHistory())
-
- act(() => {
- result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md'))
- result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md'))
- result.current.push('/vault/c.md', 2, stubEntry('/vault/c.md'))
- })
-
- expect(result.current.pop()?.path).toBe('/vault/c.md')
- expect(result.current.pop()?.path).toBe('/vault/b.md')
- expect(result.current.pop()?.path).toBe('/vault/a.md')
- expect(result.current.pop()).toBeNull()
- })
-
- it('returns null when popping empty history', () => {
- const { result } = renderHook(() => useClosedTabHistory())
- expect(result.current.pop()).toBeNull()
- })
-
- it('caps history at 20 entries', () => {
- const { result } = renderHook(() => useClosedTabHistory())
-
- act(() => {
- for (let i = 0; i < 25; i++) {
- result.current.push(`/vault/${i}.md`, i, stubEntry(`/vault/${i}.md`))
- }
- })
-
- // Should only have last 20 entries (5-24)
- const first = result.current.pop()
- expect(first?.path).toBe('/vault/24.md')
-
- // Pop remaining 19
- for (let i = 0; i < 19; i++) {
- result.current.pop()
- }
- expect(result.current.pop()).toBeNull()
- })
-
- it('deduplicates: closing same path twice keeps only latest entry', () => {
- const { result } = renderHook(() => useClosedTabHistory())
-
- act(() => {
- result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md'))
- result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md'))
- result.current.push('/vault/a.md', 2, stubEntry('/vault/a.md')) // close a.md again at different index
- })
-
- // a.md should only appear once (the latest), at the top
- expect(result.current.pop()?.path).toBe('/vault/a.md')
- expect(result.current.pop()?.path).toBe('/vault/b.md')
- expect(result.current.pop()).toBeNull()
- })
-
- it('clear resets the history', () => {
- const { result } = renderHook(() => useClosedTabHistory())
-
- act(() => {
- result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md'))
- result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md'))
- })
-
- act(() => { result.current.clear() })
-
- expect(result.current.canReopen).toBe(false)
- expect(result.current.pop()).toBeNull()
- })
-})
diff --git a/src/hooks/useClosedTabHistory.ts b/src/hooks/useClosedTabHistory.ts
deleted file mode 100644
index f2d35ab2..00000000
--- a/src/hooks/useClosedTabHistory.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { useCallback, useRef } from 'react'
-import type { VaultEntry } from '../types'
-
-export interface ClosedTabEntry {
- path: string
- index: number
- entry: VaultEntry
-}
-
-const MAX_HISTORY = 20
-
-export function useClosedTabHistory() {
- const stackRef = useRef([])
-
- const push = useCallback((path: string, index: number, entry: VaultEntry) => {
- const stack = stackRef.current
- // Remove any existing entry for this path (dedup)
- const filtered = stack.filter(e => e.path !== path)
- filtered.push({ path, index, entry })
- // Cap at MAX_HISTORY
- if (filtered.length > MAX_HISTORY) {
- filtered.splice(0, filtered.length - MAX_HISTORY)
- }
- stackRef.current = filtered
- }, [])
-
- const pop = useCallback((): ClosedTabEntry | null => {
- const stack = stackRef.current
- if (stack.length === 0) return null
- return stack.pop() ?? null
- }, [])
-
- const clear = useCallback(() => {
- stackRef.current = []
- }, [])
-
- // Getter so callers see live state without re-render
- return {
- push, pop, clear,
- get canReopen() { return stackRef.current.length > 0 },
- }
-}
diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts
index 6c695b9c..b35ca0ce 100644
--- a/src/hooks/useCommandRegistry.ts
+++ b/src/hooks/useCommandRegistry.ts
@@ -58,7 +58,6 @@ interface CommandRegistryConfig {
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onOpenDailyNote: () => void
- onCloseTab: (path: string) => void
onGoBack?: () => void
onGoForward?: () => void
canGoBack?: boolean
@@ -161,7 +160,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
- onSelect, onOpenDailyNote, onCloseTab,
+ onSelect, onOpenDailyNote,
onGoBack, onGoForward, canGoBack, canGoForward,
onCheckForUpdates,
onCreateType,
@@ -208,7 +207,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
- { id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } },
{
id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫',
keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote,
@@ -274,7 +272,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
- onSelect, onOpenDailyNote, onCloseTab,
+ onSelect, onOpenDailyNote,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
diff --git a/src/hooks/useDeleteActions.test.ts b/src/hooks/useDeleteActions.test.ts
index dd76bd5f..5745a28f 100644
--- a/src/hooks/useDeleteActions.test.ts
+++ b/src/hooks/useDeleteActions.test.ts
@@ -15,12 +15,12 @@ function makeEntry(path: string, trashed = false) {
}
describe('useDeleteActions', () => {
- let handleCloseTab: ReturnType
+ let onDeselectNote: ReturnType
let removeEntry: ReturnType
let setToastMessage: ReturnType
beforeEach(() => {
- handleCloseTab = vi.fn()
+ onDeselectNote = vi.fn()
removeEntry = vi.fn()
setToastMessage = vi.fn()
mockInvokeFn.mockReset()
@@ -31,7 +31,7 @@ describe('useDeleteActions', () => {
useDeleteActions({
vaultPath: '/vault',
entries,
- handleCloseTab,
+ onDeselectNote,
removeEntry,
setToastMessage,
}),
@@ -68,7 +68,7 @@ describe('useDeleteActions', () => {
})
expect(ok).toBe(true)
expect(mockInvokeFn).toHaveBeenCalledWith('delete_note', { path: '/vault/a.md' })
- expect(handleCloseTab).toHaveBeenCalledWith('/vault/a.md')
+ expect(onDeselectNote).toHaveBeenCalledWith('/vault/a.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/a.md')
})
@@ -184,8 +184,8 @@ describe('useDeleteActions', () => {
})
expect(result.current.confirmDelete).toBeNull()
expect(mockInvokeFn).toHaveBeenCalledWith('empty_trash', { vaultPath: '/vault' })
- expect(handleCloseTab).toHaveBeenCalledWith('/vault/t1.md')
- expect(handleCloseTab).toHaveBeenCalledWith('/vault/t2.md')
+ expect(onDeselectNote).toHaveBeenCalledWith('/vault/t1.md')
+ expect(onDeselectNote).toHaveBeenCalledWith('/vault/t2.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/t1.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/t2.md')
expect(setToastMessage).toHaveBeenCalledWith('2 notes permanently deleted')
diff --git a/src/hooks/useDeleteActions.ts b/src/hooks/useDeleteActions.ts
index c66afe32..679216d5 100644
--- a/src/hooks/useDeleteActions.ts
+++ b/src/hooks/useDeleteActions.ts
@@ -13,7 +13,8 @@ interface ConfirmDeleteState {
interface UseDeleteActionsInput {
vaultPath: string
entries: VaultEntry[]
- handleCloseTab: (path: string) => void
+ /** Called to deselect the note if it is currently open. */
+ onDeselectNote: (path: string) => void
removeEntry: (path: string) => void
setToastMessage: (msg: string | null) => void
}
@@ -21,7 +22,7 @@ interface UseDeleteActionsInput {
export function useDeleteActions({
vaultPath,
entries,
- handleCloseTab,
+ onDeselectNote,
removeEntry,
setToastMessage,
}: UseDeleteActionsInput) {
@@ -33,14 +34,14 @@ export function useDeleteActions({
try {
if (isTauri()) await invoke('delete_note', { path })
else await mockInvoke('delete_note', { path })
- handleCloseTab(path)
+ onDeselectNote(path)
removeEntry(path)
return true
} catch (e) {
setToastMessage(`Failed to delete note: ${e}`)
return false
}
- }, [handleCloseTab, removeEntry, setToastMessage])
+ }, [onDeselectNote, removeEntry, setToastMessage])
const handleDeleteNote = useCallback(async (path: string) => {
setConfirmDelete({
@@ -82,7 +83,7 @@ export function useDeleteActions({
const tauriInvoke = isTauri() ? invoke : mockInvoke
const deleted = await tauriInvoke('empty_trash', { vaultPath })
for (const path of deleted) {
- handleCloseTab(path)
+ onDeselectNote(path)
removeEntry(path)
}
setToastMessage(`${deleted.length} note${deleted.length !== 1 ? 's' : ''} permanently deleted`)
@@ -91,7 +92,7 @@ export function useDeleteActions({
}
},
})
- }, [trashedCount, vaultPath, handleCloseTab, removeEntry, setToastMessage])
+ }, [trashedCount, vaultPath, onDeselectNote, removeEntry, setToastMessage])
return {
confirmDelete,
diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts
index 75e55210..83d19384 100644
--- a/src/hooks/useEditorTabSwap.test.ts
+++ b/src/hooks/useEditorTabSwap.test.ts
@@ -159,7 +159,6 @@ describe('replaceTitleInFrontmatter', () => {
})
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
-const blocksB = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }]
function makeTab(path: string, title: string) {
return {
@@ -306,57 +305,7 @@ describe('useEditorTabSwap scroll position', () => {
afterEach(() => { vi.restoreAllMocks() })
- it('saves scroll position when switching tabs and restores it when switching back', async () => {
- const scrollEl = { scrollTop: 0 }
- vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
- const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
-
- const docRef = { current: blocksA as unknown[] }
- const mockEditor = makeMockEditor(docRef)
- // Override document to be dynamic
- Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
-
- const tabA = makeTab('a.md', 'Note A')
- const tabB = makeTab('b.md', 'Note B')
-
- const { rerender } = renderHook(
- ({ tabs, activeTabPath }) => useEditorTabSwap({
- tabs,
- activeTabPath,
- editor: mockEditor as never,
- }),
- { initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md' } },
- )
-
- // Flush the microtask for initial content swap
- await act(() => new Promise(r => setTimeout(r, 0)))
-
- // Simulate scrolling in tab A
- scrollEl.scrollTop = 350
-
- // Switch to tab B
- rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' })
- await act(() => new Promise(r => setTimeout(r, 0)))
-
- // rAF should have been called to set scroll to 0 (new tab, no cached scroll)
- expect(rAF).toHaveBeenCalled()
-
- // Switch back to tab A
- docRef.current = blocksB // simulate B's content in editor
- scrollEl.scrollTop = 0 // B is at top
- rerender({ tabs: [tabA, tabB], activeTabPath: 'a.md' })
- await act(() => new Promise(r => setTimeout(r, 0)))
-
- // The last rAF call should restore A's scroll position (350)
- const lastRAFCall = rAF.mock.calls[rAF.mock.calls.length - 1]
- expect(lastRAFCall).toBeDefined()
- // Execute the callback to verify scrollTop is set
- scrollEl.scrollTop = 0
- ;(lastRAFCall[0] as (n: number) => void)(0)
- expect(scrollEl.scrollTop).toBe(350)
- })
-
- it('defaults to scroll top 0 for newly opened tabs', async () => {
+ it('defaults to scroll top 0 for newly opened note', async () => {
const scrollEl = { scrollTop: 0 }
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
@@ -378,47 +327,8 @@ describe('useEditorTabSwap scroll position', () => {
await act(() => new Promise(r => setTimeout(r, 0)))
- // For a fresh tab, scroll should go to 0
+ // For a fresh note, scroll should go to 0
expect(rAF).toHaveBeenCalled()
expect(scrollEl.scrollTop).toBe(0)
})
-
- it('cleans up scroll cache when a tab is closed', async () => {
- const scrollEl = { scrollTop: 100 }
- vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
-
- const docRef = { current: blocksA as unknown[] }
- const mockEditor = makeMockEditor(docRef)
- Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
-
- const tabA = makeTab('a.md', 'Note A')
- const tabB = makeTab('b.md', 'Note B')
-
- const { rerender } = renderHook(
- ({ tabs, activeTabPath }) => useEditorTabSwap({
- tabs,
- activeTabPath,
- editor: mockEditor as never,
- }),
- { initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md' } },
- )
- await act(() => new Promise(r => setTimeout(r, 0)))
-
- // Switch to B (caches A's scroll at 100)
- rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' })
- await act(() => new Promise(r => setTimeout(r, 0)))
-
- // Close tab A (only tab B remains)
- rerender({ tabs: [tabB], activeTabPath: 'b.md' })
- await act(() => new Promise(r => setTimeout(r, 0)))
-
- // Reopen tab A — should start at scroll 0, not the cached 100
- const tabANew = makeTab('a.md', 'Note A')
- scrollEl.scrollTop = 0
- rerender({ tabs: [tabB, tabANew], activeTabPath: 'a.md' })
- await act(() => new Promise(r => setTimeout(r, 0)))
-
- expect(scrollEl.scrollTop).toBe(0)
- })
})
diff --git a/src/hooks/useKeyboardNavigation.test.ts b/src/hooks/useKeyboardNavigation.test.ts
index 1f33988f..ff9473ce 100644
--- a/src/hooks/useKeyboardNavigation.test.ts
+++ b/src/hooks/useKeyboardNavigation.test.ts
@@ -34,13 +34,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({
...overrides,
})
-interface Tab {
- entry: VaultEntry
- content: string
-}
-
describe('useKeyboardNavigation', () => {
- const onSwitchTab = vi.fn()
const onReplaceActiveTab = vi.fn()
const onSelectNote = vi.fn()
@@ -50,12 +44,6 @@ describe('useKeyboardNavigation', () => {
makeEntry({ path: '/vault/c.md', title: 'C', modifiedAt: 1700000001 }),
]
- const tabs: Tab[] = [
- { entry: entries[0], content: '# A' },
- { entry: entries[1], content: '# B' },
- { entry: entries[2], content: '# C' },
- ]
-
const selection: SidebarSelection = { kind: 'filter', filter: 'all' }
let addedListeners: { type: string; handler: EventListenerOrEventListenerObject }[] = []
@@ -81,70 +69,19 @@ describe('useKeyboardNavigation', () => {
it('registers keydown listener on mount', () => {
renderHook(() =>
useKeyboardNavigation({
- tabs, activeTabPath: '/vault/a.md', entries, selection,
- onSwitchTab, onReplaceActiveTab, onSelectNote,
+ activeTabPath: '/vault/a.md', entries, selection,
+ onReplaceActiveTab, onSelectNote,
})
)
expect(addedListeners.some(l => l.type === 'keydown')).toBe(true)
})
- it('switches to next tab on Cmd+Shift+ArrowRight (browser mode)', () => {
- renderHook(() =>
- useKeyboardNavigation({
- tabs, activeTabPath: '/vault/a.md', entries, selection,
- onSwitchTab, onReplaceActiveTab, onSelectNote,
- })
- )
-
- act(() => {
- window.dispatchEvent(new KeyboardEvent('keydown', {
- key: 'ArrowRight', metaKey: true, shiftKey: true, bubbles: true,
- }))
- })
-
- expect(onSwitchTab).toHaveBeenCalledWith('/vault/b.md')
- })
-
- it('switches to previous tab on Cmd+Shift+ArrowLeft (browser mode)', () => {
- renderHook(() =>
- useKeyboardNavigation({
- tabs, activeTabPath: '/vault/b.md', entries, selection,
- onSwitchTab, onReplaceActiveTab, onSelectNote,
- })
- )
-
- act(() => {
- window.dispatchEvent(new KeyboardEvent('keydown', {
- key: 'ArrowLeft', metaKey: true, shiftKey: true, bubbles: true,
- }))
- })
-
- expect(onSwitchTab).toHaveBeenCalledWith('/vault/a.md')
- })
-
- it('wraps around when navigating past last tab', () => {
- renderHook(() =>
- useKeyboardNavigation({
- tabs, activeTabPath: '/vault/c.md', entries, selection,
- onSwitchTab, onReplaceActiveTab, onSelectNote,
- })
- )
-
- act(() => {
- window.dispatchEvent(new KeyboardEvent('keydown', {
- key: 'ArrowRight', metaKey: true, shiftKey: true, bubbles: true,
- }))
- })
-
- expect(onSwitchTab).toHaveBeenCalledWith('/vault/a.md')
- })
-
it('navigates to next note on Cmd+Alt+ArrowDown', () => {
renderHook(() =>
useKeyboardNavigation({
- tabs, activeTabPath: '/vault/a.md', entries, selection,
- onSwitchTab, onReplaceActiveTab, onSelectNote,
+ activeTabPath: '/vault/a.md', entries, selection,
+ onReplaceActiveTab, onSelectNote,
})
)
@@ -160,8 +97,8 @@ describe('useKeyboardNavigation', () => {
it('navigates to previous note on Cmd+Alt+ArrowUp', () => {
renderHook(() =>
useKeyboardNavigation({
- tabs, activeTabPath: '/vault/b.md', entries, selection,
- onSwitchTab, onReplaceActiveTab, onSelectNote,
+ activeTabPath: '/vault/b.md', entries, selection,
+ onReplaceActiveTab, onSelectNote,
})
)
@@ -177,8 +114,8 @@ describe('useKeyboardNavigation', () => {
it('selects first note when no active tab', () => {
renderHook(() =>
useKeyboardNavigation({
- tabs: [], activeTabPath: null, entries, selection,
- onSwitchTab, onReplaceActiveTab, onSelectNote,
+ activeTabPath: null, entries, selection,
+ onReplaceActiveTab, onSelectNote,
})
)
@@ -194,8 +131,8 @@ describe('useKeyboardNavigation', () => {
it('does nothing without modifier keys', () => {
renderHook(() =>
useKeyboardNavigation({
- tabs, activeTabPath: '/vault/a.md', entries, selection,
- onSwitchTab, onReplaceActiveTab, onSelectNote,
+ activeTabPath: '/vault/a.md', entries, selection,
+ onReplaceActiveTab, onSelectNote,
})
)
@@ -205,25 +142,7 @@ describe('useKeyboardNavigation', () => {
}))
})
- expect(onSwitchTab).not.toHaveBeenCalled()
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
-
- it('does nothing with empty tabs for tab navigation', () => {
- renderHook(() =>
- useKeyboardNavigation({
- tabs: [], activeTabPath: null, entries, selection,
- onSwitchTab, onReplaceActiveTab, onSelectNote,
- })
- )
-
- act(() => {
- window.dispatchEvent(new KeyboardEvent('keydown', {
- key: 'ArrowRight', metaKey: true, shiftKey: true, bubbles: true,
- }))
- })
-
- expect(onSwitchTab).not.toHaveBeenCalled()
- })
})
diff --git a/src/hooks/useKeyboardNavigation.ts b/src/hooks/useKeyboardNavigation.ts
index 6e337058..3fdb3a6b 100644
--- a/src/hooks/useKeyboardNavigation.ts
+++ b/src/hooks/useKeyboardNavigation.ts
@@ -1,19 +1,11 @@
import { useEffect, useMemo, useRef } from 'react'
-import { isTauri } from '../mock-tauri'
import { filterEntries, sortByModified, buildRelationshipGroups } from '../utils/noteListHelpers'
import type { VaultEntry, SidebarSelection } from '../types'
-interface Tab {
- entry: VaultEntry
- content: string
-}
-
interface KeyboardNavigationOptions {
- tabs: Tab[]
activeTabPath: string | null
entries: VaultEntry[]
selection: SidebarSelection
- onSwitchTab: (path: string) => void
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
}
@@ -29,21 +21,6 @@ function computeVisibleNotes(
return [...filterEntries(entries, selection)].sort(sortByModified)
}
-function navigateTab(
- tabsRef: React.RefObject,
- activeTabPathRef: React.RefObject,
- onSwitchTab: React.RefObject<(path: string) => void>,
- direction: 1 | -1,
-) {
- const currentTabs = tabsRef.current!
- if (currentTabs.length === 0) return
-
- const currentPath = activeTabPathRef.current
- const currentIndex = currentTabs.findIndex((t) => t.entry.path === currentPath)
- const nextIndex = (currentIndex + direction + currentTabs.length) % currentTabs.length
- onSwitchTab.current!(currentTabs[nextIndex].entry.path)
-}
-
function navigateNote(
visibleNotesRef: React.RefObject,
activeTabPathRef: React.RefObject,
@@ -69,21 +46,6 @@ function navigateNote(
}
}
-type ShortcutKind = 'tab' | 'note' | null
-
-function classifyShortcut(e: KeyboardEvent, inTauri: boolean): ShortcutKind {
- const mod = e.metaKey || e.ctrlKey
- if (!mod) return null
- const isTabShortcut = inTauri ? (e.altKey && !e.shiftKey) : (e.shiftKey && !e.altKey)
- if (isTabShortcut && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return 'tab'
- if (e.altKey && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) return 'note'
- return null
-}
-
-function arrowDirection(key: string): 1 | -1 {
- return (key === 'ArrowRight' || key === 'ArrowDown') ? 1 : -1
-}
-
function useLatestRef(value: T): React.RefObject {
const ref = useRef(value)
useEffect(() => { ref.current = value })
@@ -91,31 +53,31 @@ function useLatestRef(value: T): React.RefObject {
}
export function useKeyboardNavigation({
- tabs, activeTabPath, entries, selection,
- onSwitchTab, onReplaceActiveTab, onSelectNote,
+ activeTabPath, entries, selection,
+ onReplaceActiveTab, onSelectNote,
}: KeyboardNavigationOptions) {
const visibleNotes = useMemo(
() => computeVisibleNotes(entries, selection),
[entries, selection],
)
- const tabsRef = useLatestRef(tabs)
const activeTabPathRef = useLatestRef(activeTabPath)
const visibleNotesRef = useLatestRef(visibleNotes)
- const onSwitchTabRef = useLatestRef(onSwitchTab)
const onReplaceRef = useLatestRef(onReplaceActiveTab)
const onSelectNoteRef = useLatestRef(onSelectNote)
useEffect(() => {
- const inTauri = isTauri()
const handleKeyDown = (e: KeyboardEvent) => {
- const kind = classifyShortcut(e, inTauri)
- if (!kind) return
- e.preventDefault()
- if (kind === 'tab') navigateTab(tabsRef, activeTabPathRef, onSwitchTabRef, arrowDirection(e.key))
- else navigateNote(visibleNotesRef, activeTabPathRef, onReplaceRef, onSelectNoteRef, arrowDirection(e.key))
+ const mod = e.metaKey || e.ctrlKey
+ if (!mod) return
+ // Cmd+Alt+ArrowUp/Down: navigate notes in the current list
+ if (e.altKey && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
+ e.preventDefault()
+ const direction: 1 | -1 = e.key === 'ArrowDown' ? 1 : -1
+ navigateNote(visibleNotesRef, activeTabPathRef, onReplaceRef, onSelectNoteRef, direction)
+ }
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
- }, [tabsRef, activeTabPathRef, visibleNotesRef, onSwitchTabRef, onReplaceRef, onSelectNoteRef])
+ }, [activeTabPathRef, visibleNotesRef, onReplaceRef, onSelectNoteRef])
}
diff --git a/src/hooks/useMenuEvents.test.ts b/src/hooks/useMenuEvents.test.ts
index fc248dc7..c7b79f0c 100644
--- a/src/hooks/useMenuEvents.test.ts
+++ b/src/hooks/useMenuEvents.test.ts
@@ -34,10 +34,8 @@ function makeHandlers(): MenuEventHandlers {
onViewChanges: vi.fn(),
onInstallMcp: vi.fn(),
onReloadVault: vi.fn(),
- onReopenClosedTab: vi.fn(),
onOpenInNewWindow: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject,
- handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
activeTabPath: '/vault/test.md',
}
}
@@ -87,19 +85,6 @@ describe('dispatchMenuEvent', () => {
expect(h.onSave).toHaveBeenCalled()
})
- it('file-close-tab closes the active tab', () => {
- const h = makeHandlers()
- dispatchMenuEvent('file-close-tab', h)
- expect(h.handleCloseTabRef.current).toHaveBeenCalledWith('/vault/test.md')
- })
-
- it('file-close-tab does nothing when no active tab', () => {
- const h = makeHandlers()
- h.activeTabPathRef = { current: null }
- dispatchMenuEvent('file-close-tab', h)
- expect(h.handleCloseTabRef.current).not.toHaveBeenCalled()
- })
-
it('app-settings triggers open settings', () => {
const h = makeHandlers()
dispatchMenuEvent('app-settings', h)
@@ -301,13 +286,6 @@ describe('dispatchMenuEvent', () => {
expect(h.onReloadVault).toHaveBeenCalled()
})
- // File menu: reopen closed tab
- it('file-reopen-closed-tab triggers reopen closed tab', () => {
- const h = makeHandlers()
- dispatchMenuEvent('file-reopen-closed-tab', h)
- expect(h.onReopenClosedTab).toHaveBeenCalled()
- })
-
// Note: open in new window
it('note-open-in-new-window triggers open in new window', () => {
const h = makeHandlers()
diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts
index 23a7bceb..f2df6ef6 100644
--- a/src/hooks/useMenuEvents.ts
+++ b/src/hooks/useMenuEvents.ts
@@ -34,13 +34,11 @@ export interface MenuEventHandlers {
onResolveConflicts?: () => void
onViewChanges?: () => void
onInstallMcp?: () => void
- onReopenClosedTab?: () => void
onOpenInNewWindow?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onEmptyTrash?: () => void
activeTabPathRef: React.MutableRefObject
- handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
modifiedCount?: number
conflictCount?: number
@@ -83,7 +81,6 @@ type OptionalHandler =
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'
| 'onEmptyTrash'
- | 'onReopenClosedTab'
| 'onOpenInNewWindow'
const OPTIONAL_EVENT_MAP: Record = {
@@ -105,16 +102,14 @@ const OPTIONAL_EVENT_MAP: Record = {
'vault-reload': 'onReloadVault',
'vault-repair': 'onRepairVault',
'note-empty-trash': 'onEmptyTrash',
- 'file-reopen-closed-tab': 'onReopenClosedTab',
'note-open-in-new-window': 'onOpenInNewWindow',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
const path = h.activeTabPathRef.current
- if (!path) return id === 'note-archive' || id === 'note-trash' || id === 'file-close-tab'
+ if (!path) return id === 'note-archive' || id === 'note-trash'
if (id === 'note-archive') { h.onArchiveNote(path); return true }
if (id === 'note-trash') { h.onTrashNote(path); return true }
- if (id === 'file-close-tab') { h.handleCloseTabRef.current(path); return true }
return false
}
@@ -163,7 +158,7 @@ export function useMenuEvents(handlers: MenuEventHandlers) {
return () => cleanup?.()
}, [])
- // Sync menu item enabled state when active tab or git state changes
+ // Sync menu item enabled state when active note or git state changes
useEffect(() => {
if (!isTauri()) return
import('@tauri-apps/api/core').then(({ invoke }) => {
diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts
index 07888cd4..8061c588 100644
--- a/src/hooks/useNoteActions.test.ts
+++ b/src/hooks/useNoteActions.test.ts
@@ -941,30 +941,6 @@ describe('useNoteActions hook', () => {
expect(removeEntry).not.toHaveBeenCalled()
})
- it('closing unsaved tab removes entry', () => {
- const clearUnsaved = vi.fn()
- const unsavedPaths = new Set()
- const config = makeConfig()
- config.clearUnsaved = clearUnsaved
- config.unsavedPaths = unsavedPaths
-
- const { result } = renderHook(() => useNoteActions(config))
-
- act(() => {
- result.current.handleCreateNoteImmediate()
- })
-
- const createdPath = addEntry.mock.calls[0][0].path
- unsavedPaths.add(createdPath) // simulate trackUnsaved
- config.unsavedPaths = unsavedPaths // update ref
-
- act(() => {
- result.current.handleCloseTab(createdPath)
- })
-
- expect(removeEntry).toHaveBeenCalledWith(createdPath)
- expect(clearUnsaved).toHaveBeenCalledWith(createdPath)
- })
})
describe('type change does not move file', () => {
diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts
index 596dedbb..b752c47f 100644
--- a/src/hooks/useNoteActions.ts
+++ b/src/hooks/useNoteActions.ts
@@ -76,7 +76,7 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e:
export function useNoteActions(config: NoteActionsConfig) {
const { entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement()
- const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, handleCloseTabRef, activeTabPathRef, handleSwitchTab } = tabMgmt
+ const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
const updateTabContent = useCallback((path: string, newContent: string) => {
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
@@ -104,7 +104,7 @@ export function useNoteActions(config: NoteActionsConfig) {
}
}, [handleSelectNote, updateEntry, setTabs])
- const creation = useNoteCreation(config, { openTabWithContent, handleSelectNote: handleSelectNoteWithSync, handleCloseTab, handleCloseTabRef })
+ const creation = useNoteCreation(config, { openTabWithContent, handleSelectNote: handleSelectNoteWithSync })
const rename = useNoteRename(
{ entries, setToastMessage },
{ tabs: tabMgmt.tabs, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
@@ -124,7 +124,6 @@ export function useNoteActions(config: NoteActionsConfig) {
return {
...tabMgmt,
handleSelectNote: handleSelectNoteWithSync,
- handleCloseTab: creation.handleCloseTabWithCleanup,
handleNavigateWikilink,
handleCreateNote: creation.handleCreateNote,
handleCreateNoteImmediate: creation.handleCreateNoteImmediate,
diff --git a/src/hooks/useNoteCreation.test.ts b/src/hooks/useNoteCreation.test.ts
index d1c08b9d..82dbc4a0 100644
--- a/src/hooks/useNoteCreation.test.ts
+++ b/src/hooks/useNoteCreation.test.ts
@@ -213,14 +213,11 @@ describe('useNoteCreation hook', () => {
const setToastMessage = vi.fn()
const openTabWithContent = vi.fn()
const handleSelectNote = vi.fn()
- const handleCloseTab = vi.fn()
- const handleCloseTabRef = { current: vi.fn() }
-
const makeConfig = (entries: VaultEntry[] = []): NoteCreationConfig => ({
addEntry, removeEntry, entries, setToastMessage, vaultPath: '/test/vault',
})
- const tabDeps = { openTabWithContent, handleSelectNote, handleCloseTab, handleCloseTabRef }
+ const tabDeps = { openTabWithContent, handleSelectNote }
beforeEach(() => {
vi.clearAllMocks()
@@ -316,16 +313,4 @@ describe('useNoteCreation hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
- it('handleCloseTabWithCleanup removes unsaved entry', () => {
- const clearUnsaved = vi.fn()
- const unsavedPaths = new Set(['/test/vault/untitled-note.md'])
- const config = makeConfig()
- config.clearUnsaved = clearUnsaved
- config.unsavedPaths = unsavedPaths
- const { result } = renderHook(() => useNoteCreation(config, tabDeps))
- act(() => { result.current.handleCloseTabWithCleanup('/test/vault/untitled-note.md') })
- expect(removeEntry).toHaveBeenCalledWith('/test/vault/untitled-note.md')
- expect(clearUnsaved).toHaveBeenCalledWith('/test/vault/untitled-note.md')
- expect(handleCloseTab).toHaveBeenCalledWith('/test/vault/untitled-note.md')
- })
})
diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts
index c5f9663b..15d2bd02 100644
--- a/src/hooks/useNoteCreation.ts
+++ b/src/hooks/useNoteCreation.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef } from 'react'
+import { useCallback, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, addMockEntry } from '../mock-tauri'
import type { VaultEntry } from '../types'
@@ -136,7 +136,7 @@ function persistOptimistic(path: string, content: string, cbs: PersistCallbacks)
type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void
-/** Optimistically open tab, add entry to vault, and persist to disk. */
+/** Optimistically open note, add entry to vault, and persist to disk. */
function createAndPersist(
resolved: { entry: VaultEntry; content: string },
addFn: (e: VaultEntry) => void,
@@ -187,7 +187,6 @@ interface RelationshipCreateDeps {
vaultPath: string
openTabWithContent: (entry: VaultEntry, content: string) => void
addEntry: (entry: VaultEntry) => void
- handleCloseTab: (path: string) => void
removeEntry: (path: string) => void
setToastMessage: (msg: string | null) => void
onNewNotePersisted?: () => void
@@ -202,7 +201,6 @@ function createNoteForRelationship(deps: RelationshipCreateDeps, title: string):
persistNewNote(resolved.entry.path, resolved.content)
.then(() => deps.onNewNotePersisted?.())
.catch(() => {
- deps.handleCloseTab(resolved.entry.path)
deps.removeEntry(resolved.entry.path)
deps.setToastMessage('Failed to create note — disk write error')
})
@@ -226,36 +224,27 @@ export interface NoteCreationConfig {
interface CreationTabDeps {
openTabWithContent: (entry: VaultEntry, content: string) => void
handleSelectNote: (entry: VaultEntry) => void
- handleCloseTab: (path: string) => void
- handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTabDeps) {
const { addEntry, removeEntry, entries, setToastMessage, addPendingSave, removePendingSave } = config
- const { openTabWithContent, handleSelectNote, handleCloseTab, handleCloseTabRef } = tabDeps
-
- const unsavedPathsRef = useRef(config.unsavedPaths)
- // eslint-disable-next-line react-hooks/refs
- unsavedPathsRef.current = config.unsavedPaths
+ const { openTabWithContent, handleSelectNote } = tabDeps
const revertOptimisticNote = useCallback((path: string) => {
- handleCloseTab(path)
removeEntry(path)
setToastMessage('Failed to create note — disk write error')
- }, [handleCloseTab, removeEntry, setToastMessage])
-
- const persistCbs: PersistCallbacks = {
- onFail: revertOptimisticNote,
- onStart: addPendingSave,
- onEnd: removePendingSave,
- onPersisted: config.onNewNotePersisted,
- }
+ }, [removeEntry, setToastMessage])
const pendingNamesRef = useRef>(new Set())
const persistNew: PersistFn = useCallback(
- (resolved) => createAndPersist(resolved, addEntry, openTabWithContent, persistCbs),
- [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave], // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
+ (resolved) => createAndPersist(resolved, addEntry, openTabWithContent, {
+ onFail: revertOptimisticNote,
+ onStart: addPendingSave,
+ onEnd: removePendingSave,
+ onPersisted: config.onNewNotePersisted,
+ }),
+ [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave, config.onNewNotePersisted],
)
const handleCreateNote = useCallback((title: string, type: string) => {
@@ -264,33 +253,19 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
}, [entries, persistNew, config.vaultPath])
const handleCreateNoteImmediate = useCallback((type?: string) => {
- try {
- createNoteImmediate({
- entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
- openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
- }, type)
- } catch (err) {
- console.error('Failed to create note:', err)
- setToastMessage('Failed to create note')
- }
- }, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
+ createNoteImmediate({
+ entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
+ openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
+ }, type)
+ }, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending, setToastMessage])
const handleCreateNoteForRelationship = useCallback((title: string): Promise => {
createNoteForRelationship({
entries, vaultPath: config.vaultPath, openTabWithContent, addEntry,
- handleCloseTab, removeEntry, setToastMessage, onNewNotePersisted: config.onNewNotePersisted,
+ removeEntry, setToastMessage, onNewNotePersisted: config.onNewNotePersisted,
}, title)
return Promise.resolve(true)
- }, [entries, openTabWithContent, addEntry, handleCloseTab, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted])
-
- /** Close tab and discard entry+unsaved state if the note was never persisted. */
- const handleCloseTabWithCleanup = useCallback((path: string) => {
- if (unsavedPathsRef.current?.has(path)) { removeEntry(path); config.clearUnsaved?.(path) }
- handleCloseTab(path)
- }, [handleCloseTab, removeEntry, config.clearUnsaved]) // eslint-disable-line react-hooks/exhaustive-deps -- ref access is stable
-
- // Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes.
- useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup })
+ }, [entries, openTabWithContent, addEntry, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted])
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath])
@@ -311,6 +286,5 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
handleOpenDailyNote,
handleCreateType,
createTypeEntrySilent,
- handleCloseTabWithCleanup,
}
}
diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts
index 705c84d9..3235a691 100644
--- a/src/hooks/useTabManagement.test.ts
+++ b/src/hooks/useTabManagement.test.ts
@@ -35,31 +35,19 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({
...overrides,
})
-const localStorageMock = (() => {
- let store: Record = {}
- return {
- getItem: vi.fn((key: string) => store[key] ?? null),
- setItem: vi.fn((key: string, val: string) => { store[key] = val }),
- removeItem: vi.fn((key: string) => { delete store[key] }),
- clear: vi.fn(() => { store = {} }),
- }
-})()
-Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
-
-describe('useTabManagement', () => {
+describe('useTabManagement (single-note model)', () => {
beforeEach(() => {
vi.clearAllMocks()
- localStorageMock.clear()
})
- it('starts with no tabs and no active tab', () => {
+ it('starts with no note and null active path', () => {
const { result } = renderHook(() => useTabManagement())
expect(result.current.tabs).toEqual([])
expect(result.current.activeTabPath).toBeNull()
})
describe('handleSelectNote', () => {
- it('opens a new tab and sets it active', async () => {
+ it('opens a note and sets it active', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/note/a.md' })
@@ -72,23 +60,33 @@ describe('useTabManagement', () => {
expect(result.current.activeTabPath).toBe('/vault/note/a.md')
})
- it('switches to existing tab without duplicating', async () => {
+ it('replaces the current note when selecting a different one', async () => {
const { result } = renderHook(() => useTabManagement())
- const entry = makeEntry({ path: '/vault/note/a.md' })
+
+ await act(async () => {
+ await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
+ })
+ await act(async () => {
+ await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
+ })
+
+ expect(result.current.tabs).toHaveLength(1)
+ expect(result.current.tabs[0].entry.path).toBe('/vault/b.md')
+ expect(result.current.activeTabPath).toBe('/vault/b.md')
+ })
+
+ it('is a no-op when selecting the already-open note', async () => {
+ const { result } = renderHook(() => useTabManagement())
+ const entry = makeEntry({ path: '/vault/a.md' })
await act(async () => {
await result.current.handleSelectNote(entry)
})
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/note/b.md', title: 'B' }))
- })
- // Select first entry again
await act(async () => {
await result.current.handleSelectNote(entry)
})
- expect(result.current.tabs).toHaveLength(2)
- expect(result.current.activeTabPath).toBe('/vault/note/a.md')
+ expect(result.current.tabs).toHaveLength(1)
})
it('handles load content failure gracefully', async () => {
@@ -103,154 +101,14 @@ describe('useTabManagement', () => {
await result.current.handleSelectNote(entry)
})
- // Tab still opens with empty content on failure
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].content).toBe('')
warnSpy.mockRestore()
})
})
- describe('handleCloseTab', () => {
- it('removes the tab', async () => {
- const { result } = renderHook(() => useTabManagement())
- const entry = makeEntry({ path: '/vault/note/a.md' })
-
- await act(async () => {
- await result.current.handleSelectNote(entry)
- })
-
- act(() => {
- result.current.handleCloseTab('/vault/note/a.md')
- })
-
- expect(result.current.tabs).toHaveLength(0)
- })
-
- it('selects next tab when active tab is closed', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
- })
-
- // Close middle tab B, should switch to C (same index)
- act(() => {
- result.current.handleSwitchTab('/vault/b.md')
- })
- act(() => {
- result.current.handleCloseTab('/vault/b.md')
- })
-
- expect(result.current.tabs).toHaveLength(2)
- expect(result.current.activeTabPath).toBe('/vault/c.md')
- })
-
- it('sets null active when last tab is closed', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md' }))
- })
-
- act(() => {
- result.current.handleCloseTab('/vault/a.md')
- })
-
- expect(result.current.activeTabPath).toBeNull()
- })
- })
-
- describe('handleSwitchTab', () => {
- it('changes the active tab path', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
-
- act(() => {
- result.current.handleSwitchTab('/vault/a.md')
- })
-
- expect(result.current.activeTabPath).toBe('/vault/a.md')
- })
- })
-
- describe('handleReorderTabs', () => {
- it('moves a tab from one position to another', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
- })
-
- act(() => {
- result.current.handleReorderTabs(2, 0)
- })
-
- expect(result.current.tabs.map(t => t.entry.title)).toEqual(['C', 'A', 'B'])
- })
-
- it('preserves active tab after reorder', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
- })
-
- // C is active (last opened). Move it to the front.
- act(() => {
- result.current.handleReorderTabs(2, 0)
- })
-
- expect(result.current.tabs.map(t => t.entry.title)).toEqual(['C', 'A', 'B'])
- expect(result.current.activeTabPath).toBe('/vault/c.md')
- })
-
- it('persists tab order to localStorage', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
-
- act(() => {
- result.current.handleReorderTabs(1, 0)
- })
-
- expect(localStorageMock.setItem).toHaveBeenCalledWith(
- 'laputa-tab-order',
- expect.any(String),
- )
- })
- })
-
describe('handleReplaceActiveTab', () => {
- it('replaces the active tab with a new entry', async () => {
+ it('replaces the current note with a new entry', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
@@ -267,7 +125,7 @@ describe('useTabManagement', () => {
expect(result.current.activeTabPath).toBe('/vault/b.md')
})
- it('does nothing when replacing with same entry', async () => {
+ it('is a no-op when replacing with the same entry', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/a.md' })
@@ -282,7 +140,7 @@ describe('useTabManagement', () => {
expect(result.current.tabs).toHaveLength(1)
})
- it('falls back to handleSelectNote when no active tab', async () => {
+ it('opens a note when no note is active', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/a.md' })
@@ -293,35 +151,25 @@ describe('useTabManagement', () => {
expect(result.current.tabs).toHaveLength(1)
expect(result.current.activeTabPath).toBe('/vault/a.md')
})
+ })
- it('switches to existing tab instead of replacing when note is already open', async () => {
+ describe('openTabWithContent', () => {
+ it('opens a note with pre-loaded content', () => {
const { result } = renderHook(() => useTabManagement())
+ const entry = makeEntry({ path: '/vault/new.md' })
- // Open two tabs: A (active) and B
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
+ act(() => {
+ result.current.openTabWithContent(entry, '# New note')
})
- // Switch back to A
- act(() => { result.current.handleSwitchTab('/vault/a.md') })
-
- // Replace active tab with B — but B is already open, so it should just switch
- await act(async () => {
- await result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
-
- // Should still have 2 tabs (not replace A), and B should be active
- expect(result.current.tabs).toHaveLength(2)
- expect(result.current.activeTabPath).toBe('/vault/b.md')
- expect(result.current.tabs.map(t => t.entry.title)).toEqual(['A', 'B'])
+ expect(result.current.tabs).toHaveLength(1)
+ expect(result.current.tabs[0].content).toBe('# New note')
+ expect(result.current.activeTabPath).toBe('/vault/new.md')
})
})
describe('setTabs entry sync', () => {
- it('updates tab entry via setTabs mapper (vault entry sync pattern)', async () => {
+ it('updates note entry via setTabs mapper (vault entry sync pattern)', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/a.md', trashed: false })
@@ -329,9 +177,6 @@ describe('useTabManagement', () => {
await result.current.handleSelectNote(entry)
})
- expect(result.current.tabs[0].entry.trashed).toBe(false)
-
- // Simulate the App.tsx sync effect: vault entry updated, sync into tab
const freshEntry = { ...entry, trashed: true, trashedAt: Date.now() / 1000 }
act(() => {
result.current.setTabs(prev => prev.map(tab =>
@@ -341,38 +186,15 @@ describe('useTabManagement', () => {
expect(result.current.tabs[0].entry.trashed).toBe(true)
})
-
- it('preserves content when syncing entry', async () => {
- const { result } = renderHook(() => useTabManagement())
- const entry = makeEntry({ path: '/vault/a.md', archived: false })
-
- await act(async () => {
- await result.current.handleSelectNote(entry)
- })
-
- const originalContent = result.current.tabs[0].content
-
- act(() => {
- result.current.setTabs(prev => prev.map(tab =>
- tab.entry.path === entry.path ? { ...tab, entry: { ...tab.entry, archived: true } } : tab
- ))
- })
-
- expect(result.current.tabs[0].entry.archived).toBe(true)
- expect(result.current.tabs[0].content).toBe(originalContent)
- })
})
describe('closeAllTabs', () => {
- it('clears all tabs and active path', async () => {
+ it('clears the note and active path', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md' }))
})
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
act(() => {
result.current.closeAllTabs()
@@ -389,17 +211,14 @@ describe('useTabManagement', () => {
vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content')
prefetchNoteContent('/vault/note/pre.md')
- // Allow the prefetch promise to resolve
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
- // Now open the note — should use prefetched content
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/note/pre.md', title: 'Pre' }))
})
expect(result.current.tabs[0].content).toBe('# Prefetched content')
- // mockInvoke was called once for prefetch, not again for handleSelectNote
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
})
@@ -411,8 +230,6 @@ describe('useTabManagement', () => {
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
clearPrefetchCache()
-
- // Reset mock to return fresh content
vi.mocked(mockInvoke).mockResolvedValue('# Fresh')
const { result } = renderHook(() => useTabManagement())
@@ -420,7 +237,6 @@ describe('useTabManagement', () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/note/stale.md', title: 'Stale' }))
})
- // Should have made a new IPC call since cache was cleared
expect(result.current.tabs[0].content).toBe('# Fresh')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
})
@@ -437,116 +253,10 @@ describe('useTabManagement', () => {
})
})
- describe('closed tab history', () => {
- it('handleCloseTab records the closed tab in history', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
- })
-
- act(() => { result.current.handleCloseTab('/vault/a.md') })
-
- expect(result.current.closedTabHistory.canReopen).toBe(true)
- })
-
- it('handleReopenClosedTab reopens the last closed tab', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
-
- act(() => { result.current.handleCloseTab('/vault/b.md') })
-
- await act(async () => {
- await result.current.handleReopenClosedTab()
- })
-
- expect(result.current.tabs).toHaveLength(2)
- expect(result.current.activeTabPath).toBe('/vault/b.md')
- })
-
- it('close 3 tabs then reopen all 3 in correct LIFO order', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
- })
-
- // Close C, B, A
- act(() => { result.current.handleCloseTab('/vault/c.md') })
- act(() => { result.current.handleCloseTab('/vault/b.md') })
- act(() => { result.current.handleCloseTab('/vault/a.md') })
-
- expect(result.current.tabs).toHaveLength(0)
-
- // Reopen: should get A first (last closed), then B, then C
- await act(async () => { await result.current.handleReopenClosedTab() })
- expect(result.current.activeTabPath).toBe('/vault/a.md')
-
- await act(async () => { await result.current.handleReopenClosedTab() })
- expect(result.current.activeTabPath).toBe('/vault/b.md')
-
- await act(async () => { await result.current.handleReopenClosedTab() })
- expect(result.current.activeTabPath).toBe('/vault/c.md')
-
- expect(result.current.tabs).toHaveLength(3)
- })
-
- it('does nothing when history is empty', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleReopenClosedTab()
- })
-
- expect(result.current.tabs).toHaveLength(0)
- expect(result.current.activeTabPath).toBeNull()
- })
-
- it('does not duplicate tab if note is already open', async () => {
- const { result } = renderHook(() => useTabManagement())
-
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
- })
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
-
- // Close B
- act(() => { result.current.handleCloseTab('/vault/b.md') })
-
- // Manually reopen B via handleSelectNote
- await act(async () => {
- await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
- })
-
- // Now try to reopen from history — B is already open, should just switch
- await act(async () => {
- await result.current.handleReopenClosedTab()
- })
-
- expect(result.current.tabs).toHaveLength(2)
- expect(result.current.activeTabPath).toBe('/vault/b.md')
- })
- })
-
describe('rapid switching safety', () => {
it('only activates the last note when switching rapidly', async () => {
const { mockInvoke } = await import('../mock-tauri')
- // Simulate slow IPC: first call resolves after second call
let resolveA: (v: string) => void
let resolveB: (v: string) => void
vi.mocked(mockInvoke)
@@ -555,29 +265,23 @@ describe('useTabManagement', () => {
const { result } = renderHook(() => useTabManagement())
- // Start loading A (don't await — simulates rapid click)
let selectADone = false
await act(async () => {
result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' })).then(() => { selectADone = true })
- // Flush microtask from sync_note_title (no-op in mock mode) so loadAndSetTab starts
await Promise.resolve()
})
- // Start loading B while A is still loading
let selectBDone = false
await act(async () => {
result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' })).then(() => { selectBDone = true })
await Promise.resolve()
})
- // B resolves first
await act(async () => { resolveB!('# B content') })
- // A resolves after
await act(async () => { resolveA!('# A content') })
await vi.waitFor(() => expect(selectADone && selectBDone).toBe(true))
- // Active tab should be B (the last click), not A
expect(result.current.activeTabPath).toBe('/vault/b.md')
})
})
diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts
index 4ffc491d..8cd348af 100644
--- a/src/hooks/useTabManagement.ts
+++ b/src/hooks/useTabManagement.ts
@@ -2,15 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
-import { useClosedTabHistory } from './useClosedTabHistory'
interface Tab {
entry: VaultEntry
content: string
}
-const TAB_ORDER_KEY = 'laputa-tab-order'
-
// --- Content prefetch cache ---
// Stores in-flight or resolved note content promises, keyed by path.
// Cleared on vault reload to prevent stale content after external edits.
@@ -38,25 +35,6 @@ export function clearPrefetchCache(): void {
prefetchCache.clear()
}
-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 []
- }
-}
-
-function clearTabOrder() {
- try { localStorage.removeItem(TAB_ORDER_KEY) } catch { /* noop */ }
-}
-
async function loadNoteContent(path: string): Promise {
// Check prefetch cache first — eliminates IPC round-trip for prefetched notes
const cached = prefetchCache.get(path)
@@ -79,166 +57,88 @@ export async function syncNoteTitle(path: string): Promise {
} catch { return false }
}
-function addTabIfAbsent(prev: Tab[], entry: VaultEntry, content: string): Tab[] {
- if (prev.some((t) => t.entry.path === entry.path)) return prev
- return [...prev, { entry, content }]
-}
-
-function resolveNextActiveTab(prev: Tab[], closedPath: string): string | null {
- const next = prev.filter((t) => t.entry.path !== closedPath)
- if (next.length === 0) return null
- const closedIdx = prev.findIndex((t) => t.entry.path === closedPath)
- const newIdx = Math.min(closedIdx, next.length - 1)
- return next[newIdx].entry.path
-}
-
-function replaceTabEntry(prev: Tab[], targetPath: string, entry: VaultEntry, content: string): Tab[] {
- return prev.map((t) => t.entry.path === targetPath ? { entry, content } : t)
-}
-
-function reorderArray(tabs: Tab[], fromIndex: number, toIndex: number): Tab[] {
- const next = [...tabs]
- const [moved] = next.splice(fromIndex, 1)
- next.splice(toIndex, 0, moved)
- return next
-}
-
-function restoreOrder(prev: Tab[], savedOrder: string[]): Tab[] {
- 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)
- }
- }
- for (const tab of pathToTab.values()) {
- ordered.push(tab)
- }
- return ordered
-}
-
-function isTabOpen(tabs: Tab[], path: string): boolean {
- return tabs.some((t) => t.entry.path === path)
-}
-
-async function loadAndSetTab(
- entry: VaultEntry,
- updater: (prev: Tab[], content: string) => Tab[],
- setTabs: React.Dispatch>,
-) {
- try {
- const content = await loadNoteContent(entry.path)
- setTabs((prev) => updater(prev, content))
- } catch (err) {
- console.warn('Failed to load note content:', err)
- setTabs((prev) => updater(prev, ''))
- }
-}
-
export type { Tab }
export function useTabManagement() {
+ // Single-note model: tabs has 0 or 1 elements.
const [tabs, setTabs] = useState([])
const [activeTabPath, setActiveTabPath] = useState(null)
const activeTabPathRef = useRef(activeTabPath)
useEffect(() => { activeTabPathRef.current = activeTabPath })
const tabsRef = useRef(tabs)
useEffect(() => { tabsRef.current = tabs })
- const handleCloseTabRef = useRef<(path: string) => void>(() => {})
- const closedTabHistory = useClosedTabHistory()
// Sequence counter for rapid-switch safety: only the latest navigation wins.
- // Prevents stale content from an earlier click appearing after a later click.
const navSeqRef = useRef(0)
+ /** Open a note — replaces the current note (single-note model). */
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
- if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return }
+ // Already viewing this note — no-op
+ if (tabsRef.current.some(t => t.entry.path === entry.path)) {
+ setActiveTabPath(entry.path)
+ return
+ }
const seq = ++navSeqRef.current
- // Sync title frontmatter with filename before loading content
await syncNoteTitle(entry.path)
- await loadAndSetTab(entry, (prev, content) => addTabIfAbsent(prev, entry, content), setTabs)
- if (navSeqRef.current === seq) setActiveTabPath(entry.path)
+ try {
+ const content = await loadNoteContent(entry.path)
+ if (navSeqRef.current === seq) {
+ setTabs([{ entry, content }])
+ setActiveTabPath(entry.path)
+ }
+ } catch (err) {
+ console.warn('Failed to load note content:', err)
+ if (navSeqRef.current === seq) {
+ setTabs([{ entry, content: '' }])
+ setActiveTabPath(entry.path)
+ }
+ }
}, [])
- const handleCloseTab = useCallback((path: string) => {
- setTabs((prev) => {
- const idx = prev.findIndex((t) => t.entry.path === path)
- if (idx !== -1) closedTabHistory.push(path, idx, prev[idx].entry)
- const next = prev.filter((t) => t.entry.path !== path)
- if (path === activeTabPathRef.current) { setActiveTabPath(resolveNextActiveTab(prev, path)) }
- return next
- })
- }, [closedTabHistory])
- useEffect(() => { handleCloseTabRef.current = handleCloseTab })
-
const handleSwitchTab = useCallback((path: string) => { setActiveTabPath(path) }, [])
- const handleReorderTabs = useCallback((fromIndex: number, toIndex: number) => {
- setTabs((prev) => { const next = reorderArray(prev, fromIndex, toIndex); saveTabOrder(next); return next })
- }, [])
-
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
- if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return }
- setTabs((prev) => addTabIfAbsent(prev, entry, content))
+ setTabs([{ entry, content }])
setActiveTabPath(entry.path)
}, [])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
- if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return }
- const currentPath = activeTabPathRef.current
- if (!currentPath) { handleSelectNote(entry); return }
- const seq = ++navSeqRef.current
- await loadAndSetTab(entry, (prev, content) => replaceTabEntry(prev, currentPath, entry, content), setTabs)
- if (navSeqRef.current === seq) setActiveTabPath(entry.path)
- }, [handleSelectNote])
-
- const handleReopenClosedTab = useCallback(async () => {
- const closed = closedTabHistory.pop()
- if (!closed) return
- // If tab is already open, just switch to it
- if (isTabOpen(tabsRef.current, closed.path)) {
- setActiveTabPath(closed.path)
+ // In single-note model, replace is the same as select
+ if (tabsRef.current.some(t => t.entry.path === entry.path)) {
+ setActiveTabPath(entry.path)
return
}
- // Reopen using the stored VaultEntry — loads fresh content from disk
- await handleSelectNote(closed.entry)
- }, [closedTabHistory, handleSelectNote])
+ const seq = ++navSeqRef.current
+ try {
+ const content = await loadNoteContent(entry.path)
+ if (navSeqRef.current === seq) {
+ setTabs([{ entry, content }])
+ setActiveTabPath(entry.path)
+ }
+ } catch (err) {
+ console.warn('Failed to load note content:', err)
+ if (navSeqRef.current === seq) {
+ setTabs([{ entry, content: '' }])
+ setActiveTabPath(entry.path)
+ }
+ }
+ }, [])
const closeAllTabs = useCallback(() => {
setTabs([])
setActiveTabPath(null)
}, [])
- useEffect(() => {
- if (tabs.length > 0) saveTabOrder(tabs)
- else clearTabOrder()
- }, [tabs])
-
- useEffect(() => {
- const savedOrder = loadTabOrder()
- if (savedOrder.length > 0) {
- setTabs((prev) => restoreOrder(prev, savedOrder)) // eslint-disable-line react-hooks/set-state-in-effect -- restore tab order on mount
- }
- }, [])
-
return {
tabs,
setTabs,
activeTabPath,
activeTabPathRef,
- handleCloseTabRef,
handleSelectNote,
openTabWithContent,
- handleCloseTab,
handleSwitchTab,
- handleReorderTabs,
handleReplaceActiveTab,
- handleReopenClosedTab,
closeAllTabs,
- closedTabHistory,
}
}
diff --git a/src/utils/tabLayout.ts b/src/utils/tabLayout.ts
deleted file mode 100644
index 56ef1c04..00000000
--- a/src/utils/tabLayout.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-/** Compute per-tab max-width so all tabs fit within the container. */
-export function computeTabMaxWidth(containerWidth: number, tabCount: number): number {
- if (tabCount === 0) return 360
- return Math.max(60, Math.min(360, Math.floor(containerWidth / tabCount)))
-}
diff --git a/tests/smoke/reopen-closed-tab.spec.ts b/tests/smoke/reopen-closed-tab.spec.ts
deleted file mode 100644
index d478c76c..00000000
--- a/tests/smoke/reopen-closed-tab.spec.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { test, expect, type Page } from '@playwright/test'
-
-/** Dispatch Ctrl+Shift+T directly via JS.
- * Chromium intercepts Ctrl+Shift+T at the browser level ("reopen browser tab"),
- * so we dispatch the event programmatically to bypass that. */
-async function pressReopenClosedTab(page: Page) {
- await page.evaluate(() => {
- window.dispatchEvent(new KeyboardEvent('keydown', {
- key: 't', code: 'KeyT', ctrlKey: true, shiftKey: true, bubbles: true,
- }))
- })
-}
-
-const TAB = '[data-tab-path]'
-
-test.describe('Reopen closed tab (Cmd+Shift+T)', () => {
- test.beforeEach(async ({ page }) => {
- await page.setViewportSize({ width: 1600, height: 900 })
- await page.goto('/')
- await page.waitForLoadState('networkidle')
- })
-
- test('open note → close tab → Cmd+Shift+T → tab reopens', async ({ page }) => {
- // Open the first note via the sidebar
- const noteListContainer = page.locator('[data-testid="note-list-container"]')
- await noteListContainer.waitFor({ timeout: 5000 })
- const firstNote = noteListContainer.locator('.cursor-pointer.border-b').first()
- await expect(firstNote).toBeVisible({ timeout: 5000 })
- await firstNote.click()
- await page.waitForTimeout(500)
-
- const tabs = page.locator(TAB)
- await expect(tabs.first()).toBeVisible({ timeout: 5000 })
- const tabTitle = await tabs.first().textContent()
-
- // Close the tab via its close button
- const closeBtn = tabs.first().locator('button').first()
- await closeBtn.click()
- await page.waitForTimeout(300)
- await expect(tabs).toHaveCount(0, { timeout: 2000 })
-
- // Reopen with Ctrl+Shift+T
- await pressReopenClosedTab(page)
- await page.waitForTimeout(500)
-
- // Verify tab is back with same title
- await expect(tabs.first()).toBeVisible({ timeout: 3000 })
- const reopenedTitle = await tabs.first().textContent()
- expect(reopenedTitle).toBe(tabTitle)
- })
-
- test('Cmd+Shift+T does nothing when no closed tabs', async ({ page }) => {
- const tabs = page.locator(TAB)
- const countBefore = await tabs.count()
-
- await pressReopenClosedTab(page)
- await page.waitForTimeout(300)
-
- const countAfter = await tabs.count()
- expect(countAfter).toBe(countBefore)
- })
-})