From 576ce1f294e8f2590253e658538e57e7cae6d595 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 24 Feb 2026 23:38:25 +0100 Subject: [PATCH 1/3] design: command-palette wireframes Three frames: empty state with grouped actions, search results with fuzzy filtering, and no-results state. Co-Authored-By: Claude Opus 4.6 --- design/command-palette.pen | 517 +++++++++++++++++++++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 design/command-palette.pen diff --git a/design/command-palette.pen b/design/command-palette.pen new file mode 100644 index 00000000..01c43cf0 --- /dev/null +++ b/design/command-palette.pen @@ -0,0 +1,517 @@ +{ + "version": 1, + "children": [ + { + "type": "frame", + "id": "cpE01", + "x": 0, + "y": 0, + "name": "Command Palette — Empty State (Cmd+K)", + "clip": true, + "width": 560, + "height": 420, + "fill": "#1a1a2e", + "layout": "vertical", + "cornerRadius": 12, + "children": [ + { + "type": "frame", + "id": "cpE02", + "name": "Search Bar", + "width": "fill_container", + "height": 52, + "padding": [0, 16], + "gap": 10, + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "#ffffff15" + }, + "children": [ + { + "type": "text", + "id": "cpE03", + "name": "searchIcon", + "content": "⌘", + "fontSize": 14, + "fontWeight": "500", + "color": "#888" + }, + { + "type": "text", + "id": "cpE04", + "name": "searchPlaceholder", + "content": "Type a command...", + "fontSize": 15, + "fontWeight": "400", + "color": "#666" + } + ] + }, + { + "type": "frame", + "id": "cpE05", + "name": "Action Groups", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [8, 0], + "gap": 4, + "children": [ + { + "type": "text", + "id": "cpE06", + "name": "groupLabel_Navigation", + "content": "Navigation", + "fontSize": 11, + "fontWeight": "600", + "color": "#666", + "padding": [4, 16] + }, + { + "type": "frame", + "id": "cpE07", + "name": "actionRow_SearchNotes", + "width": "fill_container", + "height": 36, + "fill": "#ffffff08", + "padding": [0, 16], + "gap": 8, + "alignItems": "center", + "cornerRadius": 6, + "children": [ + { + "type": "text", + "id": "cpE08", + "content": "Search Notes", + "fontSize": 14, + "color": "#ddd" + }, + { + "type": "text", + "id": "cpE09", + "name": "shortcut", + "content": "⌘P", + "fontSize": 12, + "color": "#666" + } + ] + }, + { + "type": "frame", + "id": "cpE10", + "name": "actionRow_GoToAllNotes", + "width": "fill_container", + "height": 36, + "padding": [0, 16], + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cpE11", + "content": "Go to All Notes", + "fontSize": 14, + "color": "#ddd" + }, + { + "type": "text", + "id": "cpE12", + "name": "shortcut", + "content": "", + "fontSize": 12, + "color": "#666" + } + ] + }, + { + "type": "text", + "id": "cpE13", + "name": "groupLabel_Notes", + "content": "Notes", + "fontSize": 11, + "fontWeight": "600", + "color": "#666", + "padding": [8, 16, 4, 16] + }, + { + "type": "frame", + "id": "cpE14", + "name": "actionRow_CreateNote", + "width": "fill_container", + "height": 36, + "padding": [0, 16], + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cpE15", + "content": "Create New Note", + "fontSize": 14, + "color": "#ddd" + }, + { + "type": "text", + "id": "cpE16", + "name": "shortcut", + "content": "⌘N", + "fontSize": 12, + "color": "#666" + } + ] + }, + { + "type": "text", + "id": "cpE17", + "name": "groupLabel_Git", + "content": "Git", + "fontSize": 11, + "fontWeight": "600", + "color": "#666", + "padding": [8, 16, 4, 16] + }, + { + "type": "frame", + "id": "cpE18", + "name": "actionRow_CommitPush", + "width": "fill_container", + "height": 36, + "padding": [0, 16], + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cpE19", + "content": "Commit & Push", + "fontSize": 14, + "color": "#ddd" + }, + { + "type": "text", + "id": "cpE20", + "name": "shortcut", + "content": "", + "fontSize": 12, + "color": "#666" + } + ] + }, + { + "type": "text", + "id": "cpE21", + "name": "groupLabel_Settings", + "content": "Settings", + "fontSize": 11, + "fontWeight": "600", + "color": "#666", + "padding": [8, 16, 4, 16] + }, + { + "type": "frame", + "id": "cpE22", + "name": "actionRow_OpenSettings", + "width": "fill_container", + "height": 36, + "padding": [0, 16], + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cpE23", + "content": "Open Settings", + "fontSize": 14, + "color": "#ddd" + }, + { + "type": "text", + "id": "cpE24", + "name": "shortcut", + "content": "⌘,", + "fontSize": 12, + "color": "#666" + } + ] + } + ] + }, + { + "type": "frame", + "id": "cpE25", + "name": "Footer Hints", + "width": "fill_container", + "height": 32, + "padding": [0, 16], + "gap": 16, + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "#ffffff15" + }, + "children": [ + { + "type": "text", + "id": "cpE26", + "content": "↑↓ navigate ↵ select esc close", + "fontSize": 11, + "color": "#555" + } + ] + } + ] + }, + { + "type": "frame", + "id": "cpS01", + "x": 620, + "y": 0, + "name": "Command Palette — Search Results (filtered)", + "clip": true, + "width": 560, + "height": 420, + "fill": "#1a1a2e", + "layout": "vertical", + "cornerRadius": 12, + "children": [ + { + "type": "frame", + "id": "cpS02", + "name": "Search Bar", + "width": "fill_container", + "height": 52, + "padding": [0, 16], + "gap": 10, + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "#ffffff15" + }, + "children": [ + { + "type": "text", + "id": "cpS03", + "name": "searchIcon", + "content": "⌘", + "fontSize": 14, + "fontWeight": "500", + "color": "#888" + }, + { + "type": "text", + "id": "cpS04", + "name": "searchQuery", + "content": "comm", + "fontSize": 15, + "fontWeight": "400", + "color": "#eee" + } + ] + }, + { + "type": "frame", + "id": "cpS05", + "name": "Filtered Results", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [8, 0], + "gap": 2, + "children": [ + { + "type": "frame", + "id": "cpS06", + "name": "result_CommitPush_selected", + "width": "fill_container", + "height": 40, + "fill": "#4a9eff22", + "padding": [0, 16], + "alignItems": "center", + "cornerRadius": 6, + "children": [ + { + "type": "text", + "id": "cpS07", + "content": "Commit & Push", + "fontSize": 14, + "fontWeight": "500", + "color": "#eee" + }, + { + "type": "frame", + "id": "cpS08", + "name": "spacer", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "cpS09", + "name": "group_badge", + "content": "Git", + "fontSize": 11, + "color": "#888" + } + ] + }, + { + "type": "frame", + "id": "cpS10", + "name": "result_ChangeType", + "width": "fill_container", + "height": 40, + "padding": [0, 16], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cpS11", + "content": "Change Note Type", + "fontSize": 14, + "color": "#bbb" + }, + { + "type": "frame", + "id": "cpS12", + "name": "spacer", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "cpS13", + "name": "group_badge", + "content": "Note", + "fontSize": 11, + "color": "#888" + } + ] + } + ] + }, + { + "type": "frame", + "id": "cpS14", + "name": "Footer Hints", + "width": "fill_container", + "height": 32, + "padding": [0, 16], + "gap": 16, + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "#ffffff15" + }, + "children": [ + { + "type": "text", + "id": "cpS15", + "content": "↑↓ navigate ↵ select esc close", + "fontSize": 11, + "color": "#555" + } + ] + } + ] + }, + { + "type": "frame", + "id": "cpN01", + "x": 0, + "y": 480, + "name": "Command Palette — No Results", + "clip": true, + "width": 560, + "height": 200, + "fill": "#1a1a2e", + "layout": "vertical", + "cornerRadius": 12, + "children": [ + { + "type": "frame", + "id": "cpN02", + "name": "Search Bar", + "width": "fill_container", + "height": 52, + "padding": [0, 16], + "gap": 10, + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "#ffffff15" + }, + "children": [ + { + "type": "text", + "id": "cpN03", + "name": "searchIcon", + "content": "⌘", + "fontSize": 14, + "fontWeight": "500", + "color": "#888" + }, + { + "type": "text", + "id": "cpN04", + "name": "searchQuery", + "content": "xyznotfound", + "fontSize": 15, + "fontWeight": "400", + "color": "#eee" + } + ] + }, + { + "type": "frame", + "id": "cpN05", + "name": "Empty State", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "cpN06", + "content": "No matching commands", + "fontSize": 13, + "color": "#666" + } + ] + }, + { + "type": "frame", + "id": "cpN07", + "name": "Footer Hints", + "width": "fill_container", + "height": 32, + "padding": [0, 16], + "gap": 16, + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "#ffffff15" + }, + "children": [ + { + "type": "text", + "id": "cpN08", + "content": "↑↓ navigate ↵ select esc close", + "fontSize": 11, + "color": "#555" + } + ] + } + ] + } + ], + "variables": {} +} \ No newline at end of file From 9bcc1776cc83947355037e7452466d37c3449b45 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 24 Feb 2026 23:41:54 +0100 Subject: [PATCH 2/3] feat: add command palette with Cmd+K shortcut Raycast-style command palette with fuzzy search across all app actions. Groups commands by category (Navigation, Note, Git, View, Settings) with keyboard shortcuts displayed inline. Contextual actions (trash, archive, save) are only available when a note is open. Extracts fuzzyMatch into shared utility for reuse. Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 23 ++++ src/components/CommandPalette.tsx | 191 ++++++++++++++++++++++++++++ src/components/QuickOpenPalette.tsx | 22 +--- src/hooks/useAppKeyboard.ts | 6 +- src/hooks/useCommandRegistry.ts | 100 +++++++++++++++ src/utils/fuzzyMatch.ts | 20 +++ 6 files changed, 339 insertions(+), 23 deletions(-) create mode 100644 src/components/CommandPalette.tsx create mode 100644 src/hooks/useCommandRegistry.ts create mode 100644 src/utils/fuzzyMatch.ts diff --git a/src/App.tsx b/src/App.tsx index 94be4332..7867d440 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { Editor } from './components/Editor' import { ResizeHandle } from './components/ResizeHandle' import { CreateTypeDialog } from './components/CreateTypeDialog' import { QuickOpenPalette } from './components/QuickOpenPalette' +import { CommandPalette } from './components/CommandPalette' import { Toast } from './components/Toast' import { CommitDialog } from './components/CommitDialog' import { StatusBar } from './components/StatusBar' @@ -18,6 +19,7 @@ import { useCommitFlow } from './hooks/useCommitFlow' import { useAppKeyboard } from './hooks/useAppKeyboard' import { useViewMode } from './hooks/useViewMode' import { useEntryActions } from './hooks/useEntryActions' +import { useCommandRegistry } from './hooks/useCommandRegistry' import { isTauri } from './mock-tauri' import { pickFolder } from './utils/vault-dialog' @@ -64,6 +66,7 @@ function App() { const [gitHistory, setGitHistory] = useState([]) const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false) const [showQuickOpen, setShowQuickOpen] = useState(false) + const [showCommandPalette, setShowCommandPalette] = useState(false) const [toastMessage, setToastMessage] = useState(null) const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path) const [showAIChat, setShowAIChat] = useState(false) @@ -173,6 +176,7 @@ function App() { useAppKeyboard({ onQuickOpen: () => setShowQuickOpen(true), + onCommandPalette: () => setShowCommandPalette(true), onCreateNote: handleCreateNoteImmediate, onSave: handleSave, onOpenSettings: () => setShowSettings(true), @@ -183,6 +187,24 @@ function App() { handleCloseTabRef: notes.handleCloseTabRef, }) + const commands = useCommandRegistry({ + activeTabPath: notes.activeTabPath, + entries: vault.entries, + modifiedCount: vault.modifiedFiles.length, + onQuickOpen: () => setShowQuickOpen(true), + onCreateNote: handleCreateNoteImmediate, + onSave: handleSave, + onOpenSettings: () => setShowSettings(true), + onTrashNote: entryActions.handleTrashNote, + onArchiveNote: entryActions.handleArchiveNote, + onUnarchiveNote: entryActions.handleUnarchiveNote, + onCommitPush: commitFlow.openCommitDialog, + onSetViewMode: setViewMode, + onToggleInspector: () => layout.setInspectorCollapsed(c => !c), + onSelect: setSelection, + onCloseTab: notes.handleCloseTab, + }) + const { status: updateStatus, actions: updateActions } = useUpdater() useKeyboardNavigation({ @@ -249,6 +271,7 @@ function App() { setShowSettings(true)} onOpenLocalFolder={handleOpenLocalFolder} onConnectGitHub={() => setShowGitHubVault(true)} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} /> setToastMessage(null)} /> setShowQuickOpen(false)} /> + setShowCommandPalette(false)} /> setShowCreateTypeDialog(false)} onCreate={handleCreateType} /> setShowSettings(false)} /> diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 00000000..08c805ea --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,191 @@ +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[]): { group: CommandGroup; items: CommandAction[] }[] { + const map = new Map() + for (const cmd of commands) { + const list = map.get(cmd.group) + if (list) list.push(cmd) + else map.set(cmd.group, [cmd]) + } + return Array.from(map.entries()) + .sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0])) + .map(([group, items]) => ({ group, items })) +} + +export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) { + const [query, setQuery] = useState('') + const [selectedIndex, setSelectedIndex] = useState(0) + const inputRef = useRef(null) + const listRef = useRef(null) + + useEffect(() => { + if (open) { + setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open + setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on open + 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 groups = useMemo(() => groupResults(filtered), [filtered]) + 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 ( +
+
e.stopPropagation()} + > + setQuery(e.target.value)} + /> +
+ {flatList.length === 0 ? ( +
+ No matching commands +
+ ) : ( + groups.map(({ group, items }) => { + const startIndex = runningIndex + runningIndex += items.length + return ( +
+
+ {group} +
+ {items.map((cmd, i) => { + const globalIdx = startIndex + i + return ( + setSelectedIndex(globalIdx)} + onSelect={() => { onClose(); cmd.execute() }} + /> + ) + })} +
+ ) + }) + )} +
+
+ ↑↓ navigate + ↵ select + esc close +
+
+
+ ) +} + +interface CommandRowProps { + command: CommandAction + selected: boolean + onHover: () => void + onSelect: () => void +} + +function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) { + return ( +
+ {command.label} + {command.shortcut && ( + {command.shortcut} + )} +
+ ) +} diff --git a/src/components/QuickOpenPalette.tsx b/src/components/QuickOpenPalette.tsx index 4f6aae0b..5ccbcc7f 100644 --- a/src/components/QuickOpenPalette.tsx +++ b/src/components/QuickOpenPalette.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useMemo } from 'react' import type { VaultEntry } from '../types' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' +import { fuzzyMatch } from '../utils/fuzzyMatch' interface QuickOpenPaletteProps { open: boolean @@ -10,27 +11,6 @@ interface QuickOpenPaletteProps { 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) diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index bbd13640..d4c3eb95 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -3,6 +3,7 @@ import type { ViewMode } from './useViewMode' interface KeyboardActions { onQuickOpen: () => void + onCommandPalette: () => void onCreateNote: () => void onSave: () => void onOpenSettings: () => void @@ -45,7 +46,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record) } export function useAppKeyboard({ - onQuickOpen, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, + onQuickOpen, onCommandPalette, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, onSetViewMode, activeTabPathRef, handleCloseTabRef, }: KeyboardActions) { useEffect(() => { @@ -55,6 +56,7 @@ export function useAppKeyboard({ } const cmdKeyMap: Record = { + k: onCommandPalette, p: onQuickOpen, n: onCreateNote, s: onSave, @@ -72,5 +74,5 @@ export function useAppKeyboard({ } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [onQuickOpen, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode]) + }, [onQuickOpen, onCommandPalette, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode]) } diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts new file mode 100644 index 00000000..22079b92 --- /dev/null +++ b/src/hooks/useCommandRegistry.ts @@ -0,0 +1,100 @@ +import { useMemo } from 'react' +import type { SidebarSelection, VaultEntry } from '../types' +import type { ViewMode } from './useViewMode' + +export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Settings' + +export interface CommandAction { + id: string + label: string + group: CommandGroup + shortcut?: string + keywords?: string[] + enabled: boolean + execute: () => void +} + +interface CommandRegistryConfig { + activeTabPath: string | null + entries: VaultEntry[] + modifiedCount: number + + onQuickOpen: () => void + onCreateNote: () => void + onSave: () => void + onOpenSettings: () => void + onTrashNote: (path: string) => void + onArchiveNote: (path: string) => void + onUnarchiveNote: (path: string) => void + onCommitPush: () => void + onSetViewMode: (mode: ViewMode) => void + onToggleInspector: () => void + onSelect: (sel: SidebarSelection) => void + onCloseTab: (path: string) => void +} + +const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings'] + +export function groupSortKey(group: CommandGroup): number { + return GROUP_ORDER.indexOf(group) +} + +export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] { + const { + activeTabPath, entries, modifiedCount, + onQuickOpen, onCreateNote, onSave, onOpenSettings, + onTrashNote, onArchiveNote, onUnarchiveNote, + onCommitPush, onSetViewMode, onToggleInspector, onSelect, onCloseTab, + } = config + + const hasActiveNote = activeTabPath !== null + + const activeEntry = useMemo( + () => (hasActiveNote ? entries.find(e => e.path === activeTabPath) : undefined), + [entries, activeTabPath, hasActiveNote], + ) + const isArchived = activeEntry?.archived ?? false + + return useMemo(() => { + const cmds: CommandAction[] = [ + // Navigation + { id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen }, + { id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) }, + { id: 'go-favorites', label: 'Go to Favorites', group: 'Navigation', keywords: ['starred'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'favorites' }) }, + { id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) }, + { id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) }, + { id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) }, + + // Note actions (contextual) + { id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote }, + { id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave }, + { id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } }, + { id: 'trash-note', label: 'Trash Note', group: 'Note', shortcut: '⌘⌫', keywords: ['delete', 'remove'], enabled: hasActiveNote, execute: () => { if (activeTabPath) onTrashNote(activeTabPath) } }, + { + id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E', + keywords: ['archive'], enabled: hasActiveNote, + execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) }, + }, + + // Git + { id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush }, + { id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) }, + + // View + { id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') }, + { id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') }, + { id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') }, + { id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector }, + + // Settings + { id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings }, + ] + + return cmds + }, [ + hasActiveNote, activeTabPath, isArchived, modifiedCount, + onQuickOpen, onCreateNote, onSave, onOpenSettings, + onTrashNote, onArchiveNote, onUnarchiveNote, + onCommitPush, onSetViewMode, onToggleInspector, onSelect, onCloseTab, + ]) +} diff --git a/src/utils/fuzzyMatch.ts b/src/utils/fuzzyMatch.ts new file mode 100644 index 00000000..d3cfda05 --- /dev/null +++ b/src/utils/fuzzyMatch.ts @@ -0,0 +1,20 @@ +/** Fuzzy match: all query chars must appear in order in the target. */ +export 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 } +} From 8a08fb67a314177ea4efe9c8a2bb2eaa9fbf6f87 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 24 Feb 2026 23:43:58 +0100 Subject: [PATCH 3/3] test: add command palette and fuzzy match tests Tests for CommandPalette component (rendering, filtering, keyboard navigation, grouped results, shortcuts display), useCommandRegistry hook (contextual actions, archive toggle, git availability), fuzzyMatch utility, and Cmd+K shortcut. Co-Authored-By: Claude Opus 4.6 --- src/components/CommandPalette.test.tsx | 168 +++++++++++++++++++++++++ src/hooks/useAppKeyboard.test.ts | 8 ++ src/hooks/useCommandRegistry.test.ts | 150 ++++++++++++++++++++++ src/utils/fuzzyMatch.test.ts | 46 +++++++ 4 files changed, 372 insertions(+) create mode 100644 src/components/CommandPalette.test.tsx create mode 100644 src/hooks/useCommandRegistry.test.ts create mode 100644 src/utils/fuzzyMatch.test.ts diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx new file mode 100644 index 00000000..6469cc0a --- /dev/null +++ b/src/components/CommandPalette.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { CommandPalette } from './CommandPalette' +import type { CommandAction } from '../hooks/useCommandRegistry' + +// jsdom doesn't implement scrollIntoView +Element.prototype.scrollIntoView = vi.fn() + +const makeCommand = (overrides: Partial = {}): CommandAction => ({ + id: 'test-cmd', + label: 'Test Command', + group: 'Navigation', + keywords: [], + enabled: true, + shortcut: undefined, + execute: vi.fn(), + ...overrides, +}) + +const commands: CommandAction[] = [ + makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find'] }), + makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N' }), + makeCommand({ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'sync'] }), + makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,' }), + makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }), +] + +describe('CommandPalette', () => { + const onClose = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders nothing when closed', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeNull() + }) + + it('shows search input when open', () => { + render() + expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument() + }) + + it('shows all enabled commands grouped by category', () => { + render() + expect(screen.getByText('Search Notes')).toBeInTheDocument() + expect(screen.getByText('Create New Note')).toBeInTheDocument() + expect(screen.getByText('Commit & Push')).toBeInTheDocument() + expect(screen.getByText('Open Settings')).toBeInTheDocument() + // Disabled command should not appear + expect(screen.queryByText('Disabled Command')).not.toBeInTheDocument() + }) + + it('shows group labels', () => { + render() + expect(screen.getByText('Navigation')).toBeInTheDocument() + expect(screen.getByText('Note')).toBeInTheDocument() + expect(screen.getByText('Git')).toBeInTheDocument() + expect(screen.getByText('Settings')).toBeInTheDocument() + }) + + it('shows keyboard shortcuts', () => { + render() + expect(screen.getByText('⌘P')).toBeInTheDocument() + expect(screen.getByText('⌘N')).toBeInTheDocument() + expect(screen.getByText('⌘,')).toBeInTheDocument() + }) + + it('filters commands by fuzzy search', () => { + render() + const input = screen.getByPlaceholderText('Type a command...') + fireEvent.change(input, { target: { value: 'commit' } }) + + expect(screen.getByText('Commit & Push')).toBeInTheDocument() + expect(screen.queryByText('Search Notes')).not.toBeInTheDocument() + }) + + it('matches by keyword', () => { + render() + const input = screen.getByPlaceholderText('Type a command...') + fireEvent.change(input, { target: { value: 'find' } }) + + expect(screen.getByText('Search Notes')).toBeInTheDocument() + }) + + it('shows "No matching commands" when no results', () => { + render() + const input = screen.getByPlaceholderText('Type a command...') + fireEvent.change(input, { target: { value: 'zzzzzzz' } }) + + expect(screen.getByText('No matching commands')).toBeInTheDocument() + }) + + it('calls onClose when pressing Escape', () => { + render() + fireEvent.keyDown(window, { key: 'Escape' }) + expect(onClose).toHaveBeenCalled() + }) + + it('executes command and closes on Enter', () => { + render() + fireEvent.keyDown(window, { key: 'Enter' }) + + // First enabled command (Search Notes) should execute + expect(commands[0].execute).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + }) + + it('navigates with arrow keys and selects with Enter', () => { + render() + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + fireEvent.keyDown(window, { key: 'Enter' }) + + // Second enabled command (Create New Note) should execute + expect(commands[1].execute).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + }) + + it('does not go below the last item', () => { + render() + + for (let i = 0; i < 20; i++) { + fireEvent.keyDown(window, { key: 'ArrowDown' }) + } + fireEvent.keyDown(window, { key: 'Enter' }) + + // Should select last enabled command (Open Settings) + expect(commands[3].execute).toHaveBeenCalled() + }) + + it('does not go above first item', () => { + render() + + fireEvent.keyDown(window, { key: 'ArrowUp' }) + fireEvent.keyDown(window, { key: 'Enter' }) + + // Should still select first command + expect(commands[0].execute).toHaveBeenCalled() + }) + + it('calls onClose when clicking backdrop', () => { + render() + + const backdrop = screen.getByPlaceholderText('Type a command...').closest('.fixed')! + fireEvent.click(backdrop) + + expect(onClose).toHaveBeenCalled() + }) + + it('executes command when clicking an item', () => { + render() + fireEvent.click(screen.getByText('Commit & Push')) + + expect(commands[2].execute).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + }) + + it('shows footer hints', () => { + render() + expect(screen.getByText('↑↓ navigate')).toBeInTheDocument() + expect(screen.getByText('↵ select')).toBeInTheDocument() + expect(screen.getByText('esc close')).toBeInTheDocument() + }) +}) diff --git a/src/hooks/useAppKeyboard.test.ts b/src/hooks/useAppKeyboard.test.ts index 51198c5a..3e3b0538 100644 --- a/src/hooks/useAppKeyboard.test.ts +++ b/src/hooks/useAppKeyboard.test.ts @@ -17,6 +17,7 @@ function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlK function makeActions() { return { onQuickOpen: vi.fn(), + onCommandPalette: vi.fn(), onCreateNote: vi.fn(), onSave: vi.fn(), onOpenSettings: vi.fn(), @@ -86,4 +87,11 @@ describe('useAppKeyboard', () => { fireKey('4', { altKey: true }) expect(actions.onSetViewMode).not.toHaveBeenCalled() }) + + it('Cmd+K triggers command palette', () => { + const actions = makeActions() + renderHook(() => useAppKeyboard(actions)) + fireKey('k', { metaKey: true }) + expect(actions.onCommandPalette).toHaveBeenCalled() + }) }) diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts new file mode 100644 index 00000000..a0d859b4 --- /dev/null +++ b/src/hooks/useCommandRegistry.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useCommandRegistry, groupSortKey } from './useCommandRegistry' +import type { VaultEntry } from '../types' + +const makeEntry = (overrides: Partial = {}): 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, +}) + +function makeConfig(overrides: Record = {}) { + return { + activeTabPath: null as string | null, + entries: [] as VaultEntry[], + modifiedCount: 0, + onQuickOpen: vi.fn(), + onCreateNote: vi.fn(), + onSave: vi.fn(), + onOpenSettings: vi.fn(), + onTrashNote: vi.fn(), + onArchiveNote: vi.fn(), + onUnarchiveNote: vi.fn(), + onCommitPush: vi.fn(), + onSetViewMode: vi.fn(), + onToggleInspector: vi.fn(), + onSelect: vi.fn(), + onCloseTab: vi.fn(), + ...overrides, + } +} + +describe('useCommandRegistry', () => { + it('returns all command groups', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig())) + const groups = new Set(result.current.map(c => c.group)) + expect(groups).toContain('Navigation') + expect(groups).toContain('Note') + expect(groups).toContain('Git') + expect(groups).toContain('View') + expect(groups).toContain('Settings') + }) + + it('has search-notes command with shortcut', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig())) + const cmd = result.current.find(c => c.id === 'search-notes') + expect(cmd).toBeDefined() + expect(cmd!.shortcut).toBe('⌘P') + expect(cmd!.enabled).toBe(true) + }) + + it('disables contextual actions when no note is open', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null }))) + const trashCmd = result.current.find(c => c.id === 'trash-note') + expect(trashCmd!.enabled).toBe(false) + + const saveCmd = result.current.find(c => c.id === 'save-note') + expect(saveCmd!.enabled).toBe(false) + + const closeCmd = result.current.find(c => c.id === 'close-tab') + expect(closeCmd!.enabled).toBe(false) + }) + + it('enables contextual actions when a note is open', () => { + const entries = [makeEntry({ path: '/vault/note/test.md' })] + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })), + ) + expect(result.current.find(c => c.id === 'trash-note')!.enabled).toBe(true) + expect(result.current.find(c => c.id === 'save-note')!.enabled).toBe(true) + expect(result.current.find(c => c.id === 'close-tab')!.enabled).toBe(true) + }) + + it('shows "Unarchive Note" when active note is archived', () => { + const entries = [makeEntry({ path: '/vault/note/test.md', archived: true })] + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })), + ) + const archiveCmd = result.current.find(c => c.id === 'archive-note') + expect(archiveCmd!.label).toBe('Unarchive Note') + }) + + it('shows "Archive Note" when active note is not archived', () => { + const entries = [makeEntry({ path: '/vault/note/test.md', archived: false })] + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })), + ) + const archiveCmd = result.current.find(c => c.id === 'archive-note') + expect(archiveCmd!.label).toBe('Archive Note') + }) + + it('disables commit when no modified files', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 }))) + expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false) + }) + + it('enables commit when modified files exist', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 3 }))) + expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(true) + }) + + it('calls onQuickOpen when search-notes executes', () => { + const onQuickOpen = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ onQuickOpen }))) + result.current.find(c => c.id === 'search-notes')!.execute() + expect(onQuickOpen).toHaveBeenCalled() + }) + + it('calls onSelect with filter when navigation commands execute', () => { + const onSelect = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ onSelect }))) + result.current.find(c => c.id === 'go-all')!.execute() + expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'all' }) + }) + + it('calls onSetViewMode when view commands execute', () => { + const onSetViewMode = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ onSetViewMode }))) + result.current.find(c => c.id === 'view-editor')!.execute() + expect(onSetViewMode).toHaveBeenCalledWith('editor-only') + }) +}) + +describe('groupSortKey', () => { + it('returns ordered keys for all groups', () => { + expect(groupSortKey('Navigation')).toBeLessThan(groupSortKey('Note')) + expect(groupSortKey('Note')).toBeLessThan(groupSortKey('Git')) + expect(groupSortKey('Git')).toBeLessThan(groupSortKey('View')) + expect(groupSortKey('View')).toBeLessThan(groupSortKey('Settings')) + }) +}) diff --git a/src/utils/fuzzyMatch.test.ts b/src/utils/fuzzyMatch.test.ts new file mode 100644 index 00000000..59dc41d9 --- /dev/null +++ b/src/utils/fuzzyMatch.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { fuzzyMatch } from './fuzzyMatch' + +describe('fuzzyMatch', () => { + it('matches exact string', () => { + const result = fuzzyMatch('hello', 'hello') + expect(result.match).toBe(true) + expect(result.score).toBeGreaterThan(0) + }) + + it('matches case-insensitively', () => { + expect(fuzzyMatch('hello', 'Hello World').match).toBe(true) + }) + + it('matches subsequence chars in order', () => { + expect(fuzzyMatch('cnt', 'Create New Type').match).toBe(true) + }) + + it('rejects when chars are not all present', () => { + expect(fuzzyMatch('xyz', 'hello').match).toBe(false) + }) + + it('rejects when chars are out of order', () => { + expect(fuzzyMatch('ba', 'abc').match).toBe(false) + }) + + it('returns higher score for consecutive matches', () => { + const consecutive = fuzzyMatch('com', 'Commit & Push') + const scattered = fuzzyMatch('cmt', 'Commit & Push') + expect(consecutive.score).toBeGreaterThan(scattered.score) + }) + + it('gives bonus for word-start matches', () => { + const wordStart = fuzzyMatch('cp', 'Commit Push') + const midWord = fuzzyMatch('om', 'Commit Push') + expect(wordStart.score).toBeGreaterThan(midWord.score) + }) + + it('matches empty query against any string', () => { + expect(fuzzyMatch('', 'anything').match).toBe(true) + }) + + it('handles empty target', () => { + expect(fuzzyMatch('a', '').match).toBe(false) + }) +})