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

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

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

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

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

View File

@@ -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,

View File

@@ -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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string | null>
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,
})

View File

@@ -31,7 +31,6 @@ function makeActions() {
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
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()

View File

@@ -19,10 +19,8 @@ interface KeyboardActions {
onGoForward?: () => void
onToggleAIChat?: () => void
onToggleRawEditor?: () => void
onReopenClosedTab?: () => void
onOpenInNewWindow?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
type ShortcutHandler = () => void
@@ -66,7 +64,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
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])
}

View File

@@ -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<typeof vi.fn>
let onSwitchTab: ReturnType<typeof vi.fn>
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])
})
})
})

View File

@@ -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) pathentry 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 })

View File

@@ -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()
})
})

View File

@@ -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<ClosedTabEntry[]>([])
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 },
}
}

View File

@@ -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,

View File

@@ -15,12 +15,12 @@ function makeEntry(path: string, trashed = false) {
}
describe('useDeleteActions', () => {
let handleCloseTab: ReturnType<typeof vi.fn>
let onDeselectNote: ReturnType<typeof vi.fn>
let removeEntry: ReturnType<typeof vi.fn>
let setToastMessage: ReturnType<typeof vi.fn>
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')

View File

@@ -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<string[]>('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,

View File

@@ -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)
})
})

View File

@@ -34,13 +34,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): 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()
})
})

View File

@@ -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<Tab[]>,
activeTabPathRef: React.RefObject<string | null>,
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<VaultEntry[]>,
activeTabPathRef: React.RefObject<string | null>,
@@ -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<T>(value: T): React.RefObject<T> {
const ref = useRef(value)
useEffect(() => { ref.current = value })
@@ -91,31 +53,31 @@ function useLatestRef<T>(value: T): React.RefObject<T> {
}
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])
}

View File

@@ -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<string | null>,
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()

View File

@@ -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<string | null>
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<string, OptionalHandler> = {
@@ -105,16 +102,14 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'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 }) => {

View File

@@ -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<string>()
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', () => {

View File

@@ -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,

View File

@@ -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')
})
})

View File

@@ -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<Set<string>>(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<boolean> => {
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,
}
}

View File

@@ -35,31 +35,19 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
...overrides,
})
const localStorageMock = (() => {
let store: Record<string, string> = {}
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')
})
})

View File

@@ -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<string> {
// 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<boolean> {
} 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<React.SetStateAction<Tab[]>>,
) {
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<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(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,
}
}

View File

@@ -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)))
}

View File

@@ -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)
})
})