- Wrap ref assignments in useEffect to fix refs-during-render in useTabManagement, useKeyboardNavigation, and Editor - Rewrite useAppKeyboard to define keyMap inside useEffect, eliminating ref access during render - Add eslint-disable for react-hooks/set-state-in-effect on legitimate dialog reset patterns (CommitDialog, CreateNoteDialog, CreateTypeDialog, QuickOpenPalette, AIChatPanel, useVaultLoader) - Add eslint-disable for react-hooks/static-components on icon lookups (NoteItem, NoteList PinnedCard) — stateless icon components from a static map - Add eslint-disable for react-hooks/purity on Date.now() in NoteItem TrashDateLine — intentionally memoized on trashedAt - Remove unused `model` param from buildSystemPrompt (ai-chat.ts) and update callers - Fix exhaustive-deps: suppress vault object dep in App.tsx git history effect (vault object is unstable, loadGitHistory is the real dep) - Remove unused `modifiedFiles` from useNoteListData params and caller - Add missing `ref` to Sidebar useOutsideClick deps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
146 lines
4.8 KiB
TypeScript
146 lines
4.8 KiB
TypeScript
import { useState, useRef, useEffect, useMemo } from 'react'
|
|
import type { VaultEntry } from '../types'
|
|
import { cn } from '@/lib/utils'
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
|
interface QuickOpenPaletteProps {
|
|
open: boolean
|
|
entries: VaultEntry[]
|
|
onSelect: (entry: VaultEntry) => void
|
|
onClose: () => void
|
|
}
|
|
|
|
/** Simple fuzzy match: all query chars appear in order in the target */
|
|
function fuzzyMatch(query: string, target: string): { match: boolean; score: number } {
|
|
const q = query.toLowerCase()
|
|
const t = target.toLowerCase()
|
|
let qi = 0
|
|
let score = 0
|
|
let lastMatchIndex = -1
|
|
|
|
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
if (t[ti] === q[qi]) {
|
|
if (ti === lastMatchIndex + 1) score += 2
|
|
if (ti === 0 || t[ti - 1] === ' ' || t[ti - 1] === '-') score += 3
|
|
score += 1
|
|
lastMatchIndex = ti
|
|
qi++
|
|
}
|
|
}
|
|
|
|
return { match: qi === q.length, score }
|
|
}
|
|
|
|
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)
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset on dialog open
|
|
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])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
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])
|
|
onClose()
|
|
}
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handleKey)
|
|
return () => window.removeEventListener('keydown', handleKey)
|
|
}, [open, results, selectedIndex, onSelect, onClose])
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="flex w-[500px] max-w-[90vw] max-h-[400px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
|
type="text"
|
|
placeholder="Search notes..."
|
|
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>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|