Merge pull request #61 from refactoringhq/task/command-palette

feat: command palette (Cmd+K) — quick actions and navigation
This commit is contained in:
Luca Rossi
2026-02-25 00:05:11 +01:00
committed by GitHub
11 changed files with 1228 additions and 23 deletions

517
design/command-palette.pen Normal file
View File

@@ -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": {}
}

View File

@@ -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<GitCommit[]>([])
const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false)
const [showQuickOpen, setShowQuickOpen] = useState(false)
const [showCommandPalette, setShowCommandPalette] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(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() {
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultPath} vaults={allVaults} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} onOpenLocalFolder={handleOpenLocalFolder} onConnectGitHub={() => setShowGitHubVault(true)} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={() => setShowQuickOpen(false)} />
<CommandPalette open={showCommandPalette} commands={commands} onClose={() => setShowCommandPalette(false)} />
<CreateTypeDialog open={showCreateTypeDialog} onClose={() => setShowCreateTypeDialog(false)} onCreate={handleCreateType} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<SettingsPanel open={showSettings} settings={settings} onSave={saveSettings} onClose={() => setShowSettings(false)} />

View File

@@ -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> = {}): 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(
<CommandPalette open={false} commands={commands} onClose={onClose} />,
)
expect(container.firstChild).toBeNull()
})
it('shows search input when open', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument()
})
it('shows all enabled commands grouped by category', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
expect(screen.getByText('⌘P')).toBeInTheDocument()
expect(screen.getByText('⌘N')).toBeInTheDocument()
expect(screen.getByText('⌘,')).toBeInTheDocument()
})
it('filters commands by fuzzy search', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
it('executes command and closes on Enter', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
const backdrop = screen.getByPlaceholderText('Type a command...').closest('.fixed')!
fireEvent.click(backdrop)
expect(onClose).toHaveBeenCalled()
})
it('executes command when clicking an item', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
fireEvent.click(screen.getByText('Commit & Push'))
expect(commands[2].execute).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
it('shows footer hints', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
expect(screen.getByText('↑↓ navigate')).toBeInTheDocument()
expect(screen.getByText('↵ select')).toBeInTheDocument()
expect(screen.getByText('esc close')).toBeInTheDocument()
})
})

View File

@@ -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<CommandGroup, CommandAction[]>()
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<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) // 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 (
<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-semibold uppercase tracking-wider 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>
)
}

View File

@@ -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)

View File

@@ -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()
})
})

View File

@@ -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<string, ShortcutHandler>)
}
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<string, ShortcutHandler> = {
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])
}

View File

@@ -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> = {}): 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<string, unknown> = {}) {
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'))
})
})

View File

@@ -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,
])
}

View File

@@ -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)
})
})

20
src/utils/fuzzyMatch.ts Normal file
View File

@@ -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 }
}