Compare commits

...

6 Commits

Author SHA1 Message Date
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
15 changed files with 1213 additions and 279 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,113 @@ 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('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

@@ -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,247 @@
import { createElement, useMemo, useState, type CSSProperties, type MouseEvent } from 'react'
import { Link } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
import type { VaultEntry } from '../../types'
import { findIcon } from '../../utils/iconRegistry'
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'
interface PropertyChipValue {
label: string
noteIcon: string | null
typeIcon: string | 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 resolveRelationshipChipStyle(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): CSSProperties | undefined {
const typeEntry = targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
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 = targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
return {
label,
noteIcon: targetEntry.icon ?? null,
typeIcon: targetEntry.isA ? typeEntry?.icon ?? null : 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 resolvePropertyChipValues(
entry: VaultEntry,
propName: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] {
if (propName.toLowerCase() === 'status') {
const statusChip = resolveScalarChip(entry.status)
return statusChip ? [statusChip] : []
}
const relationshipKey = Object.keys(entry.relationships).find((key) => key.toLowerCase() === propName.toLowerCase())
if (relationshipKey) {
return entry.relationships[relationshipKey]
.map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap))
.filter((chip): chip is PropertyChipValue => chip !== null)
}
const propertyKey = Object.keys(entry.properties).find((key) => key.toLowerCase() === propName.toLowerCase())
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 PropertyChipIcon({
noteIcon,
typeIcon,
tone,
}: {
noteIcon?: string | null
typeIcon?: string | 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 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' })
}
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

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

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