From b79c78a5bad90d094da43761946cdbf67620c657 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 18 Apr 2026 09:19:29 +0200 Subject: [PATCH] fix: save raw note changes before switching notes --- src/App.tsx | 11 +- src/components/Editor.tsx | 271 +++++++++++++++++--- src/hooks/useNoteActions.ts | 128 ++++++--- src/hooks/useSaveNote.ts | 2 + src/hooks/useTabManagement.test.ts | 129 +++++++++- src/hooks/useTabManagement.ts | 50 +++- tests/smoke/save-before-note-switch.spec.ts | 83 ++++++ 7 files changed, 588 insertions(+), 86 deletions(-) create mode 100644 tests/smoke/save-before-note-switch.spec.ts diff --git a/src/App.tsx b/src/App.tsx index ba8f8508..78feb6f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} /> diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 6bb3300c..a2fd03b3 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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 + 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 + diffMode: boolean + diffContent: string | null + diffLoading: boolean + handleToggleDiffExclusive: () => void | Promise + 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 + 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 + onDeleteProperty?: (path: string, key: string) => Promise + onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise + onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise + onCreateAndOpenNote?: (title: string) => Promise + onInitializeProperties?: (path: string) => void + onFileCreated?: (relativePath: string) => void + onFileModified?: (relativePath: string) => void + onVaultChanged?: () => void +}) { return (
@@ -330,4 +440,103 @@ export const Editor = memo(function Editor(props: EditorProps) {
) +} + +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 ( + + ) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index b2651bfb..3de2a125 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -14,6 +14,7 @@ export interface NoteActionsConfig { addEntry: (entry: VaultEntry) => void removeEntry: (path: string) => void entries: VaultEntry[] + flushBeforeNoteSwitch?: (path: string) => Promise flushBeforePathRename?: (path: string) => Promise reloadVault?: () => Promise 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> + activeTabPathRef: React.MutableRefObject + 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 +}) { + 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, } diff --git a/src/hooks/useSaveNote.ts b/src/hooks/useSaveNote.ts index 0faf5fd4..a8c9a16e 100644 --- a/src/hooks/useSaveNote.ts +++ b/src/hooks/useSaveNote.ts @@ -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 { if (isTauri()) { @@ -19,6 +20,7 @@ export async function persistContent(path: string, content: string): Promise void) { const saveNote = useCallback(async (path: string, content: string) => { await persistContent(path, content) + cacheNoteContent(path, content) if (!isTauri()) { updateMockContent(path, content) } diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index cf47551f..4d6a0e6b 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -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 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() { + let resolve!: (value: T) => void + const promise = new Promise((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().promise) + const deferred = createDeferred() + 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() + const second = createDeferred() + 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() + }) }) }) diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 6b0c49b6..e27af647 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -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 { export type { Tab } +interface TabManagementOptions { + beforeNavigate?: (fromPath: string, toPath: string) => Promise +} + function syncActiveTabPath( activeTabPathRef: React.MutableRefObject, setActiveTabPath: React.Dispatch>, @@ -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([]) const [activeTabPath, setActiveTabPath] = useState(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, + ) => { + 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 = [] diff --git a/tests/smoke/save-before-note-switch.spec.ts b/tests/smoke/save-before-note-switch.spec.ts new file mode 100644 index 00000000..7fc99aa2 --- /dev/null +++ b/tests/smoke/save-before-note-switch.spec.ts @@ -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 { + 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) +})