diff --git a/src/App.tsx b/src/App.tsx index 3a243b6e..101cb16b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -835,8 +835,10 @@ function App() { onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : [], resolvedPath) }, }) - const handleInitializeProperties = useCallback(async (path: string) => { - await initializeNoteProperties(notes.handleUpdateFrontmatter, path) + const handleInitializeProperties = useCallback((path: string) => { + void initializeNoteProperties(notes.handleUpdateFrontmatter, path).catch((err) => { + console.warn('Failed to initialize note properties:', err) + }) }, [notes]) const handleRemoveNoteIcon = useCallback(async (path: string) => { diff --git a/src/hooks/frontmatterOps.ts b/src/hooks/frontmatterOps.ts index 53dffaa0..42174234 100644 --- a/src/hooks/frontmatterOps.ts +++ b/src/hooks/frontmatterOps.ts @@ -272,6 +272,7 @@ export interface FrontmatterApplyCallbacks { updateEntry: (path: VaultPath, patch: Partial) => void toast: (message: ToastMessage) => void getEntry?: (path: VaultPath) => VaultEntry | undefined + onMissingNotePath?: (path: VaultPath, error: unknown) => void | Promise shouldApply?: (path: VaultPath) => boolean } @@ -339,15 +340,52 @@ function failureToastMessage(op: FrontmatterOp): ToastMessage { return `Failed to ${op} property` } -function handleFrontmatterFailure( - op: FrontmatterOp, - err: unknown, +function frontmatterErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + return String(error) +} + +export function isMissingFrontmatterTargetError(error: unknown): boolean { + return /does not exist|not found|enoent/i.test(frontmatterErrorMessage(error)) +} + +async function runMissingNotePathCallback( + path: VaultPath, + error: unknown, callbacks: FrontmatterApplyCallbacks, - options?: FrontmatterOpOptions, -): undefined { - console.error(`Failed to ${op} frontmatter:`, err) +): Promise { + try { + await callbacks.onMissingNotePath?.(path, error) + } catch (callbackError) { + console.warn('Failed to handle missing frontmatter target:', callbackError) + } +} + +interface FrontmatterFailureParams { + callbacks: FrontmatterApplyCallbacks + err: unknown + op: FrontmatterOp + options?: FrontmatterOpOptions + path: VaultPath +} + +async function handleFrontmatterFailure({ + callbacks, + err, + op, + options, + path, +}: FrontmatterFailureParams): Promise { + const missingTarget = isMissingFrontmatterTargetError(err) + if (missingTarget) { + console.warn(`Skipped ${op} frontmatter for missing note:`, err) + await runMissingNotePathCallback(path, err, callbacks) + } else { + console.error(`Failed to ${op} frontmatter:`, err) + } if (options?.silent) throw err - callbacks.toast(failureToastMessage(op)) + if (!missingTarget) callbacks.toast(failureToastMessage(op)) return undefined } @@ -363,6 +401,6 @@ export async function runFrontmatterAndApply(request: FrontmatterRunRequest): Pr notifyFrontmatterSuccess(op, callbacks, options) return newContent } catch (err) { - return handleFrontmatterFailure(op, err, callbacks, options) + return handleFrontmatterFailure({ op, path, err, callbacks, options }) } } diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index e4fcd6d5..43643fd1 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' import type { VaultEntry } from '../types' -import type { FrontmatterOpOptions } from './frontmatterOps' +import { isMissingFrontmatterTargetError, type FrontmatterOpOptions } from './frontmatterOps' import { trackEvent } from '../lib/telemetry' interface EntryActionsConfig { @@ -53,6 +53,14 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un return entries.find((entry) => entry.isA === 'Type' && entry.title === typeName) } +function logOptimisticRollback(label: string, error: unknown): void { + if (isMissingFrontmatterTargetError(error)) { + console.warn(label, error) + return + } + console.error(label, error) +} + async function findOrCreateType( deps: Pick, typeName: string, @@ -139,7 +147,7 @@ function useArchiveActions({ } catch (err) { updateEntry(path, { archived: false }) setToastMessage('Failed to archive note — rolled back') - console.error('Optimistic archive rollback:', err) + logOptimisticRollback('Optimistic archive rollback:', err) } }, [onBeforeAction, handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted]) @@ -153,7 +161,7 @@ function useArchiveActions({ } catch (err) { updateEntry(path, { archived: true }) setToastMessage('Failed to unarchive note — rolled back') - console.error('Optimistic unarchive rollback:', err) + logOptimisticRollback('Optimistic unarchive rollback:', err) } }, [handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted]) diff --git a/src/hooks/useNoteActions.missing-path.test.ts b/src/hooks/useNoteActions.missing-path.test.ts index 0341b625..10f1f367 100644 --- a/src/hooks/useNoteActions.missing-path.test.ts +++ b/src/hooks/useNoteActions.missing-path.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' +import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry } from '../types' import { useNoteActions, type NoteActionsConfig } from './useNoteActions' +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) vi.mock('../mock-tauri', () => ({ isTauri: vi.fn(() => false), addMockEntry: vi.fn(), @@ -59,9 +61,34 @@ function makeConfig(overrides: Partial = {}): NoteActionsConf } } +async function openNoteForFrontmatterRecovery( + result: { current: ReturnType }, + entry: VaultEntry, +) { + vi.mocked(mockInvoke).mockResolvedValue('# Missing Note\n') + await act(async () => { + await result.current.handleSelectNote(entry) + }) + vi.mocked(isTauri).mockReturnValue(true) + vi.mocked(invoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/missing.md')) +} + +function expectMissingFrontmatterRecovery( + result: { current: ReturnType }, + config: NoteActionsConfig, +) { + expect(result.current.tabs).toEqual([]) + expect(result.current.activeTabPath).toBeNull() + expect(config.reloadVault).toHaveBeenCalledTimes(1) + expect(config.setToastMessage).toHaveBeenCalledWith( + '"Missing Note" could not be opened because its file is missing or moved.', + ) +} + describe('useNoteActions missing-path recovery', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(invoke).mockReset() vi.mocked(isTauri).mockReturnValue(false) }) @@ -129,4 +156,44 @@ describe('useNoteActions missing-path recovery', () => { ) warnSpy.mockRestore() }) + + it('reloads and clears the active tab when a frontmatter write targets a deleted note file', async () => { + const entry = makeEntry() + const config = makeConfig({ entries: [entry] }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { result } = renderHook(() => useNoteActions(config)) + + await openNoteForFrontmatterRecovery(result, entry) + await act(async () => { + await result.current.handleUpdateFrontmatter(entry.path, 'status', 'Done') + }) + + expectMissingFrontmatterRecovery(result, config) + expect(config.updateEntry).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('still rejects silent frontmatter writes after running missing-note recovery', async () => { + const entry = makeEntry() + const config = makeConfig({ entries: [entry] }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { result } = renderHook(() => useNoteActions(config)) + + await openNoteForFrontmatterRecovery(result, entry) + let thrown: unknown + + await act(async () => { + try { + await result.current.handleUpdateFrontmatter(entry.path, '_archived', true, { silent: true }) + } catch (err) { + thrown = err + } + }) + + expect(thrown).toBeInstanceOf(Error) + expectMissingFrontmatterRecovery(result, config) + warnSpy.mockRestore() + }) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 028fe83f..55104834 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -315,6 +315,28 @@ function buildTabManagementOptions( return options } +function handleMissingFrontmatterTarget({ + activeTabPathRef, + closeAllTabs, + entries, + path, + reloadVault, + setToastMessage, +}: { + activeTabPathRef: MutableRefObject + closeAllTabs: () => void + entries: VaultEntry[] + path: string + reloadVault?: () => Promise + setToastMessage: NoteActionsConfig['setToastMessage'] +}) { + const entry = findByNotePath(entries, path) + const label = entry ? entryDisplayLabel(entry) : notePathFilename(path) || 'Note' + if (notePathsMatch(activeTabPathRef.current, path)) closeAllTabs() + setToastMessage(`"${label}" could not be opened because its file is missing or moved.`) + void reloadVault?.() +} + function useGitignoredVisibilityTabCleanup({ activeTabPathRef, closeAllTabs, @@ -430,13 +452,17 @@ function useFrontmatterActionHandlers({ function useFrontmatterRunner({ activeTabPathRef, + closeAllTabs, entries, + reloadVault, setToastMessage, updateEntry, updateTabContent, }: { activeTabPathRef: MutableRefObject + closeAllTabs: () => void entries: VaultEntry[] + reloadVault?: () => Promise setToastMessage: NoteActionsConfig['setToastMessage'] updateEntry: NoteActionsConfig['updateEntry'] updateTabContent: (path: string, newContent: string) => void @@ -452,11 +478,19 @@ function useFrontmatterRunner({ updateEntry, toast: setToastMessage, getEntry: (p) => findByNotePath(entries, p), + onMissingNotePath: (p) => handleMissingFrontmatterTarget({ + activeTabPathRef, + closeAllTabs, + entries, + path: p, + reloadVault, + setToastMessage, + }), shouldApply: (p) => activePathGuardAllowsMutation(p, activeTabPathRef, options), }, options, }), - [activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent], + [activeTabPathRef, closeAllTabs, entries, reloadVault, setToastMessage, updateEntry, updateTabContent], ) } @@ -502,7 +536,15 @@ export function useNoteActions(config: NoteActionsConfig) { [entries, handleSelectNote, tabMgmt.activeTabPath, tabMgmt.tabs], ) - const runFrontmatterOp = useFrontmatterRunner({ activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent }) + const runFrontmatterOp = useFrontmatterRunner({ + activeTabPathRef, + closeAllTabs: tabMgmt.closeAllTabs, + entries, + reloadVault: config.reloadVault, + setToastMessage, + updateEntry, + updateTabContent, + }) const frontmatterActions = useFrontmatterActionHandlers({ config, onPathRenamed: handlePathRenamed,