fix: show reference titles in breadcrumb

This commit is contained in:
lucaronin
2026-05-07 15:48:29 +02:00
parent 99e509f553
commit fb55c57536
6 changed files with 216 additions and 27 deletions

View File

@@ -297,6 +297,7 @@ The BlockNote body is the only title editing surface:
- The first H1 is the canonical display title.
- There is no separate title row above the editor, even when a note has no H1.
- Notes without an H1 show the editor body and placeholder only.
- Legacy no-H1 notes whose display title differs from the filename show that title as read-only breadcrumb context beside the editable filename, so referenced notes remain identifiable without raw mode.
- Filename changes are explicit breadcrumb actions, not a dedicated title-input side effect.
### Sidebar Selection

View File

@@ -208,7 +208,7 @@ flowchart TD
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.

View File

@@ -1,3 +1,4 @@
import type { ComponentProps } from 'react'
import { render, screen, fireEvent, act, within, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { BreadcrumbBar } from './BreadcrumbBar'
@@ -56,6 +57,52 @@ const defaultProps = {
onToggleDiff: vi.fn(),
}
type BreadcrumbBarRenderProps = Omit<ComponentProps<typeof BreadcrumbBar>, 'entry'>
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return { ...baseEntry, ...overrides }
}
function renderBreadcrumb(
entryOverrides: Partial<VaultEntry> = {},
props: Partial<BreadcrumbBarRenderProps> = {},
) {
const entry = makeEntry(entryOverrides)
return {
entry,
...render(<BreadcrumbBar entry={entry} {...defaultProps} {...props} />),
}
}
function renderEditableFilenameBreadcrumb(
entryOverrides: Partial<VaultEntry> = {},
props: Partial<BreadcrumbBarRenderProps> = {},
) {
const onRenameFilename = vi.fn()
const result = renderBreadcrumb(entryOverrides, { ...props, onRenameFilename })
return { ...result, onRenameFilename }
}
function startFilenameRename() {
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
return screen.getByTestId('breadcrumb-filename-input')
}
function expectDisplayTitleState(
entryOverrides: Partial<VaultEntry>,
expected: { displayTitle: string | null; filenameStem: string },
props: Partial<BreadcrumbBarRenderProps> = {},
) {
renderEditableFilenameBreadcrumb(entryOverrides, props)
if (expected.displayTitle) {
expect(screen.getByTestId('breadcrumb-display-title')).toHaveTextContent(expected.displayTitle)
} else {
expect(screen.queryByTestId('breadcrumb-display-title')).not.toBeInTheDocument()
}
expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent(expected.filenameStem)
}
async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
act(() => {
fireEvent.focus(trigger)
@@ -271,50 +318,121 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
})
describe('BreadcrumbBar — filename controls', () => {
it('shows a legacy display title while keeping the filename visible', () => {
expectDisplayTitleState(
{
title: 'Reference Planning Notes',
filename: 'ref-570.md',
hasH1: false,
},
{ displayTitle: 'Reference Planning Notes', filenameStem: 'ref-570' },
)
})
it('uses opened content when stale metadata marks a legacy note as H1-titled', () => {
expectDisplayTitleState(
{
title: 'Reference Planning Notes',
filename: 'ref-570.md',
hasH1: true,
},
{ displayTitle: 'Reference Planning Notes', filenameStem: 'ref-570' },
{
content: '---\ntitle: Reference Planning Notes\ntype: Note\n---\n\nBody without an H1.',
},
)
})
it('keeps content-derived H1 notes focused on the filename breadcrumb', () => {
expectDisplayTitleState(
{
title: 'Reference Planning Notes',
filename: 'manual-filename.md',
hasH1: false,
},
{ displayTitle: null, filenameStem: 'manual-filename' },
{
content: '---\ntitle: Reference Planning Notes\n---\n\n# Canonical H1\n\nBody.',
},
)
})
it('does not duplicate the display title when the filename already matches it', () => {
expectDisplayTitleState(
{
title: 'Reference Planning Notes',
filename: 'reference-planning-notes.md',
hasH1: false,
},
{ displayTitle: null, filenameStem: 'reference-planning-notes' },
)
})
it('does not duplicate the display title when the filename matches with spaces', () => {
expectDisplayTitleState(
{
title: 'Reference Planning Notes',
filename: 'Reference Planning Notes.md',
hasH1: false,
},
{ displayTitle: null, filenameStem: 'Reference Planning Notes' },
)
})
it('keeps H1-titled notes focused on the filename breadcrumb', () => {
expectDisplayTitleState(
{
title: 'Reference Planning Notes',
filename: 'manual-filename.md',
hasH1: true,
},
{ displayTitle: null, filenameStem: 'manual-filename' },
)
})
it('shows the sync button when the filename diverges from the title slug', () => {
const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' }
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={vi.fn()} />)
renderEditableFilenameBreadcrumb({ title: 'Fresh Title', filename: 'untitled-note-123.md' })
expect(screen.getByTestId('breadcrumb-sync-button')).toBeInTheDocument()
})
it('hides the sync button when the filename already matches the title slug', () => {
const entry = { ...baseEntry, title: 'Test Note', filename: 'test-note.md' }
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={vi.fn()} />)
renderEditableFilenameBreadcrumb({ title: 'Test Note', filename: 'test-note.md' })
expect(screen.queryByTestId('breadcrumb-sync-button')).not.toBeInTheDocument()
})
it('clicking the sync button renames the file to the title slug', () => {
const onRenameFilename = vi.fn()
const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' }
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={onRenameFilename} />)
const { entry, onRenameFilename } = renderEditableFilenameBreadcrumb({
title: 'Fresh Title',
filename: 'untitled-note-123.md',
})
fireEvent.click(screen.getByTestId('breadcrumb-sync-button'))
expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'fresh-title')
})
it('lets keyboard users press Enter on the filename to start editing', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={vi.fn()} />)
renderEditableFilenameBreadcrumb()
fireEvent.keyDown(screen.getByTestId('breadcrumb-filename-trigger'), { key: 'Enter' })
expect(screen.getByTestId('breadcrumb-filename-input')).toHaveValue('test')
})
it('double-clicking the filename enters edit mode and Enter confirms the rename', () => {
const onRenameFilename = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
const { entry, onRenameFilename } = renderEditableFilenameBreadcrumb()
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
const input = screen.getByTestId('breadcrumb-filename-input')
const input = startFilenameRename()
fireEvent.change(input, { target: { value: 'renamed-file' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-file')
expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'renamed-file')
})
it('pressing Escape while editing cancels the inline rename', () => {
const onRenameFilename = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
const { onRenameFilename } = renderEditableFilenameBreadcrumb()
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
const input = screen.getByTestId('breadcrumb-filename-input')
const input = startFilenameRename()
fireEvent.change(input, { target: { value: 'renamed-file' } })
fireEvent.keyDown(input, { key: 'Escape' })
@@ -323,15 +441,13 @@ describe('BreadcrumbBar — filename controls', () => {
})
it('blur confirms the inline rename when the value changed', () => {
const onRenameFilename = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
const { entry, onRenameFilename } = renderEditableFilenameBreadcrumb()
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
const input = screen.getByTestId('breadcrumb-filename-input')
const input = startFilenameRename()
fireEvent.change(input, { target: { value: 'renamed-on-blur' } })
fireEvent.blur(input)
expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-on-blur')
expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'renamed-on-blur')
})
})

View File

@@ -3,6 +3,7 @@ import type { NoteWidthMode, VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n'
import { APP_COMMAND_IDS, formatShortcutDisplay, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog'
import { extractFrontmatterTitleFromContent, extractH1TitleFromContent } from '../utils/noteTitle'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
@@ -67,6 +68,7 @@ interface BreadcrumbBarProps {
barRef?: React.Ref<HTMLDivElement>
locale?: AppLocale
loadingTitle?: boolean
content?: string | null
}
const BREADCRUMB_ICON_CLASS = 'size-[16px]'
@@ -579,6 +581,35 @@ function deriveSyncStem(entry: VaultEntry): string | null {
return expectedStem
}
interface BreadcrumbDisplayTitleState {
hasH1: boolean
title: string
}
function deriveContentDisplayTitleState(content?: string | null): BreadcrumbDisplayTitleState | null {
if (typeof content !== 'string') return null
const h1Title = extractH1TitleFromContent(content)
if (h1Title) return { title: h1Title, hasH1: true }
const frontmatterTitle = extractFrontmatterTitleFromContent(content)
return frontmatterTitle ? { title: frontmatterTitle, hasH1: false } : null
}
function deriveEntryDisplayTitleState(entry: VaultEntry): BreadcrumbDisplayTitleState {
return {
title: entry.title.trim(),
hasH1: entry.hasH1,
}
}
function deriveBreadcrumbDisplayTitle(entry: VaultEntry, filenameStem: string, content?: string | null): string | null {
const displayState = deriveContentDisplayTitleState(content) ?? deriveEntryDisplayTitleState(entry)
const displayTitle = displayState.title.trim()
if (!displayTitle || displayState.hasH1) return null
if (slugify(displayTitle) === slugify(filenameStem)) return null
return displayTitle
}
function FilenameInput({
inputRef,
draftStem,
@@ -669,6 +700,7 @@ function SyncFilenameButton({
}
function FilenameDisplay({
content,
entry,
filenameStem,
syncStem,
@@ -676,6 +708,7 @@ function FilenameDisplay({
onRenameFilename,
onStartEditing,
}: {
content?: string | null
entry: VaultEntry
filenameStem: string
syncStem: string | null
@@ -683,15 +716,29 @@ function FilenameDisplay({
onRenameFilename?: (path: string, newFilenameStem: string) => void
onStartEditing: () => void
}) {
const displayTitle = deriveBreadcrumbDisplayTitle(entry, filenameStem, content)
return (
<div className="flex min-w-0 items-center gap-1">
{displayTitle && (
<>
<span
className="min-w-0 max-w-[min(24rem,45vw)] truncate text-foreground"
data-testid="breadcrumb-display-title"
title={displayTitle}
>
{displayTitle}
</span>
<span aria-hidden="true" className="shrink-0 text-border">·</span>
</>
)}
<FilenameTrigger filenameStem={filenameStem} locale={locale} onStartEditing={onStartEditing} />
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} locale={locale} onRenameFilename={onRenameFilename} />
</div>
)
}
function FilenameCrumb({ entry, locale = 'en', onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
function FilenameCrumb({ content, entry, locale = 'en', onRenameFilename }: Pick<BreadcrumbBarProps, 'content' | 'entry' | 'locale' | 'onRenameFilename'>) {
const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename])
const syncStem = useMemo(() => deriveSyncStem(entry), [entry])
const [isEditing, setIsEditing] = useState(false)
@@ -737,6 +784,7 @@ function FilenameCrumb({ entry, locale = 'en', onRenameFilename }: Pick<Breadcru
return (
<FilenameDisplay
content={content}
entry={entry}
filenameStem={filenameStem}
syncStem={syncStem}
@@ -941,11 +989,12 @@ function BreadcrumbOverflowMenu({
}
function BreadcrumbTitle({
content,
entry,
locale,
loadingTitle,
onRenameFilename,
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'loadingTitle' | 'onRenameFilename'>) {
}: Pick<BreadcrumbBarProps, 'content' | 'entry' | 'locale' | 'loadingTitle' | 'onRenameFilename'>) {
const typeLabel = entry.isA ?? 'Note'
return (
<div className="breadcrumb-bar__title-content flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
@@ -954,13 +1003,14 @@ function BreadcrumbTitle({
<div className="flex min-w-0 items-center gap-1 truncate">
{loadingTitle
? <BreadcrumbTitleSkeleton />
: <FilenameCrumb entry={entry} locale={locale} onRenameFilename={onRenameFilename} />}
: <FilenameCrumb content={content} entry={entry} locale={locale} onRenameFilename={onRenameFilename} />}
</div>
</div>
)
}
export const BreadcrumbBar = memo(function BreadcrumbBar({
content,
entry,
barRef,
locale = 'en',
@@ -990,6 +1040,7 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
>
<div ref={titleRef} className="breadcrumb-bar__title min-w-0 flex-1 overflow-hidden">
<BreadcrumbTitle
content={content}
entry={entry}
locale={locale}
loadingTitle={loadingTitle}

View File

@@ -4,7 +4,9 @@ import { describe, expect, it, vi } from 'vitest'
import { EditorContentLayout } from './EditorContentLayout'
vi.mock('../BreadcrumbBar', () => ({
BreadcrumbBar: ({ noteWidth }: { noteWidth?: string }) => <div data-testid="breadcrumb-bar" data-note-width={noteWidth} />,
BreadcrumbBar: ({ content, noteWidth }: { content?: string; noteWidth?: string }) => (
<div data-testid="breadcrumb-bar" data-content={content} data-note-width={noteWidth} />
),
}))
vi.mock('../ArchivedNoteBanner', () => ({
@@ -109,6 +111,24 @@ describe('EditorContentLayout', () => {
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-width', 'wide')
})
it('passes the active note content into the breadcrumb', () => {
render(<EditorContentLayout {...createModel({
activeTab: {
entry: {
path: '/vault/project/ref-570.md',
filename: 'ref-570.md',
title: 'Reference Planning Notes',
},
content: '---\ntitle: Reference Planning Notes\n---\n\nBody',
},
})} />)
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute(
'data-content',
'---\ntitle: Reference Planning Notes\n---\n\nBody',
)
})
it('keeps raw mode out of the rich-editor content wrapper', () => {
render(<EditorContentLayout {...createModel({
effectiveRawMode: true,

View File

@@ -178,6 +178,7 @@ function ActiveTabBreadcrumb({
return (
<BreadcrumbBar
entry={activeTab.entry}
content={activeTab.content}
wordCount={wordCount}
barRef={barRef}
loadingTitle={loadingTitle}