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 diff --git a/src/App.tsx b/src/App.tsx index fd2dacd6..625ef9b8 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) @@ -168,6 +171,7 @@ function App() { useAppKeyboard({ onQuickOpen: () => setShowQuickOpen(true), + onCommandPalette: () => setShowCommandPalette(true), onCreateNote: handleCreateNoteImmediate, onSave: handleSave, onOpenSettings: () => setShowSettings(true), @@ -178,6 +182,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({ @@ -244,6 +266,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.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/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.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/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.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/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.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) + }) +}) 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 } +}