diff --git a/src-tauri/src/vault/file.rs b/src-tauri/src/vault/file.rs index e40fa555..7e43d56a 100644 --- a/src-tauri/src/vault/file.rs +++ b/src-tauri/src/vault/file.rs @@ -21,6 +21,10 @@ pub(crate) fn read_file_metadata(path: &Path) -> Result<(Option, Option String { + format!("File is not valid UTF-8 text: {}", path.display()) +} + /// Read the content of a single note file. pub fn get_note_content(path: &Path) -> Result { if !path.exists() { @@ -29,7 +33,8 @@ pub fn get_note_content(path: &Path) -> Result { if !path.is_file() { return Err(format!("Path is not a file: {}", path.display())); } - fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e)) + let bytes = fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + String::from_utf8(bytes).map_err(|_| invalid_utf8_text_error(path)) } fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> { diff --git a/src-tauri/src/vault/mod_tests/scan_and_file_access.rs b/src-tauri/src/vault/mod_tests/scan_and_file_access.rs index b693d982..3b09152b 100644 --- a/src-tauri/src/vault/mod_tests/scan_and_file_access.rs +++ b/src-tauri/src/vault/mod_tests/scan_and_file_access.rs @@ -120,3 +120,17 @@ fn test_get_note_content_nonexistent() { let result = get_note_content(Path::new("/nonexistent/path/file.md")); assert!(result.is_err()); } + +#[test] +fn test_get_note_content_invalid_utf8() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("invalid.csv"); + std::fs::write(&path, [0x66, 0x6f, 0x80]).unwrap(); + + let result = get_note_content(&path); + + assert_eq!( + result.unwrap_err(), + format!("File is not valid UTF-8 text: {}", path.display()) + ); +} diff --git a/src/hooks/useNoteActions.missing-path.test.ts b/src/hooks/useNoteActions.missing-path.test.ts index fe9f74f6..02c56fb3 100644 --- a/src/hooks/useNoteActions.missing-path.test.ts +++ b/src/hooks/useNoteActions.missing-path.test.ts @@ -84,4 +84,24 @@ describe('useNoteActions missing-path recovery', () => { ) warnSpy.mockRestore() }) + + it('shows a toast without reloading the vault when note content is not valid UTF-8 text', async () => { + vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File is not valid UTF-8 text: /test/vault/bad.csv')) + const config = makeConfig({ entries: [makeEntry({ path: '/test/vault/bad.csv', filename: 'bad.csv', title: 'bad.csv', fileKind: 'text' })] }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + await result.current.handleSelectNote(makeEntry({ path: '/test/vault/bad.csv', filename: 'bad.csv', title: 'bad.csv', fileKind: 'text' })) + }) + + expect(result.current.tabs).toEqual([]) + expect(result.current.activeTabPath).toBeNull() + expect(config.reloadVault).not.toHaveBeenCalled() + expect(config.setToastMessage).toHaveBeenCalledWith( + '"bad.csv" could not be opened because it is not valid UTF-8 text.', + ) + warnSpy.mockRestore() + }) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 468f7339..16a74cdb 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -201,12 +201,17 @@ function buildTabManagementOptions( const options: { beforeNavigate?: (fromPath: string, toPath: string) => Promise onMissingNotePath: (entry: VaultEntry) => void + onUnreadableNoteContent: (entry: VaultEntry) => void } = { onMissingNotePath: (entry) => { const label = entry.title.trim() || entry.filename config.setToastMessage(`"${label}" could not be opened because its file is missing or moved.`) void config.reloadVault?.() }, + onUnreadableNoteContent: (entry) => { + const label = entry.title.trim() || entry.filename + config.setToastMessage(`"${label}" could not be opened because it is not valid UTF-8 text.`) + }, } if (config.flushBeforeNoteSwitch) { diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index 61bf37d2..92997199 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -161,6 +161,29 @@ describe('useTabManagement (single-note model)', () => { warnSpy.mockRestore() }) + it('returns to the empty state when note content is not valid UTF-8 text', async () => { + const { mockInvoke } = await import('../mock-tauri') + vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File is not valid UTF-8 text: /vault/note/bad.csv')) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const onUnreadableNoteContent = vi.fn() + + const { result } = renderHook(() => useTabManagement({ onUnreadableNoteContent })) + await selectNote(result, { + path: '/vault/note/bad.csv', + filename: 'bad.csv', + title: 'bad.csv', + fileKind: 'text', + }) + + expect(result.current.tabs).toEqual([]) + expect(result.current.activeTabPath).toBeNull() + expect(onUnreadableNoteContent).toHaveBeenCalledWith( + expect.objectContaining({ path: '/vault/note/bad.csv', title: 'bad.csv' }), + expect.any(Error), + ) + warnSpy.mockRestore() + }) + it('returns to the empty state when no active vault is selected', async () => { const { mockInvoke } = await import('../mock-tauri') vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('No active vault selected')) diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index ab2aa959..e49add1f 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -74,7 +74,7 @@ function requestNoteContent({ path }: Pick): Note export function prefetchNoteContent(path: string): void { if (prefetchCache.has(path)) return void requestNoteContent({ path }).promise.catch((error) => { - if (isNoActiveVaultSelectedError(error)) return + if (isNoActiveVaultSelectedError(error) || isUnreadableNoteContentError(error)) return console.warn('Failed to prefetch note content:', error) }) } @@ -106,6 +106,7 @@ export type { Tab } interface TabManagementOptions { beforeNavigate?: (fromPath: string, toPath: string) => Promise onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise + onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise } function syncActiveTabPath( @@ -201,6 +202,15 @@ function isNoActiveVaultSelectedError(error: unknown): boolean { return /no active vault selected/i.test(message) } +function isUnreadableNoteContentError(error: unknown): boolean { + const message = error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : String(error) + return /not valid utf-8 text|invalid utf-8|stream did not contain valid utf-8/i.test(message) +} + function shouldApplyLoadedEntry(options: { seq: number navSeqRef: React.MutableRefObject @@ -225,6 +235,99 @@ function shouldApplyLoadedEntry(options: { return cachedContent !== content || !pathsMatch(activeTabPathRef.current, path) } +type EntryLoadFailureKind = + | 'missing-active-vault' + | 'missing-path' + | 'unreadable-content' + | 'load-failed' + +type RecoverableEntryLoadFailureKind = Exclude + +function getEntryLoadFailureKind(error: unknown): EntryLoadFailureKind { + if (isNoActiveVaultSelectedError(error)) return 'missing-active-vault' + if (isMissingNotePathError(error)) return 'missing-path' + if (isUnreadableNoteContentError(error)) return 'unreadable-content' + return 'load-failed' +} + +function resetFailedEntrySelection(options: { + tabsRef: React.MutableRefObject + activeTabPathRef: React.MutableRefObject + setTabs: React.Dispatch> + setActiveTabPath: React.Dispatch> +}) { + const { tabsRef, activeTabPathRef, setTabs, setActiveTabPath } = options + clearTabs(tabsRef, setTabs) + syncActiveTabPath(activeTabPathRef, setActiveTabPath, null) +} + +function runEntryFailureCallback(options: { + callback?: (entry: VaultEntry, error: unknown) => void | Promise + entry: VaultEntry + error: unknown + warning: string +}) { + const { callback, entry, error, warning } = options + Promise.resolve(callback?.(entry, error)).catch((callbackError) => { + console.warn(warning, callbackError) + }) +} + +function handleRecoverableEntryLoadFailure(options: { + kind: RecoverableEntryLoadFailureKind + entry: VaultEntry + tabsRef: React.MutableRefObject + activeTabPathRef: React.MutableRefObject + setTabs: React.Dispatch> + setActiveTabPath: React.Dispatch> + error: unknown + onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise + onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise +}) { + const { + kind, + entry, + tabsRef, + activeTabPathRef, + setTabs, + setActiveTabPath, + error, + onMissingNotePath, + onUnreadableNoteContent, + } = options + + if (kind === 'missing-active-vault') { + clearPrefetchCache() + } + + resetFailedEntrySelection({ + tabsRef, + activeTabPathRef, + setTabs, + setActiveTabPath, + }) + failNoteOpenTrace(entry.path, kind) + + if (kind === 'missing-path') { + runEntryFailureCallback({ + callback: onMissingNotePath, + entry, + error, + warning: 'Failed to handle missing note path:', + }) + return + } + + if (kind === 'unreadable-content') { + runEntryFailureCallback({ + callback: onUnreadableNoteContent, + entry, + error, + warning: 'Failed to handle unreadable note content:', + }) + } +} + function handleEntryLoadFailure(options: { entry: VaultEntry seq: number @@ -235,6 +338,7 @@ function handleEntryLoadFailure(options: { setActiveTabPath: React.Dispatch> error: unknown onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise + onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise }) { const { entry, @@ -246,26 +350,28 @@ function handleEntryLoadFailure(options: { setActiveTabPath, error, onMissingNotePath, + onUnreadableNoteContent, } = options console.warn('Failed to load note content:', error) if (navSeqRef.current !== seq) return - if (isNoActiveVaultSelectedError(error)) { - clearPrefetchCache() - clearTabs(tabsRef, setTabs) - syncActiveTabPath(activeTabPathRef, setActiveTabPath, null) - failNoteOpenTrace(entry.path, 'missing-active-vault') - return - } - if (isMissingNotePathError(error)) { - clearTabs(tabsRef, setTabs) - syncActiveTabPath(activeTabPathRef, setActiveTabPath, null) - failNoteOpenTrace(entry.path, 'missing-path') - Promise.resolve(onMissingNotePath?.(entry, error)).catch((callbackError) => { - console.warn('Failed to handle missing note path:', callbackError) + + const failureKind = getEntryLoadFailureKind(error) + if (failureKind !== 'load-failed') { + handleRecoverableEntryLoadFailure({ + kind: failureKind, + entry, + tabsRef, + activeTabPathRef, + setTabs, + setActiveTabPath, + error, + onMissingNotePath, + onUnreadableNoteContent, }) return } + setSingleTab(tabsRef, setTabs, { entry, content: '' }) failNoteOpenTrace(entry.path, 'load-failed') } @@ -279,6 +385,7 @@ async function navigateToEntry(options: { setTabs: React.Dispatch> setActiveTabPath: React.Dispatch> onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise + onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise }) { const { entry, @@ -289,6 +396,7 @@ async function navigateToEntry(options: { setTabs, setActiveTabPath, onMissingNotePath, + onUnreadableNoteContent, } = options if (entry.fileKind === 'binary') { @@ -338,6 +446,7 @@ async function navigateToEntry(options: { setActiveTabPath, error: err, onMissingNotePath, + onUnreadableNoteContent, }) } } @@ -356,6 +465,7 @@ export function useTabManagement(options: TabManagementOptions = {}) { const beforeNavigateSeqRef = useRef(0) const beforeNavigate = options.beforeNavigate const onMissingNotePath = options.onMissingNotePath + const onUnreadableNoteContent = options.onUnreadableNoteContent const executeNavigationWithBoundary = useCallback(async ( targetPath: string, @@ -391,8 +501,9 @@ export function useTabManagement(options: TabManagementOptions = {}) { setTabs, setActiveTabPath, onMissingNotePath, + onUnreadableNoteContent, })) - }, [executeNavigationWithBoundary, onMissingNotePath]) + }, [executeNavigationWithBoundary, onMissingNotePath, onUnreadableNoteContent]) const handleSwitchTab = useCallback((path: string) => { syncActiveTabPath(activeTabPathRef, setActiveTabPath, path) @@ -419,8 +530,9 @@ export function useTabManagement(options: TabManagementOptions = {}) { setTabs, setActiveTabPath, onMissingNotePath, + onUnreadableNoteContent, })) - }, [executeNavigationWithBoundary, onMissingNotePath]) + }, [executeNavigationWithBoundary, onMissingNotePath, onUnreadableNoteContent]) const closeAllTabs = useCallback(() => { tabsRef.current = []