fix: save raw note changes before switching notes

This commit is contained in:
lucaronin
2026-04-18 09:19:29 +02:00
parent 48a8882970
commit b79c78a5ba
7 changed files with 588 additions and 86 deletions

View File

@@ -354,12 +354,20 @@ function App() {
onToast: (msg) => setToastMessage(msg),
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
})
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
const notes = useNoteActions({
addEntry: vault.addEntry,
removeEntry: vault.removeEntry,
entries: vault.entries,
flushBeforePathRename: (path) => appSave.flushBeforeAction(path),
flushBeforeNoteSwitch: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
flushBeforePathRename: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
reloadVault: vault.reloadVault,
setToastMessage,
updateEntry: vault.updateEntry,
@@ -1160,6 +1168,7 @@ function App() {
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
/>
</div>
</div>

View File

@@ -87,6 +87,8 @@ interface EditorProps {
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
onKeepTheirs?: (path: string) => void
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}
function useEditorModeExclusion({
@@ -224,39 +226,147 @@ function useEditorSetup({
}
}
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
} = props
function useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
}: {
activeTab: Tab | null
rawLatestContentRef: React.MutableRefObject<string | null>
rawMode: boolean
onContentChange?: (path: string, content: string) => void
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
const flushPendingRawContent = useCallback((path: string) => {
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
const {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
onLoadDiff: props.onLoadDiff,
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
const latestContent = rawLatestContentRef.current
if (latestContent === null || latestContent === activeTab.content) return
onContentChange?.(path, latestContent)
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
useEffect(() => {
if (!flushPendingRawContentRef) return
flushPendingRawContentRef.current = flushPendingRawContent
return () => {
if (flushPendingRawContentRef.current === flushPendingRawContent) {
flushPendingRawContentRef.current = null
}
}
}, [flushPendingRawContent, flushPendingRawContentRef])
}
function EditorLayout({
tabs,
activeTab,
isLoadingNewTab,
entries,
editor,
diffMode,
diffContent,
diffLoading,
handleToggleDiffExclusive,
rawMode,
handleToggleRawExclusive,
onContentChange,
onSave,
activeStatus,
showDiffToggle,
showAIChat,
onToggleAIChat,
inspectorCollapsed,
onToggleInspector,
onNavigateWikilink,
handleEditorChange,
onToggleFavorite,
onToggleOrganized,
onDeleteNote,
onArchiveNote,
onUnarchiveNote,
vaultPath,
rawModeContent,
rawLatestContentRef,
onRenameFilename,
isConflicted,
onKeepMine,
onKeepTheirs,
onInspectorResize,
inspectorWidth,
defaultAiAgent,
defaultAiAgentReady,
inspectorEntry,
inspectorContent,
gitHistory,
noteList,
noteListFilter,
handleViewCommitDiff,
onUpdateFrontmatter,
onDeleteProperty,
onAddProperty,
onCreateMissingType,
onCreateAndOpenNote,
onInitializeProperties,
onFileCreated,
onFileModified,
onVaultChanged,
}: {
tabs: Tab[]
activeTab: Tab | null
isLoadingNewTab: boolean
entries: VaultEntry[]
editor: ReturnType<typeof useCreateBlockNote>
diffMode: boolean
diffContent: string | null
diffLoading: boolean
handleToggleDiffExclusive: () => void | Promise<void>
rawMode: boolean
handleToggleRawExclusive: () => void
onContentChange?: (path: string, content: string) => void
onSave?: () => void
activeStatus: NoteStatus
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
handleEditorChange: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawModeContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
onInspectorResize: (delta: number) => void
inspectorWidth: number
defaultAiAgent: AiAgentId
defaultAiAgentReady: boolean
inspectorEntry: VaultEntry | null
inspectorContent: string | null
gitHistory: GitCommit[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
handleViewCommitDiff: (commitHash: string) => void
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}) {
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<div className="flex flex-1 min-h-0">
@@ -330,4 +440,103 @@ export const Editor = memo(function Editor(props: EditorProps) {
</div>
</div>
)
}
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingRawContentRef,
} = props
const {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
onLoadDiff: props.onLoadDiff,
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
})
return (
<EditorLayout
tabs={tabs}
activeTab={activeTab}
isLoadingNewTab={isLoadingNewTab}
entries={entries}
editor={editor}
diffMode={diffMode}
diffContent={diffContent}
diffLoading={diffLoading}
handleToggleDiffExclusive={handleToggleDiffExclusive}
rawMode={rawMode}
handleToggleRawExclusive={handleToggleRawExclusive}
onContentChange={onContentChange}
onSave={onSave}
activeStatus={activeStatus}
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}
handleEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
onInspectorResize={onInspectorResize}
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
inspectorEntry={inspectorEntry}
inspectorContent={inspectorContent}
gitHistory={gitHistory}
noteList={noteList}
noteListFilter={noteListFilter}
handleViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
/>
)
})

View File

@@ -14,6 +14,7 @@ export interface NoteActionsConfig {
addEntry: (entry: VaultEntry) => void
removeEntry: (path: string) => void
entries: VaultEntry[]
flushBeforeNoteSwitch?: (path: string) => Promise<void>
flushBeforePathRename?: (path: string) => Promise<void>
reloadVault?: () => Promise<unknown>
setToastMessage: (msg: string | null) => void
@@ -176,9 +177,89 @@ async function updateFrontmatterAndMaybeRename({
config.onFrontmatterPersisted?.()
}
function buildTabManagementOptions(
flushBeforeNoteSwitch?: NoteActionsConfig['flushBeforeNoteSwitch'],
) {
return flushBeforeNoteSwitch
? { beforeNavigate: (fromPath: string) => flushBeforeNoteSwitch(fromPath) }
: undefined
}
function useFrontmatterActionHandlers({
config,
renameTabsRef,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
runFrontmatterOp,
}: {
config: NoteActionsConfig
renameTabsRef: TitleRenameDeps['tabsRef']
setTabs: React.Dispatch<React.SetStateAction<{ entry: VaultEntry; content: string }[]>>
activeTabPathRef: React.MutableRefObject<string | null>
handleSwitchTab: (path: string) => void
setToastMessage: (msg: string | null) => void
updateTabContent: (path: string, newContent: string) => void
runFrontmatterOp: (
op: 'update' | 'delete',
path: string,
key: string,
value?: FrontmatterValue,
options?: FrontmatterOpOptions,
) => Promise<string | undefined>
}) {
const handleUpdateFrontmatter = useCallback(async (
path: string,
key: string,
value: FrontmatterValue,
options?: FrontmatterOpOptions,
) => {
await updateFrontmatterAndMaybeRename({
config,
deps: {
vaultPath: config.vaultPath,
tabsRef: renameTabsRef,
reloadVault: config.reloadVault,
replaceEntry: config.replaceEntry,
onPathRenamed: config.onPathRenamed,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
},
path,
key,
value,
options,
runFrontmatterOp,
})
}, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent])
const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [config, runFrontmatterOp])
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [config, runFrontmatterOp])
return {
handleUpdateFrontmatter,
handleDeleteProperty,
handleAddProperty,
}
}
export function useNoteActions(config: NoteActionsConfig) {
const { entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement()
const tabMgmt = useTabManagement(buildTabManagementOptions(config.flushBeforeNoteSwitch))
const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
const updateTabContent = useCallback((path: string, newContent: string) => {
@@ -201,6 +282,16 @@ export function useNoteActions(config: NoteActionsConfig) {
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
[updateTabContent, updateEntry, setToastMessage, entries],
)
const frontmatterActions = useFrontmatterActionHandlers({
config,
renameTabsRef: rename.tabsRef,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
runFrontmatterOp,
})
return {
...tabMgmt,
@@ -210,38 +301,9 @@ export function useNoteActions(config: NoteActionsConfig) {
handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship,
handleCreateType: creation.handleCreateType,
createTypeEntrySilent: creation.createTypeEntrySilent,
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
await updateFrontmatterAndMaybeRename({
config,
deps: {
vaultPath: config.vaultPath,
tabsRef: rename.tabsRef,
reloadVault: config.reloadVault,
replaceEntry: config.replaceEntry,
onPathRenamed: config.onPathRenamed,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
},
path,
key,
value,
options,
runFrontmatterOp,
})
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter,
handleDeleteProperty: frontmatterActions.handleDeleteProperty,
handleAddProperty: frontmatterActions.handleAddProperty,
handleRenameNote: rename.handleRenameNote,
handleRenameFilename: rename.handleRenameFilename,
}

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, updateMockContent } from '../mock-tauri'
import { cacheNoteContent } from './useTabManagement'
export async function persistContent(path: string, content: string): Promise<void> {
if (isTauri()) {
@@ -19,6 +20,7 @@ export async function persistContent(path: string, content: string): Promise<voi
export function useSaveNote(updateContent: (path: string, content: string) => void) {
const saveNote = useCallback(async (path: string, content: string) => {
await persistContent(path, content)
cacheNoteContent(path, content)
if (!isTauri()) {
updateMockContent(path, content)
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { VaultEntry } from '../types'
import { useTabManagement, prefetchNoteContent, clearPrefetchCache } from './useTabManagement'
import { useTabManagement, prefetchNoteContent, cacheNoteContent, clearPrefetchCache } from './useTabManagement'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
@@ -47,15 +47,32 @@ async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntr
})
}
async function prefetchResolvedContent(path: string, content: string) {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue(content)
prefetchNoteContent(path)
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
return mockInvoke
}
function expectSingleActiveTab(result: HookState, path: string) {
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].entry.path).toBe(path)
expect(result.current.activeTabPath).toBe(path)
}
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('useTabManagement (single-note model)', () => {
beforeEach(() => {
vi.clearAllMocks()
clearPrefetchCache()
})
it('starts with no note and null active path', () => {
@@ -204,11 +221,7 @@ describe('useTabManagement (single-note model)', () => {
describe('content prefetch cache', () => {
it('prefetch serves content to loadNoteContent (no extra IPC)', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content')
prefetchNoteContent('/vault/note/pre.md')
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
const mockInvoke = await prefetchResolvedContent('/vault/note/pre.md', '# Prefetched content')
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' })
@@ -218,11 +231,7 @@ describe('useTabManagement (single-note model)', () => {
})
it('clearPrefetchCache prevents stale content from being served', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue('# Stale')
prefetchNoteContent('/vault/note/stale.md')
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
const mockInvoke = await prefetchResolvedContent('/vault/note/stale.md', '# Stale')
clearPrefetchCache()
vi.mocked(mockInvoke).mockResolvedValue('# Fresh')
@@ -244,6 +253,18 @@ describe('useTabManagement (single-note model)', () => {
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
})
it('serves refreshed cached content after a save replaces stale prefetched data', async () => {
const mockInvoke = await prefetchResolvedContent('/vault/note/saved.md', '# Stale prefetched content')
cacheNoteContent('/vault/note/saved.md', '# Persisted content')
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/note/saved.md', title: 'Saved' })
expect(result.current.tabs[0].content).toBe('# Persisted content')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
})
})
describe('rapid switching safety', () => {
@@ -277,5 +298,91 @@ describe('useTabManagement (single-note model)', () => {
expect(result.current.activeTabPath).toBe('/vault/b.md')
})
it('waits for beforeNavigate before switching away from the current note', async () => {
const beforeNavigate = vi.fn(() => createDeferred<void>().promise)
const deferred = createDeferred<void>()
beforeNavigate.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
await selectNote(result, { path: '/vault/a.md', title: 'A' })
let replaceDone = false
await act(async () => {
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
.then(() => { replaceDone = true })
await Promise.resolve()
})
expect(beforeNavigate).toHaveBeenCalledWith('/vault/a.md', '/vault/b.md')
expect(result.current.activeTabPath).toBe('/vault/a.md')
expect(replaceDone).toBe(false)
await act(async () => {
deferred.resolve(undefined)
await Promise.resolve()
})
await vi.waitFor(() => expect(replaceDone).toBe(true))
expectSingleActiveTab(result, '/vault/b.md')
})
it('keeps only the latest target when note switches overlap during beforeNavigate', async () => {
const first = createDeferred<void>()
const second = createDeferred<void>()
const beforeNavigate = vi.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
await selectNote(result, { path: '/vault/a.md', title: 'A' })
let switchToBDone = false
await act(async () => {
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
.then(() => { switchToBDone = true })
await Promise.resolve()
})
let switchToCDone = false
await act(async () => {
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/c.md', title: 'C' }))
.then(() => { switchToCDone = true })
await Promise.resolve()
})
await act(async () => {
first.resolve(undefined)
await Promise.resolve()
})
expect(result.current.activeTabPath).toBe('/vault/a.md')
await act(async () => {
second.resolve(undefined)
await Promise.resolve()
})
await vi.waitFor(() => expect(switchToBDone && switchToCDone).toBe(true))
expect(result.current.activeTabPath).toBe('/vault/c.md')
})
it('keeps the current note active when beforeNavigate fails', async () => {
const beforeNavigate = vi.fn().mockRejectedValueOnce(new Error('save failed'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
await selectNote(result, { path: '/vault/a.md', title: 'A' })
await act(async () => {
await result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
expect(result.current.activeTabPath).toBe('/vault/a.md')
expect(warnSpy).toHaveBeenCalledWith(
'Failed to persist note before navigation:',
expect.any(Error),
)
warnSpy.mockRestore()
})
})
})

View File

@@ -30,6 +30,10 @@ export function prefetchNoteContent(path: string): void {
prefetchCache.set(path, promise)
}
export function cacheNoteContent(path: string, content: string): void {
prefetchCache.set(path, Promise.resolve(content))
}
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
export function clearPrefetchCache(): void {
prefetchCache.clear()
@@ -49,6 +53,10 @@ async function loadNoteContent(path: string): Promise<string> {
export type { Tab }
interface TabManagementOptions {
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
}
function syncActiveTabPath(
activeTabPathRef: React.MutableRefObject<string | null>,
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
@@ -112,7 +120,7 @@ async function navigateToEntry(options: {
}
}
export function useTabManagement() {
export function useTabManagement(options: TabManagementOptions = {}) {
// Single-note model: tabs has 0 or 1 elements.
const [tabs, setTabs] = useState<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
@@ -123,18 +131,38 @@ export function useTabManagement() {
// Sequence counter for rapid-switch safety: only the latest navigation wins.
const navSeqRef = useRef(0)
const beforeNavigateSeqRef = useRef(0)
const beforeNavigate = options.beforeNavigate
const executeNavigationWithBoundary = useCallback(async (
targetPath: string,
navigate: () => void | Promise<void>,
) => {
const seq = ++beforeNavigateSeqRef.current
const currentPath = activeTabPathRef.current
if (beforeNavigate && currentPath && currentPath !== targetPath) {
try {
await beforeNavigate(currentPath, targetPath)
} catch (err) {
console.warn('Failed to persist note before navigation:', err)
return
}
if (beforeNavigateSeqRef.current !== seq) return
}
await navigate()
}, [beforeNavigate])
/** Open a note — replaces the current note (single-note model). */
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
await navigateToEntry({
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
}, [])
}))
}, [executeNavigationWithBoundary])
const handleSwitchTab = useCallback((path: string) => {
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
@@ -142,20 +170,22 @@ export function useTabManagement() {
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
setSingleTab(tabsRef, setTabs, { entry, content })
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
}, [])
void executeNavigationWithBoundary(entry.path, () => {
setSingleTab(tabsRef, setTabs, { entry, content })
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
})
}, [executeNavigationWithBoundary])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
await navigateToEntry({
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
}, [])
}))
}, [executeNavigationWithBoundary])
const closeAllTabs = useCallback(() => {
tabsRef.current = []

View File

@@ -0,0 +1,83 @@
import fs from 'fs'
import path from 'path'
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette } from './helpers'
let tempVaultDir: string
async function openNote(page: Page, title: string) {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.getByText(title, { exact: true }).click()
}
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.querySelector('.cm-content')
if (!el) return ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (el as any).cmTile?.view
if (view) return view.state.doc.toString() as string
return el.textContent ?? ''
})
}
async function setRawEditorContent(page: Page, content: string) {
await page.evaluate((nextContent) => {
const el = document.querySelector('.cm-content')
if (!el) {
throw new Error('CodeMirror content element is missing')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (el as any).cmTile?.view
if (!view) {
throw new Error('CodeMirror view is missing')
}
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: nextContent },
})
}, content)
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
await page.goto('/')
await page.waitForLoadState('networkidle')
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultDesktopHarness(page, tempVaultDir)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('@smoke switching notes persists unsaved raw edits without waiting for the debounce window', async ({ page }) => {
const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')
const appendedText = `Flushed before note switch ${Date.now()}`
await openNote(page, 'Note B')
await openRawMode(page)
const rawContent = await getRawEditorContent(page)
await setRawEditorContent(page, `${rawContent}\n\n${appendedText}`)
await page.waitForTimeout(100)
await openNote(page, 'Alpha Project')
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('alpha-project', { timeout: 5_000 })
await expect.poll(async () => getRawEditorContent(page)).toContain('# Alpha Project')
await expect.poll(
() => fs.readFileSync(noteBPath, 'utf8'),
{ timeout: 450, intervals: [50, 100, 100, 100, 100] },
).toContain(appendedText)
})