diff --git a/src/hooks/useNoteCreation.extra.test.ts b/src/hooks/useNoteCreation.extra.test.ts index f9f06c57..335e00c2 100644 --- a/src/hooks/useNoteCreation.extra.test.ts +++ b/src/hooks/useNoteCreation.extra.test.ts @@ -114,12 +114,12 @@ describe('buildNewEntry', () => { describe('generateUntitledName', () => { it('returns base name when no conflicts', () => { - expect(generateUntitledName([], 'Note')).toBe('Untitled note') + expect(generateUntitledName({ entries: [], type: 'Note' })).toBe('Untitled note') }) it('appends counter when base name exists', () => { const entries = [makeEntry({ title: 'Untitled note' })] - expect(generateUntitledName(entries, 'Note')).toBe('Untitled note 2') + expect(generateUntitledName({ entries, type: 'Note' })).toBe('Untitled note 2') }) it('increments counter past existing numbered entries', () => { @@ -128,87 +128,87 @@ describe('generateUntitledName', () => { makeEntry({ title: 'Untitled note 2' }), makeEntry({ title: 'Untitled note 3' }), ] - expect(generateUntitledName(entries, 'Note')).toBe('Untitled note 4') + expect(generateUntitledName({ entries, type: 'Note' })).toBe('Untitled note 4') }) it('uses type name in lowercase', () => { - expect(generateUntitledName([], 'Project')).toBe('Untitled project') + expect(generateUntitledName({ entries: [], type: 'Project' })).toBe('Untitled project') }) it('avoids names in the pending set', () => { const pending = new Set(['Untitled note']) - expect(generateUntitledName([], 'Note', pending)).toBe('Untitled note 2') + expect(generateUntitledName({ entries: [], type: 'Note', pendingTitles: pending })).toBe('Untitled note 2') }) it('avoids both existing and pending names', () => { const entries = [makeEntry({ title: 'Untitled note' })] const pending = new Set(['Untitled note 2']) - expect(generateUntitledName(entries, 'Note', pending)).toBe('Untitled note 3') + expect(generateUntitledName({ entries, type: 'Note', pendingTitles: pending })).toBe('Untitled note 3') }) }) describe('entryMatchesTarget', () => { it('matches by exact title (case-insensitive)', () => { const entry = makeEntry({ title: 'My Project' }) - expect(entryMatchesTarget(entry, 'my project')).toBe(true) + expect(entryMatchesTarget({ entry, target: 'my project' })).toBe(true) }) it('matches by alias', () => { const entry = makeEntry({ aliases: ['MP', 'TheProject'] }) - expect(entryMatchesTarget(entry, 'mp')).toBe(true) + expect(entryMatchesTarget({ entry, target: 'mp' })).toBe(true) }) it('matches legacy path-style target via filename stem', () => { const entry = makeEntry({ filename: 'my-project.md' }) - expect(entryMatchesTarget(entry, 'project/my-project')).toBe(true) + expect(entryMatchesTarget({ entry, target: 'project/my-project' })).toBe(true) }) it('matches by filename stem', () => { const entry = makeEntry({ filename: 'my-project.md' }) - expect(entryMatchesTarget(entry, 'my-project')).toBe(true) + expect(entryMatchesTarget({ entry, target: 'my-project' })).toBe(true) }) it('matches when target as words matches title', () => { const entry = makeEntry({ title: 'my project' }) - expect(entryMatchesTarget(entry, 'my-project')).toBe(true) + expect(entryMatchesTarget({ entry, target: 'my-project' })).toBe(true) }) it('returns false when nothing matches', () => { const entry = makeEntry({ title: 'Something Else', aliases: [], filename: 'else.md' }) - expect(entryMatchesTarget(entry, 'nonexistent')).toBe(false) + expect(entryMatchesTarget({ entry, target: 'nonexistent' })).toBe(false) }) it('handles pipe syntax targets', () => { const entry = makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha' }) - expect(entryMatchesTarget(entry, 'project/alpha|Alpha Project')).toBe(true) + expect(entryMatchesTarget({ entry, target: 'project/alpha|Alpha Project' })).toBe(true) }) }) describe('buildNoteContent', () => { it('generates frontmatter with title and status for regular types', () => { - const content = buildNoteContent('My Note', 'Note', 'Active') + const content = buildNoteContent({ title: 'My Note', type: 'Note', status: 'Active' }) expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n') }) it('omits title when null', () => { - const content = buildNoteContent(null, 'Note', 'Active') + const content = buildNoteContent({ title: null, type: 'Note', status: 'Active' }) expect(content).toBe('---\ntype: Note\nstatus: Active\n---\n') }) it('omits status when null', () => { - const content = buildNoteContent('AI', 'Topic', null) + const content = buildNoteContent({ title: 'AI', type: 'Topic', status: null }) expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n') }) it('includes template body when provided', () => { - const content = buildNoteContent('My Project', 'Project', 'Active', '## Objective\n\n## Notes\n\n') + const content = buildNoteContent({ title: 'My Project', type: 'Project', status: 'Active', template: '## Objective\n\n## Notes\n\n' }) expect(content).not.toContain('# My Project') expect(content).toContain('## Objective') expect(content).toContain('## Notes') }) it('ignores null template', () => { - const content = buildNoteContent('My Note', 'Note', 'Active', null) + const content = buildNoteContent({ title: 'My Note', type: 'Note', status: 'Active', template: null }) expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n') }) }) @@ -216,20 +216,20 @@ describe('buildNoteContent', () => { describe('resolveTemplate', () => { it('returns template from type entry when set', () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' }) - expect(resolveTemplate([typeEntry], 'Recipe')).toBe('## Ingredients\n\n## Steps\n\n') + expect(resolveTemplate({ entries: [typeEntry], typeName: 'Recipe' })).toBe('## Ingredients\n\n## Steps\n\n') }) it('falls back to DEFAULT_TEMPLATES for built-in types', () => { - expect(resolveTemplate([], 'Project')).toBe(DEFAULT_TEMPLATES.Project) + expect(resolveTemplate({ entries: [], typeName: 'Project' })).toBe(DEFAULT_TEMPLATES.Project) }) it('returns null when no template and no default', () => { - expect(resolveTemplate([], 'CustomType')).toBeNull() + expect(resolveTemplate({ entries: [], typeName: 'CustomType' })).toBeNull() }) it('type entry template overrides default', () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom\n\n' }) - expect(resolveTemplate([typeEntry], 'Project')).toBe('## Custom\n\n') + expect(resolveTemplate({ entries: [typeEntry], typeName: 'Project' })).toBe('## Custom\n\n') }) }) @@ -244,7 +244,7 @@ describe('DEFAULT_TEMPLATES', () => { describe('resolveNewNote', () => { it('creates note at vault root', () => { - const { entry, content } = resolveNewNote('My Project', 'Project', '/my/vault') + const { entry, content } = resolveNewNote({ title: 'My Project', type: 'Project', vaultPath: '/my/vault' }) expect(entry.path).toBe('/my/vault/my-project.md') expect(entry.isA).toBe('Project') expect(entry.status).toBe('Active') @@ -253,35 +253,35 @@ describe('resolveNewNote', () => { }) it('creates custom type note at vault root', () => { - const { entry } = resolveNewNote('First Recipe', 'Recipe', '/my/vault') + const { entry } = resolveNewNote({ title: 'First Recipe', type: 'Recipe', vaultPath: '/my/vault' }) expect(entry.path).toBe('/my/vault/first-recipe.md') }) it('omits status for Topic type', () => { - const { entry, content } = resolveNewNote('Machine Learning', 'Topic', '/my/vault') + const { entry, content } = resolveNewNote({ title: 'Machine Learning', type: 'Topic', vaultPath: '/my/vault' }) expect(entry.status).toBeNull() expect(content).not.toContain('status:') }) it('omits status for Person type', () => { - const { entry } = resolveNewNote('John Doe', 'Person', '/my/vault') + const { entry } = resolveNewNote({ title: 'John Doe', type: 'Person', vaultPath: '/my/vault' }) expect(entry.status).toBeNull() }) it('uses provided vault path', () => { - const { entry } = resolveNewNote('Test', 'Note', '/other/vault') + const { entry } = resolveNewNote({ title: 'Test', type: 'Note', vaultPath: '/other/vault' }) expect(entry.path).toBe('/other/vault/test.md') }) it('produces a valid path for custom types with special characters', () => { - const { entry } = resolveNewNote('My Note', 'Q&A', '/vault') + const { entry } = resolveNewNote({ title: 'My Note', type: 'Q&A', vaultPath: '/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') + const { entry } = resolveNewNote({ title: 'My Note', type: '+++', vaultPath: '/vault' }) expect(entry.path).not.toContain('//') expect(entry.path).toMatch(/\.md$/) }) @@ -289,7 +289,7 @@ describe('resolveNewNote', () => { describe('resolveNewType', () => { it('creates a type entry at vault root', () => { - const { entry, content } = resolveNewType('Recipe', '/my/vault') + const { entry, content } = resolveNewType({ typeName: 'Recipe', vaultPath: '/my/vault' }) expect(entry.path).toBe('/my/vault/recipe.md') expect(entry.isA).toBe('Type') expect(entry.status).toBeNull() @@ -298,7 +298,7 @@ describe('resolveNewType', () => { }) it('uses provided vault path instead of hardcoded path', () => { - const { entry } = resolveNewType('Responsibility', '/other/vault') + const { entry } = resolveNewType({ typeName: 'Responsibility', vaultPath: '/other/vault' }) expect(entry.path).toBe('/other/vault/responsibility.md') expect(entry.path).not.toContain('/Users/luca/Laputa') }) diff --git a/src/hooks/useNoteCreation.test.ts b/src/hooks/useNoteCreation.test.ts index 6e8b6140..0a77f6be 100644 --- a/src/hooks/useNoteCreation.test.ts +++ b/src/hooks/useNoteCreation.test.ts @@ -79,11 +79,11 @@ describe('buildNewEntry', () => { describe('generateUntitledName', () => { it('returns base name when no conflicts', () => { - expect(generateUntitledName([], 'Note')).toBe('Untitled note') + expect(generateUntitledName({ entries: [], type: 'Note' })).toBe('Untitled note') }) it('appends counter when base name exists', () => { - expect(generateUntitledName([makeEntry({ title: 'Untitled note' })], 'Note')).toBe('Untitled note 2') + expect(generateUntitledName({ entries: [makeEntry({ title: 'Untitled note' })], type: 'Note' })).toBe('Untitled note 2') }) it('increments counter past existing numbered entries', () => { @@ -92,50 +92,50 @@ describe('generateUntitledName', () => { makeEntry({ title: 'Untitled note 2' }), makeEntry({ title: 'Untitled note 3' }), ] - expect(generateUntitledName(entries, 'Note')).toBe('Untitled note 4') + expect(generateUntitledName({ entries, type: 'Note' })).toBe('Untitled note 4') }) it('avoids names in the pending set', () => { - expect(generateUntitledName([], 'Note', new Set(['Untitled note']))).toBe('Untitled note 2') + expect(generateUntitledName({ entries: [], type: 'Note', pendingTitles: new Set(['Untitled note']) })).toBe('Untitled note 2') }) }) describe('entryMatchesTarget', () => { it('matches by exact title (case-insensitive)', () => { - expect(entryMatchesTarget(makeEntry({ title: 'My Project' }), 'my project')).toBe(true) + expect(entryMatchesTarget({ entry: makeEntry({ title: 'My Project' }), target: 'my project' })).toBe(true) }) it('matches by alias', () => { - expect(entryMatchesTarget(makeEntry({ aliases: ['MP'] }), 'mp')).toBe(true) + expect(entryMatchesTarget({ entry: makeEntry({ aliases: ['MP'] }), target: 'mp' })).toBe(true) }) it('returns false when nothing matches', () => { - expect(entryMatchesTarget(makeEntry({ title: 'Something' }), 'nonexistent')).toBe(false) + expect(entryMatchesTarget({ entry: makeEntry({ title: 'Something' }), target: 'nonexistent' })).toBe(false) }) }) describe('buildNoteContent', () => { it('generates frontmatter with title and status', () => { - expect(buildNoteContent('My Note', 'Note', 'Active')).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n') + expect(buildNoteContent({ title: 'My Note', type: 'Note', status: 'Active' })).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n') }) it('omits title when null', () => { - expect(buildNoteContent(null, 'Note', 'Active')).toBe('---\ntype: Note\nstatus: Active\n---\n') + expect(buildNoteContent({ title: null, type: 'Note', status: 'Active' })).toBe('---\ntype: Note\nstatus: Active\n---\n') }) it('omits status when null', () => { - expect(buildNoteContent('AI', 'Topic', null)).toBe('---\ntitle: AI\ntype: Topic\n---\n') + expect(buildNoteContent({ title: 'AI', type: 'Topic', status: null })).toBe('---\ntitle: AI\ntype: Topic\n---\n') }) it('includes template body when provided', () => { - const content = buildNoteContent('P', 'Project', 'Active', '## Objective\n\n') + const content = buildNoteContent({ title: 'P', type: 'Project', status: 'Active', template: '## Objective\n\n' }) expect(content).toContain('## Objective') }) }) describe('resolveNewNote', () => { it('creates note at vault root', () => { - const { entry, content } = resolveNewNote('My Project', 'Project', '/vault') + const { entry, content } = resolveNewNote({ title: 'My Project', type: 'Project', vaultPath: '/vault' }) expect(entry.path).toBe('/vault/my-project.md') expect(entry.isA).toBe('Project') expect(entry.status).toBe('Active') @@ -143,14 +143,14 @@ describe('resolveNewNote', () => { }) it('omits status for Topic type', () => { - const { entry } = resolveNewNote('ML', 'Topic', '/vault') + const { entry } = resolveNewNote({ title: 'ML', type: 'Topic', vaultPath: '/vault' }) expect(entry.status).toBeNull() }) }) describe('resolveNewType', () => { it('creates a type entry', () => { - const { entry, content } = resolveNewType('Recipe', '/vault') + const { entry, content } = resolveNewType({ typeName: 'Recipe', vaultPath: '/vault' }) expect(entry.path).toBe('/vault/recipe.md') expect(entry.isA).toBe('Type') expect(content).toContain('type: Type') @@ -160,15 +160,15 @@ describe('resolveNewType', () => { describe('resolveTemplate', () => { it('returns template from type entry when set', () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n' }) - expect(resolveTemplate([typeEntry], 'Recipe')).toBe('## Ingredients\n\n') + expect(resolveTemplate({ entries: [typeEntry], typeName: 'Recipe' })).toBe('## Ingredients\n\n') }) it('falls back to DEFAULT_TEMPLATES', () => { - expect(resolveTemplate([], 'Project')).toBe(DEFAULT_TEMPLATES.Project) + expect(resolveTemplate({ entries: [], typeName: 'Project' })).toBe(DEFAULT_TEMPLATES.Project) }) it('returns null when no template and no default', () => { - expect(resolveTemplate([], 'CustomType')).toBeNull() + expect(resolveTemplate({ entries: [], typeName: 'CustomType' })).toBeNull() }) }) @@ -266,12 +266,42 @@ describe('useNoteCreation hook', () => { vi.restoreAllMocks() }) + it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', () => { + vi.spyOn(Date, 'now').mockReturnValue(1700000000000) + const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps)) + + act(() => { + result.current.handleCreateNoteImmediate() + result.current.handleCreateNoteImmediate() + }) + + const filenames = addEntry.mock.calls.map(([entry]: [VaultEntry]) => entry.filename) + expect(filenames).toEqual([ + 'untitled-note-1700000000.md', + 'untitled-note-1700000000-2.md', + ]) + + vi.restoreAllMocks() + }) + it('handleCreateNoteImmediate accepts custom type', () => { const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps)) act(() => { result.current.handleCreateNoteImmediate('Project') }) expect(addEntry.mock.calls[0][0].isA).toBe('Project') }) + it('handleCreateNoteImmediate slugifies custom type names for filenames', () => { + vi.spyOn(Date, 'now').mockReturnValue(1700000000000) + const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps)) + + act(() => { + result.current.handleCreateNoteImmediate('Q&A / Ops') + }) + + expect(addEntry.mock.calls[0][0].filename).toBe('untitled-q-a-ops-1700000000.md') + vi.restoreAllMocks() + }) + it('handleCreateNoteImmediate tracks unsaved state', async () => { const trackUnsaved = vi.fn() const markContentPending = vi.fn() diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index 0eac72cf..97e57006 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -35,10 +35,16 @@ function slug_to_title(slug: string): string { } /** Generate a unique "Untitled " name by checking existing entries and pending names. */ -export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set): string { +export interface UntitledNameParams { + entries: VaultEntry[] + type: string + pendingTitles?: Set +} + +export function generateUntitledName({ entries, type, pendingTitles }: UntitledNameParams): string { const baseName = `Untitled ${type.toLowerCase()}` const existingTitles = new Set(entries.map(e => e.title)) - if (pending) pending.forEach(n => existingTitles.add(n)) + if (pendingTitles) pendingTitles.forEach((title) => existingTitles.add(title)) let title = baseName let counter = 2 while (existingTitles.has(title)) { @@ -48,8 +54,13 @@ export function generateUntitledName(entries: VaultEntry[], type: string, pendin return title } -export function entryMatchesTarget(e: VaultEntry, target: string): boolean { - return resolveEntry([e], target) === e +export interface EntryMatchParams { + entry: VaultEntry + target: string +} + +export function entryMatchesTarget({ entry, target }: EntryMatchParams): boolean { + return resolveEntry([entry], target) === entry } const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal']) @@ -63,12 +74,24 @@ export const DEFAULT_TEMPLATES: Record = { } /** Look up the template for a given type from the type entry or defaults. */ -export function resolveTemplate(entries: VaultEntry[], typeName: string): string | null { - const typeEntry = entries.find(e => e.isA === 'Type' && e.title === typeName) +export interface TemplateLookupParams { + entries: VaultEntry[] + typeName: string +} + +export function resolveTemplate({ entries, typeName }: TemplateLookupParams): string | null { + const typeEntry = entries.find((entry) => entry.isA === 'Type' && entry.title === typeName) return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null } -export function buildNoteContent(title: string | null, type: string, status: string | null, template?: string | null): string { +export interface NoteContentParams { + title: string | null + type: string + status: string | null + template?: string | null +} + +export function buildNoteContent({ title, type, status, template }: NoteContentParams): string { const lines = ['---'] if (title) lines.push(`title: ${title}`) lines.push(`type: ${type}`) @@ -78,14 +101,26 @@ export function buildNoteContent(title: string | null, type: string, status: str return `${lines.join('\n')}\n${body}` } -export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { entry: VaultEntry; content: string } { +export interface NewNoteParams { + title: string + type: string + vaultPath: string + template?: string | null +} + +export function resolveNewNote({ title, type, vaultPath, template }: NewNoteParams): { entry: VaultEntry; content: string } { const slug = slugify(title) const status = NO_STATUS_TYPES.has(type) ? null : 'Active' const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title, type, status }) - return { entry, content: buildNoteContent(title, type, status, template) } + return { entry, content: buildNoteContent({ title, type, status, template }) } } -export function resolveNewType(typeName: string, vaultPath: string): { entry: VaultEntry; content: string } { +export interface NewTypeParams { + typeName: string + vaultPath: string +} + +export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } { const slug = slugify(typeName) const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null }) return { entry, content: `---\ntype: Type\n---\n` } @@ -168,7 +203,7 @@ function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => voi interface ImmediateCreateDeps { entries: VaultEntry[] vaultPath: string - pendingNames: Set + pendingSlugs: Set openTabWithContent: (entry: VaultEntry, content: string) => void addEntry: (entry: VaultEntry) => void trackUnsaved?: (path: string) => void @@ -176,21 +211,32 @@ interface ImmediateCreateDeps { } /** Generate a unique untitled filename using a timestamp. */ -function generateUntitledFilename(type: string): string { +function generateUntitledFilename(entries: VaultEntry[], type: string, pendingSlugs?: Set): string { const ts = Math.floor(Date.now() / 1000) - const prefix = type === 'Note' ? 'untitled-note' : `untitled-${type.toLowerCase()}` - return `${prefix}-${ts}` + const typeSlug = type === 'Note' ? 'note' : slugify(type) + const base = `untitled-${typeSlug}-${ts}` + const existingSlugs = new Set(entries.map((entry) => entry.filename.replace(/\.md$/, ''))) + + let candidate = base + let suffix = 2 + while (existingSlugs.has(candidate) || pendingSlugs?.has(candidate)) { + candidate = `${base}-${suffix}` + suffix += 1 + } + + pendingSlugs?.add(candidate) + return candidate } /** Create an untitled note without persisting to disk (deferred save). */ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void { const noteType = type || 'Note' - const slug = generateUntitledFilename(noteType) + const slug = generateUntitledFilename(deps.entries, noteType, deps.pendingSlugs) const title = slug_to_title(slug) - const template = resolveTemplate(deps.entries, noteType) + const template = resolveTemplate({ entries: deps.entries, typeName: noteType }) const status = NO_STATUS_TYPES.has(noteType) ? null : 'Active' const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status }) - const content = buildNoteContent(null, noteType, status, template) + const content = buildNoteContent({ title: null, type: noteType, status, template }) deps.openTabWithContent(entry, content) addEntryWithMock(entry, content, deps.addEntry) deps.trackUnsaved?.(entry.path) @@ -210,8 +256,8 @@ interface RelationshipCreateDeps { /** Create a note for a relationship link; persist in background. */ function createNoteForRelationship(deps: RelationshipCreateDeps, title: string): void { - const template = resolveTemplate(deps.entries, 'Note') - const resolved = resolveNewNote(title, 'Note', deps.vaultPath, template) + const template = resolveTemplate({ entries: deps.entries, typeName: 'Note' }) + const resolved = resolveNewNote({ title, type: 'Note', vaultPath: deps.vaultPath, template }) deps.openTabWithContent(resolved.entry, resolved.content) addEntryWithMock(resolved.entry, resolved.content, deps.addEntry) persistNewNote(resolved.entry.path, resolved.content) @@ -251,7 +297,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab setToastMessage('Failed to create note — disk write error') }, [removeEntry, setToastMessage]) - const pendingNamesRef = useRef>(new Set()) + const pendingSlugsRef = useRef>(new Set()) const persistNew: PersistFn = useCallback( (resolved) => createAndPersist(resolved, addEntry, openTabWithContent, { @@ -264,14 +310,14 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab ) const handleCreateNote = useCallback((title: string, type: string) => { - const template = resolveTemplate(entries, type) - persistNew(resolveNewNote(title, type, config.vaultPath, template)) + const template = resolveTemplate({ entries, typeName: type }) + persistNew(resolveNewNote({ title, type, vaultPath: config.vaultPath, template })) trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: 'plus_button' }) }, [entries, persistNew, config.vaultPath]) const handleCreateNoteImmediate = useCallback((type?: string) => { createNoteImmediate({ - entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current, + entries, vaultPath: config.vaultPath, pendingSlugs: pendingSlugsRef.current, openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending, }, type) trackEvent('note_created', { has_type: type ? 1 : 0, creation_path: type ? 'type_section' : 'cmd_n' }) @@ -288,13 +334,13 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath]) const handleCreateType = useCallback((typeName: string) => { - persistNew(resolveNewType(typeName, config.vaultPath)) + persistNew(resolveNewType({ typeName, vaultPath: config.vaultPath })) trackEvent('type_created') }, [persistNew, config.vaultPath]) /** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */ const createTypeEntrySilent = useCallback(async (typeName: string): Promise => { - const resolved = resolveNewType(typeName, config.vaultPath) + const resolved = resolveNewType({ typeName, vaultPath: config.vaultPath }) addEntryWithMock(resolved.entry, resolved.content, addEntry) await persistNewNote(resolved.entry.path, resolved.content) return resolved.entry diff --git a/tests/smoke/fix-crash-create-note.spec.ts b/tests/smoke/fix-crash-create-note.spec.ts index cea2eb4e..3ec5aac4 100644 --- a/tests/smoke/fix-crash-create-note.spec.ts +++ b/tests/smoke/fix-crash-create-note.spec.ts @@ -1,41 +1,49 @@ -import { test, expect } from '@playwright/test' +import { test, expect, type Page } from '@playwright/test' import { sendShortcut } from './helpers' +import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault' + +let tempVaultDir: string + +async function selectSection(page: Page, label: string): Promise { + await page.locator('aside').getByText(label, { exact: true }).first().click() +} + +async function createNoteFromListHeader(page: Page): Promise { + await page.locator('button[title="Create new note"]').click() +} + +function untitledRow(page: Page, typeLabel: string) { + return page.getByText(new RegExp(`^Untitled ${typeLabel}(?: \\d+)?$`, 'i')).first() +} test.describe('Create note crash fix', () => { test.beforeEach(async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') + tempVaultDir = createFixtureVaultCopy() + await openFixtureVault(page, tempVaultDir) + }) + + test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) }) test('clicking + next to a type section creates a note without crashing', async ({ page }) => { const errors: string[] = [] page.on('pageerror', (err) => errors.push(err.message)) - // Hover over the Projects section to reveal the + button - const sectionHeader = page.getByText('Projects').first() - await sectionHeader.hover() - - // Click the actual create button (exact match avoids the parent sortable div) - const createBtn = page.getByRole('button', { name: 'Create new Project', exact: true }) - await createBtn.click({ force: true }) - - // The new note should appear — check for tab + heading - await expect(page.getByText('Untitled project').first()).toBeVisible({ timeout: 3000 }) + await selectSection(page, 'Projects') + await createNoteFromListHeader(page) + await expect(untitledRow(page, 'project')).toBeVisible({ timeout: 5_000 }) expect(errors).toEqual([]) }) - test('Cmd+N creates a note without crashing', async ({ page }) => { + test('Cmd+N creates a note without crashing @smoke', async ({ page }) => { const errors: string[] = [] page.on('pageerror', (err) => errors.push(err.message)) await page.locator('body').click() await sendShortcut(page, 'n', ['Control']) - - await page.waitForTimeout(500) - - // An "Untitled note" tab should be visible - await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 }) + await expect(untitledRow(page, 'note')).toBeVisible({ timeout: 5_000 }) expect(errors).toEqual([]) }) @@ -44,32 +52,9 @@ test.describe('Create note crash fix', () => { const errors: string[] = [] page.on('pageerror', (err) => errors.push(err.message)) - // Hover over a custom type section (Areas exists in mock vault) - const sectionHeader = page.getByText('Areas').first() - await sectionHeader.hover() - - const createBtn = page.getByRole('button', { name: 'Create new Area', exact: true }) - await createBtn.click({ force: true }) - - await expect(page.getByText('Untitled area').first()).toBeVisible({ timeout: 3000 }) - - expect(errors).toEqual([]) - }) - - test('rapid note creation does not crash', async ({ page }) => { - const errors: string[] = [] - page.on('pageerror', (err) => errors.push(err.message)) - - const sectionHeader = page.getByText('Experiments').first() - await sectionHeader.hover() - - const createBtn = page.getByRole('button', { name: 'Create new Experiment', exact: true }) - await createBtn.click({ force: true }) - await page.waitForTimeout(200) - await createBtn.click({ force: true }) - - // At least one untitled experiment should exist (single-note model: second replaces first) - await expect(page.getByText('Untitled experiment').first()).toBeVisible({ timeout: 5000 }) + await selectSection(page, 'Events') + await createNoteFromListHeader(page) + await expect(untitledRow(page, 'event')).toBeVisible({ timeout: 5_000 }) expect(errors).toEqual([]) })