refactor: split useNoteActions into useNoteCreation + useNoteRename — CodeScene 7.92→9.38

Extract note creation (CRUD, daily notes, types, optimistic persistence) into
useNoteCreation and rename operations into useNoteRename. useNoteActions now
composes both hooks plus frontmatter/navigation logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-16 18:31:33 +01:00
parent 85fa0b8680
commit 3f5ac7a9a9
5 changed files with 524 additions and 379 deletions

View File

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

View File

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

View File

@@ -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<string>
/** 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<VaultEntry> & { 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<RenameResult> {
if (isTauri()) {
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null })
}
return mockInvoke<RenameResult>('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<string> {
return isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
}
/** Persist a newly created note to disk. Returns a Promise for error handling. */
function persistNewNote(path: string, content: string): Promise<void> {
if (!isTauri()) return Promise.resolve()
return invoke<void>('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 <type>" name by checking existing entries and pending names. */
export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set<string>): 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<string, unknown>): Promise<string> {
return invoke<string>(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<string, string> = {
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<string, Partial<VaultEntry>> = {
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<string, unknown>): Promise<string> {
return invoke<string>(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<string> {
@@ -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<void> {
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<VaultEntry> & { path: string }) => void
setTabs: React.Dispatch<React.SetStateAction<{ entry: VaultEntry; content: string }[]>>
activeTabPathRef: React.MutableRefObject<string | null>
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<void> {
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<VaultEntry> & { 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<Set<string>>(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<boolean> => {
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<VaultEntry> => {
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<VaultEntry> & { 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<VaultEntry> & { 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,
}
}

View File

@@ -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 <type>" name by checking existing entries and pending names. */
export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set<string>): 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<string, string> = {
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<void> {
if (!isTauri()) return Promise.resolve()
return invoke<void>('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<string>
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<string>
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<Set<string>>(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<boolean> => {
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<VaultEntry> => {
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,
}
}

107
src/hooks/useNoteRename.ts Normal file
View File

@@ -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<RenameResult> {
if (isTauri()) {
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null })
}
return mockInvoke<RenameResult>('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<string> {
return isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('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<void> {
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<React.SetStateAction<Tab[]>>
activeTabPathRef: React.MutableRefObject<string | null>
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<VaultEntry> & { 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 }
}