Skip to content

Commit a2971ab

Browse files
committed
Command palette: Group recents, self-heal stale IDs
- Empty-query view shows `Recent` and `All commands` subheaders so the user understands the grouping. Hidden during active search. - On mount, `pruneRecentCommands(validIds)` drops any recents that no longer match a real palette command (renamed / removed) and saves the cleaned list back — keeps the cap-10 list useful even after registry changes.
1 parent d340629 commit a2971ab

5 files changed

Lines changed: 165 additions & 57 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,29 @@ export async function pushRecentCommand(commandId: string): Promise<void> {
267267
}
268268
}
269269

270+
/**
271+
* Loads recents and drops any IDs that aren't in `validIds`. If anything was
272+
* pruned, the cleaned list is written back. Returns the (possibly pruned) list.
273+
*
274+
* Call this on palette open: it self-heals the store against commands that were
275+
* renamed or removed since the user last used them. Without it, stale IDs would
276+
* just take up slots in the cap-10 list and reduce the visible recents count.
277+
*/
278+
export async function pruneRecentCommands(validIds: ReadonlySet<string>): Promise<string[]> {
279+
try {
280+
const existing = await loadRecentCommands()
281+
const pruned = existing.filter((id) => validIds.has(id))
282+
if (pruned.length !== existing.length) {
283+
const store = await getStore()
284+
await store.set('recentCommandIds', pruned)
285+
await store.save()
286+
}
287+
return pruned
288+
} catch {
289+
return []
290+
}
291+
}
292+
270293
// ============================================================================
271294
// Settings window section persistence
272295
// ============================================================================

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ VS Code/Spotlight-style modal for searching and executing app commands via fuzzy
1414
```
1515
User presses ⌘⇧P
1616
→ +page.svelte sets showCommandPalette = true
17-
→ CommandPalette mounts, loads recentCommandIds from app-status-store, focuses input
17+
→ CommandPalette mounts, calls pruneRecentCommands(validIds) (load + drop stale + save), focuses input
1818
→ searchCommands(query, recentCommandIds) returns CommandMatch[] (reactive via $derived)
1919
→ User navigates with ↑/↓ (keyboard cursor) or mouse (hover cursor)
2020
→ Enter / click → pushRecentCommand(id), onExecute(commandId) → handleCommandExecute()
@@ -31,12 +31,14 @@ 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-
**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.
34+
**Recents on empty query**: `pruneRecentCommands` / `pushRecentCommand` from `$lib/app-status-store` (Tauri store). On
35+
mount, the palette calls `pruneRecentCommands(validIds)` — this loads the persisted recents, drops any IDs that are no
36+
longer valid palette commands (renamed / removed since last use), saves the cleaned list back, and returns it. The
37+
pruned list is then passed to `searchCommands(query, recentCommandIds)`. When the query is empty, recents lead the
38+
result (most-recent first), with a `Recent` subheader above them and an `All commands` subheader before the rest. On
39+
every Enter / click, `pushRecentCommand(id)` moves the ID to the front; duplicates are removed; the list is capped
40+
at 10. The query itself is not persisted across opens — the palette always opens empty so the user's last-executed
41+
command sits at index 0 (cursor default), making Enter re-run it.
4042

4143
**Own overlay, no shared ModalDialog**: `CommandPalette` manages its own `position: fixed` overlay and `role="dialog"`
4244
ARIA attributes. It does not use the shared `ModalDialog` component.
@@ -106,6 +108,6 @@ Add the command to `$lib/commands/command-registry.ts` and handle the ID in the
106108

107109
## Dependencies
108110

109-
- `$lib/commands``searchCommands`, `CommandMatch`
110-
- `$lib/app-status-store``loadRecentCommands`, `pushRecentCommand`
111+
- `$lib/commands``searchCommands`, `getPaletteCommands`, `CommandMatch`
112+
- `$lib/app-status-store``pruneRecentCommands`, `pushRecentCommand`
111113
- 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: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,31 @@ import CommandPalette from './CommandPalette.svelte'
1212
import { expectNoA11yViolations } from '$lib/test-a11y'
1313

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

19-
vi.mock('$lib/commands', () => ({
20-
searchCommands: vi.fn((query: string) => {
21-
const all = [
22-
{ command: { id: 'app.quit', name: 'Quit Cmdr', scope: 'App', shortcuts: ['\u2318Q'] }, matchedIndices: [] },
23-
{ command: { id: 'app.about', name: 'About Cmdr', scope: 'App', shortcuts: [] }, matchedIndices: [] },
24-
{
25-
command: {
26-
id: 'file.copyPath',
27-
name: 'Copy path to clipboard',
28-
scope: 'Main window',
29-
shortcuts: [],
30-
},
31-
matchedIndices: [],
32-
},
33-
]
34-
if (!query.trim()) return all
35-
return all.filter((c) => c.command.name.toLowerCase().includes(query.toLowerCase()))
36-
}),
37-
}))
19+
vi.mock('$lib/commands', () => {
20+
const all = [
21+
{ id: 'app.quit', name: 'Quit Cmdr', scope: 'App', shortcuts: ['\u2318Q'], showInPalette: true },
22+
{ id: 'app.about', name: 'About Cmdr', scope: 'App', shortcuts: [], showInPalette: true },
23+
{
24+
id: 'file.copyPath',
25+
name: 'Copy path to clipboard',
26+
scope: 'Main window',
27+
shortcuts: [],
28+
showInPalette: true,
29+
},
30+
]
31+
return {
32+
getPaletteCommands: vi.fn(() => all),
33+
searchCommands: vi.fn((query: string) => {
34+
const matches = all.map((command) => ({ command, matchedIndices: [] }))
35+
if (!query.trim()) return matches
36+
return matches.filter((c) => c.command.name.toLowerCase().includes(query.toLowerCase()))
37+
}),
38+
}
39+
})
3840

3941
describe('CommandPalette a11y', () => {
4042
beforeEach(() => {

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

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
* - Blocks keyboard events from propagating to file explorer
1010
*/
1111
import { onDestroy, onMount, tick } from 'svelte'
12-
import { searchCommands, type CommandMatch } from '$lib/commands'
13-
import { loadRecentCommands, pushRecentCommand } from '$lib/app-status-store'
12+
import { searchCommands, getPaletteCommands, type CommandMatch } from '$lib/commands'
13+
import { pruneRecentCommands, pushRecentCommand } from '$lib/app-status-store'
1414
1515
interface Props {
1616
/** Called when user selects a command */
@@ -40,6 +40,20 @@
4040
// user's last-executed command — Enter re-runs it.
4141
const results = $derived(searchCommands(query, recentCommandIds))
4242
43+
// Boundary between recents and the rest in the empty-query view. Used to
44+
// render the "Recent" / "All commands" subheaders. Always 0 when the query
45+
// is non-empty (no grouping during search).
46+
const recentCount = $derived.by(() => {
47+
if (query.trim() || recentCommandIds.length === 0) return 0
48+
const recentSet = new Set(recentCommandIds)
49+
let n = 0
50+
for (const r of results) {
51+
if (recentSet.has(r.command.id)) n++
52+
else break
53+
}
54+
return n
55+
})
56+
4357
// Reset cursor position when query changes
4458
$effect(() => {
4559
void query // Track
@@ -49,8 +63,10 @@
4963
5064
onMount(() => {
5165
previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
52-
// Load recents so the empty-query view leads with the user's last-executed commands.
53-
void loadRecentCommands().then((ids) => {
66+
// Load recents and prune any IDs that no longer correspond to a valid palette
67+
// command (renamed or removed since last use). Self-heals the persisted list.
68+
const validIds = new Set(getPaletteCommands().map((c) => c.id))
69+
void pruneRecentCommands(validIds).then((ids) => {
5470
recentCommandIds = ids
5571
})
5672
inputElement?.focus()
@@ -177,6 +193,12 @@
177193
<div class="no-results">No commands found</div>
178194
{:else}
179195
{#each results as match, index (match.command.id)}
196+
{#if recentCount > 0 && index === 0}
197+
<div class="group-heading">Recent</div>
198+
{/if}
199+
{#if recentCount > 0 && index === recentCount}
200+
<div class="group-heading">All commands</div>
201+
{/if}
180202
<div
181203
class="result-item"
182204
class:is-under-cursor={index === cursorIndex}
@@ -269,6 +291,22 @@
269291
font-size: var(--font-size-md);
270292
}
271293
294+
/* Section headers between recents and the rest of the palette */
295+
.group-heading {
296+
padding: var(--spacing-sm) var(--spacing-lg) var(--spacing-xxs);
297+
font-size: var(--font-size-xs);
298+
font-weight: 600;
299+
color: var(--color-text-tertiary);
300+
text-transform: uppercase;
301+
letter-spacing: 0.05em;
302+
border-top: 1px solid var(--color-border);
303+
}
304+
305+
/* First heading sits right after the input — no top border */
306+
.group-heading:first-child {
307+
border-top: none;
308+
}
309+
272310
.result-item {
273311
display: flex;
274312
justify-content: space-between;

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

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,47 +2,39 @@ 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'
5+
import { pruneRecentCommands, pushRecentCommand } from '$lib/app-status-store'
6+
7+
const ALL_COMMANDS = [
8+
{ id: 'app.quit', name: 'Quit Cmdr', scope: 'App', shortcuts: ['⌘Q'], showInPalette: true },
9+
{ id: 'app.about', name: 'About Cmdr', scope: 'App', shortcuts: [], showInPalette: true },
10+
{ id: 'file.copyPath', name: 'Copy path to clipboard', scope: 'Main window', shortcuts: [], showInPalette: true },
11+
{ id: 'view.showHidden', name: 'Toggle hidden files', scope: 'Main window', shortcuts: ['⌘⇧.'], showInPalette: true },
12+
]
613

714
// Mock the app-status-store to avoid Tauri dependency in tests
815
vi.mock('$lib/app-status-store', () => ({
9-
loadRecentCommands: vi.fn().mockResolvedValue([]),
16+
pruneRecentCommands: vi.fn().mockResolvedValue([]),
1017
pushRecentCommand: vi.fn().mockResolvedValue(undefined),
1118
}))
1219

1320
// Mock the commands module to provide test data
1421
vi.mock('$lib/commands', () => ({
22+
getPaletteCommands: vi.fn(() => ALL_COMMANDS),
1523
searchCommands: vi.fn((query: string, recentIds: string[] = []) => {
16-
const allCommands = [
17-
{ command: { id: 'app.quit', name: 'Quit Cmdr', scope: 'App', shortcuts: ['⌘Q'] }, matchedIndices: [] },
18-
{ command: { id: 'app.about', name: 'About Cmdr', scope: 'App', shortcuts: [] }, matchedIndices: [] },
19-
{
20-
command: { id: 'file.copyPath', name: 'Copy path to clipboard', scope: 'Main window', shortcuts: [] },
21-
matchedIndices: [],
22-
},
23-
{
24-
command: {
25-
id: 'view.showHidden',
26-
name: 'Toggle hidden files',
27-
scope: 'Main window',
28-
shortcuts: ['⌘⇧.'],
29-
},
30-
matchedIndices: [],
31-
},
32-
]
24+
const allMatches = ALL_COMMANDS.map((command) => ({ command, matchedIndices: [] }))
3325
if (!query.trim()) {
3426
// Mirror the real implementation's recents-first ordering so tests can
3527
// exercise the wiring without depending on the real fuzzy module.
36-
const byId = new Map(allCommands.map((m) => [m.command.id, m]))
28+
const byId = new Map(allMatches.map((m) => [m.command.id, m]))
3729
const recents = recentIds.flatMap((id) => {
3830
const match = byId.get(id)
3931
return match ? [match] : []
4032
})
4133
const recentSet = new Set(recents.map((m) => m.command.id))
42-
const rest = allCommands.filter((m) => !recentSet.has(m.command.id))
34+
const rest = allMatches.filter((m) => !recentSet.has(m.command.id))
4335
return [...recents, ...rest]
4436
}
45-
return allCommands.filter((c) => c.command.name.toLowerCase().includes(query.toLowerCase()))
37+
return allMatches.filter((c) => c.command.name.toLowerCase().includes(query.toLowerCase()))
4638
}),
4739
}))
4840

@@ -55,7 +47,7 @@ describe('CommandPalette', () => {
5547
mockOnClose = vi.fn()
5648
// Mock scrollIntoView which isn't available in jsdom
5749
Element.prototype.scrollIntoView = vi.fn()
58-
vi.mocked(loadRecentCommands).mockResolvedValue([])
50+
vi.mocked(pruneRecentCommands).mockResolvedValue([])
5951
vi.mocked(pushRecentCommand).mockClear()
6052
})
6153

@@ -305,7 +297,7 @@ describe('CommandPalette', () => {
305297
})
306298

307299
it('leads the empty-query list with recents, most-recent first', async () => {
308-
vi.mocked(loadRecentCommands).mockResolvedValue(['file.copyPath', 'app.about'])
300+
vi.mocked(pruneRecentCommands).mockResolvedValue(['file.copyPath', 'app.about'])
309301

310302
const target = document.createElement('div')
311303
mount(CommandPalette, {
@@ -358,6 +350,57 @@ describe('CommandPalette', () => {
358350
expect(mockOnExecute).toHaveBeenCalledWith('app.about')
359351
})
360352

353+
it('renders "Recent" and "All commands" subheaders when recents exist', async () => {
354+
vi.mocked(pruneRecentCommands).mockResolvedValue(['file.copyPath', 'app.about'])
355+
356+
const target = document.createElement('div')
357+
mount(CommandPalette, {
358+
target,
359+
props: { onExecute: mockOnExecute, onClose: mockOnClose },
360+
})
361+
await tick()
362+
await tick()
363+
364+
const headings = Array.from(target.querySelectorAll('.group-heading')).map((el) => el.textContent?.trim())
365+
expect(headings).toEqual(['Recent', 'All commands'])
366+
})
367+
368+
it('renders no group subheaders when there are no recents', async () => {
369+
vi.mocked(pruneRecentCommands).mockResolvedValue([])
370+
371+
const target = document.createElement('div')
372+
mount(CommandPalette, {
373+
target,
374+
props: { onExecute: mockOnExecute, onClose: mockOnClose },
375+
})
376+
await tick()
377+
await tick()
378+
379+
expect(target.querySelectorAll('.group-heading').length).toBe(0)
380+
})
381+
382+
it('renders no group subheaders during an active search', async () => {
383+
vi.mocked(pruneRecentCommands).mockResolvedValue(['file.copyPath'])
384+
385+
const target = document.createElement('div')
386+
mount(CommandPalette, {
387+
target,
388+
props: { onExecute: mockOnExecute, onClose: mockOnClose },
389+
})
390+
await tick()
391+
await tick()
392+
393+
const input = target.querySelector('input')
394+
if (input) {
395+
input.value = 'copy'
396+
input.dispatchEvent(new InputEvent('input', { bubbles: true }))
397+
}
398+
await tick()
399+
400+
// Grouping is for the recents view only; typing collapses it.
401+
expect(target.querySelectorAll('.group-heading').length).toBe(0)
402+
})
403+
361404
it('does not throw if the previously focused element is no longer in the DOM', async () => {
362405
const trigger = document.createElement('button')
363406
document.body.appendChild(trigger)

0 commit comments

Comments
 (0)