diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index db3d9939..6cc9317f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -58,7 +58,7 @@ flowchart LR #### Invariants 1. **Disk-first writes**: All functions that change vault data must write to disk (via Tauri IPC) *before* updating React state. This ensures that if the disk write fails, React state remains consistent with what's actually on disk. -2. **Optimistic UI with rollback**: Where responsiveness matters (e.g. `persistOptimistic` in `useNoteActions`), state may update before disk confirmation — but a failure callback must revert the optimistic state. +2. **Optimistic UI with rollback**: Where responsiveness matters (e.g. `persistOptimistic` in `useNoteCreation`), state may update before disk confirmation — but a failure callback must revert the optimistic state. 3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update. 4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file. 5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem. @@ -723,7 +723,9 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: |-------------|-------|---------| | `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state | | `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data | -| `useNoteActions` | `tabs`, `activeTabPath` | Open tabs and note operations | +| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + frontmatter ops | +| `useNoteCreation` | — (delegates to `useTabManagement`) | Note/type/daily-note creation with optimistic persistence | +| `useNoteRename` | — (delegates to `useTabManagement`) | Note renaming with wikilink update | | `useTabManagement` | Tab ordering, pinning, swapping, closed-tab history | Tab lifecycle | | `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching | | `useThemeManager` | `themes`, `activeThemeId`, `isDark` | Theme state | diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 97abd5f7..b157d72d 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -85,7 +85,9 @@ laputa-app/ │ │ ├── useVaultLoader.ts # Loads vault entries + content │ │ ├── useVaultSwitcher.ts # Multi-vault management │ │ ├── useVaultConfig.ts # Per-vault UI settings -│ │ ├── useNoteActions.ts # Tab management, navigation, CRUD +│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter +│ │ ├── useNoteCreation.ts # Note/type/daily-note creation +│ │ ├── useNoteRename.ts # Note renaming + wikilink updates │ │ ├── useTabManagement.ts # Tab ordering + lifecycle │ │ ├── useAIChat.ts # AI chat state │ │ ├── useAiAgent.ts # AI agent state + tool tracking @@ -205,7 +207,7 @@ laputa-app/ | File | Why it matters | |------|---------------| | `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. | -| `src/hooks/useNoteActions.ts` | Tab management, wikilink navigation, frontmatter CRUD. The biggest hook. | +| `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. | | `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, Getting Started vault. | | `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. | diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index b122047c..81a4430c 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -1,24 +1,24 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback } from 'react' import { invoke } from '@tauri-apps/api/core' -import { isTauri, mockInvoke, addMockEntry, updateMockContent, trackMockChange } from '../mock-tauri' +import { isTauri } from '../mock-tauri' import type { VaultEntry } from '../types' import type { FrontmatterValue } from '../components/Inspector' import { useTabManagement } from './useTabManagement' import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers' +import { updateMockContent, trackMockChange } from '../mock-tauri' import { resolveEntry } from '../utils/wikilink' +import { useNoteCreation } from './useNoteCreation' +import { + useNoteRename, + performRename, loadNoteContent, renameToastMessage, reloadTabsAfterRename, +} from './useNoteRename' -interface NewEntryParams { - path: string - slug: string - title: string - type: string - status: string | null -} - -interface RenameResult { - new_path: string - updated_files: number -} +// Re-export pure functions so existing consumers don't need to change imports. +export { buildNewEntry, slugify, generateUntitledName, entryMatchesTarget } from './useNoteCreation' +export { buildNoteContent, resolveNewNote, resolveNewType, resolveTemplate, DEFAULT_TEMPLATES } from './useNoteCreation' +export { todayDateString, buildDailyNoteContent, resolveDailyNote, findDailyNote } from './useNoteCreation' +export { needsRenameOnSave } from './useNoteRename' +export type { NewEntryParams } from './useNoteCreation' export interface NoteActionsConfig { addEntry: (entry: VaultEntry) => void @@ -32,11 +32,8 @@ export interface NoteActionsConfig { trackUnsaved?: (path: string) => void clearUnsaved?: (path: string) => void unsavedPaths?: Set - /** Called when an unsaved note is created so the save system can buffer its initial content. */ markContentPending?: (path: string, content: string) => void - /** Called after a new note is persisted to disk (e.g. to refresh git status for Changes view). */ onNewNotePersisted?: () => void - /** Replace an entry at oldPath with a patch (handles path changes in entries). */ replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void } @@ -45,108 +42,6 @@ function isTitleKey(key: string): boolean { return key.toLowerCase().replace(/\s+/g, '_') === 'title' } -async function performRename( - path: string, - newTitle: string, - vaultPath: string, - oldTitle?: string, -): Promise { - if (isTauri()) { - return invoke('rename_note', { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null }) - } - return mockInvoke('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null }) -} - -function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry { - const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') - return { ...entry, path: newPath, filename: `${slug}.md`, title: newTitle } -} - -async function loadNoteContent(path: string): Promise { - return isTauri() - ? invoke('get_note_content', { path }) - : mockInvoke('get_note_content', { path }) -} - -/** Persist a newly created note to disk. Returns a Promise for error handling. */ -function persistNewNote(path: string, content: string): Promise { - if (!isTauri()) return Promise.resolve() - return invoke('save_note_content', { path, content }).then(() => {}) -} - -export function buildNewEntry({ path, slug, title, type, status }: NewEntryParams): VaultEntry { - const now = Math.floor(Date.now() / 1000) - return { - path, filename: `${slug}.md`, title, isA: type, - aliases: [], belongsTo: [], relatedTo: [], - status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, - modifiedAt: now, createdAt: now, fileSize: 0, - snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, - } -} - -export function slugify(text: string): string { - const result = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') - return result || 'untitled' -} - -/** Check if a note's filename doesn't match the slug of its current title. */ -export function needsRenameOnSave(title: string, filename: string): boolean { - return `${slugify(title)}.md` !== filename -} - -/** 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)) { - title = `${baseName} ${counter}` - counter++ - } - return title -} - -export function entryMatchesTarget(e: VaultEntry, target: string): boolean { - return resolveEntry([e], target) === e -} - -async function invokeFrontmatter(command: string, args: Record): Promise { - return invoke(command, args) -} - -function applyMockFrontmatterUpdate(path: string, key: string, value: FrontmatterValue): string { - const content = updateMockFrontmatter(path, key, value) - updateMockContent(path, content) - trackMockChange(path) - return content -} - -function applyMockFrontmatterDelete(path: string, key: string): string { - const content = deleteMockFrontmatterProperty(path, key) - updateMockContent(path, content) - trackMockChange(path) - return content -} - -const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal']) - -/** Default templates for built-in types. Used when the type entry has no custom template. */ -export const DEFAULT_TEMPLATES: Record = { - Project: '## Objective\n\n\n\n## Key Results\n\n\n\n## Notes\n\n', - Person: '## Role\n\n\n\n## Contact\n\n\n\n## Notes\n\n', - Responsibility: '## Description\n\n\n\n## Key Activities\n\n\n\n## Notes\n\n', - Experiment: '## Hypothesis\n\n\n\n## Method\n\n\n\n## Results\n\n\n\n## Conclusion\n\n', -} - -/** 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) - return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null -} - const ENTRY_DELETE_MAP: Record> = { type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null }, icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null }, @@ -177,108 +72,22 @@ export function frontmatterToEntryPatch( return updates[k] ?? {} } -function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry) => void) { - if (!isTauri()) addMockEntry(entry, content) - addEntry(entry) +async function invokeFrontmatter(command: string, args: Record): Promise { + return invoke(command, args) } -export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string { - const lines = ['---', `title: ${title}`, `type: ${type}`] - if (status) lines.push(`status: ${status}`) - lines.push('---') - const body = template ? `\n${template}` : '\n' - return `${lines.join('\n')}\n\n# ${title}\n${body}` +function applyMockFrontmatterUpdate(path: string, key: string, value: FrontmatterValue): string { + const content = updateMockFrontmatter(path, key, value) + updateMockContent(path, content) + trackMockChange(path) + return content } -export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { 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) } -} - -export function resolveNewType(typeName: string, vaultPath: string): { 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\n# ${typeName}\n\n` } -} - -export function todayDateString(): string { - return new Date().toISOString().split('T')[0] -} - -export function buildDailyNoteContent(date: string): string { - const lines = ['---', `title: ${date}`, 'type: Journal', `date: ${date}`, '---'] - return `${lines.join('\n')}\n\n# ${date}\n\n## Intentions\n\n\n\n## Reflections\n\n` -} - -export function resolveDailyNote(date: string, vaultPath: string): { entry: VaultEntry; content: string } { - const entry = buildNewEntry({ path: `${vaultPath}/${date}.md`, slug: date, title: date, type: 'Journal', status: null }) - return { entry, content: buildDailyNoteContent(date) } -} - -export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry | undefined { - return entries.find(e => e.filename === `${date}.md` && e.isA === 'Journal') -} - -type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void - -/** Open today's daily note: navigate to it if it exists, or create + persist a new one. */ -function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn, vaultPath: string): void { - const date = todayDateString() - const existing = findDailyNote(entries, date) - if (existing) selectNote(existing) - else persist(resolveDailyNote(date, vaultPath)) - signalFocusEditor() -} - -function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined { - return resolveEntry(entries, target) -} - -/** Navigate to a wikilink target, logging a warning if not found. */ -function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e: VaultEntry) => void): void { - const found = findWikilinkTarget(entries, target) - if (found) selectNote(found) - else console.warn(`Navigation target not found: ${target}`) -} - -/** Dispatch focus-editor event with perf timing marker. */ -function signalFocusEditor(opts?: { selectTitle?: boolean }): void { - window.dispatchEvent(new CustomEvent('laputa:focus-editor', { - detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false }, - })) -} - -interface PersistCallbacks { - onFail: (p: string) => void - onStart?: (p: string) => void - onEnd?: (p: string) => void - onPersisted?: () => void -} - -/** Persist to disk; track pending state via onStart/onEnd; revert on failure. */ -function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): void { - cbs.onStart?.(path) - persistNewNote(path, content) - .then(() => { cbs.onEnd?.(path); cbs.onPersisted?.() }) - .catch(() => { cbs.onEnd?.(path); cbs.onFail(path) }) -} - -/** Optimistically open tab, add entry to vault, and persist to disk. - * Tab creation (setTabs/setActiveTabPath) runs at normal priority so the - * tab appears instantly. addEntry uses startTransition internally so the - * expensive entries update (NoteList re-filter/sort on 9000+ entries) is - * deferred and doesn't block the tab from rendering. */ -function createAndPersist( - resolved: { entry: VaultEntry; content: string }, - addFn: (e: VaultEntry) => void, - openTab: (e: VaultEntry, c: string) => void, - cbs: PersistCallbacks, -): void { - openTab(resolved.entry, resolved.content) - addEntryWithMock(resolved.entry, resolved.content, addFn) - persistOptimistic(resolved.entry.path, resolved.content, cbs) +function applyMockFrontmatterDelete(path: string, key: string): string { + const content = deleteMockFrontmatterProperty(path, key) + updateMockContent(path, content) + trackMockChange(path) + return content } async function executeFrontmatterOp(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue): Promise { @@ -288,23 +97,6 @@ async function executeFrontmatterOp(op: 'update' | 'delete', path: string, key: return isTauri() ? invokeFrontmatter('delete_frontmatter_property', { path, key }) : applyMockFrontmatterDelete(path, key) } -function renameToastMessage(updatedFiles: number): string { - if (updatedFiles === 0) return 'Renamed' - return `Renamed — updated ${updatedFiles} wiki link${updatedFiles > 1 ? 's' : ''}` -} - -/** Reload content for open tabs whose wikilinks may have changed after a rename. */ -async function reloadTabsAfterRename( - tabPaths: string[], - updateTabContent: (path: string, content: string) => void, -): Promise { - for (const tabPath of tabPaths) { - try { - updateTabContent(tabPath, await loadNoteContent(tabPath)) - } catch { /* skip tabs that fail to reload */ } - } -} - /** Run a frontmatter update/delete and apply the result to state. */ async function runFrontmatterAndApply( op: 'update' | 'delete', path: string, key: string, value: FrontmatterValue | undefined, @@ -321,175 +113,101 @@ async function runFrontmatterAndApply( } } +interface TitleRenameDeps { + vaultPath: string + tabsRef: React.MutableRefObject<{ entry: VaultEntry; content: string }[]> + replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void + setTabs: React.Dispatch> + activeTabPathRef: React.MutableRefObject + handleSwitchTab: (path: string) => void + setToastMessage: (msg: string | null) => void + updateTabContent: (path: string, content: string) => void +} + +/** After a frontmatter title change, rename the file and update all tabs. */ +async function renameAfterTitleChange(path: string, newTitle: string, deps: TitleRenameDeps): Promise { + const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title + const result = await performRename(path, newTitle, deps.vaultPath, oldTitle) + if (result.new_path !== path) { + const newFilename = result.new_path.split('/').pop() ?? '' + deps.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: newTitle } as Partial & { path: string }) + const newContent = await loadNoteContent(result.new_path) + deps.setTabs(prev => prev.map(t => t.entry.path === path + ? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: newTitle }, content: newContent } + : t)) + if (deps.activeTabPathRef.current === path) deps.handleSwitchTab(result.new_path) + const otherTabPaths = deps.tabsRef.current.filter(t => t.entry.path !== path && t.entry.path !== result.new_path).map(t => t.entry.path) + await reloadTabsAfterRename(otherTabPaths, deps.updateTabContent) + } + deps.setToastMessage(renameToastMessage(result.updated_files)) +} + +function shouldRenameOnTitleUpdate(key: string, value: FrontmatterValue): value is string { + return isTitleKey(key) && typeof value === 'string' && value !== '' +} + +function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined { + return resolveEntry(entries, target) +} + +/** Navigate to a wikilink target, logging a warning if not found. */ +function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e: VaultEntry) => void): void { + const found = findWikilinkTarget(entries, target) + if (found) selectNote(found) + else console.warn(`Navigation target not found: ${target}`) +} + export function useNoteActions(config: NoteActionsConfig) { - const { addEntry, removeEntry, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config + const { entries, setToastMessage, updateEntry } = config const tabMgmt = useTabManagement() const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, handleCloseTabRef, activeTabPathRef, handleSwitchTab } = tabMgmt - const tabsRef = useRef(tabMgmt.tabs) - // eslint-disable-next-line react-hooks/refs - tabsRef.current = tabMgmt.tabs - const unsavedPathsRef = useRef(config.unsavedPaths) - // eslint-disable-next-line react-hooks/refs - unsavedPathsRef.current = config.unsavedPaths const updateTabContent = useCallback((path: string, newContent: string) => { setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t)) }, [setTabs]) + const creation = useNoteCreation(config, { openTabWithContent, handleSelectNote, handleCloseTab, handleCloseTabRef }) + const rename = useNoteRename( + { entries, setToastMessage }, + { tabs: tabMgmt.tabs, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent }, + ) + const handleNavigateWikilink = useCallback( (target: string) => navigateWikilink(entries, target, handleSelectNote), [entries, handleSelectNote], ) - const revertOptimisticNote = useCallback((path: string) => { - handleCloseTab(path) - removeEntry(path) - setToastMessage('Failed to create note — disk write error') - }, [handleCloseTab, removeEntry, setToastMessage]) - - const persistCbs: PersistCallbacks = { - onFail: revertOptimisticNote, - onStart: addPendingSave, - onEnd: removePendingSave, - onPersisted: config.onNewNotePersisted, - } - - const pendingNamesRef = useRef>(new Set()) - - const persistNew: PersistFn = useCallback( - (resolved) => createAndPersist(resolved, addEntry, openTabWithContent, persistCbs), - [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave], // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are - ) - - const handleCreateNote = useCallback((title: string, type: string) => { - const template = resolveTemplate(entries, type) - persistNew(resolveNewNote(title, type, config.vaultPath, template)) - }, [entries, persistNew, config.vaultPath]) - - const handleCreateNoteImmediate = useCallback((type?: string) => { - 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 - - /** Create a note with the given title, open it in a tab, and persist to disk. - * Returns true synchronously — persistence runs in the background to keep - * all React state updates batched in one tick (avoiding radix-ui infinite - * update loops that occur when `await` forces an early flush). */ - const handleCreateNoteForRelationship = useCallback((title: string): Promise => { - const template = resolveTemplate(entries, 'Note') - const resolved = resolveNewNote(title, 'Note', config.vaultPath, template) - openTabWithContent(resolved.entry, resolved.content) - addEntryWithMock(resolved.entry, resolved.content, addEntry) - // Fire-and-forget persistence — mirrors handleCreateNoteImmediate's pattern. - persistNewNote(resolved.entry.path, resolved.content) - .then(() => config.onNewNotePersisted?.()) - .catch(() => { - handleCloseTab(resolved.entry.path) - removeEntry(resolved.entry.path) - setToastMessage('Failed to create note — disk write error') - }) - return Promise.resolve(true) - }, [entries, openTabWithContent, addEntry, handleCloseTab, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable - - /** Close tab and discard entry+unsaved state if the note was never persisted. */ - const handleCloseTabWithCleanup = useCallback((path: string) => { - if (unsavedPathsRef.current?.has(path)) { removeEntry(path); config.clearUnsaved?.(path) } - handleCloseTab(path) - }, [handleCloseTab, removeEntry, config.clearUnsaved]) // eslint-disable-line react-hooks/exhaustive-deps -- ref access is stable - - // Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes. - useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup }) - - 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, 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) - addEntryWithMock(resolved.entry, resolved.content, addEntry) - await persistNewNote(resolved.entry.path, resolved.content) - return resolved.entry - }, [addEntry, config.vaultPath]) - - const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage } - const runFrontmatterOp = useCallback( (op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue) => - runFrontmatterAndApply(op, path, key, value, fmCallbacks), - [updateTabContent, updateEntry, setToastMessage], // eslint-disable-line react-hooks/exhaustive-deps -- fmCallbacks is stable when deps are + runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage }), + [updateTabContent, updateEntry, setToastMessage], ) - const handleRenameNote = useCallback(async ( - path: string, newTitle: string, vaultPath: string, - onEntryRenamed: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void, - ) => { - try { - const entry = entries.find((e) => e.path === path) - const result = await performRename(path, newTitle, vaultPath, entry?.title) - const newContent = await loadNoteContent(result.new_path) - const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path) - const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path).map(t => t.entry.path) - setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t)) - if (activeTabPathRef.current === path) handleSwitchTab(result.new_path) - onEntryRenamed(path, newEntry, newContent) - await reloadTabsAfterRename(otherTabPaths, updateTabContent) - setToastMessage(renameToastMessage(result.updated_files)) - } catch (err) { - console.error('Failed to rename note:', err) - setToastMessage('Failed to rename note') - } - }, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, setToastMessage]) - return { ...tabMgmt, - handleCloseTab: handleCloseTabWithCleanup, + handleCloseTab: creation.handleCloseTabWithCleanup, handleNavigateWikilink, - handleCreateNote, - handleCreateNoteImmediate, - handleCreateNoteForRelationship, - handleOpenDailyNote, - handleCreateType, - createTypeEntrySilent, + handleCreateNote: creation.handleCreateNote, + handleCreateNoteImmediate: creation.handleCreateNoteImmediate, + handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship, + handleOpenDailyNote: creation.handleOpenDailyNote, + handleCreateType: creation.handleCreateType, + createTypeEntrySilent: creation.createTypeEntrySilent, handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue) => { await runFrontmatterOp('update', path, key, value) - if (isTitleKey(key) && typeof value === 'string' && value !== '') { + if (shouldRenameOnTitleUpdate(key, value)) { try { - const oldTitle = tabsRef.current.find(t => t.entry.path === path)?.entry.title - const result = await performRename(path, value, config.vaultPath, oldTitle) - if (result.new_path !== path) { - const newFilename = result.new_path.split('/').pop() ?? '' - config.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: value } as Partial & { path: string }) - const newContent = await loadNoteContent(result.new_path) - setTabs(prev => prev.map(t => t.entry.path === path - ? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: value }, content: newContent } - : t)) - if (activeTabPathRef.current === path) handleSwitchTab(result.new_path) - const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path && t.entry.path !== result.new_path).map(t => t.entry.path) - await reloadTabsAfterRename(otherTabPaths, updateTabContent) - } - setToastMessage(renameToastMessage(result.updated_files)) + await renameAfterTitleChange(path, value, { + vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry, + setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent, + }) } catch (err) { console.error('Failed to rename note after title change:', err) } } - }, [runFrontmatterOp, config.vaultPath, config.replaceEntry, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]), + }, [runFrontmatterOp, config.vaultPath, config.replaceEntry, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]), handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]), handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]), - handleRenameNote, + handleRenameNote: rename.handleRenameNote, } } diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts new file mode 100644 index 00000000..6e27113a --- /dev/null +++ b/src/hooks/useNoteCreation.ts @@ -0,0 +1,316 @@ +import { useCallback, useEffect, useRef } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, addMockEntry } from '../mock-tauri' +import type { VaultEntry } from '../types' +import { resolveEntry } from '../utils/wikilink' + +export interface NewEntryParams { + path: string + slug: string + title: string + type: string + status: string | null +} + +export function buildNewEntry({ path, slug, title, type, status }: NewEntryParams): VaultEntry { + const now = Math.floor(Date.now() / 1000) + return { + path, filename: `${slug}.md`, title, isA: type, + aliases: [], belongsTo: [], relatedTo: [], + status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, + modifiedAt: now, createdAt: now, fileSize: 0, + snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, + } +} + +export function slugify(text: string): string { + 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. */ +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)) { + title = `${baseName} ${counter}` + counter++ + } + return title +} + +export function entryMatchesTarget(e: VaultEntry, target: string): boolean { + return resolveEntry([e], target) === e +} + +const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal']) + +/** Default templates for built-in types. Used when the type entry has no custom template. */ +export const DEFAULT_TEMPLATES: Record = { + Project: '## Objective\n\n\n\n## Key Results\n\n\n\n## Notes\n\n', + Person: '## Role\n\n\n\n## Contact\n\n\n\n## Notes\n\n', + Responsibility: '## Description\n\n\n\n## Key Activities\n\n\n\n## Notes\n\n', + Experiment: '## Hypothesis\n\n\n\n## Method\n\n\n\n## Results\n\n\n\n## Conclusion\n\n', +} + +/** 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) + return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null +} + +export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string { + const lines = ['---', `title: ${title}`, `type: ${type}`] + if (status) lines.push(`status: ${status}`) + lines.push('---') + const body = template ? `\n${template}` : '\n' + return `${lines.join('\n')}\n\n# ${title}\n${body}` +} + +export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { 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) } +} + +export function resolveNewType(typeName: string, vaultPath: string): { 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\n# ${typeName}\n\n` } +} + +export function todayDateString(): string { + return new Date().toISOString().split('T')[0] +} + +export function buildDailyNoteContent(date: string): string { + const lines = ['---', `title: ${date}`, 'type: Journal', `date: ${date}`, '---'] + return `${lines.join('\n')}\n\n# ${date}\n\n## Intentions\n\n\n\n## Reflections\n\n` +} + +export function resolveDailyNote(date: string, vaultPath: string): { entry: VaultEntry; content: string } { + const entry = buildNewEntry({ path: `${vaultPath}/${date}.md`, slug: date, title: date, type: 'Journal', status: null }) + return { entry, content: buildDailyNoteContent(date) } +} + +export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry | undefined { + return entries.find(e => e.filename === `${date}.md` && e.isA === 'Journal') +} + +/** Persist a newly created note to disk. Returns a Promise for error handling. */ +export function persistNewNote(path: string, content: string): Promise { + if (!isTauri()) return Promise.resolve() + return invoke('save_note_content', { path, content }).then(() => {}) +} + +function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry) => void) { + if (!isTauri()) addMockEntry(entry, content) + addEntry(entry) +} + +/** Dispatch focus-editor event with perf timing marker. */ +function signalFocusEditor(opts?: { selectTitle?: boolean }): void { + window.dispatchEvent(new CustomEvent('laputa:focus-editor', { + detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false }, + })) +} + +interface PersistCallbacks { + onFail: (p: string) => void + onStart?: (p: string) => void + onEnd?: (p: string) => void + onPersisted?: () => void +} + +/** Persist to disk; track pending state via onStart/onEnd; revert on failure. */ +function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): void { + cbs.onStart?.(path) + persistNewNote(path, content) + .then(() => { cbs.onEnd?.(path); cbs.onPersisted?.() }) + .catch(() => { cbs.onEnd?.(path); cbs.onFail(path) }) +} + +type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void + +/** Optimistically open tab, add entry to vault, and persist to disk. */ +function createAndPersist( + resolved: { entry: VaultEntry; content: string }, + addFn: (e: VaultEntry) => void, + openTab: (e: VaultEntry, c: string) => void, + cbs: PersistCallbacks, +): void { + openTab(resolved.entry, resolved.content) + addEntryWithMock(resolved.entry, resolved.content, addFn) + persistOptimistic(resolved.entry.path, resolved.content, cbs) +} + +/** Open today's daily note: navigate to it if it exists, or create + persist a new one. */ +function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn, vaultPath: string): void { + const date = todayDateString() + const existing = findDailyNote(entries, date) + if (existing) selectNote(existing) + else persist(resolveDailyNote(date, vaultPath)) + signalFocusEditor() +} + +interface ImmediateCreateDeps { + entries: VaultEntry[] + vaultPath: string + pendingNames: Set + openTabWithContent: (entry: VaultEntry, content: string) => void + addEntry: (entry: VaultEntry) => void + trackUnsaved?: (path: string) => void + markContentPending?: (path: string, content: string) => void +} + +/** Create an untitled note without persisting to disk (deferred save). */ +function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void { + const noteType = type || 'Note' + const title = generateUntitledName(deps.entries, noteType, deps.pendingNames) + deps.pendingNames.add(title) + const template = resolveTemplate(deps.entries, noteType) + const resolved = resolveNewNote(title, noteType, deps.vaultPath, template) + deps.openTabWithContent(resolved.entry, resolved.content) + addEntryWithMock(resolved.entry, resolved.content, deps.addEntry) + deps.trackUnsaved?.(resolved.entry.path) + deps.markContentPending?.(resolved.entry.path, resolved.content) + signalFocusEditor({ selectTitle: true }) + setTimeout(() => deps.pendingNames.delete(title), 500) +} + +interface RelationshipCreateDeps { + entries: VaultEntry[] + vaultPath: string + openTabWithContent: (entry: VaultEntry, content: string) => void + addEntry: (entry: VaultEntry) => void + handleCloseTab: (path: string) => void + removeEntry: (path: string) => void + setToastMessage: (msg: string | null) => void + onNewNotePersisted?: () => void +} + +/** 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) + deps.openTabWithContent(resolved.entry, resolved.content) + addEntryWithMock(resolved.entry, resolved.content, deps.addEntry) + persistNewNote(resolved.entry.path, resolved.content) + .then(() => deps.onNewNotePersisted?.()) + .catch(() => { + deps.handleCloseTab(resolved.entry.path) + deps.removeEntry(resolved.entry.path) + deps.setToastMessage('Failed to create note — disk write error') + }) +} + +export interface NoteCreationConfig { + addEntry: (entry: VaultEntry) => void + removeEntry: (path: string) => void + entries: VaultEntry[] + setToastMessage: (msg: string | null) => void + vaultPath: string + addPendingSave?: (path: string) => void + removePendingSave?: (path: string) => void + trackUnsaved?: (path: string) => void + clearUnsaved?: (path: string) => void + unsavedPaths?: Set + markContentPending?: (path: string, content: string) => void + onNewNotePersisted?: () => void +} + +interface CreationTabDeps { + openTabWithContent: (entry: VaultEntry, content: string) => void + handleSelectNote: (entry: VaultEntry) => void + handleCloseTab: (path: string) => void + handleCloseTabRef: React.MutableRefObject<(path: string) => void> +} + +export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTabDeps) { + const { addEntry, removeEntry, entries, setToastMessage, addPendingSave, removePendingSave } = config + const { openTabWithContent, handleSelectNote, handleCloseTab, handleCloseTabRef } = tabDeps + + const unsavedPathsRef = useRef(config.unsavedPaths) + // eslint-disable-next-line react-hooks/refs + unsavedPathsRef.current = config.unsavedPaths + + const revertOptimisticNote = useCallback((path: string) => { + handleCloseTab(path) + removeEntry(path) + setToastMessage('Failed to create note — disk write error') + }, [handleCloseTab, removeEntry, setToastMessage]) + + const persistCbs: PersistCallbacks = { + onFail: revertOptimisticNote, + onStart: addPendingSave, + onEnd: removePendingSave, + onPersisted: config.onNewNotePersisted, + } + + const pendingNamesRef = useRef>(new Set()) + + const persistNew: PersistFn = useCallback( + (resolved) => createAndPersist(resolved, addEntry, openTabWithContent, persistCbs), + [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave], // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are + ) + + const handleCreateNote = useCallback((title: string, type: string) => { + const template = resolveTemplate(entries, type) + persistNew(resolveNewNote(title, type, config.vaultPath, template)) + }, [entries, persistNew, config.vaultPath]) + + const handleCreateNoteImmediate = useCallback((type?: string) => { + try { + createNoteImmediate({ + entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current, + openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending, + }, type) + } 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 + + const handleCreateNoteForRelationship = useCallback((title: string): Promise => { + createNoteForRelationship({ + entries, vaultPath: config.vaultPath, openTabWithContent, addEntry, + handleCloseTab, removeEntry, setToastMessage, onNewNotePersisted: config.onNewNotePersisted, + }, title) + return Promise.resolve(true) + }, [entries, openTabWithContent, addEntry, handleCloseTab, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted]) + + /** Close tab and discard entry+unsaved state if the note was never persisted. */ + const handleCloseTabWithCleanup = useCallback((path: string) => { + if (unsavedPathsRef.current?.has(path)) { removeEntry(path); config.clearUnsaved?.(path) } + handleCloseTab(path) + }, [handleCloseTab, removeEntry, config.clearUnsaved]) // eslint-disable-line react-hooks/exhaustive-deps -- ref access is stable + + // Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes. + useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup }) + + 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, 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) + addEntryWithMock(resolved.entry, resolved.content, addEntry) + await persistNewNote(resolved.entry.path, resolved.content) + return resolved.entry + }, [addEntry, config.vaultPath]) + + return { + handleCreateNote, + handleCreateNoteImmediate, + handleCreateNoteForRelationship, + handleOpenDailyNote, + handleCreateType, + createTypeEntrySilent, + handleCloseTabWithCleanup, + } +} diff --git a/src/hooks/useNoteRename.ts b/src/hooks/useNoteRename.ts new file mode 100644 index 00000000..aef96e8a --- /dev/null +++ b/src/hooks/useNoteRename.ts @@ -0,0 +1,107 @@ +import { useCallback, useRef } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { VaultEntry } from '../types' +import { slugify } from './useNoteCreation' + +interface RenameResult { + new_path: string + updated_files: number +} + +export { slugify } + +/** Check if a note's filename doesn't match the slug of its current title. */ +export function needsRenameOnSave(title: string, filename: string): boolean { + return `${slugify(title)}.md` !== filename +} + +export async function performRename( + path: string, + newTitle: string, + vaultPath: string, + oldTitle?: string, +): Promise { + if (isTauri()) { + return invoke('rename_note', { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null }) + } + return mockInvoke('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null }) +} + +export function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry { + const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + return { ...entry, path: newPath, filename: `${slug}.md`, title: newTitle } +} + +export async function loadNoteContent(path: string): Promise { + return isTauri() + ? invoke('get_note_content', { path }) + : mockInvoke('get_note_content', { path }) +} + +export function renameToastMessage(updatedFiles: number): string { + if (updatedFiles === 0) return 'Renamed' + return `Renamed — updated ${updatedFiles} wiki link${updatedFiles > 1 ? 's' : ''}` +} + +/** Reload content for open tabs whose wikilinks may have changed after a rename. */ +export async function reloadTabsAfterRename( + tabPaths: string[], + updateTabContent: (path: string, content: string) => void, +): Promise { + for (const tabPath of tabPaths) { + try { + updateTabContent(tabPath, await loadNoteContent(tabPath)) + } catch { /* skip tabs that fail to reload */ } + } +} + +interface Tab { + entry: VaultEntry + content: string +} + +export interface NoteRenameConfig { + entries: VaultEntry[] + setToastMessage: (msg: string | null) => void +} + +interface RenameTabDeps { + tabs: Tab[] + setTabs: React.Dispatch> + activeTabPathRef: React.MutableRefObject + handleSwitchTab: (path: string) => void + updateTabContent: (path: string, content: string) => void +} + +export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps) { + const { entries, setToastMessage } = config + const { setTabs, activeTabPathRef, handleSwitchTab, updateTabContent } = tabDeps + + const tabsRef = useRef(tabDeps.tabs) + // eslint-disable-next-line react-hooks/refs + tabsRef.current = tabDeps.tabs + + const handleRenameNote = useCallback(async ( + path: string, newTitle: string, vaultPath: string, + onEntryRenamed: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void, + ) => { + try { + const entry = entries.find((e) => e.path === path) + const result = await performRename(path, newTitle, vaultPath, entry?.title) + const newContent = await loadNoteContent(result.new_path) + const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path) + const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path).map(t => t.entry.path) + setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t)) + if (activeTabPathRef.current === path) handleSwitchTab(result.new_path) + onEntryRenamed(path, newEntry, newContent) + await reloadTabsAfterRename(otherTabPaths, updateTabContent) + setToastMessage(renameToastMessage(result.updated_files)) + } catch (err) { + console.error('Failed to rename note:', err) + setToastMessage('Failed to rename note') + } + }, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, setToastMessage]) + + return { handleRenameNote, tabsRef } +}