From d1021b91316c08439dc7ef1522772f3eade84fbc Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Mar 2026 16:42:32 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20prevent=20crash=20in=20handleCreateNoteI?= =?UTF-8?q?mmediate=20=E2=80=94=20slugify=20fallback=20+=20try/catch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - slugify now returns 'untitled' instead of empty string when input has only special characters, preventing invalid paths like '/vault//note.md' - handleCreateNoteImmediate wrapped in try/catch — worst case shows a toast error instead of crashing the app - Added Rust test for deeply nested directory creation in save_note_content - Regression tests for slugify edge cases and handleCreateNoteImmediate with special-character types Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/vault/mod.rs | 11 +++++++ src/hooks/useNoteActions.test.ts | 54 ++++++++++++++++++++++++++++++-- src/hooks/useNoteActions.ts | 30 +++++++++++------- 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index a09bfd63..8f1b4094 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -1261,6 +1261,17 @@ References: assert_eq!(fs::read_to_string(&path).unwrap(), content); } + #[test] + fn test_save_note_content_deeply_nested_new_directory() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("a/b/c/deep-note.md"); + let content = "---\ntitle: Deep\n---\n"; + + save_note_content(path.to_str().unwrap(), content).unwrap(); + assert!(path.exists()); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + } + // --- sidebar_label tests --- #[test] diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 19500021..092b441b 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -77,13 +77,21 @@ describe('slugify', () => { expect(slugify('--hello--')).toBe('hello') }) - it('handles empty string', () => { - expect(slugify('')).toBe('') + it('handles empty string with fallback', () => { + expect(slugify('')).toBe('untitled') }) it('collapses multiple separators into one hyphen', () => { expect(slugify('hello world---foo')).toBe('hello-world-foo') }) + + it('returns fallback for strings with only special characters', () => { + // slugify('+++') should not return empty string — it causes invalid paths + expect(slugify('+++')).not.toBe('') + expect(slugify('!!!')).not.toBe('') + expect(slugify('---')).not.toBe('') + expect(slugify('@#$')).not.toBe('') + }) }) describe('buildNewEntry', () => { @@ -270,6 +278,20 @@ describe('resolveNewNote', () => { expect(entry.path).toBe('/other/vault/note/test.md') expect(entry.path).not.toContain('/Users/luca/Laputa') }) + + it('produces a valid path for custom types with special characters', () => { + const { entry } = resolveNewNote('My Note', 'Q&A', '/vault') + expect(entry.path).not.toContain('//') + expect(entry.path).toMatch(/\.md$/) + expect(entry.filename).not.toBe('.md') + }) + + it('produces a valid path when type is all special characters', () => { + const { entry } = resolveNewNote('My Note', '+++', '/vault') + // folder should not be empty, path should not have double slashes + expect(entry.path).not.toContain('//') + expect(entry.path).toMatch(/\.md$/) + }) }) describe('resolveNewType', () => { @@ -612,6 +634,34 @@ describe('useNoteActions hook', () => { expect(tabContent).toContain('## Steps') }) + it('handleCreateNoteImmediate does not throw for custom types with special characters', () => { + const { result } = renderHook(() => useNoteActions(makeConfig())) + + expect(() => { + act(() => { + result.current.handleCreateNoteImmediate('Q&A') + }) + }).not.toThrow() + + const [entry] = addEntry.mock.calls[0] + expect(entry.isA).toBe('Q&A') + expect(entry.path).not.toContain('//') + }) + + it('handleCreateNoteImmediate does not throw for types that slugify to empty string', () => { + const { result } = renderHook(() => useNoteActions(makeConfig())) + + expect(() => { + act(() => { + result.current.handleCreateNoteImmediate('+++') + }) + }).not.toThrow() + + const [entry] = addEntry.mock.calls[0] + expect(entry.path).not.toContain('//') + expect(entry.filename).not.toBe('.md') + }) + it('handleCreateNoteImmediate uses template for typed notes', () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' }) const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry]))) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 4530f4c2..5fb610d7 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -100,7 +100,8 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam } export function slugify(text: string): string { - return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + const result = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + return result || 'untitled' } /** Generate a unique "Untitled " name by checking existing entries and pending names. */ @@ -392,17 +393,22 @@ export function useNoteActions(config: NoteActionsConfig) { }, [entries, persistNew, config.vaultPath]) const handleCreateNoteImmediate = useCallback((type?: string) => { - const noteType = type || 'Note' - const title = generateUntitledName(entries, noteType, pendingNamesRef.current) - pendingNamesRef.current.add(title) - const template = resolveTemplate(entries, noteType) - const resolved = resolveNewNote(title, noteType, config.vaultPath, template) - openTabWithContent(resolved.entry, resolved.content) - addEntryWithMock(resolved.entry, resolved.content, addEntry) - config.trackUnsaved?.(resolved.entry.path) - config.markContentPending?.(resolved.entry.path, resolved.content) - signalFocusEditor({ selectTitle: true }) - setTimeout(() => pendingNamesRef.current.delete(title), 500) + try { + const noteType = type || 'Note' + const title = generateUntitledName(entries, noteType, pendingNamesRef.current) + pendingNamesRef.current.add(title) + const template = resolveTemplate(entries, noteType) + const resolved = resolveNewNote(title, noteType, config.vaultPath, template) + openTabWithContent(resolved.entry, resolved.content) + addEntryWithMock(resolved.entry, resolved.content, addEntry) + config.trackUnsaved?.(resolved.entry.path) + config.markContentPending?.(resolved.entry.path, resolved.content) + signalFocusEditor({ selectTitle: true }) + setTimeout(() => pendingNamesRef.current.delete(title), 500) + } catch (err) { + console.error('Failed to create note:', err) + setToastMessage('Failed to create note') + } }, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable /** Close tab and discard entry+unsaved state if the note was never persisted. */