fix: stop syncing title frontmatter on note open

This commit is contained in:
Test
2026-04-07 20:43:13 +02:00
parent 95d75f7a7a
commit 478ed789f8
5 changed files with 26 additions and 71 deletions

View File

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

View File

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

View File

@@ -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')
})
})

View File

@@ -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<VaultEntry>('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,

View File

@@ -47,16 +47,6 @@ async function loadNoteContent(path: string): Promise<string> {
: mockInvoke<string>('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<boolean> {
try {
return isTauri()
? await invoke<boolean>('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) {