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:
@@ -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,
|
||||
|
||||
40
src/App.tsx
40
src/App.tsx
@@ -185,7 +185,7 @@ function App() {
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
|
||||
|
||||
// Keep tab entries in sync with vault entries so banners (trash/archive)
|
||||
// Keep note entry in sync with vault entries so banners (trash/archive)
|
||||
// and read-only state react immediately without reopening the note.
|
||||
useEffect(() => {
|
||||
notes.setTabs(prev => {
|
||||
@@ -204,10 +204,8 @@ function App() {
|
||||
|
||||
const { handleGoBack, handleGoForward, canGoBack, canGoForward, entriesByPath } = useAppNavigation({
|
||||
entries: vault.entries,
|
||||
tabs: notes.tabs,
|
||||
activeTabPath: notes.activeTabPath,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
onSwitchTab: notes.handleSwitchTab,
|
||||
})
|
||||
|
||||
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
|
||||
@@ -251,11 +249,11 @@ function App() {
|
||||
|
||||
const handleAgentFileModified = useCallback((relativePath: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const matchPath = notes.tabs.some(t => t.entry.path === relativePath) ? relativePath : fullPath
|
||||
if (notes.tabs.some(t => t.entry.path === matchPath)) {
|
||||
const currentPath = notes.activeTabPath
|
||||
if (currentPath === relativePath || currentPath === fullPath) {
|
||||
vault.reloadVault()
|
||||
}
|
||||
}, [vault, notes, resolvedPath])
|
||||
}, [vault, notes.activeTabPath, resolvedPath])
|
||||
|
||||
const handleAgentVaultChanged = useCallback(() => {
|
||||
vault.reloadVault()
|
||||
@@ -283,7 +281,7 @@ function App() {
|
||||
const handleOpenInNewWindow = useCallback(() => {
|
||||
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
|
||||
if (activeTab) openNoteInNewWindow(activeTab.entry.path, resolvedPath, activeTab.entry.title)
|
||||
}, [notes.tabs, notes.activeTabPath, resolvedPath])
|
||||
}, [notes.tabs, notes.activeTabPath, resolvedPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** Open a specific note entry in a new window (Cmd+Shift+Click). */
|
||||
const handleOpenEntryInNewWindow = useCallback((entry: VaultEntry) => {
|
||||
@@ -345,22 +343,6 @@ function App() {
|
||||
}
|
||||
}, [resolvedPath, vault.entries, notes, dialogs])
|
||||
|
||||
/** Flush pending auto-save before closing a tab to prevent data loss. */
|
||||
const handleCloseTabWithFlush = useCallback((path: string) => {
|
||||
savePendingForPath(path).catch(() => {})
|
||||
notes.handleCloseTab(path)
|
||||
}, [savePendingForPath, notes])
|
||||
|
||||
// Wrap the close-tab ref so Cmd+W and menu bar also flush auto-save
|
||||
const closeTabWithFlushRef = useRef<(path: string) => void>(handleCloseTabWithFlush)
|
||||
useEffect(() => {
|
||||
const original = notes.handleCloseTabRef.current
|
||||
closeTabWithFlushRef.current = (path: string) => {
|
||||
savePendingForPath(path).catch(() => {})
|
||||
original(path)
|
||||
}
|
||||
})
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
@@ -395,7 +377,7 @@ function App() {
|
||||
const deleteActions = useDeleteActions({
|
||||
vaultPath: resolvedPath,
|
||||
entries: vault.entries,
|
||||
handleCloseTab: notes.handleCloseTab,
|
||||
onDeselectNote: (path: string) => { if (notes.activeTabPath === path) notes.closeAllTabs() },
|
||||
removeEntry: vault.removeEntry,
|
||||
setToastMessage,
|
||||
})
|
||||
@@ -460,7 +442,6 @@ function App() {
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
handleCloseTabRef: closeTabWithFlushRef, tabs: notes.tabs,
|
||||
entries: vault.entries,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
@@ -483,8 +464,8 @@ function App() {
|
||||
onToggleRawEditor: () => rawToggleRef.current(),
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: handleSetSelection, onCloseTab: notes.handleCloseTab,
|
||||
onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab,
|
||||
onSelect: handleSetSelection,
|
||||
onReplaceActiveTab: notes.handleReplaceActiveTab,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
onGoBack: handleGoBack, onGoForward: handleGoForward,
|
||||
canGoBack: canGoBack, canGoForward: canGoForward,
|
||||
@@ -500,7 +481,6 @@ function App() {
|
||||
onInstallMcp: installMcp,
|
||||
onEmptyTrash: deleteActions.handleEmptyTrash,
|
||||
trashedCount: deleteActions.trashedCount,
|
||||
onReopenClosedTab: notes.handleReopenClosedTab,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
onSetNoteIcon: handleSetNoteIconCommand,
|
||||
@@ -570,9 +550,6 @@ function App() {
|
||||
tabs={notes.tabs}
|
||||
activeTabPath={notes.activeTabPath}
|
||||
entries={vault.entries}
|
||||
onSwitchTab={notes.handleSwitchTab}
|
||||
onCloseTab={handleCloseTabWithFlush}
|
||||
onReorderTabs={notes.handleReorderTabs}
|
||||
onNavigateWikilink={notes.handleNavigateWikilink}
|
||||
onLoadDiff={vault.loadDiff}
|
||||
onLoadDiffAtCommit={vault.loadDiffAtCommit}
|
||||
@@ -599,7 +576,6 @@ function App() {
|
||||
onDeleteNote={deleteActions.handleDeleteNote}
|
||||
onArchiveNote={entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
onRenameTab={handleRenameTab}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
onTitleSync={handleTitleSync}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,34 +3,26 @@ import { useNavigationHistory } from './useNavigationHistory'
|
||||
import { useNavigationGestures } from './useNavigationGestures'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface TabLike {
|
||||
entry: { path: string }
|
||||
}
|
||||
|
||||
interface UseAppNavigationParams {
|
||||
entries: VaultEntry[]
|
||||
tabs: TabLike[]
|
||||
activeTabPath: string | null
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onSwitchTab: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates browser-style back/forward navigation for the app:
|
||||
* - Navigation history (push on tab change, back/forward traversal)
|
||||
* - Navigation history (push on note change, back/forward traversal)
|
||||
* - Mouse button & trackpad gesture bindings
|
||||
* - O(1) path→entry lookup map
|
||||
* - O(1) path->entry lookup map
|
||||
*/
|
||||
export function useAppNavigation({
|
||||
entries,
|
||||
tabs,
|
||||
activeTabPath,
|
||||
onSelectNote,
|
||||
onSwitchTab,
|
||||
}: UseAppNavigationParams) {
|
||||
const navHistory = useNavigationHistory()
|
||||
|
||||
// Push to navigation history whenever the active tab changes (user-initiated)
|
||||
// Push to navigation history whenever the active note changes (user-initiated)
|
||||
const navFromHistoryRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (activeTabPath && !navFromHistoryRef.current) {
|
||||
@@ -48,27 +40,19 @@ export function useAppNavigation({
|
||||
const target = navHistory.goBack(isEntryExists)
|
||||
if (target) {
|
||||
navFromHistoryRef.current = true
|
||||
if (tabs.some(t => t.entry.path === target)) {
|
||||
onSwitchTab(target)
|
||||
} else {
|
||||
const entry = entries.find(e => e.path === target)
|
||||
if (entry) onSelectNote(entry)
|
||||
}
|
||||
const entry = entries.find(e => e.path === target)
|
||||
if (entry) onSelectNote(entry)
|
||||
}
|
||||
}, [navHistory, isEntryExists, entries, tabs, onSelectNote, onSwitchTab])
|
||||
}, [navHistory, isEntryExists, entries, onSelectNote])
|
||||
|
||||
const handleGoForward = useCallback(() => {
|
||||
const target = navHistory.goForward(isEntryExists)
|
||||
if (target) {
|
||||
navFromHistoryRef.current = true
|
||||
if (tabs.some(t => t.entry.path === target)) {
|
||||
onSwitchTab(target)
|
||||
} else {
|
||||
const entry = entries.find(e => e.path === target)
|
||||
if (entry) onSelectNote(entry)
|
||||
}
|
||||
const entry = entries.find(e => e.path === target)
|
||||
if (entry) onSelectNote(entry)
|
||||
}
|
||||
}, [navHistory, isEntryExists, entries, tabs, onSelectNote, onSwitchTab])
|
||||
}, [navHistory, isEntryExists, entries, onSelectNote])
|
||||
|
||||
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user