fix: prevent crash in handleCreateNoteImmediate — slugify fallback + try/catch

- 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 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-11 16:42:32 +01:00
parent b9139e2d57
commit d1021b9131
3 changed files with 81 additions and 14 deletions

View File

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

View File

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

View File

@@ -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 <type>" 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. */