fix: clear stale editor state on vault switch

This commit is contained in:
lucaronin
2026-05-07 00:13:52 +02:00
parent 5c3a49d2ca
commit d211d89ab8
8 changed files with 469 additions and 84 deletions

View File

@@ -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', {

View File

@@ -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 })

View File

@@ -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,
})

View File

@@ -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
}) {

View File

@@ -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' })

View File

@@ -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

View 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}/`)
}

View 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([])
})