diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 564a3ed6..29a68cc2 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -12,8 +12,7 @@ const FILE_NEW_TYPE: &str = "file-new-type"; const FILE_DAILY_NOTE: &str = "file-daily-note"; const FILE_QUICK_OPEN: &str = "file-quick-open"; const FILE_SAVE: &str = "file-save"; -const FILE_CLOSE_TAB: &str = "file-close-tab"; -const FILE_REOPEN_CLOSED_TAB: &str = "file-reopen-closed-tab"; + const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault"; const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor"; @@ -62,8 +61,6 @@ const CUSTOM_IDS: &[&str] = &[ FILE_DAILY_NOTE, FILE_QUICK_OPEN, FILE_SAVE, - FILE_CLOSE_TAB, - FILE_REOPEN_CLOSED_TAB, EDIT_FIND_IN_VAULT, EDIT_TOGGLE_RAW_EDITOR, EDIT_TOGGLE_DIFF, @@ -102,7 +99,6 @@ const CUSTOM_IDS: &[&str] = &[ /// IDs of menu items that should be disabled when no note tab is active. const NOTE_DEPENDENT_IDS: &[&str] = &[ FILE_SAVE, - FILE_CLOSE_TAB, NOTE_ARCHIVE, NOTE_TRASH, EDIT_TOGGLE_RAW_EDITOR, @@ -165,15 +161,6 @@ fn build_file_menu(app: &App) -> MenuResult { .id(FILE_SAVE) .accelerator("CmdOrCtrl+S") .build(app)?; - let close_tab = MenuItemBuilder::new("Close Tab") - .id(FILE_CLOSE_TAB) - .accelerator("CmdOrCtrl+W") - .build(app)?; - let reopen_closed_tab = MenuItemBuilder::new("Reopen Closed Tab") - .id(FILE_REOPEN_CLOSED_TAB) - .accelerator("CmdOrCtrl+Shift+T") - .build(app)?; - Ok(SubmenuBuilder::new(app, "File") .item(&new_note) .item(&new_type) @@ -181,8 +168,6 @@ fn build_file_menu(app: &App) -> MenuResult { .item(&quick_open) .separator() .item(&save) - .item(&close_tab) - .item(&reopen_closed_tab) .build()?) } @@ -461,7 +446,6 @@ mod tests { FILE_DAILY_NOTE, FILE_QUICK_OPEN, FILE_SAVE, - FILE_CLOSE_TAB, EDIT_FIND_IN_VAULT, EDIT_TOGGLE_RAW_EDITOR, EDIT_TOGGLE_DIFF, diff --git a/src/App.tsx b/src/App.tsx index 379caa3c..2de46776 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -185,7 +185,7 @@ function App() { const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry }) - // Keep tab entries in sync with vault entries so banners (trash/archive) + // Keep note entry in sync with vault entries so banners (trash/archive) // and read-only state react immediately without reopening the note. useEffect(() => { notes.setTabs(prev => { @@ -204,10 +204,8 @@ function App() { const { handleGoBack, handleGoForward, canGoBack, canGoForward, entriesByPath } = useAppNavigation({ entries: vault.entries, - tabs: notes.tabs, activeTabPath: notes.activeTabPath, onSelectNote: notes.handleSelectNote, - onSwitchTab: notes.handleSwitchTab, }) // MCP UI bridge: react to AI-driven open/highlight/vault-change events @@ -251,11 +249,11 @@ function App() { const handleAgentFileModified = useCallback((relativePath: string) => { const fullPath = `${resolvedPath}/${relativePath}` - const matchPath = notes.tabs.some(t => t.entry.path === relativePath) ? relativePath : fullPath - if (notes.tabs.some(t => t.entry.path === matchPath)) { + const currentPath = notes.activeTabPath + if (currentPath === relativePath || currentPath === fullPath) { vault.reloadVault() } - }, [vault, notes, resolvedPath]) + }, [vault, notes.activeTabPath, resolvedPath]) const handleAgentVaultChanged = useCallback(() => { vault.reloadVault() @@ -283,7 +281,7 @@ function App() { const handleOpenInNewWindow = useCallback(() => { const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath) if (activeTab) openNoteInNewWindow(activeTab.entry.path, resolvedPath, activeTab.entry.title) - }, [notes.tabs, notes.activeTabPath, resolvedPath]) + }, [notes.tabs, notes.activeTabPath, resolvedPath]) // eslint-disable-line react-hooks/exhaustive-deps /** Open a specific note entry in a new window (Cmd+Shift+Click). */ const handleOpenEntryInNewWindow = useCallback((entry: VaultEntry) => { @@ -345,22 +343,6 @@ function App() { } }, [resolvedPath, vault.entries, notes, dialogs]) - /** Flush pending auto-save before closing a tab to prevent data loss. */ - const handleCloseTabWithFlush = useCallback((path: string) => { - savePendingForPath(path).catch(() => {}) - notes.handleCloseTab(path) - }, [savePendingForPath, notes]) - - // Wrap the close-tab ref so Cmd+W and menu bar also flush auto-save - const closeTabWithFlushRef = useRef<(path: string) => void>(handleCloseTabWithFlush) - useEffect(() => { - const original = notes.handleCloseTabRef.current - closeTabWithFlushRef.current = (path: string) => { - savePendingForPath(path).catch(() => {}) - original(path) - } - }) - const handleRenameTab = useCallback(async (path: string, newTitle: string) => { await savePendingForPath(path) await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles) @@ -395,7 +377,7 @@ function App() { const deleteActions = useDeleteActions({ vaultPath: resolvedPath, entries: vault.entries, - handleCloseTab: notes.handleCloseTab, + onDeselectNote: (path: string) => { if (notes.activeTabPath === path) notes.closeAllTabs() }, removeEntry: vault.removeEntry, setToastMessage, }) @@ -460,7 +442,6 @@ function App() { const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, - handleCloseTabRef: closeTabWithFlushRef, tabs: notes.tabs, entries: vault.entries, modifiedCount: vault.modifiedFiles.length, activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath), @@ -483,8 +464,8 @@ function App() { onToggleRawEditor: () => rawToggleRef.current(), onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, zoomLevel: zoom.zoomLevel, - onSelect: handleSetSelection, onCloseTab: notes.handleCloseTab, - onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab, + onSelect: handleSetSelection, + onReplaceActiveTab: notes.handleReplaceActiveTab, onSelectNote: notes.handleSelectNote, onGoBack: handleGoBack, onGoForward: handleGoForward, canGoBack: canGoBack, canGoForward: canGoForward, @@ -500,7 +481,6 @@ function App() { onInstallMcp: installMcp, onEmptyTrash: deleteActions.handleEmptyTrash, trashedCount: deleteActions.trashedCount, - onReopenClosedTab: notes.handleReopenClosedTab, onReloadVault: vault.reloadVault, onRepairVault: handleRepairVault, onSetNoteIcon: handleSetNoteIconCommand, @@ -570,9 +550,6 @@ function App() { tabs={notes.tabs} activeTabPath={notes.activeTabPath} entries={vault.entries} - onSwitchTab={notes.handleSwitchTab} - onCloseTab={handleCloseTabWithFlush} - onReorderTabs={notes.handleReorderTabs} onNavigateWikilink={notes.handleNavigateWikilink} onLoadDiff={vault.loadDiff} onLoadDiffAtCommit={vault.loadDiffAtCommit} @@ -599,7 +576,6 @@ function App() { onDeleteNote={deleteActions.handleDeleteNote} onArchiveNote={entryActions.handleArchiveNote} onUnarchiveNote={entryActions.handleUnarchiveNote} - onRenameTab={handleRenameTab} onContentChange={handleContentChange} onSave={handleSave} onTitleSync={handleTitleSync} diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 5cc9ae51..94a26f88 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -98,8 +98,6 @@ const defaultProps = { tabs: [] as { entry: VaultEntry; content: string }[], activeTabPath: null as string | null, entries: [mockEntry], - onSwitchTab: vi.fn(), - onCloseTab: vi.fn(), onNavigateWikilink: vi.fn(), inspectorCollapsed: true, onToggleInspector: vi.fn(), @@ -143,62 +141,6 @@ describe('Editor', () => { expect(screen.getByText(/words/)).toBeInTheDocument() }) - it('calls onCloseTab when close button is clicked', () => { - const onCloseTab = vi.fn() - render( - - ) - // Find the close button (X icon) in the tab - const closeButtons = document.querySelectorAll('button') - const tabCloseBtn = Array.from(closeButtons).find(btn => { - const svg = btn.querySelector('svg') - return svg && btn.closest('[class*="group"]') - }) - if (tabCloseBtn) { - fireEvent.click(tabCloseBtn) - expect(onCloseTab).toHaveBeenCalledWith(mockEntry.path) - } - }) - - it('calls onSwitchTab when clicking a tab', () => { - const secondEntry: VaultEntry = { - ...mockEntry, - path: '/vault/topic/dev.md', - title: 'Dev Topic', - isA: 'Topic', - } - const onSwitchTab = vi.fn() - render( - - ) - fireEvent.click(screen.getByText('Dev Topic')) - expect(onSwitchTab).toHaveBeenCalledWith(secondEntry.path) - }) - - it('renders new note button in tab bar', () => { - const onCreateNote = vi.fn() - render( - - ) - const newNoteBtn = screen.getByTitle('New note') - expect(newNoteBtn).toBeInTheDocument() - fireEvent.click(newNoteBtn) - expect(onCreateNote).toHaveBeenCalled() - }) - it('shows BlockNote editor when a tab is active', () => { render( void - onCloseTab: (path: string) => void - onReorderTabs?: (fromIndex: number, toIndex: number) => void onNavigateWikilink: (target: string) => void onLoadDiff?: (path: string) => Promise onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise @@ -55,7 +51,6 @@ interface EditorProps { onDeleteNote?: (path: string) => void onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void - onRenameTab?: (path: string, newTitle: string) => void onContentChange?: (path: string, content: string) => void onSave?: () => void /** Called when the user edits the title in TitleField. */ @@ -153,20 +148,6 @@ function useRawModeWithFlush( activeTabPath, onBeforeRawEnd: handleBeforeRawEnd, }) - // Flush raw editor content when switching tabs while raw mode stays active. - const prevTabPathRef = useRef(activeTabPath) - const onContentChangeRef = useRef(onContentChange) - useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange]) - useEffect(() => { - const prev = prevTabPathRef.current - prevTabPathRef.current = activeTabPath - const hasUnflushedContent = prev && prev !== activeTabPath && rawMode && rawLatestContentRef.current != null - if (hasUnflushedContent) { - onContentChangeRef.current?.(prev, rawLatestContentRef.current!) - rawLatestContentRef.current = null - } - }, [activeTabPath, rawMode]) - return { rawMode, handleToggleRaw, rawLatestContentRef } } @@ -217,8 +198,8 @@ function useEditorSetup({ export const Editor = memo(function Editor(props: EditorProps) { const { - tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink, - getNoteStatus, onCreateNote, + tabs, activeTabPath, entries, onNavigateWikilink, + getNoteStatus, inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize, inspectorEntry, inspectorContent, gitHistory, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, @@ -226,7 +207,6 @@ export const Editor = memo(function Editor(props: EditorProps) { vaultPath, noteList, noteListFilter, onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote, onContentChange, onSave, onTitleSync, - canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed, onFileCreated, onFileModified, onVaultChanged, onSetNoteIcon, onRemoveNoteIcon, isConflicted, onKeepMine, onKeepTheirs, @@ -247,21 +227,6 @@ export const Editor = memo(function Editor(props: EditorProps) { return (
-
{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) - }) -})