fix: show reference titles in breadcrumb
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -178,6 +178,7 @@ function ActiveTabBreadcrumb({
|
||||
return (
|
||||
<BreadcrumbBar
|
||||
entry={activeTab.entry}
|
||||
content={activeTab.content}
|
||||
wordCount={wordCount}
|
||||
barRef={barRef}
|
||||
loadingTitle={loadingTitle}
|
||||
|
||||
Reference in New Issue
Block a user