Compare commits

...

13 Commits

Author SHA1 Message Date
Test
b9879b15c9 fix: keep note list icons inline 2026-04-09 13:01:28 +02:00
Test
32a30b40b9 fix: hide legacy title chrome for frontmatter titles 2026-04-09 12:51:06 +02:00
Test
5dc2fe9341 fix: narrow pending tab swap target path 2026-04-09 12:38:39 +02:00
Test
b390dbaf77 fix: swap note content after pending navigation 2026-04-09 12:36:09 +02:00
Test
81eb5e844d fix: hide stale title chrome while notes load 2026-04-09 12:20:03 +02:00
Test
05dd46c8d0 fix: type property chip icon helper 2026-04-09 12:08:31 +02:00
Test
746efacf28 fix: restore built-in relationship chip icons 2026-04-09 12:05:55 +02:00
Test
02a6622e64 chore: ratchet CodeScene thresholds to 9.68/9.32 2026-04-09 11:53:34 +02:00
Test
8979b71bda fix: unclip view filter field combobox 2026-04-09 11:48:26 +02:00
Test
13edf894fb feat: preview relative date filters 2026-04-08 21:44:20 +02:00
Test
70e7e72120 feat: enhance note list property chips 2026-04-08 21:28:28 +02:00
Test
01cabc21e1 feat: add searchable filter field combobox 2026-04-08 21:15:49 +02:00
Test
5ceed87d36 fix: make AI panel shortcut command-only 2026-04-08 21:05:16 +02:00
31 changed files with 2197 additions and 685 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.68
AVERAGE_THRESHOLD=9.3
AVERAGE_THRESHOLD=9.32

View File

@@ -27,6 +27,12 @@ describe('FilterBuilder value inputs', () => {
)
}
function openFieldCombobox() {
const input = screen.getByTestId('filter-field-combobox-input')
fireEvent.focus(input)
return input
}
it('renders a plain text input for text operators', () => {
renderBuilder()
expect(screen.getByTestId('filter-value-input')).toBeInTheDocument()
@@ -129,7 +135,74 @@ describe('FilterBuilder value inputs', () => {
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
})
it('shows body field in field dropdown separated from property fields', () => {
it('filters the field combobox as the user types', () => {
render(
<FilterBuilder
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
onChange={vi.fn()}
availableFields={['type', 'status', 'title', 'Owner']}
/>,
)
const input = openFieldCombobox()
fireEvent.change(input, { target: { value: 'tit' } })
expect(screen.getByTestId('filter-field-option-title')).toBeInTheDocument()
expect(screen.queryByTestId('filter-field-option-status')).not.toBeInTheDocument()
expect(screen.queryByTestId('filter-field-option-Owner')).not.toBeInTheDocument()
})
it('supports keyboard navigation and Enter selection in the field combobox', () => {
render(
<FilterBuilder
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
onChange={onChange}
availableFields={['title', 'status', 'Owner']}
/>,
)
const input = openFieldCombobox()
fireEvent.change(input, { target: { value: 'sta' } })
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
all: [{ field: 'status', op: 'contains', value: '' }],
}),
)
})
it('shows an empty state when no field matches the search text', () => {
renderBuilder()
const input = openFieldCombobox()
fireEvent.change(input, { target: { value: 'zzz' } })
expect(screen.getByTestId('filter-field-combobox-empty')).toHaveTextContent('No results')
})
it('reopens the field combobox with the selected field prefilled and all options visible', () => {
render(
<FilterBuilder
group={{ all: [{ field: 'status', op: 'contains', value: '' }] }}
onChange={vi.fn()}
availableFields={['type', 'status', 'title']}
/>,
)
const input = openFieldCombobox()
expect(input).toHaveValue('status')
expect(screen.getByTestId('filter-field-option-type')).toBeInTheDocument()
expect(screen.getByTestId('filter-field-option-status')).toBeInTheDocument()
expect(screen.getByTestId('filter-field-option-title')).toBeInTheDocument()
fireEvent.keyDown(input, { key: 'Escape' })
expect(screen.queryByTestId('filter-field-combobox-options')).not.toBeInTheDocument()
})
it('shows body field in the searchable field combobox', () => {
render(
<FilterBuilder
group={{ all: [{ field: 'body', op: 'contains', value: 'test' }] }}
@@ -138,6 +211,8 @@ describe('FilterBuilder value inputs', () => {
/>,
)
expect(screen.getByText('body')).toBeInTheDocument()
openFieldCombobox()
expect(screen.getByTestId('filter-field-option-body')).toBeInTheDocument()
})
})

View File

@@ -1,13 +1,11 @@
import { Plus, X, CalendarBlank, WarningCircle } from '@phosphor-icons/react'
import { format } from 'date-fns'
import { Plus, X, WarningCircle } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
import { parseDateFilterInput } from '../utils/filterDates'
import { FilterFieldCombobox } from './FilterFieldCombobox'
import { DateValueInput } from './filter-builder/DateValueInput'
const OPERATORS: { value: FilterOp; label: string }[] = [
{ value: 'equals', label: 'equals' },
@@ -58,42 +56,6 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
return mode === 'all' ? { all: children } : { any: children }
}
const CONTENT_FIELDS = new Set(['body'])
function FieldSelect({ value, fields, onChange }: {
value: string
fields: string[]
onChange: (v: string) => void
}) {
const isCustom = value !== '' && !fields.includes(value)
const propertyFields = fields.filter(f => !CONTENT_FIELDS.has(f))
const contentFields = fields.filter(f => CONTENT_FIELDS.has(f))
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
size="sm"
className="h-8 min-w-[100px] flex-1 gap-1 border-input bg-background px-2 text-sm shadow-none"
>
<SelectValue placeholder="field" />
</SelectTrigger>
<SelectContent position="popper">
{isCustom && <SelectItem value={value}>{value}</SelectItem>}
{propertyFields.map((f) => (
<SelectItem key={f} value={f}>{f}</SelectItem>
))}
{contentFields.length > 0 && (
<>
<SelectSeparator />
{contentFields.map((f) => (
<SelectItem key={f} value={f}>{f}</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
)
}
function OperatorSelect({ value, onChange }: {
value: FilterOp
onChange: (v: FilterOp) => void
@@ -116,43 +78,6 @@ function OperatorSelect({ value, onChange }: {
)
}
function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const selected = value ? parseDateFilterInput(value) ?? undefined : undefined
return (
<div className="flex flex-1 min-w-0 items-center gap-1">
<Input
className="h-8 flex-1 min-w-0 text-sm"
placeholder='YYYY-MM-DD or "10 days ago"'
value={value}
onChange={(e) => onChange(e.target.value)}
data-testid="date-value-input"
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-testid="date-picker-trigger"
className="h-8 w-8 shrink-0 px-0"
title={selected ? format(selected, 'MMM d, yyyy') : 'Pick a date'}
aria-label={selected ? `Open date picker (${format(selected, 'MMM d, yyyy')})` : 'Open date picker'}
>
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
/>
</PopoverContent>
</Popover>
</div>
)
}
function TextValueInput({ value, onChange, regexEnabled, regexSupported, invalidRegex, onToggleRegex }: {
value: string
onChange: (v: string) => void
@@ -217,7 +142,7 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
return (
<div className="flex items-center gap-1.5">
<FieldSelect
<FilterFieldCombobox
value={condition.field}
fields={fields}
onChange={(v) => onUpdate({ ...condition, field: v })}

View File

@@ -0,0 +1,314 @@
import { CaretUpDown } from '@phosphor-icons/react'
import { useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type RefObject } from 'react'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { FilterFieldOptionsList } from './filter-builder/FilterFieldOptionsList'
const CONTENT_FIELDS = new Set(['body'])
interface FilterFieldComboboxProps {
value: string
fields: string[]
onChange: (value: string) => void
}
interface FieldGroup {
key: 'property' | 'content'
label: string
options: string[]
}
function normalizeFieldQuery(query: string): string {
return query.trim().toLowerCase()
}
function buildFieldGroups(fields: string[], currentValue: string, query: string): FieldGroup[] {
const allFields = currentValue !== '' && !fields.includes(currentValue)
? [currentValue, ...fields]
: fields
const normalized = normalizeFieldQuery(query)
const matches = (field: string) => normalized === '' || field.toLowerCase().includes(normalized)
const propertyOptions = allFields.filter((field) => !CONTENT_FIELDS.has(field) && matches(field))
const contentOptions = allFields.filter((field) => CONTENT_FIELDS.has(field) && matches(field))
const groups: FieldGroup[] = []
if (propertyOptions.length > 0) groups.push({ key: 'property', label: 'Properties', options: propertyOptions })
if (contentOptions.length > 0) groups.push({ key: 'content', label: 'Content', options: contentOptions })
return groups
}
function flattenGroups(groups: FieldGroup[]): string[] {
return groups.flatMap((group) => group.options)
}
function initialHighlightIndex(options: string[], currentValue: string): number {
if (options.length === 0) return -1
const currentIndex = options.indexOf(currentValue)
return currentIndex >= 0 ? currentIndex : 0
}
function stepHighlightedIndex(current: number, optionCount: number, direction: 'next' | 'previous'): number {
if (current < 0) return direction === 'next' ? 0 : optionCount - 1
if (direction === 'next') return (current + 1) % optionCount
return (current - 1 + optionCount) % optionCount
}
function handleFilterFieldKeyDown({
event,
open,
options,
highlightedIndex,
openCombobox,
setHighlightedIndex,
selectOption,
closeCombobox,
}: {
event: KeyboardEvent<HTMLInputElement>
open: boolean
options: string[]
highlightedIndex: number
openCombobox: () => void
setHighlightedIndex: (updater: number | ((current: number) => number)) => void
selectOption: (value: string) => void
closeCombobox: () => void
}) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
if (!open) {
openCombobox()
return
}
if (options.length === 0) return
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'next'))
return
case 'ArrowUp':
event.preventDefault()
if (!open) {
openCombobox()
return
}
if (options.length === 0) return
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'previous'))
return
case 'Enter':
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
event.preventDefault()
selectOption(options[highlightedIndex])
return
case 'Escape':
if (!open) return
event.preventDefault()
closeCombobox()
return
default:
return
}
}
function FilterFieldInput({
inputRef,
open,
query,
value,
listboxId,
highlightedIndex,
onFocus,
onChange,
onKeyDown,
}: {
inputRef: RefObject<HTMLInputElement | null>
open: boolean
query: string
value: string
listboxId: string
highlightedIndex: number
onFocus: () => void
onChange: (event: ChangeEvent<HTMLInputElement>) => void
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
}) {
return (
<>
<Input
ref={inputRef}
value={open ? query : value}
onFocus={onFocus}
onChange={onChange}
onKeyDown={onKeyDown}
role="combobox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded={open}
aria-activedescendant={highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined}
className="h-8 pr-7 text-sm"
data-testid="filter-field-combobox-input"
/>
<CaretUpDown
size={14}
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
</>
)
}
function FilterFieldPopoverPanel({
open,
contentWidth,
listboxId,
fieldGroups,
options,
highlightedIndex,
onHighlight,
onSelect,
}: {
open: boolean
contentWidth: number
listboxId: string
fieldGroups: FieldGroup[]
options: string[]
highlightedIndex: number
onHighlight: (index: number) => void
onSelect: (value: string) => void
}) {
if (!open) return null
return (
<PopoverContent
align="start"
sideOffset={4}
className="max-h-60 overflow-y-auto p-1"
style={{ width: contentWidth }}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
data-testid="filter-field-combobox-popover"
>
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
<FilterFieldOptionsList
listboxId={listboxId}
fieldGroups={fieldGroups}
options={options}
highlightedIndex={highlightedIndex}
onHighlight={onHighlight}
onSelect={onSelect}
/>
</div>
</PopoverContent>
)
}
export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComboboxProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState(value)
const [hasTyped, setHasTyped] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [contentWidth, setContentWidth] = useState<number>(220)
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listboxId = useId()
const effectiveQuery = hasTyped ? query : ''
const fieldGroups = useMemo(() => buildFieldGroups(fields, value, effectiveQuery), [fields, value, effectiveQuery])
const options = useMemo(() => flattenGroups(fieldGroups), [fieldGroups])
const resetToCurrentValue = () => {
setQuery(value)
setHasTyped(false)
setHighlightedIndex(initialHighlightIndex(flattenGroups(buildFieldGroups(fields, value, '')), value))
}
const openCombobox = () => {
resetToCurrentValue()
setOpen(true)
requestAnimationFrame(() => inputRef.current?.select())
}
const closeCombobox = () => {
setOpen(false)
resetToCurrentValue()
}
const selectOption = (nextValue: string) => {
onChange(nextValue)
setQuery(nextValue)
setHasTyped(false)
setHighlightedIndex(-1)
setOpen(false)
}
useEffect(() => {
if (!open) return
const updateWidth = () => {
const nextWidth = rootRef.current?.getBoundingClientRect().width ?? 220
setContentWidth(Math.max(nextWidth, 220))
}
updateWidth()
window.addEventListener('resize', updateWidth)
return () => window.removeEventListener('resize', updateWidth)
}, [open])
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
if (rootRef.current?.contains(event.relatedTarget as Node | null)) return
closeCombobox()
}
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const nextQuery = event.target.value
const nextGroups = buildFieldGroups(fields, value, nextQuery)
const nextOptions = flattenGroups(nextGroups)
setOpen(true)
setQuery(nextQuery)
setHasTyped(true)
setHighlightedIndex(nextOptions.length > 0 ? 0 : -1)
}
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
handleFilterFieldKeyDown({
event,
open,
options,
highlightedIndex,
openCombobox,
setHighlightedIndex,
selectOption,
closeCombobox,
})
}
return (
<Popover open={open}>
<PopoverAnchor asChild>
<div
ref={rootRef}
className="relative flex-1 min-w-[160px]"
onBlur={handleBlur}
data-testid="filter-field-combobox"
>
<FilterFieldInput
inputRef={inputRef}
open={open}
query={query}
value={value}
listboxId={listboxId}
highlightedIndex={highlightedIndex}
onFocus={openCombobox}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
/>
</div>
</PopoverAnchor>
<FilterFieldPopoverPanel
open={open}
contentWidth={contentWidth}
listboxId={listboxId}
fieldGroups={fieldGroups}
options={options}
highlightedIndex={highlightedIndex}
onHighlight={setHighlightedIndex}
onSelect={selectOption}
/>
</Popover>
)
}

View File

@@ -1,9 +1,22 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NoteItem } from './NoteItem'
import { makeEntry } from '../test-utils/noteListTestUtils'
vi.mock('../utils/url', async () => {
const actual = await vi.importActual('../utils/url') as typeof import('../utils/url')
return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) }
})
const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & {
openExternalUrl: ReturnType<typeof vi.fn>
}
describe('NoteItem', () => {
beforeEach(() => {
openExternalUrl.mockClear()
})
it('renders binary files as non-clickable muted rows', () => {
const binaryEntry = makeEntry({
path: '/vault/photo.png',
@@ -73,4 +86,153 @@ describe('NoteItem', () => {
expect(screen.queryByText('note.md')).not.toBeInTheDocument()
expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument()
})
it('colors relationship chips by target type and opens the related note on Cmd+click only', () => {
const linkedProject = makeEntry({
path: '/vault/project/build-app.md',
filename: 'build-app.md',
title: 'Build App',
isA: 'Project',
})
const projectType = makeEntry({
path: '/vault/type/project.md',
filename: 'project.md',
title: 'Project',
isA: 'Type',
color: 'red',
icon: 'wrench',
})
const sourceEntry = makeEntry({
path: '/vault/note/source.md',
filename: 'source.md',
title: 'Source',
isA: 'Note',
relationships: { 'Belongs to': ['[[project/build-app]]'] },
})
const onClickNote = vi.fn()
render(
<NoteItem
entry={sourceEntry}
isSelected={false}
typeEntryMap={{ Project: projectType }}
allEntries={[sourceEntry, linkedProject, projectType]}
displayPropsOverride={['Belongs to']}
onClickNote={onClickNote}
/>,
)
const chip = screen.getByTestId('property-chip-belongs-to-0')
expect(chip).toHaveTextContent('Build App')
expect(chip.className).toContain('cursor-pointer')
expect(chip).toHaveStyle({ color: 'var(--accent-red)', backgroundColor: 'var(--accent-red-light)' })
fireEvent.click(chip)
expect(onClickNote).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
expect(onClickNote).toHaveBeenCalledWith(linkedProject, expect.objectContaining({ metaKey: true }))
})
it('falls back to the built-in type icon for relationship chips when the Type has no custom icon', () => {
const linkedTopic = makeEntry({
path: '/vault/topic/ai.md',
filename: 'ai.md',
title: 'AI',
isA: 'topic',
})
const topicType = makeEntry({
path: '/vault/type/topic.md',
filename: 'topic.md',
title: 'Topic',
isA: 'Type',
color: 'green',
icon: null,
})
const sourceEntry = makeEntry({
path: '/vault/note/source.md',
filename: 'source.md',
title: 'Source',
isA: 'Note',
relationships: { Topics: ['[[topic/ai]]'] },
})
render(
<NoteItem
entry={sourceEntry}
isSelected={false}
typeEntryMap={{ Topic: topicType, topic: topicType }}
allEntries={[sourceEntry, linkedTopic, topicType]}
displayPropsOverride={['Topics']}
onClickNote={vi.fn()}
/>,
)
const chip = screen.getByTestId('property-chip-topics-0')
expect(chip).toHaveTextContent('Ai')
expect(chip).toHaveStyle({ color: 'var(--accent-green)', backgroundColor: 'var(--accent-green-light)' })
expect(chip.querySelector('svg')).not.toBeNull()
})
it('opens URL chips on Cmd+click only and keeps regular clicks inert', () => {
const entry = makeEntry({
path: '/vault/note/source.md',
filename: 'source.md',
title: 'Source',
properties: { URL: 'https://example.com/docs' },
})
const onClickNote = vi.fn()
render(
<NoteItem
entry={entry}
isSelected={false}
typeEntryMap={{}}
displayPropsOverride={['URL']}
onClickNote={onClickNote}
/>,
)
const chip = screen.getByTestId('property-chip-url-0')
expect(chip).toHaveTextContent('example.com')
expect(chip.className).toContain('cursor-pointer')
expect(chip).toHaveStyle({ color: 'var(--accent-blue)', backgroundColor: 'var(--accent-blue-light)' })
fireEvent.click(chip)
expect(openExternalUrl).not.toHaveBeenCalled()
expect(onClickNote).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
expect(openExternalUrl).toHaveBeenCalledWith('https://example.com/docs')
expect(onClickNote).not.toHaveBeenCalled()
})
it('renders broken relationship chips as neutral and non-interactive', () => {
const entry = makeEntry({
path: '/vault/note/source.md',
filename: 'source.md',
title: 'Source',
relationships: { Related: ['[[missing/note]]'] },
})
const onClickNote = vi.fn()
render(
<NoteItem
entry={entry}
isSelected={false}
typeEntryMap={{}}
allEntries={[entry]}
displayPropsOverride={['Related']}
onClickNote={onClickNote}
/>,
)
const chip = screen.getByTestId('property-chip-related-0')
expect(chip).toHaveTextContent('Note')
expect(chip.className).not.toContain('cursor-pointer')
fireEvent.click(chip, { metaKey: true })
expect(onClickNote).not.toHaveBeenCalled()
expect(openExternalUrl).not.toHaveBeenCalled()
})
})

View File

@@ -1,4 +1,4 @@
import { createElement, useMemo, useState, type ComponentType, type SVGAttributes } from 'react'
import { useMemo, type ComponentType, type SVGAttributes } from 'react'
import type { VaultEntry, NoteStatus } from '../types'
import { cn } from '@/lib/utils'
import {
@@ -7,11 +7,10 @@ import {
File, FileDashed,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { findIcon, resolveIcon } from '../utils/iconRegistry'
import { resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
import { resolveNoteIcon } from '../utils/noteIcon'
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../utils/wikilink'
import { NoteTitleIcon } from './NoteTitleIcon'
import { PropertyChips } from './note-item/PropertyChips'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
@@ -60,134 +59,6 @@ function StateBadge({ archived }: { archived: boolean }) {
return null
}
function formatChipValue(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null
const s = String(value)
// URL: show only hostname
try {
if (s.startsWith('http://') || s.startsWith('https://')) return new URL(s).hostname
} catch { /* not a URL */ }
return s.length > 40 ? s.slice(0, 37) + '…' : s
}
interface PropertyChipValue {
label: string
noteIcon: string | null
typeIcon: string | null
}
function resolveChipValues(
entry: VaultEntry,
propName: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] {
if (propName.toLowerCase() === 'status') {
const formatted = formatChipValue(entry.status)
return formatted ? [{ label: formatted, noteIcon: null, typeIcon: null }] : []
}
// Check relationships first (wikilink values)
const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === propName.toLowerCase())
if (relKey) {
return entry.relationships[relKey]
.map((ref) => {
const targetEntry = resolveEntry(allEntries, wikilinkTarget(ref))
const label = wikilinkDisplay(ref)
return label ? {
label,
noteIcon: targetEntry?.icon ?? null,
typeIcon: targetEntry?.isA ? typeEntryMap[targetEntry.isA]?.icon ?? null : null,
} : null
})
.filter((value): value is PropertyChipValue => value !== null)
}
// Check scalar properties
const propKey = Object.keys(entry.properties).find((k) => k.toLowerCase() === propName.toLowerCase())
if (!propKey) return []
const val = entry.properties[propKey]
if (Array.isArray(val)) {
return val
.map((v) => formatChipValue(v))
.filter((v): v is string => v !== null)
.map((label) => ({ label, noteIcon: null, typeIcon: null }))
}
const formatted = formatChipValue(val)
return formatted ? [{ label: formatted, noteIcon: null, typeIcon: null }] : []
}
function PropertyChipIcon({ noteIcon, typeIcon }: { noteIcon?: string | null; typeIcon?: string | null }) {
const [imageFailed, setImageFailed] = useState(false)
const resolvedNoteIcon = resolveNoteIcon(noteIcon)
const TypeIcon = findIcon(typeIcon)
if (resolvedNoteIcon.kind === 'emoji') {
return (
<span aria-hidden="true" className="inline-flex shrink-0 items-center justify-center leading-none" style={{ fontSize: 11, lineHeight: 1 }}>
{resolvedNoteIcon.value}
</span>
)
}
if (resolvedNoteIcon.kind === 'phosphor') {
return <resolvedNoteIcon.Icon aria-hidden="true" width={11} height={11} className="shrink-0" />
}
if (resolvedNoteIcon.kind === 'image' && !imageFailed) {
return (
<img
src={resolvedNoteIcon.src}
alt=""
aria-hidden="true"
className="h-[11px] w-[11px] shrink-0 rounded-sm object-cover"
onError={() => setImageFailed(true)}
/>
)
}
if (!TypeIcon) return null
return createElement(TypeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
}
function PropertyChips({
entry,
displayProps,
allEntries,
typeEntryMap,
}: {
entry: VaultEntry
displayProps: string[]
allEntries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
}) {
const chips = useMemo(() => {
const result: { key: string; values: PropertyChipValue[] }[] = []
for (const prop of displayProps) {
const values = resolveChipValues(entry, prop, allEntries, typeEntryMap)
if (values.length > 0) result.push({ key: prop, values })
}
return result
}, [entry, displayProps, allEntries, typeEntryMap])
if (chips.length === 0) return null
return (
<div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips">
{chips.map(({ key, values }) =>
values.map((v, i) => (
<span
key={`${key}-${i}`}
className="inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
<PropertyChipIcon noteIcon={v.noteIcon} typeIcon={v.typeIcon} />
<span className="truncate whitespace-nowrap">{v.label}</span>
</span>
))
)}
</div>
)
}
const CHANGE_STATUS_DISPLAY: Record<string, { label: string; color: string; symbol: string }> = {
modified: { label: 'Modified', color: 'var(--accent-orange, #f59e0b)', symbol: '·' },
added: { label: 'Added', color: 'var(--accent-green, #22c55e)', symbol: '+' },
@@ -210,6 +81,117 @@ function ChangeStatusIcon({ status }: { status: string }) {
)
}
function noteItemClassName({
isBinary,
isSelected,
isMultiSelected,
isHighlighted,
}: {
isBinary: boolean
isSelected: boolean
isMultiSelected: boolean
isHighlighted: boolean
}) {
return cn(
'relative border-b border-[var(--border)] transition-colors',
isBinary ? 'cursor-default opacity-50' : 'cursor-pointer',
isSelected && !isMultiSelected && !isBinary && 'border-l-[3px]',
!isSelected && !isMultiSelected && !isBinary && 'hover:bg-muted',
isHighlighted && !isSelected && !isMultiSelected && !isBinary && 'bg-muted',
)
}
function ChangeStatusContent({
entry,
changeStatus,
isSelected,
isDeletedChange,
}: {
entry: VaultEntry
changeStatus: NonNullable<NoteItemProps['changeStatus']>
isSelected: boolean
isDeletedChange: boolean
}) {
return (
<>
<ChangeStatusIcon status={changeStatus} />
<div className="pr-5">
<div
className={cn(
'truncate text-[13px] font-mono',
isSelected ? 'font-semibold' : 'font-normal',
isDeletedChange && 'text-muted-foreground line-through opacity-70',
)}
style={{ fontSize: 12 }}
>
{entry.filename}
</div>
</div>
</>
)
}
function StandardNoteContent({
entry,
isBinary,
noteStatus,
isSelected,
typeColor,
displayProps,
allEntries,
typeEntryMap,
onClickNote,
}: {
entry: VaultEntry
isBinary: boolean
noteStatus: NoteStatus
isSelected: boolean
typeColor: string
displayProps: string[]
allEntries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onClickNote: NoteItemProps['onClickNote']
}) {
const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown'
const te = typeEntryMap[entry.isA ?? '']
const TypeIcon = useMemo(() => {
if (isNonMarkdown) return getFileKindIcon(entry.fileKind)
return getTypeIcon(entry.isA, te?.icon)
}, [entry.fileKind, entry.isA, isNonMarkdown, te?.icon])
return (
<>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="pr-5">
<div className={cn('truncate text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
{!isBinary && <StateBadge archived={entry.archived} />}
</div>
</div>
{entry.snippet && !isBinary && (
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
)}
{!isBinary && displayProps.length > 0 && (
<PropertyChips
entry={entry}
displayProps={displayProps}
allEntries={allEntries}
typeEntryMap={typeEntryMap}
onOpenNote={onClickNote}
/>
)}
{!isBinary && (
<div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
)}
</>
)
}
function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties {
const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' }
if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)'
@@ -228,7 +210,7 @@ function resolveDisplayProps(entry: VaultEntry, typeEntryMap: Record<string, Vau
return typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, allEntries, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: {
type NoteItemProps = {
entry: VaultEntry
isSelected: boolean
isMultiSelected?: boolean
@@ -242,32 +224,34 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
onPrefetch?: (path: string) => void
onContextMenu?: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
}
function createNoteItemClickHandler(
entry: VaultEntry,
isBinary: boolean,
onClickNote: NoteItemProps['onClickNote'],
) {
if (isBinary) {
return (event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}
}
return (event: React.MouseEvent) => onClickNote(entry, event)
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, allEntries, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: NoteItemProps) {
const isBinary = entry.fileKind === 'binary'
const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown'
const isDeletedChange = changeStatus === 'deleted'
const te = typeEntryMap[entry.isA ?? '']
const displayProps = resolveDisplayProps(entry, typeEntryMap, displayPropsOverride)
const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
const TypeIcon = useMemo(() => {
if (isNonMarkdown) return getFileKindIcon(entry.fileKind)
return getTypeIcon(entry.isA, te?.icon)
}, [entry.isA, te?.icon, entry.fileKind, isNonMarkdown])
const handleClick = isBinary
? (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation() }
: (e: React.MouseEvent) => onClickNote(entry, e)
const handleClick = createNoteItemClickHandler(entry, isBinary, onClickNote)
return (
<div
className={cn(
"relative border-b border-[var(--border)] transition-colors",
isBinary ? "cursor-default opacity-50" : "cursor-pointer",
isSelected && !isMultiSelected && !isBinary && "border-l-[3px]",
!isSelected && !isMultiSelected && !isBinary && "hover:bg-muted",
isHighlighted && !isSelected && !isMultiSelected && !isBinary && "bg-muted"
)}
className={noteItemClassName({ isBinary, isSelected, isMultiSelected, isHighlighted })}
style={isBinary ? { padding: '14px 16px' } : noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
onClick={handleClick}
onContextMenu={onContextMenu ? (e) => onContextMenu(entry, e) : undefined}
@@ -279,45 +263,24 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
title={isBinary ? 'Cannot open this file type' : undefined}
>
{changeStatus ? (
<>
<ChangeStatusIcon status={changeStatus} />
<div className="pr-5">
<div
className={cn(
"truncate text-[13px] font-mono",
isSelected ? "font-semibold" : "font-normal",
isDeletedChange && "text-muted-foreground line-through opacity-70",
)}
style={{ fontSize: 12 }}
>
{entry.filename}
</div>
</div>
</>
<ChangeStatusContent
entry={entry}
changeStatus={changeStatus}
isSelected={isSelected}
isDeletedChange={isDeletedChange}
/>
) : (
<>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="pr-5">
<div className={cn("truncate text-[13px]", isBinary ? "text-muted-foreground" : "text-foreground", isSelected && !isBinary ? "font-semibold" : "font-medium")}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
{!isBinary && <StateBadge archived={entry.archived} />}
</div>
</div>
{entry.snippet && !isBinary && (
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
)}
{!isBinary && displayProps.length > 0 && (
<PropertyChips entry={entry} displayProps={displayProps} allEntries={allEntries ?? [entry]} typeEntryMap={typeEntryMap} />
)}
{!isBinary && (
<div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
)}
</>
<StandardNoteContent
entry={entry}
isBinary={isBinary}
noteStatus={noteStatus}
isSelected={isSelected}
typeColor={typeColor}
displayProps={displayProps}
allEntries={allEntries ?? [entry]}
typeEntryMap={typeEntryMap}
onClickNote={onClickNote}
/>
)}
</div>
)

View File

@@ -293,6 +293,41 @@ describe('NoteList rendering', () => {
expect(screen.getByText('Luca')).toBeInTheDocument()
expect(screen.queryByText('High')).not.toBeInTheDocument()
})
it('Cmd+clicks relationship chips through the note list without triggering the row click', () => {
const projectType = makeTypeDefinition('Project')
const taskType = makeTypeDefinition('Task', ['Belongs to'])
const projectEntry = makeEntry({
path: '/vault/project/build-app.md',
filename: 'build-app.md',
title: 'Build App',
isA: 'Project',
createdAt: 1700000000,
})
const taskEntry = makeEntry({
path: '/vault/task/write-tests.md',
filename: 'write-tests.md',
title: 'Write tests',
isA: 'Task',
relationships: { 'Belongs to': ['[[project/build-app]]'] },
createdAt: 1700000001,
})
const { onReplaceActiveTab, onSelectNote } = renderNoteList({
entries: [projectType, taskType, projectEntry, taskEntry],
selection: { kind: 'sectionGroup', type: 'Task' },
})
const chip = screen.getByTestId('property-chip-belongs-to-0')
fireEvent.click(chip)
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(projectEntry)
expect(onReplaceActiveTab).not.toHaveBeenCalled()
})
})
describe('NoteList click behavior', () => {

View File

@@ -10,18 +10,20 @@ describe('NoteTitleIcon', () => {
})
it('renders a Phosphor icon when the name is recognized', () => {
render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" />)
const { container } = render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" className="mr-1" />)
const icon = screen.getByTestId('note-title-icon')
expect(icon.tagName.toLowerCase()).toBe('svg')
expect(container.querySelector('span.inline-flex.mr-1')).not.toBeNull()
})
it('renders an image when the icon is an http url', () => {
render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" />)
const { container } = render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" className="mr-1" />)
const icon = screen.getByTestId('note-title-icon')
expect(icon.tagName.toLowerCase()).toBe('img')
expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png')
expect(container.querySelector('span.inline-flex.mr-1')).not.toBeNull()
})
it('renders nothing for an unrecognized icon value', () => {

View File

@@ -9,6 +9,21 @@ interface NoteTitleIconProps {
testId?: string
}
function IconWrapper({
children,
className,
size,
}: Pick<NoteTitleIconProps, 'className' | 'size'> & { children: React.ReactNode }) {
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center align-middle', className)}
style={{ width: size, height: size, lineHeight: 1 }}
>
{children}
</span>
)
}
export function NoteTitleIcon({ icon, size = 14, className, color, testId }: NoteTitleIconProps) {
const resolved = resolveNoteIcon(icon)
@@ -16,39 +31,40 @@ export function NoteTitleIcon({ icon, size = 14, className, color, testId }: Not
if (resolved.kind === 'emoji') {
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', className)}
style={{ fontSize: size, lineHeight: 1 }}
data-testid={testId}
>
{resolved.value}
</span>
<IconWrapper className={className} size={size}>
<span style={{ fontSize: size, lineHeight: 1 }} data-testid={testId}>
{resolved.value}
</span>
</IconWrapper>
)
}
if (resolved.kind === 'image') {
return (
<img
src={resolved.src}
alt=""
aria-hidden="true"
className={cn('shrink-0 rounded-sm object-cover', className)}
style={{ width: size, height: size }}
onError={(event) => {
event.currentTarget.style.display = 'none'
}}
data-testid={testId}
/>
<IconWrapper className={className} size={size}>
<img
src={resolved.src}
alt=""
aria-hidden="true"
className="block h-full w-full rounded-sm object-cover"
onError={(event) => {
event.currentTarget.style.display = 'none'
}}
data-testid={testId}
/>
</IconWrapper>
)
}
return (
<resolved.Icon
width={size}
height={size}
className={cn('shrink-0', className)}
style={color ? { color } : undefined}
data-testid={testId}
/>
<IconWrapper className={className} size={size}>
<resolved.Icon
width={size}
height={size}
className="block"
style={color ? { color } : undefined}
data-testid={testId}
/>
</IconWrapper>
)
}

View File

@@ -117,4 +117,18 @@ describe('EditorContentLayout', () => {
expect(titleRow?.querySelector('[data-testid="note-icon-display"]')).not.toBeNull()
expect(titleRow?.querySelector('[data-testid="title-field-input"]')).not.toBeNull()
})
it('shows the loading skeleton instead of stale editor chrome while switching tabs', () => {
const { container } = render(
<EditorContentLayout
{...createModel({
activeTab: null,
isLoadingNewTab: true,
})}
/>,
)
expect(container.querySelector('.animate-pulse')).not.toBeNull()
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
})
})

View File

@@ -302,7 +302,11 @@ export function EditorContentLayout(model: EditorContentModel) {
} = model
if (!activeTab) {
return <div className="flex flex-1 flex-col min-w-0 min-h-0" />
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</div>
)
}
return (

View File

@@ -62,6 +62,26 @@ describe('deriveEditorContentState', () => {
content: '---\ntitle: Legacy Project\n---\nBody without a heading',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
})
it('hides the legacy title section when a frontmatter title drives the display title', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Spring 2026\nstatus: Active\n---\n## Goals',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
})
it('keeps the title section when the document title still comes from the filename', () => {
const state = deriveState({
entry: baseEntry,
content: '---\nstatus: Active\n---\nBody without a heading',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(true)
})

View File

@@ -1,5 +1,5 @@
import type { NoteStatus, VaultEntry } from '../../types'
import { extractH1TitleFromContent } from '../../utils/noteTitle'
import { contentDefinesDisplayTitle, extractH1TitleFromContent } from '../../utils/noteTitle'
import { countWords } from '../../utils/wikilinks'
export interface EditorContentTab {
@@ -14,6 +14,19 @@ interface EditorContentStateInput {
activeStatus: NoteStatus
}
interface TitleSectionState {
hasDisplayTitle: boolean
hasH1: boolean
}
interface VisibilityState {
effectiveRawMode: boolean
isDeletedPreview: boolean
isNonMarkdownText: boolean
showEditor: boolean
showTitleSection: boolean
}
export interface EditorContentState {
freshEntry: VaultEntry | undefined
isArchived: boolean
@@ -36,10 +49,53 @@ function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean {
return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false
}
function contentDefinesTitle(activeTab: EditorContentTab | null): boolean {
return activeTab ? contentDefinesDisplayTitle(activeTab.content) : false
}
function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean {
return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true
}
function resolveHasDisplayTitle(activeTab: EditorContentTab | null, hasH1: boolean): boolean {
return hasH1 || contentDefinesTitle(activeTab)
}
function deriveTitleSectionState(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): TitleSectionState {
const hasH1 = resolveHasH1(activeTab, freshEntry)
return {
hasDisplayTitle: resolveHasDisplayTitle(activeTab, hasH1),
hasH1,
}
}
function deriveVisibilityState(input: {
activeStatus: NoteStatus
activeTab: EditorContentTab | null
freshEntry: VaultEntry | undefined
hasDisplayTitle: boolean
rawMode: boolean
}): VisibilityState {
const {
activeStatus,
activeTab,
freshEntry,
hasDisplayTitle,
rawMode,
} = input
const isDeletedPreview = !!activeTab && !freshEntry
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
const effectiveRawMode = rawMode || isNonMarkdownText
return {
isDeletedPreview,
isNonMarkdownText,
effectiveRawMode,
showEditor: !effectiveRawMode,
showTitleSection: !isDeletedPreview && !hasDisplayTitle && !isUnsavedUntitledDraft(activeTab, activeStatus),
}
}
function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean {
if (!activeTab) return false
if (!activeTab.entry.filename.startsWith('untitled-')) return false
@@ -53,22 +109,20 @@ export function deriveEditorContentState({
activeStatus,
}: EditorContentStateInput): EditorContentState {
const freshEntry = findFreshEntry(activeTab, entries)
const isDeletedPreview = !!activeTab && !freshEntry
const hasH1 = resolveHasH1(activeTab, freshEntry)
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
const effectiveRawMode = rawMode || isNonMarkdownText
const showEditor = !effectiveRawMode
const showTitleSection = !isDeletedPreview && !hasH1 && !isUnsavedUntitledDraft(activeTab, activeStatus)
const titleState = deriveTitleSectionState(activeTab, freshEntry)
const visibilityState = deriveVisibilityState({
activeStatus,
activeTab,
freshEntry,
hasDisplayTitle: titleState.hasDisplayTitle,
rawMode,
})
return {
freshEntry,
isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false,
hasH1,
isDeletedPreview,
isNonMarkdownText,
effectiveRawMode,
showEditor,
showTitleSection,
hasH1: titleState.hasH1,
...visibilityState,
path: activeTab?.entry.path ?? '',
wordCount: activeTab ? countWords(activeTab.content) : 0,
}

View File

@@ -0,0 +1,59 @@
import { useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { DateValueInput } from './DateValueInput'
describe('DateValueInput', () => {
afterEach(() => {
vi.useRealTimers()
})
function renderControlledDateValueInput(initialValue = '') {
function ControlledDateValueInput() {
const [value, setValue] = useState(initialValue)
return <DateValueInput value={value} onChange={setValue} />
}
return render(<ControlledDateValueInput />)
}
it('shows a debounced resolved-date preview while focused', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-08T12:00:00Z'))
renderControlledDateValueInput()
const input = screen.getByTestId('date-value-input')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: '10 days ago' } })
expect(screen.queryByTestId('date-value-preview')).not.toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(250)
})
expect(screen.getByTestId('date-value-preview')).toHaveTextContent('Resolves to March 29, 2026')
})
it('shows a neutral hint for unrecognized input and hides the preview on blur', async () => {
vi.useFakeTimers()
renderControlledDateValueInput()
const input = screen.getByTestId('date-value-input')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'eventually maybe' } })
await act(async () => {
vi.advanceTimersByTime(250)
})
expect(screen.getByTestId('date-value-preview-unrecognized')).toHaveTextContent('Not recognized')
fireEvent.blur(input)
expect(screen.queryByTestId('date-value-preview')).not.toBeInTheDocument()
expect(screen.queryByTestId('date-value-preview-unrecognized')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react'
import { CalendarBlank } from '@phosphor-icons/react'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { parseDateFilterInput } from '@/utils/filterDates'
const DATE_PREVIEW_DEBOUNCE_MS = 250
export function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const selected = value ? parseDateFilterInput(value) ?? undefined : undefined
const [showPreview, setShowPreview] = useState(false)
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timeoutId = window.setTimeout(() => setDebouncedValue(value), DATE_PREVIEW_DEBOUNCE_MS)
return () => window.clearTimeout(timeoutId)
}, [value])
const previewValue = showPreview ? debouncedValue.trim() : ''
const resolvedPreview = previewValue ? parseDateFilterInput(previewValue) : null
const previewLabel = resolvedPreview
? format(resolvedPreview, 'MMMM d, yyyy')
: previewValue
? 'Not recognized'
: null
return (
<div className="flex flex-1 min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-1">
<Input
className="h-8 flex-1 min-w-0 text-sm"
placeholder='YYYY-MM-DD or "10 days ago"'
value={value}
onChange={(e) => {
setShowPreview(true)
onChange(e.target.value)
}}
onFocus={() => setShowPreview(true)}
onBlur={() => setShowPreview(false)}
data-testid="date-value-input"
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-testid="date-picker-trigger"
className="h-8 w-8 shrink-0 px-0"
title={selected ? format(selected, 'MMM d, yyyy') : 'Pick a date'}
aria-label={selected ? `Open date picker (${format(selected, 'MMM d, yyyy')})` : 'Open date picker'}
>
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
/>
</PopoverContent>
</Popover>
</div>
{previewLabel && (
<div
className="pl-1 text-[11px] text-muted-foreground"
data-testid={resolvedPreview ? 'date-value-preview' : 'date-value-preview-unrecognized'}
>
{resolvedPreview ? `Resolves to ${previewLabel}` : previewLabel}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { FilterFieldCombobox } from '../FilterFieldCombobox'
describe('FilterFieldCombobox', () => {
it('renders its option list outside the clipped field container', () => {
render(
<div className="h-12 overflow-hidden">
<FilterFieldCombobox value="status" fields={['status', 'title', 'Owner']} onChange={vi.fn()} />
</div>,
)
const root = screen.getByTestId('filter-field-combobox')
const input = screen.getByTestId('filter-field-combobox-input')
fireEvent.focus(input)
const listbox = screen.getByRole('listbox')
expect(listbox).toBeInTheDocument()
expect(root.contains(listbox)).toBe(false)
})
})

View File

@@ -0,0 +1,69 @@
import { cn } from '@/lib/utils'
interface FieldGroup {
key: 'property' | 'content'
label: string
options: string[]
}
function optionTestId(field: string): string {
return `filter-field-option-${field.replace(/[^a-z0-9_-]+/gi, '-')}`
}
export function FilterFieldOptionsList({
listboxId,
fieldGroups,
options,
highlightedIndex,
onHighlight,
onSelect,
}: {
listboxId: string
fieldGroups: FieldGroup[]
options: string[]
highlightedIndex: number
onHighlight: (index: number) => void
onSelect: (field: string) => void
}) {
if (options.length === 0) {
return (
<div className="px-2 py-6 text-center text-sm text-muted-foreground" data-testid="filter-field-combobox-empty">
No results
</div>
)
}
return (
<>
{fieldGroups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && <div className="my-1 border-t border-border" />}
{group.options.map((field) => {
const optionIndex = options.indexOf(field)
return (
<button
key={field}
id={`${listboxId}-option-${optionIndex}`}
type="button"
role="option"
aria-selected={optionIndex === highlightedIndex}
className={cn(
'flex w-full items-center rounded px-2 py-1.5 text-left text-sm',
optionIndex === highlightedIndex
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => onHighlight(optionIndex)}
onClick={() => onSelect(field)}
data-testid={optionTestId(field)}
>
<span className="truncate">{field}</span>
</button>
)
})}
</div>
))}
</>
)
}

View File

@@ -0,0 +1,280 @@
import { createElement, useMemo, useState, type ComponentType, type CSSProperties, type MouseEvent, type ReactNode, type SVGAttributes } from 'react'
import { Link } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
import type { VaultEntry } from '../../types'
import { resolveNoteIcon } from '../../utils/noteIcon'
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { isUrlValue, normalizeUrl, openExternalUrl } from '../../utils/url'
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../../utils/wikilink'
import { getTypeIcon } from './typeIcon'
interface PropertyChipValue {
label: string
noteIcon: string | null
typeIcon: ComponentType<SVGAttributes<SVGSVGElement>> | null
style?: CSSProperties
action?: { kind: 'note'; entry: VaultEntry } | { kind: 'url'; url: string }
tone: 'neutral' | 'relationship' | 'url'
}
const URL_CHIP_STYLE: CSSProperties = {
backgroundColor: 'var(--accent-blue-light)',
color: 'var(--accent-blue)',
}
function toChipTestId(propName: string, index: number): string {
const slug = propName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
return `property-chip-${slug || 'value'}-${index}`
}
function normalizeOpenableUrl(value: string): string | null {
if (!isUrlValue(value)) return null
const normalized = normalizeUrl(value)
try {
const url = new URL(normalized)
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null
} catch {
return null
}
}
function formatChipLabel(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null
const raw = String(value)
const openableUrl = normalizeOpenableUrl(raw)
if (openableUrl) return new URL(openableUrl).hostname
return raw.length > 40 ? `${raw.slice(0, 37)}` : raw
}
function resolveTargetTypeEntry(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): VaultEntry | undefined {
return targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
}
function findMatchingKey(values: Record<string, unknown>, propName: string): string | undefined {
return Object.keys(values).find((key) => key.toLowerCase() === propName.toLowerCase())
}
function resolveRelationshipChipStyle(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): CSSProperties | undefined {
const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
const color = getTypeColor(targetEntry.isA, typeEntry?.color)
const backgroundColor = getTypeLightColor(targetEntry.isA, typeEntry?.color)
if (color === 'var(--muted-foreground)' && backgroundColor === 'var(--muted)') return undefined
return { color, backgroundColor }
}
function resolveRelationshipChip(
ref: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue | null {
const label = wikilinkDisplay(ref)
if (!label) return null
const targetEntry = resolveEntry(allEntries, wikilinkTarget(ref))
if (!targetEntry) {
return {
label,
noteIcon: null,
typeIcon: null,
tone: 'neutral',
}
}
const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
return {
label,
noteIcon: targetEntry.icon ?? null,
typeIcon: targetEntry.isA ? getTypeIcon(targetEntry.isA, typeEntry?.icon) : null,
style: resolveRelationshipChipStyle(targetEntry, typeEntryMap),
action: { kind: 'note', entry: targetEntry },
tone: 'relationship',
}
}
function resolveScalarChip(value: unknown): PropertyChipValue | null {
const label = formatChipLabel(value)
if (!label) return null
const openableUrl = typeof value === 'string' ? normalizeOpenableUrl(value) : null
if (openableUrl) {
return {
label,
noteIcon: null,
typeIcon: null,
style: URL_CHIP_STYLE,
action: { kind: 'url', url: openableUrl },
tone: 'url',
}
}
return {
label,
noteIcon: null,
typeIcon: null,
tone: 'neutral',
}
}
function resolveRelationshipChipValues(
entry: VaultEntry,
propName: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] | null {
const relationshipKey = findMatchingKey(entry.relationships, propName)
if (!relationshipKey) return null
return entry.relationships[relationshipKey]
.map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap))
.filter((chip): chip is PropertyChipValue => chip !== null)
}
function resolveScalarChipValues(entry: VaultEntry, propName: string): PropertyChipValue[] {
const propertyKey = findMatchingKey(entry.properties, propName)
if (!propertyKey) return []
const rawValue = entry.properties[propertyKey]
const values = Array.isArray(rawValue) ? rawValue : [rawValue]
return values
.map((value) => resolveScalarChip(value))
.filter((chip): chip is PropertyChipValue => chip !== null)
}
function resolvePropertyChipValues(
entry: VaultEntry,
propName: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] {
if (propName.toLowerCase() === 'status') {
const statusChip = resolveScalarChip(entry.status)
return statusChip ? [statusChip] : []
}
return resolveRelationshipChipValues(entry, propName, allEntries, typeEntryMap) ?? resolveScalarChipValues(entry, propName)
}
function RelationshipTypeIcon({
typeIcon,
}: {
typeIcon?: ComponentType<SVGAttributes<SVGSVGElement>> | null
}) {
if (!typeIcon) return null
return createElement(typeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
}
function renderResolvedNoteIcon(
noteIcon: string | null | undefined,
imageFailed: boolean,
onImageError: () => void,
): ReactNode {
const resolvedNoteIcon = resolveNoteIcon(noteIcon)
if (resolvedNoteIcon.kind === 'emoji') {
return (
<span aria-hidden="true" className="inline-flex shrink-0 items-center justify-center leading-none" style={{ fontSize: 11, lineHeight: 1 }}>
{resolvedNoteIcon.value}
</span>
)
}
if (resolvedNoteIcon.kind === 'phosphor') {
return <resolvedNoteIcon.Icon aria-hidden="true" width={11} height={11} className="shrink-0" />
}
if (resolvedNoteIcon.kind !== 'image' || imageFailed) return null
return (
<img
src={resolvedNoteIcon.src}
alt=""
aria-hidden="true"
className="h-[11px] w-[11px] shrink-0 rounded-sm object-cover"
onError={onImageError}
/>
)
}
function PropertyChipIcon({
noteIcon,
typeIcon,
tone,
}: {
noteIcon?: string | null
typeIcon?: ComponentType<SVGAttributes<SVGSVGElement>> | null
tone: PropertyChipValue['tone']
}) {
const [imageFailed, setImageFailed] = useState(false)
if (tone === 'url') {
return <Link aria-hidden="true" width={11} height={11} className="shrink-0" />
}
const noteIconElement = renderResolvedNoteIcon(noteIcon, imageFailed, () => setImageFailed(true))
if (noteIconElement) return noteIconElement
return <RelationshipTypeIcon typeIcon={typeIcon} />
}
async function handleChipClick(
event: MouseEvent<HTMLSpanElement>,
chip: PropertyChipValue,
onOpenNote: (entry: VaultEntry, event: MouseEvent) => void,
) {
event.preventDefault()
event.stopPropagation()
if (!event.metaKey || !chip.action) return
if (chip.action.kind === 'note') {
onOpenNote(chip.action.entry, event)
return
}
await openExternalUrl(chip.action.url).catch(() => {})
}
export function PropertyChips({
entry,
displayProps,
allEntries,
typeEntryMap,
onOpenNote,
}: {
entry: VaultEntry
displayProps: string[]
allEntries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onOpenNote: (entry: VaultEntry, event: MouseEvent) => void
}) {
const chips = useMemo(() => {
const result: { key: string; values: PropertyChipValue[] }[] = []
for (const prop of displayProps) {
const values = resolvePropertyChipValues(entry, prop, allEntries, typeEntryMap)
if (values.length > 0) result.push({ key: prop, values })
}
return result
}, [allEntries, displayProps, entry, typeEntryMap])
if (chips.length === 0) return null
return (
<div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips">
{chips.map(({ key, values }) =>
values.map((chip, index) => (
<span
key={`${key}-${index}`}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground',
chip.action && 'cursor-pointer',
)}
style={chip.style}
onClick={(event) => { void handleChipClick(event, chip, onOpenNote) }}
data-testid={toChipTestId(key, index)}
>
<PropertyChipIcon noteIcon={chip.noteIcon} typeIcon={chip.typeIcon} tone={chip.tone} />
<span className="truncate whitespace-nowrap">{chip.label}</span>
</span>
))
)}
</div>
)
}

View File

@@ -0,0 +1,37 @@
import type { ComponentType, SVGAttributes } from 'react'
import {
ArrowsClockwise,
CalendarBlank,
FileText,
Flask,
StackSimple,
Tag,
Target,
Users,
Wrench,
} from '@phosphor-icons/react'
import { resolveIcon } from '../../utils/iconRegistry'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
project: Wrench,
Experiment: Flask,
experiment: Flask,
Responsibility: Target,
responsibility: Target,
Procedure: ArrowsClockwise,
procedure: ArrowsClockwise,
Person: Users,
person: Users,
Event: CalendarBlank,
event: CalendarBlank,
Topic: Tag,
topic: Tag,
Type: StackSimple,
type: StackSimple,
}
export function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
if (customIcon) return resolveIcon(customIcon)
return (isA && TYPE_ICON_MAP[isA]) || FileText
}

View File

@@ -42,12 +42,16 @@ function isTextInputFocused(): boolean {
return tag === 'INPUT' || tag === 'TEXTAREA'
}
function isCmdOnly(e: KeyboardEvent): boolean {
function isCommandOrCtrlOnly(e: KeyboardEvent): boolean {
return (e.metaKey || e.ctrlKey) && e.altKey === false
}
function isCmdShiftOnly(e: KeyboardEvent): boolean {
return isCmdOnly(e) && e.shiftKey
function isCommandOrCtrlShiftOnly(e: KeyboardEvent): boolean {
return isCommandOrCtrlOnly(e) && e.shiftKey
}
function isCommandShiftOnly(e: KeyboardEvent): boolean {
return e.metaKey && e.ctrlKey === false && e.altKey === false && e.shiftKey
}
function withActiveTab(
@@ -86,7 +90,6 @@ export function createCommandKeyMap(actions: KeyboardActions): ShortcutMap {
export function createShiftCommandKeyMap(actions: KeyboardActions): ShortcutMap {
return {
l: () => actions.onToggleAIChat?.(),
f: () => {
trackEvent('search_used')
actions.onSearch()
@@ -97,7 +100,7 @@ export function createShiftCommandKeyMap(actions: KeyboardActions): ShortcutMap
}
export function handleViewModeKey(e: KeyboardEvent, onSetViewMode: (mode: ViewMode) => void): boolean {
if (isCmdOnly(e) === false || e.shiftKey) return false
if (isCommandOrCtrlOnly(e) === false || e.shiftKey) return false
const mode = VIEW_MODE_KEYS[e.key]
if (mode === undefined) return false
e.preventDefault()
@@ -106,7 +109,7 @@ export function handleViewModeKey(e: KeyboardEvent, onSetViewMode: (mode: ViewMo
}
export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean {
if (isCmdOnly(e) === false || e.shiftKey) return false
if (isCommandOrCtrlOnly(e) === false || e.shiftKey) return false
const handler = keyMap[e.key]
if (handler === undefined) return false
if (TEXT_EDITING_KEYS.has(e.key) && isTextInputFocused()) return false
@@ -115,8 +118,15 @@ export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean
return true
}
export function handleAiPanelKey(e: KeyboardEvent, onToggleAIChat?: () => void): boolean {
if (isCommandShiftOnly(e) === false || e.key.toLowerCase() !== 'l' || onToggleAIChat === undefined) return false
e.preventDefault()
onToggleAIChat()
return true
}
export function handleShiftCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean {
if (isCmdShiftOnly(e) === false) return false
if (isCommandOrCtrlShiftOnly(e) === false) return false
const handler = keyMap[e.key.toLowerCase()]
if (handler === undefined) return false
e.preventDefault()
@@ -125,6 +135,7 @@ export function handleShiftCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): bo
}
export function handleAppKeyboardEvent(actions: KeyboardActions, event: KeyboardEvent) {
if (handleAiPanelKey(event, actions.onToggleAIChat)) return
const shiftKeyMap = createShiftCommandKeyMap(actions)
if (handleShiftCommandKey(event, shiftKeyMap)) return
if (handleViewModeKey(event, actions.onSetViewMode)) return

View File

@@ -201,6 +201,14 @@ describe('useAppKeyboard', () => {
})
})
it('Ctrl+Shift+L does not trigger toggle AI chat', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('l', { ctrlKey: true, shiftKey: true })
expect(onToggleAIChat).not.toHaveBeenCalled()
})
it('Cmd+I does not trigger AI chat (reserved for italic)', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()

View File

@@ -36,11 +36,11 @@ export function useEditorSaveWithLinks(config: {
const filename = path.split('/').pop() ?? path
const fmPatch = {
...frontmatterPatch,
...deriveDisplayTitleState(
...deriveDisplayTitleState({
content,
filename,
typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
),
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
}),
}
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {

View File

@@ -185,6 +185,41 @@ function makeMockEditor(docRef: { current: unknown[] }) {
describe('useEditorTabSwap raw mode sync', () => {
afterEach(() => { vi.restoreAllMocks() })
it('swaps in the new note when the path updates before tabs catch up', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'March 2024')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
rerender({ tabs: [tabA], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
expect.stringContaining('March 2024'),
)
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks'
@@ -9,6 +9,10 @@ interface Tab {
content: string
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
type EditorBlocks = any[]
type CachedTabState = { blocks: EditorBlocks; scrollTop: number }
interface UseEditorTabSwapOptions {
tabs: Tab[]
activeTabPath: string | null
@@ -27,15 +31,17 @@ export function extractEditorBody(rawFileContent: string): string {
/** Extract H1 text from the editor's first block, or null if not an H1. */
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
if (!blocks?.length) return null
const first = blocks[0] as {
const first = blocks?.[0] as {
type?: string
props?: { level?: number }
content?: Array<{ type?: string; text?: string }>
}
if (first.type !== 'heading' || first.props?.level !== 1) return null
if (!Array.isArray(first.content)) return null
const text = first.content
} | undefined
const content = first?.type === 'heading' && first.props?.level === 1 && Array.isArray(first.content)
? first.content
: null
if (!content) return null
const text = content
.filter(item => item.type === 'text')
.map(item => item.text || '')
.join('')
@@ -47,6 +53,395 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string)
return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`)
}
function readEditorScrollTop(): number {
const scrollEl = document.querySelector('.editor__blocknote-container')
return scrollEl?.scrollTop ?? 0
}
function cacheEditorState(
cache: Map<string, CachedTabState>,
path: string,
blocks: EditorBlocks,
) {
cache.set(path, {
blocks,
scrollTop: readEditorScrollTop(),
})
}
function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
if (!preprocessed.trim()) {
return [{ type: 'paragraph', content: [] }]
}
const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/)
if (!h1OnlyMatch) return null
return [
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] },
{ type: 'paragraph', content: [], children: [] },
]
}
async function parseMarkdownBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
preprocessed: string,
): Promise<EditorBlocks> {
const result = editor.tryParseMarkdownToBlocks(preprocessed)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks
if (result && typeof (result as any).then === 'function') {
return (result as unknown as Promise<EditorBlocks>)
}
return result as EditorBlocks
}
async function resolveBlocksForTarget(
editor: ReturnType<typeof useCreateBlockNote>,
cache: Map<string, CachedTabState>,
targetPath: string,
content: string,
): Promise<CachedTabState> {
const cached = cache.get(targetPath)
if (cached) return cached
const body = extractEditorBody(content)
const preprocessed = preProcessWikilinks(body)
const fastPathBlocks = buildFastPathBlocks(preprocessed)
if (fastPathBlocks) {
const nextState = { blocks: fastPathBlocks, scrollTop: 0 }
cache.set(targetPath, nextState)
return nextState
}
const parsed = await parseMarkdownBlocks(editor, preprocessed)
const withWikilinks = injectWikilinks(parsed)
if (withWikilinks.length > 0) {
cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 })
}
return { blocks: withWikilinks, scrollTop: 0 }
}
function applyBlocksToEditor(
editor: ReturnType<typeof useCreateBlockNote>,
blocks: EditorBlocks,
scrollTop: number,
suppressChangeRef: MutableRefObject<boolean>,
) {
suppressChangeRef.current = true
try {
const current = editor.document
if (current.length > 0 && blocks.length > 0) {
editor.replaceBlocks(current, blocks)
} else if (blocks.length > 0) {
editor.insertBlocks(blocks, current[0], 'before')
}
} catch (err) {
console.error('applyBlocks failed, trying fallback:', err)
try {
const html = editor.blocksToHTMLLossy(blocks)
editor._tiptapEditor.commands.setContent(html)
} catch (err2) {
console.error('Fallback also failed:', err2)
}
} finally {
queueMicrotask(() => { suppressChangeRef.current = false })
}
requestAnimationFrame(() => {
const scrollEl = document.querySelector('.editor__blocknote-container')
if (scrollEl) scrollEl.scrollTop = scrollTop
})
}
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
return activeTabPath
? tabs.find(tab => tab.entry.path === activeTabPath)
: undefined
}
function useLatestRef<T>(value: T): MutableRefObject<T> {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
function useEditorMountState(
editor: ReturnType<typeof useCreateBlockNote>,
editorMountedRef: MutableRefObject<boolean>,
pendingSwapRef: MutableRefObject<(() => void) | null>,
) {
useEffect(() => {
if (editor.prosemirrorView) {
editorMountedRef.current = true
}
const cleanup = editor.onMount(() => {
editorMountedRef.current = true
if (pendingSwapRef.current) {
const swap = pendingSwapRef.current
pendingSwapRef.current = null
queueMicrotask(swap)
}
})
return cleanup
}, [editor, editorMountedRef, pendingSwapRef])
}
function useEditorChangeHandler(options: {
editor: ReturnType<typeof useCreateBlockNote>
tabsRef: MutableRefObject<Tab[]>
onContentChangeRef: MutableRefObject<((path: string, content: string) => void) | undefined>
prevActivePathRef: MutableRefObject<string | null>
suppressChangeRef: MutableRefObject<boolean>
}) {
const {
editor,
tabsRef,
onContentChangeRef,
prevActivePathRef,
suppressChangeRef,
} = options
return useCallback(() => {
if (suppressChangeRef.current) return
const path = prevActivePathRef.current
if (!path) return
const tab = tabsRef.current.find(t => t.entry.path === path)
if (!tab) return
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const [frontmatter] = splitFrontmatter(tab.content)
onContentChangeRef.current?.(path, `${frontmatter}${bodyMarkdown}`)
}, [editor, onContentChangeRef, prevActivePathRef, suppressChangeRef, tabsRef])
}
function consumeRawModeTransition(
prevRawModeRef: MutableRefObject<boolean>,
rawMode: boolean | undefined,
) {
const rawModeJustEnded = prevRawModeRef.current && !rawMode
prevRawModeRef.current = !!rawMode
return rawModeJustEnded
}
function cachePreviousTabOnPathChange(options: {
prevPath: string | null
pathChanged: boolean
editorMountedRef: MutableRefObject<boolean>
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
}) {
const { prevPath, pathChanged, editorMountedRef, cache, editor } = options
if (!prevPath || !pathChanged || !editorMountedRef.current) return
cacheEditorState(cache, prevPath, editor.document)
}
function rememberPendingTabArrival(
activeTabPath: string | null,
activeTab: Tab | undefined,
pendingTabArrivalPathRef: MutableRefObject<string | null>,
) {
if (!activeTabPath) {
pendingTabArrivalPathRef.current = null
return false
}
if (activeTab) {
pendingTabArrivalPathRef.current = null
return true
}
pendingTabArrivalPathRef.current = activeTabPath
return false
}
function handleStableActivePath(options: {
pathChanged: boolean
rawModeJustEnded: boolean
activeTabPath: string | null
activeTab: Tab | undefined
pendingTabArrival: boolean
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
}) {
const {
pathChanged,
rawModeJustEnded,
activeTabPath,
activeTab,
pendingTabArrival,
cache,
editor,
editorMountedRef,
rawSwapPendingRef,
} = options
if (pathChanged) return false
if (rawModeJustEnded && activeTabPath) {
cache.delete(activeTabPath)
rawSwapPendingRef.current = true
return false
}
if (pendingTabArrival) return false
if (rawSwapPendingRef.current) return true
if (activeTabPath && activeTab && editorMountedRef.current) {
cacheEditorState(cache, activeTabPath, editor.document)
}
return true
}
function scheduleTabSwap(options: {
editor: ReturnType<typeof useCreateBlockNote>
cache: Map<string, CachedTabState>
targetPath: string
activeTab: Tab
pendingSwapRef: MutableRefObject<(() => void) | null>
prevActivePathRef: MutableRefObject<string | null>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
}) {
const {
editor,
cache,
targetPath,
activeTab,
pendingSwapRef,
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
} = options
const doSwap = () => {
if (prevActivePathRef.current !== targetPath) return
rawSwapPendingRef.current = false
void resolveBlocksForTarget(editor, cache, targetPath, activeTab.content)
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
})
.catch((err: unknown) => {
console.error('Failed to parse/swap editor content:', err)
})
}
if (editor.prosemirrorView) {
queueMicrotask(doSwap)
return
}
pendingSwapRef.current = doSwap
}
function useTabSwapEffect(options: {
tabs: Tab[]
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
rawMode?: boolean
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
prevActivePathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
pendingSwapRef: MutableRefObject<(() => void) | null>
pendingTabArrivalPathRef: MutableRefObject<string | null>
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
}) {
const {
tabs,
activeTabPath,
editor,
rawMode,
tabCacheRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
} = options
useEffect(() => {
const cache = tabCacheRef.current
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
const activeTab = findActiveTab(tabs, activeTabPath)
const pendingTabArrival = activeTabPath !== null
&& pendingTabArrivalPathRef.current === activeTabPath
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
prevActivePathRef.current = activeTabPath
if (handleStableActivePath({
pathChanged,
rawModeJustEnded,
activeTabPath,
activeTab,
pendingTabArrival,
cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})) {
return
}
if (!rememberPendingTabArrival(activeTabPath, activeTab, pendingTabArrivalPathRef)) {
return
}
const targetPath = activeTabPath
const readyActiveTab = activeTab
if (!targetPath || !readyActiveTab) return
scheduleTabSwap({
editor,
cache,
targetPath,
activeTab: readyActiveTab,
pendingSwapRef,
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
})
}, [
activeTabPath,
editor,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevActivePathRef,
prevRawModeRef,
rawMode,
rawSwapPendingRef,
suppressChangeRef,
tabCacheRef,
tabs,
])
}
function useTabCacheCleanup(
tabs: Tab[],
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>,
) {
const tabPathsRef = useRef<Set<string>>(new Set())
useEffect(() => {
const currentPaths = new Set(tabs.map(t => t.entry.path))
for (const path of tabPathsRef.current) {
if (!currentPaths.has(path)) {
tabCacheRef.current.delete(path)
}
}
tabPathsRef.current = currentPaths
}, [tabs, tabCacheRef])
}
/**
* Manages the tab content-swap machinery for the BlockNote editor.
*
@@ -59,241 +454,40 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string)
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
*/
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode }: UseEditorTabSwapOptions) {
// Cache parsed blocks + scroll position per tab path for instant switching
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
const tabCacheRef = useRef<Map<string, { blocks: any[]; scrollTop: number }>>(new Map())
const tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
const prevActivePathRef = useRef<string | null>(null)
const editorMountedRef = useRef(false)
const pendingSwapRef = useRef<(() => void) | null>(null)
const pendingTabArrivalPathRef = useRef<string | null>(null)
const prevRawModeRef = useRef(!!rawMode)
// Guard: prevents a subsequent effect run from re-caching stale blocks
// while a raw-mode swap is still pending in a microtask/pendingSwap.
const rawSwapPendingRef = useRef(false)
// Suppress onChange during programmatic content swaps (tab switching / initial load)
const suppressChangeRef = useRef(false)
const onContentChangeRef = useLatestRef(onContentChange)
const tabsRef = useLatestRef(tabs)
const handleEditorChange = useEditorChangeHandler({
editor,
tabsRef,
onContentChangeRef,
prevActivePathRef,
suppressChangeRef,
})
// Keep refs to callbacks for the onChange handler
const onContentChangeRef = useRef(onContentChange)
onContentChangeRef.current = onContentChange
const tabsRef = useRef(tabs)
tabsRef.current = tabs
// Track editor mount state
useEffect(() => {
// Check if already mounted (prosemirrorView exists)
if (editor.prosemirrorView) {
editorMountedRef.current = true
}
const cleanup = editor.onMount(() => {
editorMountedRef.current = true
// Execute any pending content swap that was queued before mount.
// Defer via queueMicrotask so BlockNote's internal flushSync calls
// don't collide with React's commit phase.
if (pendingSwapRef.current) {
const swap = pendingSwapRef.current
pendingSwapRef.current = null
queueMicrotask(swap)
}
})
return cleanup
}, [editor])
// onChange handler: serialize editor blocks → markdown, reconstruct full file, call save
const handleEditorChange = useCallback(() => {
if (suppressChangeRef.current) return
const path = prevActivePathRef.current
if (!path) return
const tab = tabsRef.current.find(t => t.entry.path === path)
if (!tab) return
// Convert blocks → markdown, restoring wikilinks first
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
// Reconstruct full file: frontmatter + body (which now includes H1 if present)
const [frontmatter] = splitFrontmatter(tab.content)
const fullContent = `${frontmatter}${bodyMarkdown}`
onContentChangeRef.current?.(path, fullContent)
}, [editor])
// Swap document content when active tab changes.
// Uses queueMicrotask to defer BlockNote mutations outside React's commit phase,
// avoiding flushSync-inside-lifecycle errors that silently prevent content from rendering.
useEffect(() => {
const cache = tabCacheRef.current
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
// Detect raw mode transition: true → false means we need to re-parse
// from tab.content since the cached blocks are stale.
const rawModeJustEnded = prevRawModeRef.current && !rawMode
prevRawModeRef.current = !!rawMode
// While raw mode is active the BlockNote editor is hidden — skip all
// swap logic to avoid touching the invisible editor.
if (rawMode) return
// Save current editor state + scroll position for the tab we're leaving
if (prevPath && pathChanged && editorMountedRef.current) {
const scrollEl = document.querySelector('.editor__blocknote-container')
cache.set(prevPath, {
blocks: editor.document,
scrollTop: scrollEl?.scrollTop ?? 0,
})
}
prevActivePathRef.current = activeTabPath
if (!pathChanged) {
if (rawModeJustEnded && activeTabPath) {
// Raw mode just ended — invalidate stale cached blocks so we
// re-parse from the latest tab.content below.
cache.delete(activeTabPath)
rawSwapPendingRef.current = true
} else {
// While a raw-mode swap is pending (scheduled via microtask), a second
// effect run can fire due to the tabs prop updating. Skip re-caching
// stale editor.document to avoid poisoning the cache before doSwap runs.
if (rawSwapPendingRef.current) return
// When tab content updates but the active tab stays the same (e.g. after
// Cmd+S save), refresh the cache with the current editor blocks so a later
// tab switch doesn't revert to stale content. Do NOT re-apply blocks —
// the editor already shows the user's edits.
if (activeTabPath && editorMountedRef.current) {
const scrollEl = document.querySelector('.editor__blocknote-container')
cache.set(activeTabPath, {
blocks: editor.document,
scrollTop: scrollEl?.scrollTop ?? 0,
})
}
return
}
}
if (!activeTabPath) return
const tab = tabs.find(t => t.entry.path === activeTabPath)
if (!tab) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote's PartialBlock generic is extremely complex
const applyBlocks = (blocks: any[], scrollTop = 0) => {
suppressChangeRef.current = true
try {
const current = editor.document
if (current.length > 0 && blocks.length > 0) {
editor.replaceBlocks(current, blocks)
} else if (blocks.length > 0) {
editor.insertBlocks(blocks, current[0], 'before')
}
} catch (err) {
console.error('applyBlocks failed, trying fallback:', err)
try {
const html = editor.blocksToHTMLLossy(blocks)
editor._tiptapEditor.commands.setContent(html)
} catch (err2) {
console.error('Fallback also failed:', err2)
}
} finally {
// Re-enable change detection on next microtask, after BlockNote
// finishes its internal state updates from the content swap
queueMicrotask(() => { suppressChangeRef.current = false })
}
// Restore scroll position after layout updates from the content swap
requestAnimationFrame(() => {
const scrollEl = document.querySelector('.editor__blocknote-container')
if (scrollEl) scrollEl.scrollTop = scrollTop
})
}
const targetPath = activeTabPath
const doSwap = () => {
// Guard: bail if user switched tabs since this swap was scheduled
if (prevActivePathRef.current !== targetPath) return
rawSwapPendingRef.current = false
if (cache.has(targetPath)) {
const cached = cache.get(targetPath)!
applyBlocks(cached.blocks, cached.scrollTop)
return
}
const body = extractEditorBody(tab.content)
const preprocessed = preProcessWikilinks(body)
// Fast path: empty body (e.g. newly created notes). Skip the
// potentially-async markdown parser and set a single empty paragraph
// so the editor is immediately interactive.
if (!preprocessed.trim()) {
const emptyDoc = [{ type: 'paragraph', content: [] }]
cache.set(targetPath, { blocks: emptyDoc, scrollTop: 0 })
applyBlocks(emptyDoc)
return
}
// Fast path: H1-only content (e.g. newly created notes that just have
// the title heading). Build blocks directly to stay instant.
const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/)
if (h1OnlyMatch) {
const h1Doc = [
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] },
{ type: 'paragraph', content: [], children: [] },
]
cache.set(targetPath, { blocks: h1Doc, scrollTop: 0 })
applyBlocks(h1Doc)
return
}
try {
const result = editor.tryParseMarkdownToBlocks(preprocessed)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
const handleBlocks = (blocks: any[]) => {
if (prevActivePathRef.current !== targetPath) return
const withWikilinks = injectWikilinks(blocks)
// Only cache non-empty results to avoid poisoning the cache
if (withWikilinks.length > 0) {
cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 })
}
applyBlocks(withWikilinks)
}
/* eslint-disable @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks */
if (result && typeof (result as any).then === 'function') {
(result as unknown as Promise<any[]>).then(handleBlocks).catch((err: unknown) => {
console.error('Async markdown parse failed:', err)
})
} else {
handleBlocks(result as any[])
}
/* eslint-enable @typescript-eslint/no-explicit-any */
} catch (err) {
console.error('Failed to parse/swap editor content:', err)
}
}
if (editor.prosemirrorView) {
// Defer the swap outside React's commit phase so BlockNote's internal
// flushSync calls don't collide with React's rendering lifecycle.
queueMicrotask(doSwap)
} else {
pendingSwapRef.current = doSwap
}
}, [activeTabPath, tabs, editor, rawMode])
// Clean up cache entries when tabs are closed
const tabPathsRef = useRef<Set<string>>(new Set())
useEffect(() => {
const currentPaths = new Set(tabs.map(t => t.entry.path))
for (const path of tabPathsRef.current) {
if (!currentPaths.has(path)) {
tabCacheRef.current.delete(path)
}
}
tabPathsRef.current = currentPaths
}, [tabs])
useEditorMountState(editor, editorMountedRef, pendingSwapRef)
useTabSwapEffect({
tabs,
activeTabPath,
editor,
rawMode,
tabCacheRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
})
useTabCacheCleanup(tabs, tabCacheRef)
return { handleEditorChange, editorMountedRef }
}

View File

@@ -33,6 +33,26 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
...overrides,
})
type HookState = { current: ReturnType<typeof useTabManagement> }
async function selectNote(result: HookState, overrides: Partial<VaultEntry>) {
await act(async () => {
await result.current.handleSelectNote(makeEntry(overrides))
})
}
async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntry>) {
await act(async () => {
await result.current.handleReplaceActiveTab(makeEntry(overrides))
})
}
function expectSingleActiveTab(result: HookState, path: string) {
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].entry.path).toBe(path)
expect(result.current.activeTabPath).toBe(path)
}
describe('useTabManagement (single-note model)', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -47,41 +67,47 @@ describe('useTabManagement (single-note model)', () => {
describe('handleSelectNote', () => {
it('opens a note and sets it active', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/note/a.md' })
await selectNote(result, { path: '/vault/note/a.md' })
expectSingleActiveTab(result, '/vault/note/a.md')
})
await act(async () => {
await result.current.handleSelectNote(entry)
it('switches the active path immediately while the next note is still loading', async () => {
const { mockInvoke } = await import('../mock-tauri')
let resolveContent: (value: string) => void
vi.mocked(mockInvoke).mockImplementationOnce(
() => new Promise<string>((resolve) => { resolveContent = resolve }),
)
const { result } = renderHook(() => useTabManagement())
void act(() => {
void result.current.handleSelectNote(makeEntry({ path: '/vault/note/pending.md', title: 'Pending' }))
})
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].entry.path).toBe('/vault/note/a.md')
expect(result.current.activeTabPath).toBe('/vault/note/a.md')
expect(result.current.activeTabPath).toBe('/vault/note/pending.md')
expect(result.current.tabs).toEqual([])
await act(async () => {
resolveContent!('# Pending content')
})
expect(result.current.tabs[0].entry.path).toBe('/vault/note/pending.md')
expect(result.current.tabs[0].content).toBe('# Pending content')
})
it('replaces the current note when selecting a different one', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].entry.path).toBe('/vault/b.md')
expect(result.current.activeTabPath).toBe('/vault/b.md')
await selectNote(result, { path: '/vault/a.md', title: 'A' })
await selectNote(result, { path: '/vault/b.md', title: 'B' })
expectSingleActiveTab(result, '/vault/b.md')
})
it('is a no-op when selecting the already-open note', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/a.md' })
const entry = { path: '/vault/a.md' }
await selectNote(result, entry)
await act(async () => {
await result.current.handleSelectNote(entry)
})
await act(async () => {
await result.current.handleSelectNote(entry)
await result.current.handleSelectNote(makeEntry(entry))
})
expect(result.current.tabs).toHaveLength(1)
@@ -93,11 +119,7 @@ describe('useTabManagement (single-note model)', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry()
await act(async () => {
await result.current.handleSelectNote(entry)
})
await selectNote(result, {})
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].content).toBe('')
@@ -108,31 +130,17 @@ describe('useTabManagement (single-note model)', () => {
describe('handleReplaceActiveTab', () => {
it('replaces the current note with a new entry', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
})
const replacement = makeEntry({ path: '/vault/b.md', title: 'B' })
await act(async () => {
await result.current.handleReplaceActiveTab(replacement)
})
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].entry.path).toBe('/vault/b.md')
expect(result.current.activeTabPath).toBe('/vault/b.md')
await selectNote(result, { path: '/vault/a.md', title: 'A' })
await replaceActiveNote(result, { path: '/vault/b.md', title: 'B' })
expectSingleActiveTab(result, '/vault/b.md')
})
it('is a no-op when replacing with the same entry', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/a.md' })
const entry = { path: '/vault/a.md' }
await selectNote(result, entry)
await act(async () => {
await result.current.handleSelectNote(entry)
})
await act(async () => {
await result.current.handleReplaceActiveTab(entry)
await result.current.handleReplaceActiveTab(makeEntry(entry))
})
expect(result.current.tabs).toHaveLength(1)
@@ -140,14 +148,8 @@ describe('useTabManagement (single-note model)', () => {
it('opens a note when no note is active', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/a.md' })
await act(async () => {
await result.current.handleReplaceActiveTab(entry)
})
expect(result.current.tabs).toHaveLength(1)
expect(result.current.activeTabPath).toBe('/vault/a.md')
await replaceActiveNote(result, { path: '/vault/a.md' })
expectSingleActiveTab(result, '/vault/a.md')
})
})
@@ -189,10 +191,7 @@ describe('useTabManagement (single-note model)', () => {
describe('closeAllTabs', () => {
it('clears the note and active path', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md' }))
})
await selectNote(result, { path: '/vault/a.md' })
act(() => {
result.current.closeAllTabs()
@@ -212,9 +211,7 @@ describe('useTabManagement (single-note model)', () => {
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/note/pre.md', title: 'Pre' }))
})
await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' })
expect(result.current.tabs[0].content).toBe('# Prefetched content')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
@@ -231,9 +228,7 @@ describe('useTabManagement (single-note model)', () => {
vi.mocked(mockInvoke).mockResolvedValue('# Fresh')
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/note/stale.md', title: 'Stale' }))
})
await selectNote(result, { path: '/vault/note/stale.md', title: 'Stale' })
expect(result.current.tabs[0].content).toBe('# Fresh')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)

View File

@@ -49,6 +49,69 @@ async function loadNoteContent(path: string): Promise<string> {
export type { Tab }
function syncActiveTabPath(
activeTabPathRef: React.MutableRefObject<string | null>,
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
path: string | null,
) {
activeTabPathRef.current = path
setActiveTabPath(path)
}
function setSingleTab(
tabsRef: React.MutableRefObject<Tab[]>,
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
nextTab: Tab,
) {
tabsRef.current = [nextTab]
setTabs([nextTab])
}
function isAlreadyViewingPath(
tabsRef: React.MutableRefObject<Tab[]>,
activeTabPathRef: React.MutableRefObject<string | null>,
path: string,
) {
return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path)
}
async function navigateToEntry(options: {
entry: VaultEntry
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
}) {
const {
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
} = options
if (entry.fileKind === 'binary') return
if (isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) {
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
return
}
const seq = ++navSeqRef.current
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
try {
const content = await loadNoteContent(entry.path)
if (navSeqRef.current !== seq) return
setSingleTab(tabsRef, setTabs, { entry, content })
} catch (err) {
console.warn('Failed to load note content:', err)
if (navSeqRef.current !== seq) return
setSingleTab(tabsRef, setTabs, { entry, content: '' })
}
}
export function useTabManagement() {
// Single-note model: tabs has 0 or 1 elements.
const [tabs, setTabs] = useState<Tab[]>([])
@@ -63,64 +126,41 @@ export function useTabManagement() {
/** Open a note — replaces the current note (single-note model). */
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
// Binary files cannot be opened
if (entry.fileKind === 'binary') return
// Already viewing this note — no-op
if (tabsRef.current.some(t => t.entry.path === entry.path)) {
setActiveTabPath(entry.path)
return
}
const seq = ++navSeqRef.current
try {
const content = await loadNoteContent(entry.path)
if (navSeqRef.current === seq) {
setTabs([{ entry, content }])
setActiveTabPath(entry.path)
}
} catch (err) {
console.warn('Failed to load note content:', err)
if (navSeqRef.current === seq) {
setTabs([{ entry, content: '' }])
setActiveTabPath(entry.path)
}
}
await navigateToEntry({
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
}, [])
const handleSwitchTab = useCallback((path: string) => { setActiveTabPath(path) }, [])
const handleSwitchTab = useCallback((path: string) => {
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
}, [])
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
setTabs([{ entry, content }])
setActiveTabPath(entry.path)
setSingleTab(tabsRef, setTabs, { entry, content })
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
}, [])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
// Binary files cannot be opened
if (entry.fileKind === 'binary') return
// In single-note model, replace is the same as select
if (tabsRef.current.some(t => t.entry.path === entry.path)) {
setActiveTabPath(entry.path)
return
}
const seq = ++navSeqRef.current
try {
const content = await loadNoteContent(entry.path)
if (navSeqRef.current === seq) {
setTabs([{ entry, content }])
setActiveTabPath(entry.path)
}
} catch (err) {
console.warn('Failed to load note content:', err)
if (navSeqRef.current === seq) {
setTabs([{ entry, content: '' }])
setActiveTabPath(entry.path)
}
}
await navigateToEntry({
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
}, [])
const closeAllTabs = useCallback(() => {
tabsRef.current = []
setTabs([])
setActiveTabPath(null)
syncActiveTabPath(activeTabPathRef, setActiveTabPath, null)
}, [])
return {

View File

@@ -18,6 +18,12 @@ describe('filterDates', () => {
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2026-03-28')
})
it('parses numeric relative year phrases', () => {
const reference = new Date('2026-04-08T12:00:00Z')
const parsed = parseDateFilterInput('10 years ago', reference)
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2016-04-08')
})
it('parses word-based relative week phrases', () => {
const reference = new Date('2026-04-07T12:00:00Z')
const parsed = parseDateFilterInput('one week ago', reference)

View File

@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'
import { deriveDisplayTitleState, extractH1TitleFromContent, filenameStemToTitle } from './noteTitle'
import {
contentDefinesDisplayTitle,
deriveDisplayTitleState,
extractFrontmatterTitleFromContent,
extractH1TitleFromContent,
filenameStemToTitle,
} from './noteTitle'
describe('filenameStemToTitle', () => {
it('converts kebab-case filenames into title case', () => {
@@ -23,10 +29,32 @@ describe('extractH1TitleFromContent', () => {
})
})
describe('extractFrontmatterTitleFromContent', () => {
it('extracts the frontmatter title when present', () => {
const content = '---\ntitle: Legacy Title\nstatus: Active\n---\n## Body'
expect(extractFrontmatterTitleFromContent(content)).toBe('Legacy Title')
})
it('returns null when the frontmatter title is missing', () => {
expect(extractFrontmatterTitleFromContent('---\nstatus: Active\n---\n## Body')).toBeNull()
})
})
describe('contentDefinesDisplayTitle', () => {
it('returns true when the document title comes from frontmatter', () => {
const content = '---\ntitle: Spring 2026\n---\n## Goals'
expect(contentDefinesDisplayTitle(content)).toBe(true)
})
it('returns false when title still comes from the filename', () => {
expect(contentDefinesDisplayTitle('Body only')).toBe(false)
})
})
describe('deriveDisplayTitleState', () => {
it('prefers H1 over frontmatter title and filename', () => {
const content = '---\ntitle: Legacy Title\n---\n# Updated Title\n\nBody'
expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({
expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({
title: 'Updated Title',
hasH1: true,
})
@@ -34,14 +62,22 @@ describe('deriveDisplayTitleState', () => {
it('falls back to frontmatter title when no H1 is present', () => {
const content = '---\ntitle: Legacy Title\n---\nBody'
expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({
expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({
title: 'Legacy Title',
hasH1: false,
})
})
it('reads the frontmatter title from content when no explicit title is passed', () => {
const content = '---\ntitle: Spring 2026\n---\n## Goals'
expect(deriveDisplayTitleState({ content, filename: 'spring-2026.md' })).toEqual({
title: 'Spring 2026',
hasH1: false,
})
})
it('falls back to filename title when there is no H1 or frontmatter title', () => {
expect(deriveDisplayTitleState('Body only', 'renamed-note.md')).toEqual({
expect(deriveDisplayTitleState({ content: 'Body only', filename: 'renamed-note.md' })).toEqual({
title: 'Renamed Note',
hasH1: false,
})

View File

@@ -1,5 +1,22 @@
import { parseFrontmatter } from './frontmatter'
import { splitFrontmatter } from './wikilinks'
interface ResolvedContentTitle {
source: 'h1' | 'frontmatter'
title: string
}
interface DisplayTitleInput {
content: string
filename: string
frontmatterTitle?: string | null
}
interface DisplayTitleState {
title: string
hasH1: boolean
}
function replaceWikilinkAliases(text: string): string {
return text.replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1')
}
@@ -49,20 +66,46 @@ export function extractH1TitleFromContent(content: string): string | null {
return null
}
export function deriveDisplayTitleState(
content: string,
filename: string,
frontmatterTitle?: string | null,
): { title: string, hasH1: boolean } {
export function extractFrontmatterTitleFromContent(content: string): string | null {
const title = parseFrontmatter(content).title
if (typeof title !== 'string') return null
const trimmed = title.trim()
return trimmed || null
}
function resolveContentTitle(content: string, frontmatterTitle?: string | null): ResolvedContentTitle | null {
const h1Title = extractH1TitleFromContent(content)
if (h1Title) {
return { title: h1Title, hasH1: true }
return { title: h1Title, source: 'h1' }
}
const trimmedFrontmatterTitle = frontmatterTitle?.trim()
if (trimmedFrontmatterTitle) {
return { title: trimmedFrontmatterTitle, hasH1: false }
const resolvedFrontmatterTitle = frontmatterTitle?.trim() || extractFrontmatterTitleFromContent(content)
if (resolvedFrontmatterTitle) {
return { title: resolvedFrontmatterTitle, source: 'frontmatter' }
}
return { title: filenameStemToTitle(filename), hasH1: false }
return null
}
export function contentDefinesDisplayTitle(content: string): boolean {
return resolveContentTitle(content) !== null
}
export function deriveDisplayTitleState({
content,
filename,
frontmatterTitle,
}: DisplayTitleInput): DisplayTitleState {
const resolvedTitle = resolveContentTitle(content, frontmatterTitle)
if (resolvedTitle) {
return {
title: resolvedTitle.title,
hasH1: resolvedTitle.source === 'h1',
}
}
return {
title: filenameStemToTitle(filename),
hasH1: false,
}
}

View File

@@ -0,0 +1,10 @@
---
title: Spring 2026
Is A: Quarter
Status: Active
---
## Goals
- [[Alpha Project]]
- [[Note B]]

View File

@@ -69,12 +69,17 @@ test('creating an untitled draft hides the legacy title section in the editor',
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
})
test('@smoke older notes with an H1 do not render the legacy title section', async ({ page }) => {
test('@smoke older notes with a document title do not render the legacy title section', async ({ page }) => {
await openNote(page, 'Alpha Project')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
await openNote(page, 'Spring 2026')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
})
test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete', async ({ page }) => {