Merge pull request #78 from refactoringhq/task/unify-note-search

feat: Unify note search/autocomplete into a single reusable component
This commit is contained in:
Luca Rossi
2026-02-25 19:06:14 +01:00
committed by GitHub
10 changed files with 658 additions and 216 deletions

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,9 @@ import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistor
import type { ReferencedByItem } from './InspectorPanels'
import type { VaultEntry, GitCommit } from '../types'
// jsdom doesn't implement scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',

View File

@@ -9,7 +9,8 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import { findEntryByTarget } from '../utils/wikilinkColors'
import type { FrontmatterValue } from './Inspector'
import { NoteAutocomplete } from './NoteAutocomplete'
import { NoteSearchList } from './NoteSearchList'
import { useNoteSearch } from '../hooks/useNoteSearch'
function isWikilink(value: string): boolean {
return /^\[\[.*\]\]$/.test(value)
@@ -101,22 +102,50 @@ function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap: Recor
}
}
function InlineAddNote({ entries, typeEntryMap, onAdd }: {
function SearchDropdown({ search, onSelect }: {
search: ReturnType<typeof useNoteSearch>
onSelect: (title: string) => void
}) {
return (
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
<NoteSearchList
items={search.results}
selectedIndex={search.selectedIndex}
getItemKey={(item) => item.entry.path}
onItemClick={(item) => onSelect(item.entry.title)}
onItemHover={(i) => search.setSelectedIndex(i)}
className="max-h-[160px] overflow-y-auto"
/>
</div>
)
}
function InlineAddNote({ entries, onAdd }: {
entries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onAdd: (noteTitle: string) => void
}) {
const [active, setActive] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const search = useNoteSearch(entries, query, 8)
const handleSubmit = useCallback((title: string) => {
const trimmed = title.trim()
if (!trimmed) return
onAdd(trimmed)
const selectAndClose = useCallback((title: string) => {
onAdd(title)
setQuery('')
setActive(false)
}, [onAdd])
const handleConfirm = useCallback(() => {
const title = search.selectedEntry?.title ?? query.trim()
if (title) selectAndClose(title)
}, [search.selectedEntry, query, selectAndClose])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
search.handleKeyDown(e)
if (e.key === 'Enter') { e.preventDefault(); handleConfirm() }
else if (e.key === 'Escape') { setQuery(''); setActive(false) }
}, [search, handleConfirm])
if (!active) {
return (
<button
@@ -132,33 +161,36 @@ function InlineAddNote({ entries, typeEntryMap, onAdd }: {
}
return (
<div className="mt-1 flex items-center gap-1">
<div className="flex-1" style={{ minWidth: 0 }}>
<NoteAutocomplete
entries={entries}
typeEntryMap={typeEntryMap}
value={query}
onChange={setQuery}
onSelect={handleSubmit}
onEscape={() => { setQuery(''); setActive(false) }}
placeholder="Note title"
<div className="relative mt-1">
<div className="flex items-center gap-1">
<input
ref={inputRef}
autoFocus
testId="add-relation-ref-input"
className="flex-1 border border-border bg-transparent px-2 py-0.5 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none', minWidth: 0 }}
placeholder="Note title"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
data-testid="add-relation-ref-input"
/>
<button
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
onClick={handleConfirm}
disabled={!query.trim()}
>
<Plus size={12} />
</button>
<button
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
onClick={() => { setQuery(''); setActive(false) }}
>
<X size={12} />
</button>
</div>
<button
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
onClick={() => handleSubmit(query)}
disabled={!query.trim()}
>
<Plus size={12} />
</button>
<button
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
onClick={() => { setQuery(''); setActive(false) }}
>
<X size={12} />
</button>
{query.trim() && search.results.length > 0 && (
<SearchDropdown search={search} onSelect={selectAndClose} />
)}
</div>
)
}
@@ -185,7 +217,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
)
})}
</div>
{onAddRef && <InlineAddNote entries={entries} typeEntryMap={typeEntryMap} onAdd={onAddRef} />}
{onAddRef && <InlineAddNote entries={entries} onAdd={onAddRef} />}
</div>
)
}
@@ -202,9 +234,48 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
.filter(({ refs }) => refs.length > 0)
}
function AddRelationshipForm({ entries, typeEntryMap, onAddProperty }: {
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: {
entries: VaultEntry[]
value: string
onChange: (v: string) => void
onSubmit?: () => void
onCancel?: () => void
}) {
const [focused, setFocused] = useState(false)
const search = useNoteSearch(entries, value, 8)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
search.handleKeyDown(e)
if (e.key === 'Enter') {
e.preventDefault()
if (search.selectedEntry) { onChange(search.selectedEntry.title); setFocused(false) }
else onSubmit?.()
} else if (e.key === 'Escape') { onCancel?.() }
}, [search, onChange, onSubmit, onCancel])
const showDropdown = focused && value.trim() && search.results.length > 0
return (
<div className="relative">
<input
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Note title"
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 150)}
onKeyDown={handleKeyDown}
/>
{showDropdown && (
<SearchDropdown search={search} onSelect={(title) => { onChange(title); setFocused(false) }} />
)}
</div>
)
}
function AddRelationshipForm({ entries, onAddProperty }: {
entries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onAddProperty: (key: string, value: FrontmatterValue) => void
}) {
const [relKey, setRelKey] = useState('')
@@ -220,7 +291,9 @@ function AddRelationshipForm({ entries, typeEntryMap, onAddProperty }: {
setRelKey(''); setRelTarget(''); setShowForm(false)
}, [relKey, relTarget, onAddProperty])
const resetForm = () => { setShowForm(false); setRelKey(''); setRelTarget('') }
const resetForm = useCallback(() => {
setShowForm(false); setRelKey(''); setRelTarget('')
}, [])
if (!showForm) {
return (
@@ -238,18 +311,9 @@ function AddRelationshipForm({ entries, typeEntryMap, onAddProperty }: {
placeholder="Relationship name"
value={relKey}
onChange={e => setRelKey(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') resetForm() }}
/>
<NoteAutocomplete
entries={entries}
typeEntryMap={typeEntryMap}
value={relTarget}
onChange={setRelTarget}
onSelect={(title) => submitForm(title)}
onEscape={resetForm}
placeholder="Note title"
testId="add-relationship-note-input"
onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }}
/>
<NoteTargetInput entries={entries} value={relTarget} onChange={setRelTarget} onSubmit={submitForm} onCancel={resetForm} />
<div className="flex gap-1.5">
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
@@ -258,22 +322,17 @@ function AddRelationshipForm({ entries, typeEntryMap, onAddProperty }: {
)
}
function removeRefFromGroup(groups: { key: string; refs: string[] }[], key: string, refToRemove: string, onUpdate: (k: string, v: FrontmatterValue) => void, onDelete: (k: string) => void) {
const group = groups.find(g => g.key === key)
if (!group) return
const remaining = group.refs.filter(r => r !== refToRemove)
if (remaining.length === 0) onDelete(key)
else if (remaining.length === 1) onUpdate(key, remaining[0])
else onUpdate(key, remaining)
function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterValue | null {
const remaining = refs.filter(r => r !== refToRemove)
if (remaining.length === 0) return null
return remaining.length === 1 ? remaining[0] : remaining
}
function addRefToGroup(groups: { key: string; refs: string[] }[], key: string, noteTitle: string, onUpdate: (k: string, v: FrontmatterValue) => void) {
const existing = groups.find(g => g.key === key)?.refs ?? []
function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false {
const newRef = `[[${noteTitle}]]`
if (existing.includes(newRef)) return
const updated = [...existing, newRef]
if (updated.length === 1) onUpdate(key, updated[0])
else onUpdate(key, updated)
if (refs.includes(newRef)) return false
const updated = [...refs, newRef]
return updated.length === 1 ? updated[0] : updated
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: {
@@ -287,12 +346,18 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
if (!onUpdateProperty || !onDeleteProperty) return
removeRefFromGroup(relationshipEntries, key, refToRemove, onUpdateProperty, onDeleteProperty)
const group = relationshipEntries.find(g => g.key === key)
if (!group) return
const result = updateRefsForRemoval(group.refs, refToRemove)
if (result === null) onDeleteProperty(key)
else onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
const handleAddRef = useCallback((key: string, noteTitle: string) => {
if (!onUpdateProperty) return
addRefToGroup(relationshipEntries, key, noteTitle, onUpdateProperty)
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
const result = updateRefsForAddition(existing, noteTitle)
if (result !== false) onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty])
const canEdit = !!onUpdateProperty && !!onDeleteProperty
@@ -310,7 +375,7 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
))
}
{onAddProperty
? <AddRelationshipForm entries={entries} typeEntryMap={typeEntryMap} onAddProperty={onAddProperty} />
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} />
: <button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Link existing</button>
}
</div>

View File

@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { NoteSearchList } from './NoteSearchList'
import type { NoteSearchResultItem } from './NoteSearchList'
// jsdom doesn't implement scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
interface TestItem extends NoteSearchResultItem {
id: string
}
const items: TestItem[] = [
{ id: '1', title: 'Alpha Project', noteType: 'Project', typeColor: 'var(--accent-blue)' },
{ id: '2', title: 'Beta Notes' },
{ id: '3', title: 'Gamma Experiment', noteType: 'Experiment' },
]
describe('NoteSearchList', () => {
const onItemClick = vi.fn()
const onItemHover = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('renders all items with titles', () => {
render(
<NoteSearchList
items={items}
selectedIndex={0}
getItemKey={(item) => item.id}
onItemClick={onItemClick}
/>,
)
expect(screen.getByText('Alpha Project')).toBeInTheDocument()
expect(screen.getByText('Beta Notes')).toBeInTheDocument()
expect(screen.getByText('Gamma Experiment')).toBeInTheDocument()
})
it('shows type badge when noteType is present', () => {
render(
<NoteSearchList
items={items}
selectedIndex={0}
getItemKey={(item) => item.id}
onItemClick={onItemClick}
/>,
)
expect(screen.getByText('Project')).toBeInTheDocument()
expect(screen.getByText('Experiment')).toBeInTheDocument()
})
it('does not show type badge when noteType is absent', () => {
render(
<NoteSearchList
items={[{ id: '2', title: 'Beta Notes' }]}
selectedIndex={0}
getItemKey={(item: TestItem) => item.id}
onItemClick={onItemClick}
/>,
)
expect(screen.getByText('Beta Notes')).toBeInTheDocument()
// No badge element should exist
expect(screen.queryByText('Note')).not.toBeInTheDocument()
})
it('applies typeColor to badge when provided', () => {
render(
<NoteSearchList
items={[items[0]]}
selectedIndex={0}
getItemKey={(item) => item.id}
onItemClick={onItemClick}
/>,
)
const badge = screen.getByText('Project')
expect(badge.style.color).toBe('var(--accent-blue)')
})
it('shows empty message when no items', () => {
render(
<NoteSearchList
items={[]}
selectedIndex={0}
getItemKey={() => ''}
onItemClick={onItemClick}
emptyMessage="No matching notes"
/>,
)
expect(screen.getByText('No matching notes')).toBeInTheDocument()
})
it('shows default empty message', () => {
render(
<NoteSearchList
items={[]}
selectedIndex={0}
getItemKey={() => ''}
onItemClick={onItemClick}
/>,
)
expect(screen.getByText('No results')).toBeInTheDocument()
})
it('calls onItemClick when an item is clicked', () => {
render(
<NoteSearchList
items={items}
selectedIndex={0}
getItemKey={(item) => item.id}
onItemClick={onItemClick}
/>,
)
fireEvent.click(screen.getByText('Beta Notes'))
expect(onItemClick).toHaveBeenCalledWith(items[1], 1)
})
it('calls onItemHover when mouse enters an item', () => {
render(
<NoteSearchList
items={items}
selectedIndex={0}
getItemKey={(item) => item.id}
onItemClick={onItemClick}
onItemHover={onItemHover}
/>,
)
fireEvent.mouseEnter(screen.getByText('Gamma Experiment'))
expect(onItemHover).toHaveBeenCalledWith(2)
})
it('highlights selected item with accent background', () => {
render(
<NoteSearchList
items={items}
selectedIndex={1}
getItemKey={(item) => item.id}
onItemClick={onItemClick}
/>,
)
const selectedItem = screen.getByText('Beta Notes').closest('div')!
expect(selectedItem.className).toContain('bg-accent')
const unselectedItem = screen.getByText('Alpha Project').closest('div')!
expect(unselectedItem.className).not.toContain('bg-accent')
})
it('calls scrollIntoView on the selected item', () => {
const scrollMock = vi.fn()
Element.prototype.scrollIntoView = scrollMock
const { rerender } = render(
<NoteSearchList
items={items}
selectedIndex={0}
getItemKey={(item) => item.id}
onItemClick={onItemClick}
/>,
)
scrollMock.mockClear()
rerender(
<NoteSearchList
items={items}
selectedIndex={2}
getItemKey={(item) => item.id}
onItemClick={onItemClick}
/>,
)
expect(scrollMock).toHaveBeenCalledWith({ block: 'nearest' })
})
})

View File

@@ -0,0 +1,76 @@
import { useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
export interface NoteSearchResultItem {
title: string
noteType?: string
typeColor?: string
}
interface NoteSearchListProps<T extends NoteSearchResultItem> {
items: T[]
selectedIndex: number
getItemKey: (item: T, index: number) => string
onItemClick: (item: T, index: number) => void
onItemHover?: (index: number) => void
emptyMessage?: string
className?: string
}
export function NoteSearchList<T extends NoteSearchResultItem>({
items,
selectedIndex,
getItemKey,
onItemClick,
onItemHover,
emptyMessage = 'No results',
className,
}: NoteSearchListProps<T>) {
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!listRef.current) return
const el = listRef.current.children[selectedIndex] as HTMLElement | undefined
el?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
if (items.length === 0) {
return (
<div ref={listRef} className={cn('py-1', className)}>
<div className="px-4 py-3 text-center text-[13px] text-muted-foreground">
{emptyMessage}
</div>
</div>
)
}
return (
<div ref={listRef} className={cn('py-1', className)}>
{items.map((item, i) => (
<div
key={getItemKey(item, i)}
className={cn(
'flex cursor-pointer items-center justify-between gap-2 px-3 py-1.5 transition-colors',
i === selectedIndex ? 'bg-accent' : 'hover:bg-secondary',
)}
onClick={() => onItemClick(item, i)}
onMouseEnter={() => onItemHover?.(i)}
>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{item.title}
</span>
{item.noteType && (
<Badge
variant="secondary"
className="shrink-0 text-[11px]"
style={item.typeColor ? { color: item.typeColor } : undefined}
>
{item.noteType}
</Badge>
)}
</div>
))}
</div>
)
}

View File

@@ -1,8 +1,7 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { useState, useRef, useEffect } from 'react'
import type { VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { fuzzyMatch } from '../utils/fuzzyMatch'
import { NoteSearchList } from './NoteSearchList'
import { useNoteSearch } from '../hooks/useNoteSearch'
interface QuickOpenPaletteProps {
open: boolean
@@ -13,63 +12,37 @@ interface QuickOpenPaletteProps {
export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpenPaletteProps) {
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const { results, selectedIndex, setSelectedIndex, handleKeyDown } = useNoteSearch(entries, query)
useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset on dialog open
setQuery(''); setSelectedIndex(0)
setQuery('')
setSelectedIndex(0)
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])
const results = useMemo(() => {
if (!query.trim()) {
return [...entries].sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0)).slice(0, 20)
}
return entries
.map((entry) => ({ entry, ...fuzzyMatch(query, entry.title) }))
.filter((r) => r.match)
.sort((a, b) => b.score - a.score)
.slice(0, 20)
.map((r) => r.entry)
}, [entries, query])
useEffect(() => {
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset selection on query change
}, [query])
useEffect(() => {
if (!listRef.current) return
const selected = listRef.current.children[selectedIndex] as HTMLElement | undefined
selected?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
}, [open, setSelectedIndex])
useEffect(() => {
if (!open) return
const handleKey = (e: KeyboardEvent) => {
const handler = (e: KeyboardEvent) => {
handleKeyDown(e)
if (e.key === 'Escape') {
e.preventDefault()
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex((i) => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
if (results[selectedIndex]) {
onSelect(results[selectedIndex])
const selected = results[selectedIndex]
if (selected) {
onSelect(selected.entry)
onClose()
}
}
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [open, results, selectedIndex, onSelect, onClose])
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [open, results, selectedIndex, onSelect, onClose, handleKeyDown])
if (!open) return null
@@ -90,35 +63,18 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
{results.length === 0 ? (
<div className="px-4 py-4 text-center text-[13px] text-muted-foreground">
No matching notes
</div>
) : (
results.map((entry, i) => (
<div
key={entry.path}
className={cn(
"flex cursor-pointer items-center justify-between px-4 py-2 transition-colors",
i === selectedIndex ? "bg-accent" : "hover:bg-secondary"
)}
onClick={() => {
onSelect(entry)
onClose()
}}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="text-sm text-foreground">{entry.title}</span>
{entry.isA && (
<Badge variant="secondary" className="text-[11px]">
{entry.isA}
</Badge>
)}
</div>
))
)}
</div>
<NoteSearchList
items={results}
selectedIndex={selectedIndex}
getItemKey={(item) => item.entry.path}
onItemClick={(item) => {
onSelect(item.entry)
onClose()
}}
onItemHover={(i) => setSelectedIndex(i)}
emptyMessage="No matching notes"
className="flex-1 overflow-y-auto"
/>
</div>
</div>
)

View File

@@ -1,58 +1,12 @@
/* Wikilink autocomplete — flat suggestion menu */
/* Wikilink autocomplete — floating menu container (positioned by BlockNote) */
.wikilink-menu {
background: var(--bg-primary, #fff);
border: 1px solid var(--border, rgba(0, 0, 0, 0.1));
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
max-height: 320px;
overflow-y: auto;
padding: 4px;
overflow: hidden;
min-width: 260px;
max-width: 400px;
z-index: 9999;
}
.wikilink-menu__empty {
padding: 8px 12px;
font-size: 13px;
color: var(--text-muted, #999);
}
.wikilink-menu__item {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
}
.wikilink-menu__item:hover {
background: var(--muted, rgba(0, 0, 0, 0.04));
}
.wikilink-menu__item--selected {
background: var(--accent, rgba(21, 93, 255, 0.08));
}
.wikilink-menu__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary, #1a1a1a);
}
.wikilink-menu__type {
flex-shrink: 0;
font-size: 11px;
font-weight: 500;
opacity: 0.8;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect } from 'react'
import { NoteSearchList } from './NoteSearchList'
import './WikilinkSuggestionMenu.css'
export interface WikilinkSuggestionItem {
@@ -19,41 +19,18 @@ interface WikilinkSuggestionMenuProps {
}
export function WikilinkSuggestionMenu({ items, selectedIndex, onItemClick }: WikilinkSuggestionMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (selectedIndex === undefined || !menuRef.current) return
const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined
el?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
if (items.length === 0) {
return (
<div className="wikilink-menu" ref={menuRef}>
<div className="wikilink-menu__empty">No results</div>
</div>
)
}
return (
<div className="wikilink-menu" ref={menuRef}>
{items.map((item, index) => (
<div
key={`${item.title}-${item.path ?? index}`}
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
onClick={() => {
item.onItemClick()
onItemClick?.(item)
}}
>
<span className="wikilink-menu__title">{item.title}</span>
{item.noteType && (
<span className="wikilink-menu__type" style={{ color: item.typeColor }}>
{item.noteType}
</span>
)}
</div>
))}
<div className="wikilink-menu">
<NoteSearchList
items={items}
selectedIndex={selectedIndex ?? 0}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => {
item.onItemClick()
onItemClick?.(item)
}}
emptyMessage="No results"
/>
</div>
)
}

View File

@@ -0,0 +1,174 @@
import { describe, it, expect, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useNoteSearch } from './useNoteSearch'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
relationships: {},
icon: null,
color: null,
order: null,
...overrides,
})
const entries: VaultEntry[] = [
makeEntry({ path: '/vault/a.md', title: 'Alpha Project', isA: 'Project', modifiedAt: 1700000003 }),
makeEntry({ path: '/vault/b.md', title: 'Beta Notes', isA: 'Note', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/c.md', title: 'Gamma Experiment', isA: 'Experiment', modifiedAt: 1700000001 }),
]
describe('useNoteSearch', () => {
it('returns entries sorted by modifiedAt when query is empty', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
expect(result.current.results.map((r) => r.title)).toEqual([
'Alpha Project',
'Beta Notes',
'Gamma Experiment',
])
})
it('filters entries by fuzzy match', () => {
const { result } = renderHook(() => useNoteSearch(entries, 'alpha'))
expect(result.current.results).toHaveLength(1)
expect(result.current.results[0].title).toBe('Alpha Project')
})
it('returns empty results when query has no matches', () => {
const { result } = renderHook(() => useNoteSearch(entries, 'zzzzzzz'))
expect(result.current.results).toHaveLength(0)
})
it('respects maxResults', () => {
const { result } = renderHook(() => useNoteSearch(entries, '', 2))
expect(result.current.results).toHaveLength(2)
})
it('includes noteType for non-Note entries', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
const project = result.current.results.find((r) => r.title === 'Alpha Project')
expect(project?.noteType).toBe('Project')
expect(project?.typeColor).toBeTruthy()
})
it('excludes noteType for Note entries', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
const note = result.current.results.find((r) => r.title === 'Beta Notes')
expect(note?.noteType).toBeUndefined()
expect(note?.typeColor).toBeUndefined()
})
it('includes original VaultEntry in results', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
expect(result.current.results[0].entry).toBe(entries[0])
})
it('starts with selectedIndex 0', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
expect(result.current.selectedIndex).toBe(0)
})
it('resets selectedIndex when query changes', () => {
let query = ''
const { result, rerender } = renderHook(() => useNoteSearch(entries, query))
act(() => {
result.current.setSelectedIndex(2)
})
expect(result.current.selectedIndex).toBe(2)
query = 'alpha'
rerender()
expect(result.current.selectedIndex).toBe(0)
})
it('handleKeyDown moves selection down on ArrowDown', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
act(() => {
result.current.handleKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' }),
)
})
expect(result.current.selectedIndex).toBe(1)
})
it('handleKeyDown moves selection up on ArrowUp', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
act(() => {
result.current.setSelectedIndex(2)
})
act(() => {
result.current.handleKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowUp' }),
)
})
expect(result.current.selectedIndex).toBe(1)
})
it('handleKeyDown clamps selection at boundaries', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
// Can't go below 0
act(() => {
result.current.handleKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowUp' }),
)
})
expect(result.current.selectedIndex).toBe(0)
// Can't go above last index
act(() => {
result.current.setSelectedIndex(2)
})
act(() => {
result.current.handleKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' }),
)
})
expect(result.current.selectedIndex).toBe(2)
})
it('selectedEntry reflects current selection', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
expect(result.current.selectedEntry).toBe(entries[0])
act(() => {
result.current.setSelectedIndex(1)
})
expect(result.current.selectedEntry).toBe(entries[1])
})
it('selectedEntry is null when no results', () => {
const { result } = renderHook(() => useNoteSearch(entries, 'zzzzzzz'))
expect(result.current.selectedEntry).toBeNull()
})
it('does not prevent default for non-arrow keys', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
const event = new KeyboardEvent('keydown', { key: 'Enter' })
const preventDefaultSpy = vi.spyOn(event, 'preventDefault')
act(() => {
result.current.handleKeyDown(event)
})
expect(preventDefaultSpy).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,61 @@
import { useState, useMemo, useCallback, useEffect } from 'react'
import type { VaultEntry } from '../types'
import { fuzzyMatch } from '../utils/fuzzyMatch'
import { getTypeColor } from '../utils/typeColors'
import type { NoteSearchResultItem } from '../components/NoteSearchList'
const DEFAULT_MAX_RESULTS = 20
export interface NoteSearchResult extends NoteSearchResultItem {
entry: VaultEntry
}
function toResult(e: VaultEntry): NoteSearchResult {
const noteType = e.isA && e.isA !== 'Note' ? e.isA : undefined
return {
entry: e,
title: e.title,
noteType,
typeColor: noteType ? getTypeColor(e.isA) : undefined,
}
}
export function useNoteSearch(entries: VaultEntry[], query: string, maxResults = DEFAULT_MAX_RESULTS) {
const [selectedIndex, setSelectedIndex] = useState(0)
const results: NoteSearchResult[] = useMemo(() => {
if (!query.trim()) {
return [...entries]
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
.slice(0, maxResults)
.map(toResult)
}
return entries
.map((e) => ({ entry: e, ...fuzzyMatch(query, e.title) }))
.filter((r) => r.match)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults)
.map((r) => toResult(r.entry))
}, [entries, query, maxResults])
useEffect(() => {
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change
}, [query])
const selectedEntry = results[selectedIndex]?.entry ?? null
const handleKeyDown = useCallback(
(e: React.KeyboardEvent | KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex((i) => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex((i) => Math.max(i - 1, 0))
}
},
[results.length],
)
return { results, selectedIndex, setSelectedIndex, selectedEntry, handleKeyDown }
}