Remove IBM Plex Mono + text-transform: uppercase from all UI labels. CSS classes .font-mono-label and .font-mono-overline now use Inter with no text-transform. Inline monospace+uppercase styles in StatusDropdown, TagsDropdown, ReferencedByPanel, AiActionCard replaced. Tailwind uppercase removed from Sidebar, CommandPalette, PulseView, CreateNoteDialog, CreateTypeDialog. Hardcoded ALL CAPS text (COLOR, ICON, TEMPLATE) converted to sentence case. Code blocks and commit hashes remain monospaced. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
198 lines
6.5 KiB
TypeScript
198 lines
6.5 KiB
TypeScript
import { useState, useRef, useEffect, useMemo } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
import { fuzzyMatch } from '../utils/fuzzyMatch'
|
|
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
|
|
import { groupSortKey } from '../hooks/useCommandRegistry'
|
|
|
|
interface CommandPaletteProps {
|
|
open: boolean
|
|
commands: CommandAction[]
|
|
onClose: () => void
|
|
}
|
|
|
|
interface ScoredCommand {
|
|
command: CommandAction
|
|
score: number
|
|
}
|
|
|
|
function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null {
|
|
const labelResult = fuzzyMatch(query, cmd.label)
|
|
if (labelResult.match) return { command: cmd, score: labelResult.score }
|
|
|
|
for (const kw of cmd.keywords ?? []) {
|
|
const kwResult = fuzzyMatch(query, kw)
|
|
if (kwResult.match) return { command: cmd, score: kwResult.score - 1 }
|
|
}
|
|
|
|
const groupResult = fuzzyMatch(query, cmd.group)
|
|
if (groupResult.match) return { command: cmd, score: groupResult.score - 2 }
|
|
|
|
return null
|
|
}
|
|
|
|
function groupResults(
|
|
commands: CommandAction[],
|
|
byRelevance: boolean,
|
|
): { group: CommandGroup; items: CommandAction[] }[] {
|
|
const map = new Map<CommandGroup, CommandAction[]>()
|
|
for (const cmd of commands) {
|
|
const list = map.get(cmd.group)
|
|
if (list) list.push(cmd)
|
|
else map.set(cmd.group, [cmd])
|
|
}
|
|
const entries = Array.from(map.entries())
|
|
if (!byRelevance) {
|
|
entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
|
|
}
|
|
return entries.map(([group, items]) => ({ group, items }))
|
|
}
|
|
|
|
export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) {
|
|
const [query, setQuery] = useState('')
|
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
|
|
setSelectedIndex(0)
|
|
setTimeout(() => inputRef.current?.focus(), 50)
|
|
}
|
|
}, [open])
|
|
|
|
const enabledCommands = useMemo(
|
|
() => commands.filter(c => c.enabled),
|
|
[commands],
|
|
)
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!query.trim()) return enabledCommands
|
|
return enabledCommands
|
|
.map(cmd => matchCommand(query, cmd))
|
|
.filter((r): r is ScoredCommand => r !== null)
|
|
.sort((a, b) => b.score - a.score)
|
|
.map(r => r.command)
|
|
}, [enabledCommands, query])
|
|
|
|
const hasQuery = !!query.trim()
|
|
const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery])
|
|
const flatList = useMemo(() => groups.flatMap(g => g.items), [groups])
|
|
|
|
useEffect(() => {
|
|
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change
|
|
}, [query])
|
|
|
|
useEffect(() => {
|
|
if (!listRef.current) return
|
|
const el = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | undefined
|
|
el?.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, flatList.length - 1))
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0))
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
const cmd = flatList[selectedIndex]
|
|
if (cmd) { onClose(); cmd.execute() }
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handleKey)
|
|
return () => window.removeEventListener('keydown', handleKey)
|
|
}, [open, flatList, selectedIndex, onClose])
|
|
|
|
if (!open) return null
|
|
|
|
let runningIndex = 0
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="flex w-[520px] max-w-[90vw] max-h-[440px] 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="Type a command..."
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
/>
|
|
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
|
{flatList.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
|
No matching commands
|
|
</div>
|
|
) : (
|
|
groups.map(({ group, items }) => {
|
|
const startIndex = runningIndex
|
|
runningIndex += items.length
|
|
return (
|
|
<div key={group}>
|
|
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
|
{group}
|
|
</div>
|
|
{items.map((cmd, i) => {
|
|
const globalIdx = startIndex + i
|
|
return (
|
|
<CommandRow
|
|
key={cmd.id}
|
|
command={cmd}
|
|
selected={globalIdx === selectedIndex}
|
|
onHover={() => setSelectedIndex(globalIdx)}
|
|
onSelect={() => { onClose(); cmd.execute() }}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
|
<span>↑↓ navigate</span>
|
|
<span>↵ select</span>
|
|
<span>esc close</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface CommandRowProps {
|
|
command: CommandAction
|
|
selected: boolean
|
|
onHover: () => void
|
|
onSelect: () => void
|
|
}
|
|
|
|
function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) {
|
|
return (
|
|
<div
|
|
data-selected={selected}
|
|
className={cn(
|
|
'mx-1 flex cursor-pointer items-center justify-between rounded-md px-3 py-1.5 transition-colors',
|
|
selected ? 'bg-accent' : 'hover:bg-secondary',
|
|
)}
|
|
onClick={onSelect}
|
|
onMouseEnter={onHover}
|
|
>
|
|
<span className="text-sm text-foreground">{command.label}</span>
|
|
{command.shortcut && (
|
|
<span className="text-[11px] text-muted-foreground">{command.shortcut}</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|