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:
@@ -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]
|
||||
|
||||
@@ -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])))
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user