Skip to content

Commit 211083f

Browse files
authored
Merge pull request #279 from Stvad/claude/cool-hypatia-d97199
fix(command-palette): give same-description actions distinct cmdk values
2 parents f10a04e + 7774cb9 commit 211083f

2 files changed

Lines changed: 116 additions & 2 deletions

File tree

src/plugins/command-palette/CommandPalette.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useActionContext } from '@/shortcuts/useActionContext.js'
1111
import { useRunAction } from '@/shortcuts/runAction.js'
1212
import { useActiveContextsState, editorViewFromActiveContexts } from '@/shortcuts/ActiveContexts.js'
1313
import { acquireEditModeKeepalive } from '@/components/editModeKeepalive.js'
14+
import { actionRuntimeKey } from '@/shortcuts/effectiveActions.js'
1415
import {
1516
type ActionConfig,
1617
type ShortcutBinding,
@@ -130,10 +131,21 @@ export function CommandPalette() {
130131
{actionsInGroup.map((action: ActionConfig) => {
131132
const bindings = bindingsFor(action)
132133
const shortcutKeys = formatShortcutKeys(bindings)
134+
// cmdk tracks selection by `value`, so it MUST be unique across
135+
// the whole list, and a bare `action.id` is not: a description
136+
// can be shared (ArrowUp/ArrowLeft "move to previous block" CM
137+
// nav), and an id can be shared by distinct actions live in
138+
// different contexts at once (global `undo`/`redo` + vim
139+
// normal-mode `undo`/`redo`). A duplicate value makes cmdk
140+
// highlight both rows and loop arrow-nav between them, so key on
141+
// the context-qualified runtime key. `keywords` keeps the human
142+
// group/description text searchable; onSelect runs the bare id.
143+
const itemKey = actionRuntimeKey(action)
133144
return (
134145
<CommandItem
135-
key={action.id}
136-
value={`${groupHeading} ${action.description}`}
146+
key={itemKey}
147+
value={itemKey}
148+
keywords={[groupHeading, action.description]}
137149
onSelect={() => runCommand(action.id)}
138150
className="flex justify-between items-center"
139151
>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// @vitest-environment jsdom
2+
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
5+
// The bug under test lives purely in how each cmdk `value` is derived in
6+
// CommandPalette, so stub the data hook with the two collision shapes plus the
7+
// surrounding context hooks the surface doesn't need here:
8+
// - same description, different id, same context — the real
9+
// move_up_from_cm_start / move_left_from_cm_start CM-nav pair (↑ vs ←).
10+
// - same id, different context, both active at once — the real global vs vim
11+
// normal-mode `undo` pair (a bare-id value would still collide on these).
12+
vi.mock('../useCommandPaletteActions.ts', () => ({
13+
useCommandPaletteActions: () => ({
14+
actions: [
15+
{id: 'move_up_from_cm_start', description: 'Move to previous block', context: 'edit_mode_cm'},
16+
{id: 'move_left_from_cm_start', description: 'Move to previous block', context: 'edit_mode_cm'},
17+
{id: 'undo', description: 'Undo', context: 'global'},
18+
{id: 'undo', description: 'Undo', context: 'normal_mode'},
19+
],
20+
activeContexts: [
21+
{config: {type: 'edit_mode_cm', displayName: 'Editing'}, dependencies: {}},
22+
{config: {type: 'global', displayName: 'Global'}, dependencies: {}},
23+
{config: {type: 'normal_mode', displayName: 'Normal mode'}, dependencies: {}},
24+
],
25+
bindingsFor: (action: {id: string}) =>
26+
action.id === 'move_up_from_cm_start' ? [{keys: 'ArrowUp'}]
27+
: action.id === 'move_left_from_cm_start' ? [{keys: 'ArrowLeft'}]
28+
: [],
29+
}),
30+
}))
31+
vi.mock('@/shortcuts/useActionContext.js', () => ({useActionContext: () => {}}))
32+
vi.mock('@/shortcuts/runAction.js', () => ({useRunAction: () => () => {}}))
33+
vi.mock('@/shortcuts/ActiveContexts.js', () => ({
34+
useActiveContextsState: () => new Map(),
35+
editorViewFromActiveContexts: () => undefined,
36+
}))
37+
38+
import { CommandPalette } from '../CommandPalette.tsx'
39+
import { commandPaletteToggle } from '../toggleStore.ts'
40+
41+
// cmdk's CommandList observes its size and scrolls the active item into view —
42+
// neither API exists in jsdom, so stub them to no-ops.
43+
globalThis.ResizeObserver ??= class {
44+
observe() {}
45+
unobserve() {}
46+
disconnect() {}
47+
} as never
48+
Element.prototype.scrollIntoView ??= () => {}
49+
50+
const items = () => Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]'))
51+
const selectedItems = () =>
52+
Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item][aria-selected="true"]'))
53+
54+
afterEach(() => {
55+
cleanup()
56+
commandPaletteToggle.close()
57+
})
58+
59+
describe('CommandPalette item identity', () => {
60+
it('gives every action a distinct value so only one row is ever selected', async () => {
61+
commandPaletteToggle.open()
62+
render(<CommandPalette/>)
63+
64+
// Every action renders as a separate item with a unique (context-qualified) value...
65+
await waitFor(() => expect(items()).toHaveLength(4))
66+
const values = items().map(el => el.getAttribute('data-value'))
67+
expect(new Set(values).size).toBe(4)
68+
// ...including same-id actions that are live in two contexts at once.
69+
expect(values).toContain('global:undo')
70+
expect(values).toContain('normal_mode:undo')
71+
72+
// Exactly ONE row is selected; a shared value would mark several at once.
73+
await waitFor(() => expect(selectedItems()).toHaveLength(1))
74+
const firstSelected = selectedItems()[0].getAttribute('data-value')
75+
76+
// ArrowDown advances to a different single item rather than looping/sticking.
77+
const input = document.querySelector('[cmdk-input]')!
78+
fireEvent.keyDown(input, {key: 'ArrowDown'})
79+
await waitFor(() => {
80+
expect(selectedItems()).toHaveLength(1)
81+
expect(selectedItems()[0].getAttribute('data-value')).not.toBe(firstSelected)
82+
})
83+
})
84+
85+
it('still filters actions by their human description text', async () => {
86+
commandPaletteToggle.open()
87+
render(<CommandPalette/>)
88+
await waitFor(() => expect(items()).toHaveLength(4))
89+
90+
const input = document.querySelector('[cmdk-input]')!
91+
fireEvent.change(input, {target: {value: 'previous block'}})
92+
93+
// Description text must remain searchable even though the value is now an id.
94+
await waitFor(() => {
95+
const shown = items().map(el => el.getAttribute('data-value'))
96+
expect(shown).toEqual([
97+
'edit_mode_cm:move_up_from_cm_start',
98+
'edit_mode_cm:move_left_from_cm_start',
99+
])
100+
})
101+
})
102+
})

0 commit comments

Comments
 (0)