Merge pull request #61 from refactoringhq/task/command-palette
feat: command palette (Cmd+K) — quick actions and navigation
This commit is contained in:
517
design/command-palette.pen
Normal file
517
design/command-palette.pen
Normal 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": {}
|
||||
}
|
||||
23
src/App.tsx
23
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<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)} />
|
||||
|
||||
168
src/components/CommandPalette.test.tsx
Normal file
168
src/components/CommandPalette.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
191
src/components/CommandPalette.tsx
Normal file
191
src/components/CommandPalette.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
150
src/hooks/useCommandRegistry.test.ts
Normal file
150
src/hooks/useCommandRegistry.test.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
100
src/hooks/useCommandRegistry.ts
Normal file
100
src/hooks/useCommandRegistry.ts
Normal 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,
|
||||
])
|
||||
}
|
||||
46
src/utils/fuzzyMatch.test.ts
Normal file
46
src/utils/fuzzyMatch.test.ts
Normal 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
20
src/utils/fuzzyMatch.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user