Skip to content

Commit d340629

Browse files
committed
Command palette: Recents on empty query (last 10, LRU)
- Empty-query view leads with the user's recently executed commands, most-recent first. Cursor defaults to index 0, so Enter re-runs the last command. - Recents persist in the Tauri store (`recentCommandIds`); capped at 10; dedup-and-prepend on every Enter / click. - Replaces the old "persist last query across opens" behavior — single mechanism covers the same reopen case AND gives the previous 9 commands one arrow press away. - New API in `app-status-store`: `loadRecentCommands` / `pushRecentCommand` (plus pure `dedupAndPrependRecent` for unit testing). Old `loadPaletteQuery` / `savePaletteQuery` removed. - `searchCommands(query, recentCommandIds?)` filters stale IDs through `getPaletteCommands()` so removed/renamed entries drop silently.
1 parent 050c5ce commit d340629

9 files changed

Lines changed: 218 additions & 58 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { dedupAndPrependRecent, RECENT_COMMANDS_LIMIT } from './app-status-store'
3+
4+
describe('dedupAndPrependRecent', () => {
5+
it('prepends a new ID to the front', () => {
6+
expect(dedupAndPrependRecent(['b', 'c'], 'a')).toEqual(['a', 'b', 'c'])
7+
})
8+
9+
it('moves an existing ID to the front (LRU)', () => {
10+
expect(dedupAndPrependRecent(['a', 'b', 'c'], 'c')).toEqual(['c', 'a', 'b'])
11+
})
12+
13+
it('keeps the list at most RECENT_COMMANDS_LIMIT entries', () => {
14+
const existing = Array.from({ length: RECENT_COMMANDS_LIMIT }, (_, i) => `cmd${i}`)
15+
const next = dedupAndPrependRecent(existing, 'new')
16+
expect(next).toHaveLength(RECENT_COMMANDS_LIMIT)
17+
expect(next[0]).toBe('new')
18+
// The oldest entry was dropped.
19+
expect(next).not.toContain(`cmd${RECENT_COMMANDS_LIMIT - 1}`)
20+
})
21+
22+
it('starts a new list from an empty input', () => {
23+
expect(dedupAndPrependRecent([], 'a')).toEqual(['a'])
24+
})
25+
})

apps/desktop/src/lib/app-status-store.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -222,33 +222,48 @@ export async function saveLastUsedPathForVolume(volumeId: string, path: string):
222222
}
223223

224224
// ============================================================================
225-
// Command palette query persistence
225+
// Command palette recents persistence
226226
// ============================================================================
227227

228+
export const RECENT_COMMANDS_LIMIT = 10
229+
228230
/**
229-
* Loads the last used command palette query.
230-
* Returns empty string if not previously saved.
231+
* Pure update step for the recents list: move `commandId` to the front,
232+
* drop any prior occurrence, cap at RECENT_COMMANDS_LIMIT. Exposed for testing.
231233
*/
232-
export async function loadPaletteQuery(): Promise<string> {
234+
export function dedupAndPrependRecent(existing: string[], commandId: string): string[] {
235+
return [commandId, ...existing.filter((id) => id !== commandId)].slice(0, RECENT_COMMANDS_LIMIT)
236+
}
237+
238+
/**
239+
* Loads the list of recently executed command IDs, most-recent first.
240+
* Returns an empty array if nothing was saved or parsing fails.
241+
*/
242+
export async function loadRecentCommands(): Promise<string[]> {
233243
try {
234244
const store = await getStore()
235-
const query = await store.get('paletteQuery')
236-
return typeof query === 'string' ? query : ''
245+
const raw = await store.get('recentCommandIds')
246+
if (!Array.isArray(raw)) return []
247+
return raw.filter((id): id is string => typeof id === 'string').slice(0, RECENT_COMMANDS_LIMIT)
237248
} catch {
238-
return ''
249+
return []
239250
}
240251
}
241252

242253
/**
243-
* Saves the current command palette query for next time.
254+
* Records a command execution. The given ID is moved to the front; if it was
255+
* already in the list, the previous entry is dropped (no duplicates). The list
256+
* is capped at RECENT_COMMANDS_LIMIT entries.
244257
*/
245-
export async function savePaletteQuery(query: string): Promise<void> {
258+
export async function pushRecentCommand(commandId: string): Promise<void> {
246259
try {
247260
const store = await getStore()
248-
await store.set('paletteQuery', query)
261+
const existing = await loadRecentCommands()
262+
const next = dedupAndPrependRecent(existing, commandId)
263+
await store.set('recentCommandIds', next)
249264
await store.save()
250265
} catch {
251-
// Silently fail
266+
// Silently fail - persistence is nice-to-have
252267
}
253268
}
254269

apps/desktop/src/lib/command-palette/CLAUDE.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,21 @@ VS Code/Spotlight-style modal for searching and executing app commands via fuzzy
44

55
## Files
66

7-
| File | Purpose |
8-
| ------------------------ | --------------------------------------------------------------------------------- |
9-
| `CommandPalette.svelte` | Modal UI: keyboard nav, mouse hover, fuzzy-highlighted results, query persistence |
10-
| `CommandPalette.test.ts` | Vitest tests with mocked `$lib/commands` and `$lib/app-status-store` |
7+
| File | Purpose |
8+
| ------------------------ | -------------------------------------------------------------------------------- |
9+
| `CommandPalette.svelte` | Modal UI: keyboard nav, mouse hover, fuzzy-highlighted results, recents on empty |
10+
| `CommandPalette.test.ts` | Vitest tests with mocked `$lib/commands` and `$lib/app-status-store` |
1111

1212
## Data flow
1313

1414
```
1515
User presses ⌘⇧P
1616
→ +page.svelte sets showCommandPalette = true
17-
→ CommandPalette mounts, loads persisted query from app-status-store, focuses input
18-
→ searchCommands(query) returns CommandMatch[] (reactive via $derived)
17+
→ CommandPalette mounts, loads recentCommandIds from app-status-store, focuses input
18+
→ searchCommands(query, recentCommandIds) returns CommandMatch[] (reactive via $derived)
1919
→ User navigates with ↑/↓ (keyboard cursor) or mouse (hover cursor)
20-
→ Enter / click → onExecute(commandId) → handleCommandExecute() in command-dispatch.ts
21-
→ Escape / overlay click → query saved, onClose() called
20+
→ Enter / click → pushRecentCommand(id), onExecute(commandId) → handleCommandExecute()
21+
→ Escape / overlay click → onClose() called
2222
```
2323

2424
## Key patterns
@@ -31,8 +31,12 @@ overlay).
3131
**Event propagation**: `stopPropagation()` is called on every `keydown` in the overlay `div`'s handler. This prevents
3232
the file list from scrolling or handling shortcuts behind the modal.
3333

34-
**Query persistence**: `loadPaletteQuery` / `savePaletteQuery` from `$lib/app-status-store` (Tauri store). Query is
35-
loaded on mount; saved on Escape, Enter, and overlay-click close.
34+
**Recents on empty query**: `loadRecentCommands` / `pushRecentCommand` from `$lib/app-status-store` (Tauri store). On
35+
mount, the palette loads the recent command IDs and passes them to `searchCommands(query, recentCommandIds)`. When the
36+
query is empty, recents lead the result (most-recent first), then the rest of the palette commands in registry order. On
37+
every Enter / click, `pushRecentCommand(id)` records the execution: the ID moves to the front, duplicates are removed,
38+
the list is capped at 10. The query itself is not persisted across opens — the palette always opens empty so the user's
39+
last-executed command sits at index 0 (cursor default), making Enter re-run it.
3640

3741
**Own overlay, no shared ModalDialog**: `CommandPalette` manages its own `position: fixed` overlay and `role="dialog"`
3842
ARIA attributes. It does not use the shared `ModalDialog` component.
@@ -61,9 +65,12 @@ Spotlight both have this behavior: arrow keys move a "hard" cursor, while mouse
6165
doesn't interfere with keyboard navigation. Arrow keys clear `hoveredIndex` so there's never two items highlighted at
6266
the same intensity. Without this separation, moving the mouse would fight keyboard navigation.
6367

64-
**Decision**: Query persisted across open/close via `app-status-store`. **Why**: Users often open the palette, run a
65-
command, then reopen to run a related command. Preserving the query saves retyping. Saved on every close path (Escape,
66-
Enter, overlay click) to ensure nothing is lost.
68+
**Decision**: Empty-query view shows recents (last 10 executed commands, most-recent first) instead of persisting the
69+
last query. **Why**: Users tend to reach for the same handful of commands. Persisting the query only helped the "run a
70+
related command" reopen case; recents covers that AND every other reopen (the last-executed command is at index 0, so
71+
Enter re-runs it just like the old query-persist behavior, but the previous 9 commands are also one arrow press away).
72+
Single mechanism replaces two. Recents update on every Enter / click via `pushRecentCommand`, which dedups and caps
73+
at 10.
6774

6875
**Decision**: `$derived` for search results instead of debounced input. **Why**: `searchCommands()` via uFuzzy is fast
6976
enough for ~60 commands that debouncing would only add latency. The `$derived` reactive binding reruns the search
@@ -100,5 +107,5 @@ Add the command to `$lib/commands/command-registry.ts` and handle the ID in the
100107
## Dependencies
101108

102109
- `$lib/commands``searchCommands`, `CommandMatch`
103-
- `$lib/app-status-store``loadPaletteQuery`, `savePaletteQuery`
110+
- `$lib/app-status-store``loadRecentCommands`, `pushRecentCommand`
104111
- CSS variables from `app.css` (`--z-modal`, `--color-accent-subtle`, `--color-bg-secondary`, etc.)

apps/desktop/src/lib/command-palette/CommandPalette.a11y.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import CommandPalette from './CommandPalette.svelte'
1212
import { expectNoA11yViolations } from '$lib/test-a11y'
1313

1414
vi.mock('$lib/app-status-store', () => ({
15-
loadPaletteQuery: vi.fn(() => Promise.resolve('')),
16-
savePaletteQuery: vi.fn(() => Promise.resolve()),
15+
loadRecentCommands: vi.fn(() => Promise.resolve([])),
16+
pushRecentCommand: vi.fn(() => Promise.resolve()),
1717
}))
1818

1919
vi.mock('$lib/commands', () => ({

apps/desktop/src/lib/command-palette/CommandPalette.svelte

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
* Features:
66
* - Fuzzy search with highlighted matches
77
* - Keyboard navigation (↑/↓/Enter/Escape)
8-
* - Persists last query across app restarts
8+
* - Empty-query view lists recently executed commands, most-recent first
99
* - Blocks keyboard events from propagating to file explorer
1010
*/
1111
import { onDestroy, onMount, tick } from 'svelte'
1212
import { searchCommands, type CommandMatch } from '$lib/commands'
13-
import { loadPaletteQuery, savePaletteQuery } from '$lib/app-status-store'
13+
import { loadRecentCommands, pushRecentCommand } from '$lib/app-status-store'
1414
1515
interface Props {
1616
/** Called when user selects a command */
@@ -22,6 +22,7 @@
2222
const { onExecute, onClose }: Props = $props()
2323
2424
let query = $state('')
25+
let recentCommandIds = $state<string[]>([])
2526
let cursorIndex = $state(0)
2627
let hoveredIndex = $state<number | null>(null)
2728
let inputElement: HTMLInputElement | undefined = $state()
@@ -34,8 +35,10 @@
3435
*/
3536
let previousActiveElement: HTMLElement | null = null
3637
37-
// Derived: filtered and ranked results
38-
const results = $derived(searchCommands(query))
38+
// Derived: filtered and ranked results. When the query is empty, recents
39+
// lead the list (most-recent first) so the cursor at index 0 lands on the
40+
// user's last-executed command — Enter re-runs it.
41+
const results = $derived(searchCommands(query, recentCommandIds))
3942
4043
// Reset cursor position when query changes
4144
$effect(() => {
@@ -46,14 +49,11 @@
4649
4750
onMount(() => {
4851
previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
49-
// Load persisted query and focus input
50-
void loadPaletteQuery().then((savedQuery) => {
51-
query = savedQuery
52-
void tick().then(() => {
53-
inputElement?.focus()
54-
inputElement?.select()
55-
})
52+
// Load recents so the empty-query view leads with the user's last-executed commands.
53+
void loadRecentCommands().then((ids) => {
54+
recentCommandIds = ids
5655
})
56+
inputElement?.focus()
5757
})
5858
5959
onDestroy(() => {
@@ -72,7 +72,6 @@
7272
switch (e.key) {
7373
case 'Escape':
7474
e.preventDefault()
75-
void savePaletteQuery(query)
7675
onClose()
7776
break
7877
case 'ArrowDown':
@@ -90,8 +89,9 @@
9089
case 'Enter':
9190
e.preventDefault()
9291
if (results[cursorIndex]) {
93-
void savePaletteQuery(query)
94-
onExecute(results[cursorIndex].command.id)
92+
const id = results[cursorIndex].command.id
93+
void pushRecentCommand(id)
94+
onExecute(id)
9595
}
9696
break
9797
}
@@ -105,14 +105,14 @@
105105
}
106106
107107
function handleResultClick(index: number) {
108-
void savePaletteQuery(query)
109-
onExecute(results[index].command.id)
108+
const id = results[index].command.id
109+
void pushRecentCommand(id)
110+
onExecute(id)
110111
}
111112
112113
function handleOverlayClick(e: MouseEvent) {
113114
// Only close if clicking the overlay itself, not the modal content
114115
if (e.target === e.currentTarget) {
115-
void savePaletteQuery(query)
116116
onClose()
117117
}
118118
}

apps/desktop/src/lib/command-palette/CommandPalette.test.ts

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
22
import { mount, unmount } from 'svelte'
33
import { tick } from 'svelte'
44
import CommandPalette from './CommandPalette.svelte'
5+
import { loadRecentCommands, pushRecentCommand } from '$lib/app-status-store'
56

67
// Mock the app-status-store to avoid Tauri dependency in tests
78
vi.mock('$lib/app-status-store', () => ({
8-
loadPaletteQuery: vi.fn().mockResolvedValue(''),
9-
savePaletteQuery: vi.fn().mockResolvedValue(undefined),
9+
loadRecentCommands: vi.fn().mockResolvedValue([]),
10+
pushRecentCommand: vi.fn().mockResolvedValue(undefined),
1011
}))
1112

1213
// Mock the commands module to provide test data
1314
vi.mock('$lib/commands', () => ({
14-
searchCommands: vi.fn((query: string) => {
15+
searchCommands: vi.fn((query: string, recentIds: string[] = []) => {
1516
const allCommands = [
1617
{ command: { id: 'app.quit', name: 'Quit Cmdr', scope: 'App', shortcuts: ['⌘Q'] }, matchedIndices: [] },
1718
{ command: { id: 'app.about', name: 'About Cmdr', scope: 'App', shortcuts: [] }, matchedIndices: [] },
@@ -29,7 +30,18 @@ vi.mock('$lib/commands', () => ({
2930
matchedIndices: [],
3031
},
3132
]
32-
if (!query.trim()) return allCommands
33+
if (!query.trim()) {
34+
// Mirror the real implementation's recents-first ordering so tests can
35+
// exercise the wiring without depending on the real fuzzy module.
36+
const byId = new Map(allCommands.map((m) => [m.command.id, m]))
37+
const recents = recentIds.flatMap((id) => {
38+
const match = byId.get(id)
39+
return match ? [match] : []
40+
})
41+
const recentSet = new Set(recents.map((m) => m.command.id))
42+
const rest = allCommands.filter((m) => !recentSet.has(m.command.id))
43+
return [...recents, ...rest]
44+
}
3345
return allCommands.filter((c) => c.command.name.toLowerCase().includes(query.toLowerCase()))
3446
}),
3547
}))
@@ -43,6 +55,8 @@ describe('CommandPalette', () => {
4355
mockOnClose = vi.fn()
4456
// Mock scrollIntoView which isn't available in jsdom
4557
Element.prototype.scrollIntoView = vi.fn()
58+
vi.mocked(loadRecentCommands).mockResolvedValue([])
59+
vi.mocked(pushRecentCommand).mockClear()
4660
})
4761

4862
it('renders the modal with search input', async () => {
@@ -290,6 +304,60 @@ describe('CommandPalette', () => {
290304
target.remove()
291305
})
292306

307+
it('leads the empty-query list with recents, most-recent first', async () => {
308+
vi.mocked(loadRecentCommands).mockResolvedValue(['file.copyPath', 'app.about'])
309+
310+
const target = document.createElement('div')
311+
mount(CommandPalette, {
312+
target,
313+
props: { onExecute: mockOnExecute, onClose: mockOnClose },
314+
})
315+
316+
// Two ticks: one for onMount to start, one for the resolved recents to land.
317+
await tick()
318+
await tick()
319+
320+
const input = target.querySelector('input')
321+
input?.focus()
322+
input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
323+
await tick()
324+
325+
expect(mockOnExecute).toHaveBeenCalledWith('file.copyPath')
326+
})
327+
328+
it('records the executed command on Enter', async () => {
329+
const target = document.createElement('div')
330+
mount(CommandPalette, {
331+
target,
332+
props: { onExecute: mockOnExecute, onClose: mockOnClose },
333+
})
334+
await tick()
335+
336+
const input = target.querySelector('input')
337+
input?.focus()
338+
input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
339+
await tick()
340+
341+
expect(pushRecentCommand).toHaveBeenCalledWith('app.quit')
342+
})
343+
344+
it('records the executed command on click', async () => {
345+
const target = document.createElement('div')
346+
mount(CommandPalette, {
347+
target,
348+
props: { onExecute: mockOnExecute, onClose: mockOnClose },
349+
})
350+
await tick()
351+
352+
const items = target.querySelectorAll('[class*="result-item"]')
353+
expect(items.length).toBeGreaterThan(1)
354+
;(items[1] as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
355+
await tick()
356+
357+
expect(pushRecentCommand).toHaveBeenCalledWith('app.about')
358+
expect(mockOnExecute).toHaveBeenCalledWith('app.about')
359+
})
360+
293361
it('does not throw if the previously focused element is no longer in the DOM', async () => {
294362
const trigger = document.createElement('button')
295363
document.body.appendChild(trigger)

apps/desktop/src/lib/commands/CLAUDE.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Centralized command registry and fuzzy search engine for the command palette.
88
| ---------------------- | ---------------------------------------------------------------------------------------- |
99
| `types.ts` | `Command`, `CommandMatch`, `CommandScope` types |
1010
| `command-registry.ts` | The `commands` array — single source of truth. `getPaletteCommands()` filter. |
11-
| `fuzzy-search.ts` | `searchCommands(query)` using `@leeoniya/ufuzzy` |
11+
| `fuzzy-search.ts` | `searchCommands(query, recentCommandIds?)` using `@leeoniya/ufuzzy` |
1212
| `index.ts` | Barrel re-export |
1313
| `fuzzy-search.test.ts` | Vitest tests: empty query, exact/fuzzy matches, ranking, index bounds, palette filtering |
1414

@@ -46,14 +46,16 @@ keyboard routing is handled by each UI component.
4646

4747
## Fuzzy search
4848

49-
`searchCommands(query)` wraps `@leeoniya/ufuzzy`:
49+
`searchCommands(query, recentCommandIds?)` wraps `@leeoniya/ufuzzy`:
5050

5151
```
52-
query empty → return all getPaletteCommands() with matchedIndices: []
52+
query empty →
53+
recents (filtered through getPaletteCommands to drop stale IDs) first,
54+
then the rest of getPaletteCommands() in registry order
5355
query non-empty →
5456
haystack = paletteCommands.map(c => c.name)
5557
[idxs, info, order] = fuzzy.search(haystack, query)
56-
order.map(...) → CommandMatch[] ranked by relevance
58+
order.map(...) → CommandMatch[] ranked by relevance (recents argument ignored)
5759
```
5860

5961
uFuzzy configuration:

0 commit comments

Comments
 (0)