fix: stop syncing title frontmatter on note open
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>` |
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user