diff --git a/src/App.tsx b/src/App.tsx index 94be4332..0cd9ff68 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,7 +12,7 @@ import { SettingsPanel } from './components/SettingsPanel' import { GitHubVaultModal } from './components/GitHubVaultModal' import { useVaultLoader } from './hooks/useVaultLoader' import { useSettings } from './hooks/useSettings' -import { useNoteActions, generateUntitledName } from './hooks/useNoteActions' +import { useNoteActions } from './hooks/useNoteActions' import { useEditorSave } from './hooks/useEditorSave' import { useCommitFlow } from './hooks/useCommitFlow' import { useAppKeyboard } from './hooks/useAppKeyboard' @@ -105,12 +105,7 @@ function App() { setToastMessage, }) - // Immediate note creation — no dialog, just create and open - const handleCreateNoteImmediate = useCallback((type?: string) => { - const noteType = type || 'Note' - notes.handleCreateNote(generateUntitledName(vault.entries, noteType), noteType) - window.dispatchEvent(new CustomEvent('laputa:focus-editor')) - }, [vault.entries, notes]) + const handleCreateNoteImmediate = notes.handleCreateNoteImmediate const handleSwitchVault = useCallback((path: string) => { setVaultPath(path) diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 53bd2deb..0a83e39d 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -129,6 +129,17 @@ describe('generateUntitledName', () => { it('uses type name in lowercase', () => { expect(generateUntitledName([], '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') + }) + + 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') + }) }) describe('entryMatchesTarget', () => { @@ -373,6 +384,48 @@ describe('useNoteActions hook', () => { expect(setToastMessage).toHaveBeenCalledWith('Property deleted') }) + it('handleCreateNoteImmediate creates note with auto-generated title', () => { + const { result } = renderHook(() => useNoteActions(makeConfig())) + + act(() => { + result.current.handleCreateNoteImmediate() + }) + + expect(addEntry).toHaveBeenCalledTimes(1) + const [createdEntry] = addEntry.mock.calls[0] + expect(createdEntry.title).toBe('Untitled note') + expect(createdEntry.isA).toBe('Note') + }) + + it('handleCreateNoteImmediate generates unique names on rapid calls', () => { + const { result } = renderHook(() => useNoteActions(makeConfig())) + + act(() => { + result.current.handleCreateNoteImmediate() + result.current.handleCreateNoteImmediate() + result.current.handleCreateNoteImmediate() + }) + + expect(addEntry).toHaveBeenCalledTimes(3) + const titles = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.title) + expect(titles[0]).toBe('Untitled note') + expect(titles[1]).toBe('Untitled note 2') + expect(titles[2]).toBe('Untitled note 3') + }) + + it('handleCreateNoteImmediate accepts custom type', () => { + const { result } = renderHook(() => useNoteActions(makeConfig())) + + act(() => { + result.current.handleCreateNoteImmediate('Project') + }) + + expect(addEntry).toHaveBeenCalledTimes(1) + const [createdEntry] = addEntry.mock.calls[0] + expect(createdEntry.title).toBe('Untitled project') + expect(createdEntry.isA).toBe('Project') + }) + it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => { const { result } = renderHook(() => useNoteActions(makeConfig())) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index c20d00ae..7dd2aa58 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -49,6 +49,15 @@ async function loadNoteContent(path: string): Promise { : mockInvoke('get_note_content', { path }) } +/** Fire-and-forget: persist a newly created note to disk. */ +function persistNewNote(path: string, content: string): void { + if (isTauri()) { + invoke('save_note_content', { path, content }).catch((err) => + console.error('Failed to persist new note:', err), + ) + } +} + export function buildNewEntry({ path, slug, title, type, status }: NewEntryParams): VaultEntry { const now = Math.floor(Date.now() / 1000) return { @@ -64,10 +73,11 @@ export function slugify(text: string): string { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') } -/** Generate a unique "Untitled " name by checking existing entries. */ -export function generateUntitledName(entries: VaultEntry[], type: string): string { +/** Generate a unique "Untitled " name by checking existing entries and pending names. */ +export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set): string { const baseName = `Untitled ${type.toLowerCase()}` const existingTitles = new Set(entries.map(e => e.title)) + if (pending) pending.forEach(n => existingTitles.add(n)) let title = baseName let counter = 2 while (existingTitles.has(title)) { @@ -195,7 +205,7 @@ async function reloadTabsAfterRename( export function useNoteActions(config: NoteActionsConfig) { const { addEntry, updateContent, entries, setToastMessage, updateEntry } = config const tabMgmt = useTabManagement() - const { setTabs, handleSelectNote, activeTabPathRef, handleSwitchTab } = tabMgmt + const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt const tabsRef = useRef(tabMgmt.tabs) // eslint-disable-next-line react-hooks/refs tabsRef.current = tabMgmt.tabs @@ -211,17 +221,31 @@ export function useNoteActions(config: NoteActionsConfig) { else console.warn(`Navigation target not found: ${target}`) }, [entries, handleSelectNote]) + const pendingNamesRef = useRef>(new Set()) + const handleCreateNote = useCallback((title: string, type: string) => { const { entry, content } = resolveNewNote(title, type) addEntryWithMock(entry, content, addEntry) - handleSelectNote(entry) - }, [handleSelectNote, addEntry]) + openTabWithContent(entry, content) + persistNewNote(entry.path, content) + }, [openTabWithContent, addEntry]) + + /** Create a note immediately with an auto-generated unique title. Dedup-safe for rapid calls. */ + const handleCreateNoteImmediate = useCallback((type?: string) => { + const noteType = type || 'Note' + const title = generateUntitledName(entries, noteType, pendingNamesRef.current) + pendingNamesRef.current.add(title) + handleCreateNote(title, noteType) + window.dispatchEvent(new CustomEvent('laputa:focus-editor')) + setTimeout(() => pendingNamesRef.current.delete(title), 500) + }, [entries, handleCreateNote]) const handleCreateType = useCallback((typeName: string) => { const { entry, content } = resolveNewType(typeName) addEntryWithMock(entry, content, addEntry) - handleSelectNote(entry) - }, [handleSelectNote, addEntry]) + openTabWithContent(entry, content) + persistNewNote(entry.path, content) + }, [openTabWithContent, addEntry]) const runFrontmatterOp = useCallback(async (op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue) => { try { @@ -267,6 +291,7 @@ export function useNoteActions(config: NoteActionsConfig) { ...tabMgmt, handleNavigateWikilink, handleCreateNote, + handleCreateNoteImmediate, handleCreateType, handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]), handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]), diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 7d7a3498..76b82f33 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -126,6 +126,13 @@ export function useTabManagement() { setTabs((prev) => { const next = reorderArray(prev, fromIndex, toIndex); saveTabOrder(next); return next }) }, []) + /** Open a tab with known content — no IPC round-trip. Used for newly created notes. */ + const openTabWithContent = useCallback((entry: VaultEntry, content: string) => { + if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return } + setTabs((prev) => addTabIfAbsent(prev, entry, content)) + setActiveTabPath(entry.path) + }, []) + const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return } const currentPath = activeTabPathRef.current @@ -158,6 +165,7 @@ export function useTabManagement() { activeTabPathRef, handleCloseTabRef, handleSelectNote, + openTabWithContent, handleCloseTab, handleSwitchTab, handleReorderTabs, diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 42b9ebba..55404b81 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -98,6 +98,28 @@ describe('useVaultLoader', () => { expect(result.current.entries[0].title).toBe('New Note') expect(result.current.allContent['/vault/note/new.md']).toBe('# New Note') }) + + it('ignores duplicate entry with same path', async () => { + const { result } = renderHook(() => useVaultLoader('/vault')) + + await waitFor(() => { + expect(result.current.entries).toHaveLength(1) + }) + + const newEntry: VaultEntry = { + ...mockEntries[0], + path: '/vault/note/new.md', + filename: 'new.md', + title: 'New Note', + } + + act(() => { + result.current.addEntry(newEntry, '# New Note') + result.current.addEntry(newEntry, '# New Note') + }) + + expect(result.current.entries).toHaveLength(2) + }) }) describe('updateContent', () => { diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 5bcf3b92..6807247a 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -77,7 +77,10 @@ export function useVaultLoader(vaultPath: string) { useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load const addEntry = useCallback((entry: VaultEntry, content: string) => { - setEntries((prev) => [entry, ...prev]) + setEntries((prev) => { + if (prev.some(e => e.path === entry.path)) return prev + return [entry, ...prev] + }) setAllContent((prev) => ({ ...prev, [entry.path]: content })) tracker.trackNew(entry.path) }, [tracker])