fix: clear stale editor state on vault switch
This commit is contained in:
@@ -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<typeof renderAutoSaveScopeDraft>,
|
||||
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', {
|
||||
|
||||
@@ -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<string>
|
||||
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<string>
|
||||
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 })
|
||||
|
||||
@@ -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<string>
|
||||
/** 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<PendingContent | null>
|
||||
autoSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
|
||||
setTabs: EditorSaveConfig['setTabs']
|
||||
setToastMessage: EditorSaveConfig['setToastMessage']
|
||||
saveNote: (path: string, content: string) => Promise<void>
|
||||
onAfterSave: () => void
|
||||
onAfterSaveRef: MutableRefObject<() => void>
|
||||
onNotePersisted?: EditorSaveConfig['onNotePersisted']
|
||||
resolvePath?: EditorSaveConfig['resolvePath']
|
||||
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
|
||||
canPersistRef: MutableRefObject<boolean>
|
||||
persistenceScopeRef: MutableRefObject<string | undefined>
|
||||
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<void>
|
||||
resolvePath?: EditorSaveConfig['resolvePath']
|
||||
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
|
||||
}): Promise<string> {
|
||||
persistenceScopeRef: MutableRefObject<string | undefined>
|
||||
}): Promise<string | null> {
|
||||
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<PendingContent | null>
|
||||
saveNote: (path: string, content: string) => Promise<void>
|
||||
@@ -172,6 +197,7 @@ function usePendingContentFlush({
|
||||
resolvePath?: EditorSaveConfig['resolvePath']
|
||||
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
|
||||
canPersistRef: MutableRefObject<boolean>
|
||||
persistenceScopeRef: MutableRefObject<string | undefined>
|
||||
}) {
|
||||
return useCallback(async (pathFilter?: string): Promise<boolean> => {
|
||||
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<ReturnType<typeof setTimeout> | null>) {
|
||||
@@ -205,18 +236,39 @@ function useCancelAutoSave(autoSaveTimerRef: MutableRefObject<ReturnType<typeof
|
||||
return cancelAutoSave
|
||||
}
|
||||
|
||||
function usePendingContentScopeReset({
|
||||
cancelAutoSave,
|
||||
pendingContentRef,
|
||||
persistenceScope,
|
||||
}: {
|
||||
cancelAutoSave: () => void
|
||||
pendingContentRef: MutableRefObject<PendingContent | null>
|
||||
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<void>
|
||||
onNotePersisted?: EditorSaveConfig['onNotePersisted']
|
||||
resolvePath?: EditorSaveConfig['resolvePath']
|
||||
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
|
||||
persistenceScopeRef: MutableRefObject<string | undefined>
|
||||
}): Promise<boolean> {
|
||||
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<string | undefined>
|
||||
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<string | undefined>
|
||||
canPersistRef: MutableRefObject<boolean>
|
||||
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<boolean> => {
|
||||
@@ -367,6 +427,8 @@ function useContentChangeCommand({
|
||||
flushPending,
|
||||
onAfterSaveRef,
|
||||
canPersistRef,
|
||||
persistenceScopeRef,
|
||||
resolvePath,
|
||||
t,
|
||||
}: {
|
||||
pendingContentRef: MutableRefObject<PendingContent | null>
|
||||
@@ -377,15 +439,19 @@ function useContentChangeCommand({
|
||||
flushPending: () => Promise<boolean>
|
||||
onAfterSaveRef: MutableRefObject<() => void>
|
||||
canPersistRef: MutableRefObject<boolean>
|
||||
persistenceScopeRef: MutableRefObject<string | undefined>
|
||||
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<PendingContent | null>
|
||||
autoSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
|
||||
setTabs: EditorSaveConfig['setTabs']
|
||||
setToastMessage: EditorSaveConfig['setToastMessage']
|
||||
saveNote: (path: string, content: string) => Promise<void>
|
||||
onAfterSave: () => void
|
||||
onAfterSaveRef: MutableRefObject<() => void>
|
||||
onNotePersisted?: EditorSaveConfig['onNotePersisted']
|
||||
resolvePath?: EditorSaveConfig['resolvePath']
|
||||
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
|
||||
canPersistRef: MutableRefObject<boolean>
|
||||
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<ReturnType<typeof setTimeout> | 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,
|
||||
})
|
||||
|
||||
@@ -98,6 +98,7 @@ export function useEditorSaveWithLinks(config: {
|
||||
resolvePath?: (path: string) => string
|
||||
resolvePathBeforeSave?: (path: string) => Promise<string>
|
||||
canPersist?: boolean
|
||||
persistenceScope?: string
|
||||
disabledSaveMessage?: string
|
||||
locale?: AppLocale
|
||||
}) {
|
||||
|
||||
@@ -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<string>()
|
||||
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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
20
src/utils/vaultPathContainment.ts
Normal file
20
src/utils/vaultPathContainment.ts
Normal file
@@ -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}/`)
|
||||
}
|
||||
215
tests/smoke/vault-switch-clears-stale-editor.spec.ts
Normal file
215
tests/smoke/vault-switch-clears-stale-editor.spec.ts
Normal file
@@ -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<string, string[]>
|
||||
outgoingLinks: string[]
|
||||
properties: Record<string, unknown>
|
||||
template: null
|
||||
sort: null
|
||||
}
|
||||
|
||||
interface SaveCall {
|
||||
path: string
|
||||
content: string
|
||||
activeVaultPath: string
|
||||
}
|
||||
|
||||
interface SmokeVaultData {
|
||||
contentByPath: Record<string, string>
|
||||
entriesByVault: Record<string, MockEntry[]>
|
||||
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<string, unknown>) => 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<SmokeVaultData> {
|
||||
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<string, MockHandler> | 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<string, MockHandler>
|
||||
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<void> {
|
||||
await page.getByTestId('note-list-container').getByText(title, { exact: true }).click()
|
||||
}
|
||||
|
||||
async function openRawMode(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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<SaveCall[]> {
|
||||
return page.evaluate(() => {
|
||||
const smokeWindow = window as Window & { __staleVaultSaveProbe?: SaveCall[] }
|
||||
return smokeWindow.__staleVaultSaveProbe ?? []
|
||||
})
|
||||
}
|
||||
|
||||
async function clearSaveProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const smokeWindow = window as Window & { __staleVaultSaveProbe?: SaveCall[] }
|
||||
if (smokeWindow.__staleVaultSaveProbe) smokeWindow.__staleVaultSaveProbe.length = 0
|
||||
})
|
||||
}
|
||||
|
||||
async function switchToPersonalVault(page: Page): Promise<void> {
|
||||
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([])
|
||||
})
|
||||
Reference in New Issue
Block a user