From 478ed789f8a71db8bba12c25c7690b7287aa805c Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 7 Apr 2026 20:43:13 +0200 Subject: [PATCH] fix: stop syncing title frontmatter on note open --- docs/ABSTRACTIONS.md | 16 ++++++++++------ docs/ARCHITECTURE.md | 6 +++--- src/hooks/useNoteActions.test.ts | 31 +++++++++--------------------- src/hooks/useNoteActions.ts | 33 ++++---------------------------- src/hooks/useTabManagement.ts | 11 ----------- 5 files changed, 26 insertions(+), 71 deletions(-) diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 118dba51..8eb9fd79 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -232,16 +232,20 @@ All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex an ### Title / Filename Sync -Every note has a `title` field in frontmatter that stores the human-readable title. The filename is always the slug of the title (`slugify(title).md`). The two are kept in sync: +Laputa separates **display title** from the file identifier: -- **Source of truth**: filename (on open), user input (on rename inside Laputa) -- **`extract_title`** reads `title` from frontmatter; falls back to deriving a title from the filename via `slug_to_title()` (hyphens → spaces, title-case). Never reads from H1. Logic in `vault/parsing.rs`. -- **On note open** (`sync_title_on_open`): if `title` frontmatter is absent or desynced from the filename, it is auto-corrected (filename wins). Logic in `vault/title_sync.rs`. -- **On rename** (`rename_note`): updates both `title` frontmatter and filename atomically, plus wikilinks across the vault. Always writes `title` to frontmatter. +- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem. +- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter. +- **On rename / explicit title edits** (`rename_note`): Laputa updates both filename and `title` frontmatter atomically, plus wikilinks across the vault. +- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1. ### Title Field (UI) -The editor displays a dedicated `TitleField` component above the BlockNote editor. This is the primary title editing surface — the H1 block inside BlockNote is hidden via CSS. Changing the title field triggers `onTitleSync`, which updates the frontmatter `title:` field and renames the file to match `slugify(title).md`. The title field also responds to `laputa:focus-editor` events with `selectTitle: true` for new note creation. +The dedicated `TitleField` is a fallback editing surface, not the canonical one: + +- If the note already has an H1, the editor body is the primary title surface and the dedicated title row is hidden. +- If the note has no H1 and is not an untitled draft, `TitleField` appears above the editor and `onTitleSync` updates `title:` frontmatter plus the filename. +- `TitleField` also responds to `laputa:focus-editor` events with `selectTitle: true` for new-note flows that start without an H1. ### Sidebar Selection diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b7eb88ed..289ebc87 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,8 +546,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | File | Purpose | |------|---------| | `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship/link extraction | -| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title`, `slug_to_title` | -| `title_sync.rs` | `sync_title_on_open` — ensures `title` frontmatter matches filename on note open | +| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` | +| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow | | `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers | | `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault | | `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames | @@ -583,7 +583,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `save_note_content` | Write note content to disk | | `delete_note` | Permanently delete note from disk (with confirm dialog) | | `rename_note` | Rename note + update `title` frontmatter + cross-vault wikilinks | -| `sync_note_title` | Sync `title` frontmatter with filename on note open → `bool` (modified) | +| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow | | `batch_archive_notes` | Archive multiple notes | | `batch_delete_notes` | Permanently delete notes from disk | | `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec` | diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index cc5dcc67..777909e1 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -1045,38 +1045,25 @@ describe('useNoteActions hook', () => { }) }) - describe('title sync on reopen', () => { - it('calls sync_note_title when reopening an already-open tab', async () => { + describe('note open is read-only', () => { + it('does not sync title or reload entry when opening or reopening a note', async () => { vi.mocked(isTauri).mockReturnValue(true) const entry = makeEntry({ path: '/test/vault/qa-test.md', filename: 'qa-test.md', title: 'Qa Test' }) - - // First open: handleSelectNoteWithSync calls sync, then handleSelectNote calls sync again + loads - vi.mocked(invoke) - .mockResolvedValueOnce(false) // sync_note_title (from handleSelectNoteWithSync) - .mockResolvedValueOnce(false) // sync_note_title (from handleSelectNote) - .mockResolvedValueOnce('# Qa Test\n') // get_note_content - .mockResolvedValueOnce({ ...entry }) // reload_vault_entry (first open) - - // Second open (reopen, tab already exists): handleSelectNoteWithSync calls sync + reload - vi.mocked(invoke) - .mockResolvedValueOnce(true) // sync_note_title (reopen — file was modified) - .mockResolvedValueOnce('---\ntitle: Qa Test\n---\n# Qa Test\n') // get_note_content (reload) - .mockResolvedValueOnce({ ...entry, title: 'Qa Test' }) // reload_vault_entry (reopen) + vi.mocked(invoke).mockResolvedValueOnce('# Qa Test\n') const { result } = renderHook(() => useNoteActions(makeConfig([entry]))) - // Open the note (creates tab) await act(async () => { await result.current.handleSelectNote(entry) }) + const callCountAfterFirstOpen = vi.mocked(invoke).mock.calls.length - // Reopen the same note (tab already open — this is the Cmd+P reopen scenario) const desyncedEntry = { ...entry, title: 'Wrong Title Desynced' } await act(async () => { await result.current.handleSelectNote(desyncedEntry) }) - // sync_note_title should have been called for both opens (3 total: 2 for first open, 1 for reopen) - const syncCalls = vi.mocked(invoke).mock.calls.filter(c => c[0] === 'sync_note_title') - expect(syncCalls.length).toBeGreaterThanOrEqual(2) - // Critical: sync was called on reopen (the bug fix) - expect(syncCalls.at(-1)).toEqual(['sync_note_title', { path: '/test/vault/qa-test.md' }]) + expect(vi.mocked(invoke)).toHaveBeenCalledTimes(callCountAfterFirstOpen) + expect(vi.mocked(invoke).mock.calls).toEqual([ + ['get_note_content', { path: '/test/vault/qa-test.md' }], + ]) + expect(result.current.tabs[0].entry.title).toBe('Qa Test') }) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index b752c47f..84b0619c 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -1,9 +1,7 @@ import { useCallback } from 'react' -import { invoke } from '@tauri-apps/api/core' -import { isTauri } from '../mock-tauri' import type { VaultEntry } from '../types' import type { FrontmatterValue } from '../components/Inspector' -import { useTabManagement, syncNoteTitle } from './useTabManagement' +import { useTabManagement } from './useTabManagement' import { resolveEntry } from '../utils/wikilink' import { useNoteCreation } from './useNoteCreation' import { @@ -82,37 +80,15 @@ export function useNoteActions(config: NoteActionsConfig) { setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t)) }, [setTabs]) - // After opening a note, reload its VaultEntry so title reflects any sync. - const handleSelectNoteWithSync = useCallback(async (entry: VaultEntry) => { - // Always sync title with filename — even for already-open tabs. - // handleSelectNote skips sync for open tabs (early return), so we call it here first. - const wasModified = await syncNoteTitle(entry.path) - await handleSelectNote(entry) - // Reload entry from disk to pick up title changes from sync_note_title - if (isTauri()) { - try { - const fresh = await invoke('reload_vault_entry', { path: entry.path }) - if (fresh.title !== entry.title) updateEntry(entry.path, { title: fresh.title }) - // If sync modified the file and tab was already open, refresh tab content - if (wasModified) { - const content = await loadNoteContent(entry.path) - setTabs(prev => prev.map(t => t.entry.path === entry.path - ? { entry: { ...t.entry, title: fresh.title }, content } - : t)) - } - } catch { /* non-fatal: entry display may be stale */ } - } - }, [handleSelectNote, updateEntry, setTabs]) - - const creation = useNoteCreation(config, { openTabWithContent, handleSelectNote: handleSelectNoteWithSync }) + const creation = useNoteCreation(config, { openTabWithContent, handleSelectNote }) const rename = useNoteRename( { entries, setToastMessage }, { tabs: tabMgmt.tabs, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent }, ) const handleNavigateWikilink = useCallback( - (target: string) => navigateWikilink(entries, target, handleSelectNoteWithSync), - [entries, handleSelectNoteWithSync], + (target: string) => navigateWikilink(entries, target, handleSelectNote), + [entries, handleSelectNote], ) const runFrontmatterOp = useCallback( @@ -123,7 +99,6 @@ export function useNoteActions(config: NoteActionsConfig) { return { ...tabMgmt, - handleSelectNote: handleSelectNoteWithSync, handleNavigateWikilink, handleCreateNote: creation.handleCreateNote, handleCreateNoteImmediate: creation.handleCreateNoteImmediate, diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 83f9c7ee..47a639bd 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -47,16 +47,6 @@ async function loadNoteContent(path: string): Promise { : mockInvoke('get_note_content', { path }) } -/** Sync title frontmatter with filename on note open. - * Returns true if the file was modified. */ -export async function syncNoteTitle(path: string): Promise { - try { - return isTauri() - ? await invoke('sync_note_title', { path }) - : false // mock: no-op - } catch { return false } -} - export type { Tab } export function useTabManagement() { @@ -81,7 +71,6 @@ export function useTabManagement() { return } const seq = ++navSeqRef.current - if (!entry.fileKind || entry.fileKind === 'markdown') await syncNoteTitle(entry.path) try { const content = await loadNoteContent(entry.path) if (navSeqRef.current === seq) {