diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index 0348fd05..396b9d9b 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -127,6 +127,70 @@ describe('useAppSave', () => { } } + function renderMissingVaultDraft(path = 'C:\\Users\\Luca\\Notes\\draft.md') { + vi.mocked(isTauri).mockReturnValue(true) + const entry = makeEntry(path, 'Draft', 'draft.md') + + return { + path, + ...renderSave({ + resolvedPath: '', + tabs: [{ entry, content: '# Draft\n\nBody' }], + activeTabPath: path, + unsavedPaths: new Set([path]), + }), + } + } + + function renderAutoSaveScopeDraft({ + initialVaultPath, + notePath, + }: { + initialVaultPath: string + notePath: string + }) { + vi.useFakeTimers() + vi.mocked(isTauri).mockReturnValue(true) + const entry = makeEntry(notePath, 'Draft', 'draft.md') + const tabs = [{ entry, content: '# Draft' }] + + return { + entry, + ...renderHook( + ({ vaultPath }: { vaultPath: string }) => useAppSave({ + ...deps, + resolvedPath: vaultPath, + tabs, + activeTabPath: entry.path, + unsavedPaths: new Set([entry.path]), + }), + { initialProps: { vaultPath: initialVaultPath } }, + ), + } + } + + function expectNoSaveNoteContent() { + expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything()) + expect(deps.clearUnsaved).not.toHaveBeenCalled() + } + + async function expectPendingAutosaveDroppedAfterVaultChange( + hook: ReturnType, + nextVaultPath: string, + ) { + act(() => { + hook.result.current.handleContentChange(hook.entry.path, '# Draft\n\nUnsaved') + }) + + hook.rerender({ vaultPath: nextVaultPath }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) + }) + + expectNoSaveNoteContent() + } + it('exposes contentChangeRef', () => { const { result } = renderSave() expect(result.current.contentChangeRef).toBeDefined() @@ -182,6 +246,7 @@ describe('useAppSave', () => { ) const { result } = renderSave({ + resolvedPath: 'C:\\Users\\@raflymln\\notes', tabs: [{ entry, content: '# Draft\n\nBody' }], activeTabPath: path, unsavedPaths: new Set([path]), @@ -202,16 +267,7 @@ describe('useAppSave', () => { }) it('pauses manual saves when a stale editor has no active vault', async () => { - vi.mocked(isTauri).mockReturnValue(true) - const path = 'C:\\Users\\Luca\\Notes\\draft.md' - const entry = makeEntry(path, 'Draft', 'draft.md') - - const { result } = renderSave({ - resolvedPath: '', - tabs: [{ entry, content: '# Draft\n\nBody' }], - activeTabPath: path, - unsavedPaths: new Set([path]), - }) + const { result } = renderMissingVaultDraft() let saved = true await act(async () => { @@ -219,61 +275,51 @@ describe('useAppSave', () => { }) expect(saved).toBe(false) - expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything()) expect(deps.setToastMessage).toHaveBeenCalledWith('Select or restore a vault before saving.') - expect(deps.clearUnsaved).not.toHaveBeenCalled() + expectNoSaveNoteContent() }) it('pauses stale auto-save timers when the active vault disappears before debounce fires', async () => { - vi.useFakeTimers() - vi.mocked(isTauri).mockReturnValue(true) - const entry = makeEntry('/vault/draft.md', 'Draft', 'draft.md') - const tabs = [{ entry, content: '# Draft' }] - - const { result, rerender } = renderHook( - ({ vaultPath }: { vaultPath: string }) => useAppSave({ - ...deps, - resolvedPath: vaultPath, - tabs, - activeTabPath: entry.path, - unsavedPaths: new Set([entry.path]), - }), - { initialProps: { vaultPath: '/vault' } }, + await expectPendingAutosaveDroppedAfterVaultChange( + renderAutoSaveScopeDraft({ initialVaultPath: '/vault', notePath: '/vault/draft.md' }), + '', ) + }) - act(() => { - result.current.handleContentChange(entry.path, '# Draft\n\nUnsaved') + it('drops pending auto-save content when the active vault changes', async () => { + await expectPendingAutosaveDroppedAfterVaultChange( + renderAutoSaveScopeDraft({ initialVaultPath: '/old-vault', notePath: '/old-vault/draft.md' }), + '/new-vault', + ) + }) + + it('ignores stale editor changes outside the active vault', async () => { + const { result, entry } = renderAutoSaveScopeDraft({ + initialVaultPath: '/new-vault', + notePath: '/old-vault/draft.md', }) - rerender({ vaultPath: '' }) + act(() => { + result.current.handleContentChange(entry.path, '# Draft\n\nStale edit') + }) await act(async () => { await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) }) - expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything()) - expect(deps.clearUnsaved).not.toHaveBeenCalled() + expectNoSaveNoteContent() + expect(deps.trackUnsaved).not.toHaveBeenCalled() }) it('does not flush unsaved tab content to disk without an active vault', async () => { - vi.mocked(isTauri).mockReturnValue(true) - const path = 'C:\\Users\\Luca\\Notes\\draft.md' - const entry = makeEntry(path, 'Draft', 'draft.md') - - const { result } = renderSave({ - resolvedPath: '', - tabs: [{ entry, content: '# Draft\n\nBody' }], - activeTabPath: path, - unsavedPaths: new Set([path]), - }) + const { path, result } = renderMissingVaultDraft() await act(async () => { await result.current.flushBeforeAction(path) }) - expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything()) expect(deps.setToastMessage).toHaveBeenCalledWith('Select or restore a vault before saving.') - expect(deps.clearUnsaved).not.toHaveBeenCalled() + expectNoSaveNoteContent() }) it('handleContentChange is a function', () => { @@ -338,9 +384,13 @@ describe('useAppSave', () => { unsavedPaths: new Set([entry.path]), }) - await act(async () => { + act(() => { result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody') + }) + + await act(async () => { await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) + await vi.advanceTimersByTimeAsync(0) }) expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) @@ -352,6 +402,7 @@ describe('useAppSave', () => { await act(async () => { await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(0) }) expect(vi.mocked(invoke)).toHaveBeenCalledWith('auto_rename_untitled', { diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index 69899152..df6f518b 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -6,6 +6,7 @@ import { extractH1TitleFromContent } from '../utils/noteTitle' import { isTauri } from '../mock-tauri' import type { VaultEntry } from '../types' import { createTranslator, type AppLocale } from '../lib/i18n' +import { canWritePathToVault } from '../utils/vaultPathContainment' interface TabState { entry: VaultEntry @@ -437,6 +438,23 @@ interface AppSaveDeps { locale?: AppLocale } +interface EditorPersistenceOptions { + updateEntry: AppSaveDeps['updateEntry'] + setTabs: AppSaveDeps['setTabs'] + setToastMessage: AppSaveDeps['setToastMessage'] + loadModifiedFiles: AppSaveDeps['loadModifiedFiles'] + trackUnsaved?: AppSaveDeps['trackUnsaved'] + clearUnsaved: AppSaveDeps['clearUnsaved'] + onInternalVaultWrite?: AppSaveDeps['onInternalVaultWrite'] + reloadViews: AppSaveDeps['reloadViews'] + scheduleUntitledRename: (path: string, content: string) => void + resolveCurrentPath: (path: string) => string + resolvePathBeforeSave: (path: string) => Promise + canPersist: boolean + persistenceScope: string + locale: AppLocale +} + function useAppSaveStateRefs({ tabs, activeTabPath, @@ -619,22 +637,9 @@ function useEditorPersistence({ resolveCurrentPath, resolvePathBeforeSave, canPersist, + persistenceScope, locale, -}: { - updateEntry: AppSaveDeps['updateEntry'] - setTabs: AppSaveDeps['setTabs'] - setToastMessage: AppSaveDeps['setToastMessage'] - loadModifiedFiles: AppSaveDeps['loadModifiedFiles'] - trackUnsaved?: AppSaveDeps['trackUnsaved'] - clearUnsaved: AppSaveDeps['clearUnsaved'] - onInternalVaultWrite?: AppSaveDeps['onInternalVaultWrite'] - reloadViews: AppSaveDeps['reloadViews'] - scheduleUntitledRename: (path: string, content: string) => void - resolveCurrentPath: (path: string) => string - resolvePathBeforeSave: (path: string) => Promise - canPersist: boolean - locale: AppLocale -}) { +}: EditorPersistenceOptions) { const onAfterSave = useCallback(() => { loadModifiedFiles() }, [loadModifiedFiles]) @@ -660,18 +665,23 @@ function useEditorPersistence({ resolvePath: resolveCurrentPath, resolvePathBeforeSave, canPersist, + persistenceScope, locale, }) const handleContentChange = useCallback((path: string, content: string) => { const currentPath = resolveCurrentPath(path) + if (!canWritePathToVault(currentPath, persistenceScope)) return trackUnsaved?.(currentPath) handleContentChangeRaw(currentPath, content) - }, [handleContentChangeRaw, resolveCurrentPath, trackUnsaved]) + }, [handleContentChangeRaw, persistenceScope, resolveCurrentPath, trackUnsaved]) - const savePendingForPath = useCallback((path: string) => ( - savePendingForPathRaw(resolveCurrentPath(path)) - ), [savePendingForPathRaw, resolveCurrentPath]) + const savePendingForPath = useCallback((path: string) => { + const currentPath = resolveCurrentPath(path) + return canWritePathToVault(currentPath, persistenceScope) + ? savePendingForPathRaw(currentPath) + : Promise.resolve(false) + }, [savePendingForPathRaw, persistenceScope, resolveCurrentPath]) return { handleSaveRaw, handleContentChange, savePendingForPath, savePending } } @@ -798,6 +808,7 @@ export function useAppSave({ updateEntry, setTabs, setToastMessage, loadModifiedFiles, trackUnsaved, clearUnsaved, onInternalVaultWrite, reloadViews, scheduleUntitledRename, resolveCurrentPath, resolvePathBeforeSave, canPersist, + persistenceScope: resolvedPath, locale, }) const replaceRenamedEntry = useReplaceRenamedEntry({ registerRenamedPath, replaceEntry }) diff --git a/src/hooks/useEditorSave.ts b/src/hooks/useEditorSave.ts index f5f09683..593739c8 100644 --- a/src/hooks/useEditorSave.ts +++ b/src/hooks/useEditorSave.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, type MutableR import type { SetStateAction } from 'react' import { useSaveNote } from './useSaveNote' import { createTranslator, type AppLocale } from '../lib/i18n' +import { canWritePathToVault } from '../utils/vaultPathContainment' interface Tab { entry: { path: string } @@ -22,6 +23,8 @@ interface EditorSaveConfig { resolvePathBeforeSave?: (path: string) => Promise /** False when editor state is present but no vault is available to receive writes. */ canPersist?: boolean + /** Clears pending debounced content when the persistence target changes. */ + persistenceScope?: string disabledSaveMessage?: string locale?: AppLocale } @@ -41,6 +44,24 @@ interface PendingContent { content: string } +interface EditorSaveCommandsParams { + pendingContentRef: MutableRefObject + autoSaveTimerRef: MutableRefObject | null> + setTabs: EditorSaveConfig['setTabs'] + setToastMessage: EditorSaveConfig['setToastMessage'] + saveNote: (path: string, content: string) => Promise + onAfterSave: () => void + onAfterSaveRef: MutableRefObject<() => void> + onNotePersisted?: EditorSaveConfig['onNotePersisted'] + resolvePath?: EditorSaveConfig['resolvePath'] + resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] + canPersistRef: MutableRefObject + persistenceScopeRef: MutableRefObject + persistenceScope?: EditorSaveConfig['persistenceScope'] + disabledSaveMessage: string + t: Translator +} + function errorMessage(error: unknown): string { if (error instanceof Error) return error.message return String(error) @@ -105,14 +126,17 @@ async function persistResolvedContent({ saveNote, resolvePath, resolvePathBeforeSave, + persistenceScopeRef, }: { path: string content: string saveNote: (path: string, content: string) => Promise resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] -}): Promise { + persistenceScopeRef: MutableRefObject +}): Promise { const targetPath = await resolvePersistPath(path, resolvePath, resolvePathBeforeSave) + if (!canWritePathToVault(targetPath, persistenceScopeRef.current ?? '')) return null await saveNote(targetPath, content) return targetPath } @@ -165,6 +189,7 @@ function usePendingContentFlush({ resolvePath, resolvePathBeforeSave, canPersistRef, + persistenceScopeRef, }: { pendingContentRef: MutableRefObject saveNote: (path: string, content: string) => Promise @@ -172,6 +197,7 @@ function usePendingContentFlush({ resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] canPersistRef: MutableRefObject + persistenceScopeRef: MutableRefObject }) { return useCallback(async (pathFilter?: string): Promise => { const pending = pendingContentRef.current @@ -184,14 +210,19 @@ function usePendingContentFlush({ saveNote, resolvePath, resolvePathBeforeSave, + persistenceScopeRef, }) + if (targetPath === null) { + if (pendingContentRef.current === pending) pendingContentRef.current = null + return false + } if (!matchesPendingContent(pendingContentRef.current, targetPath, content, resolvePath)) { return false } pendingContentRef.current = null onNotePersisted?.(targetPath, content) return true - }, [canPersistRef, onNotePersisted, pendingContentRef, resolvePath, resolvePathBeforeSave, saveNote]) + }, [canPersistRef, onNotePersisted, pendingContentRef, persistenceScopeRef, resolvePath, resolvePathBeforeSave, saveNote]) } function useCancelAutoSave(autoSaveTimerRef: MutableRefObject | null>) { @@ -205,18 +236,39 @@ function useCancelAutoSave(autoSaveTimerRef: MutableRefObject void + pendingContentRef: MutableRefObject + persistenceScope?: string +}) { + const previousScopeRef = useRef(persistenceScope) + + useLayoutEffect(() => { + if (previousScopeRef.current === persistenceScope) return + previousScopeRef.current = persistenceScope + pendingContentRef.current = null + cancelAutoSave() + }, [cancelAutoSave, pendingContentRef, persistenceScope]) +} + async function persistUnsavedFallback({ unsavedFallback, saveNote, onNotePersisted, resolvePath, resolvePathBeforeSave, + persistenceScopeRef, }: { unsavedFallback?: { path: string; content: string } saveNote: (path: string, content: string) => Promise onNotePersisted?: EditorSaveConfig['onNotePersisted'] resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] + persistenceScopeRef: MutableRefObject }): Promise { if (!unsavedFallback) return false const targetPath = await persistResolvedContent({ @@ -225,7 +277,9 @@ async function persistUnsavedFallback({ saveNote, resolvePath, resolvePathBeforeSave, + persistenceScopeRef, }) + if (targetPath === null) return false onNotePersisted?.(targetPath, unsavedFallback.content) return true } @@ -258,6 +312,7 @@ async function persistImmediateSave({ onNotePersisted, resolvePath, resolvePathBeforeSave, + persistenceScopeRef, setToastMessage, onAfterSave, t, @@ -268,6 +323,7 @@ async function persistImmediateSave({ onNotePersisted?: EditorSaveConfig['onNotePersisted'] resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] + persistenceScopeRef: MutableRefObject setToastMessage: EditorSaveConfig['setToastMessage'] onAfterSave: () => void t: Translator @@ -280,6 +336,7 @@ async function persistImmediateSave({ onNotePersisted, resolvePath, resolvePathBeforeSave, + persistenceScopeRef, }) setToastMessage(saved || savedFallback ? t('save.toast.saved') : t('save.toast.nothingToSave')) onAfterSave() @@ -301,6 +358,7 @@ function useImmediateSaveCommands({ onNotePersisted, resolvePath, resolvePathBeforeSave, + persistenceScopeRef, canPersistRef, disabledSaveMessage, t, @@ -314,6 +372,7 @@ function useImmediateSaveCommands({ onNotePersisted?: EditorSaveConfig['onNotePersisted'] resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] + persistenceScopeRef: MutableRefObject canPersistRef: MutableRefObject disabledSaveMessage: string t: Translator @@ -336,11 +395,12 @@ function useImmediateSaveCommands({ onNotePersisted, resolvePath, resolvePathBeforeSave, + persistenceScopeRef, setToastMessage, onAfterSave, t, }) - }, [canPersistRef, cancelAutoSave, disabledSaveMessage, flushPending, onAfterSave, onNotePersisted, pendingContentRef, resolvePath, resolvePathBeforeSave, saveNote, setToastMessage, t]) + }, [canPersistRef, cancelAutoSave, disabledSaveMessage, flushPending, onAfterSave, onNotePersisted, pendingContentRef, persistenceScopeRef, resolvePath, resolvePathBeforeSave, saveNote, setToastMessage, t]) const savePendingForPath = useCallback( (path: string): Promise => { @@ -367,6 +427,8 @@ function useContentChangeCommand({ flushPending, onAfterSaveRef, canPersistRef, + persistenceScopeRef, + resolvePath, t, }: { pendingContentRef: MutableRefObject @@ -377,15 +439,19 @@ function useContentChangeCommand({ flushPending: () => Promise onAfterSaveRef: MutableRefObject<() => void> canPersistRef: MutableRefObject + persistenceScopeRef: MutableRefObject + resolvePath?: EditorSaveConfig['resolvePath'] t: Translator }) { return useCallback((path: string, content: string) => { - pendingContentRef.current = { path, content } - applyTabContent(setTabs, path, content) + const currentPath = resolveBufferedPath(path, resolvePath) + if (!canWritePathToVault(currentPath, persistenceScopeRef.current ?? '')) return + pendingContentRef.current = { path: currentPath, content } + applyTabContent(setTabs, currentPath, content) cancelAutoSave() if (!canPersistRef.current) return scheduleAutoSave({ autoSaveTimerRef, flushPending, onAfterSaveRef, setToastMessage, t }) - }, [autoSaveTimerRef, canPersistRef, cancelAutoSave, flushPending, onAfterSaveRef, pendingContentRef, setTabs, setToastMessage, t]) + }, [autoSaveTimerRef, canPersistRef, cancelAutoSave, flushPending, onAfterSaveRef, pendingContentRef, persistenceScopeRef, resolvePath, setTabs, setToastMessage, t]) } function useEditorSaveCommands({ @@ -400,23 +466,11 @@ function useEditorSaveCommands({ resolvePath, resolvePathBeforeSave, canPersistRef, + persistenceScopeRef, + persistenceScope, disabledSaveMessage, t, -}: { - pendingContentRef: MutableRefObject - autoSaveTimerRef: MutableRefObject | null> - setTabs: EditorSaveConfig['setTabs'] - setToastMessage: EditorSaveConfig['setToastMessage'] - saveNote: (path: string, content: string) => Promise - onAfterSave: () => void - onAfterSaveRef: MutableRefObject<() => void> - onNotePersisted?: EditorSaveConfig['onNotePersisted'] - resolvePath?: EditorSaveConfig['resolvePath'] - resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] - canPersistRef: MutableRefObject - disabledSaveMessage: string - t: Translator -}) { +}: EditorSaveCommandsParams) { const flushPending = usePendingContentFlush({ pendingContentRef, saveNote, @@ -424,8 +478,10 @@ function useEditorSaveCommands({ resolvePath, resolvePathBeforeSave, canPersistRef, + persistenceScopeRef, }) const cancelAutoSave = useCancelAutoSave(autoSaveTimerRef) + usePendingContentScopeReset({ cancelAutoSave, pendingContentRef, persistenceScope }) const { handleSave, savePendingForPath, savePending } = useImmediateSaveCommands({ pendingContentRef, cancelAutoSave, @@ -436,6 +492,7 @@ function useEditorSaveCommands({ onNotePersisted, resolvePath, resolvePathBeforeSave, + persistenceScopeRef, canPersistRef, disabledSaveMessage, t, @@ -449,6 +506,8 @@ function useEditorSaveCommands({ flushPending: () => flushPending(), onAfterSaveRef, canPersistRef, + persistenceScopeRef, + resolvePath, t, }) @@ -464,12 +523,14 @@ export function useEditorSave({ resolvePath, resolvePathBeforeSave, canPersist = true, + persistenceScope, disabledSaveMessage, locale = 'en', }: EditorSaveConfig) { const pendingContentRef = useRef<{ path: string; content: string } | null>(null) const autoSaveTimerRef = useRef | null>(null) const canPersistRef = useLatestValueRef(canPersist) + const persistenceScopeRef = useLatestValueRef(persistenceScope) const t = useMemo(() => createTranslator(locale), [locale]) const disabledSaveText = disabledSaveMessage ?? t('save.toast.missingActiveVault') @@ -499,6 +560,8 @@ export function useEditorSave({ resolvePath, resolvePathBeforeSave, canPersistRef, + persistenceScopeRef, + persistenceScope, disabledSaveMessage: disabledSaveText, t, }) diff --git a/src/hooks/useEditorSaveWithLinks.ts b/src/hooks/useEditorSaveWithLinks.ts index 59045d8b..8479dd51 100644 --- a/src/hooks/useEditorSaveWithLinks.ts +++ b/src/hooks/useEditorSaveWithLinks.ts @@ -98,6 +98,7 @@ export function useEditorSaveWithLinks(config: { resolvePath?: (path: string) => string resolvePathBeforeSave?: (path: string) => Promise canPersist?: boolean + persistenceScope?: string disabledSaveMessage?: string locale?: AppLocale }) { diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index c0e771d3..40a92ad9 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -155,6 +155,28 @@ describe('useTabManagement (single-note model)', () => { expect(result.current.tabs[0].content).toBe('# Pending content') }) + it('does not reopen a stale note load after all tabs are closed', async () => { + const content = createDeferred() + vi.mocked(mockInvoke).mockImplementationOnce(() => content.promise) + + const { result } = renderHook(() => useTabManagement()) + void act(() => { + void result.current.handleSelectNote(makeEntry({ path: '/old-vault/note/pending.md', title: 'Pending' })) + }) + + act(() => { + result.current.closeAllTabs() + }) + + await act(async () => { + content.resolve('# Stale content') + await content.promise + }) + + expect(result.current.tabs).toEqual([]) + expect(result.current.activeTabPath).toBeNull() + }) + it('replaces the current note when selecting a different one', async () => { const { result } = renderHook(() => useTabManagement()) await selectNote(result, { path: '/vault/a.md', title: 'A' }) diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index e4f6a839..96c34112 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -545,6 +545,8 @@ export function useTabManagement(options: TabManagementOptions = {}) { }, [executeNavigationWithBoundary, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent]) const closeAllTabs = useCallback(() => { + navSeqRef.current += 1 + beforeNavigateSeqRef.current += 1 tabsRef.current = [] setTabs([]) requestedActiveTabPathRef.current = null diff --git a/src/utils/vaultPathContainment.ts b/src/utils/vaultPathContainment.ts new file mode 100644 index 00000000..64162099 --- /dev/null +++ b/src/utils/vaultPathContainment.ts @@ -0,0 +1,20 @@ +function normalizeVaultContainmentPath(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function compareVaultContainmentPath(path: string, vaultPath: string): [string, string] { + const normalizedPath = normalizeVaultContainmentPath(path) + const normalizedVaultPath = normalizeVaultContainmentPath(vaultPath) + const hasWindowsDrive = /^[a-z]:/i.test(normalizedPath) || /^[a-z]:/i.test(normalizedVaultPath) + return hasWindowsDrive + ? [normalizedPath.toLowerCase(), normalizedVaultPath.toLowerCase()] + : [normalizedPath, normalizedVaultPath] +} + +export function canWritePathToVault(path: string, vaultPath: string): boolean { + const trimmedVaultPath = vaultPath.trim() + if (!trimmedVaultPath) return true + const [targetPath, rootPath] = compareVaultContainmentPath(path, trimmedVaultPath) + if (!rootPath || !targetPath) return false + return targetPath === rootPath || targetPath.startsWith(`${rootPath}/`) +} diff --git a/tests/smoke/vault-switch-clears-stale-editor.spec.ts b/tests/smoke/vault-switch-clears-stale-editor.spec.ts new file mode 100644 index 00000000..43b8360e --- /dev/null +++ b/tests/smoke/vault-switch-clears-stale-editor.spec.ts @@ -0,0 +1,215 @@ +import { test, expect, type Page } from '@playwright/test' +import { executeCommand, openCommandPalette } from './helpers' + +interface MockEntry { + path: string + filename: string + title: string + isA: string + aliases: string[] + belongsTo: string[] + relatedTo: string[] + status: string | null + archived: boolean + modifiedAt: number + createdAt: number | null + fileSize: number + snippet: string + wordCount: number + relationships: Record + outgoingLinks: string[] + properties: Record + template: null + sort: null +} + +interface SaveCall { + path: string + content: string + activeVaultPath: string +} + +interface SmokeVaultData { + contentByPath: Record + entriesByVault: Record + personalVaultPath: string + workVaultPath: string +} + +interface CodeMirrorView { + state: { doc: { length: number } } + dispatch: (transaction: { changes: { from: number; to: number; insert: string } }) => void +} + +interface CodeMirrorElement extends Element { + cmTile?: { view?: CodeMirrorView } +} + +type MockHandler = (args?: Record) => unknown + +function makeEntry(vaultPath: string, filename: string, title: string): MockEntry { + return { + path: `${vaultPath}/${filename}`, + filename, + title, + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: 1_700_000_000, + createdAt: null, + fileSize: 128, + snippet: `${title} body`, + wordCount: 4, + relationships: {}, + outgoingLinks: [], + properties: {}, + template: null, + sort: null, + } +} + +function buildSmokeVaultData(): SmokeVaultData { + const workVaultPath = '/Users/mock/Work' + const personalVaultPath = '/Users/mock/Personal' + const workEntry = makeEntry(workVaultPath, 'work-home.md', 'Work Home') + const personalEntry = makeEntry(personalVaultPath, 'personal-home.md', 'Personal Home') + + return { + workVaultPath, + personalVaultPath, + entriesByVault: { + [workVaultPath]: [workEntry], + [personalVaultPath]: [personalEntry], + }, + contentByPath: { + [workEntry.path]: '# Work Home\n\nWork body', + [personalEntry.path]: '# Personal Home\n\nPersonal body', + }, + } +} + +async function installVaultSwitchMocks(page: Page): Promise { + const data = buildSmokeVaultData() + await page.addInitScript((data: SmokeVaultData) => { + localStorage.clear() + localStorage.setItem('tolaria_welcome_dismissed', '1') + localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1') + + let activeVaultPath = data.workVaultPath + let handlers: Record | null = null + const saveCalls: SaveCall[] = [] + const contentByPath = { ...data.contentByPath } + const vaultPaths = new Set([data.workVaultPath, data.personalVaultPath]) + const smokeWindow = window as Window & { __staleVaultSaveProbe?: SaveCall[] } + smokeWindow.__staleVaultSaveProbe = saveCalls + + Object.defineProperty(window, '__mockHandlers', { + configurable: true, + set(value: unknown) { + handlers = value as Record + handlers.load_vault_list = () => ({ + vaults: [ + { label: 'Work Vault', path: data.workVaultPath }, + { label: 'Personal Vault', path: data.personalVaultPath }, + ], + active_vault: activeVaultPath, + hidden_defaults: [], + }) + handlers.save_vault_list = (args = {}) => { + const list = args.list as { active_vault?: unknown } + if (typeof list.active_vault === 'string') activeVaultPath = list.active_vault + return null + } + handlers.check_vault_exists = (args = {}) => vaultPaths.has(String(args.path)) + handlers.get_default_vault_path = () => data.workVaultPath + handlers.list_vault = (args = {}) => data.entriesByVault[String(args.path)] ?? [] + handlers.reload_vault = handlers.list_vault + handlers.list_vault_folders = () => [] + handlers.list_views = () => [] + handlers.get_note_content = (args = {}) => contentByPath[String(args.path)] ?? '' + handlers.validate_note_content = () => true + handlers.save_note_content = (args = {}) => { + const path = String(args.path) + const content = String(args.content) + saveCalls.push({ path, content, activeVaultPath }) + contentByPath[path] = content + return null + } + handlers.get_modified_files = () => [] + handlers.get_file_history = () => [] + handlers.is_git_repo = () => true + handlers.sync_mcp_bridge_vault = () => null + }, + get() { + return handlers + }, + }) + }, data) + return data +} + +async function openNote(page: Page, title: string): Promise { + await page.getByTestId('note-list-container').getByText(title, { exact: true }).click() +} + +async function openRawMode(page: Page): Promise { + await openCommandPalette(page) + await executeCommand(page, 'Toggle Raw') + await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 }) +} + +async function setRawEditorContent(page: Page, content: string): Promise { + await page.evaluate((nextContent) => { + const element = document.querySelector('.cm-content') as CodeMirrorElement | null + const view = element?.cmTile?.view + if (!view) throw new Error('CodeMirror view is missing') + + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: nextContent }, + }) + }, content) +} + +async function readSaveProbe(page: Page): Promise { + return page.evaluate(() => { + const smokeWindow = window as Window & { __staleVaultSaveProbe?: SaveCall[] } + return smokeWindow.__staleVaultSaveProbe ?? [] + }) +} + +async function clearSaveProbe(page: Page): Promise { + await page.evaluate(() => { + const smokeWindow = window as Window & { __staleVaultSaveProbe?: SaveCall[] } + if (smokeWindow.__staleVaultSaveProbe) smokeWindow.__staleVaultSaveProbe.length = 0 + }) +} + +async function switchToPersonalVault(page: Page): Promise { + await page.getByTestId('status-vault-trigger').click() + await page.getByTestId('vault-menu-item-Personal Vault').click() +} + +test('switching vaults clears stale pending editor saves @smoke', async ({ page }) => { + const data = await installVaultSwitchMocks(page) + await page.goto('/', { waitUntil: 'domcontentloaded' }) + + await openNote(page, 'Work Home') + await openRawMode(page) + await setRawEditorContent(page, '# Work Home\n\nUnsaved draft before vault switch') + + await switchToPersonalVault(page) + await expect(page.getByTestId('note-list-container').getByText('Personal Home', { exact: true })).toBeVisible() + expect(await readSaveProbe(page)).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ + activeVaultPath: data.personalVaultPath, + path: `${data.workVaultPath}/work-home.md`, + }), + ])) + + await clearSaveProbe(page) + await page.waitForTimeout(1_800) + expect(await readSaveProbe(page)).toEqual([]) +})