Skip to content

Commit 96b4fad

Browse files
committed
fix(hub-ui): allow commands to opt out of keyboard shortcuts
1 parent 3e7f0fe commit 96b4fad

9 files changed

Lines changed: 152 additions & 30 deletions

File tree

docs/content/1.guide/16.hub.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ ctx.commands.register({
6262

6363
Set `showInPalette: 'without-children'` on a parent to keep its whole subtree out of root search while leaving it reachable by drilling down.
6464

65+
Set `allowShortcuts: false` for commands that require arguments from their caller. The hub UI hides their shortcut settings and ignores default and saved bindings. Explicit calls through `ctx.commands.execute(id, ...args)` remain available. This option is independent of `showInPalette` and applies to each command individually, including nested commands.
66+
6567
## Cross-iframe dock activation
6668

6769
A mounted devframe's iframe uses `hub:docks:activate` to switch the active dock.

packages/hub-ui/src/client/components/views-builtin/SettingsShortcuts.vue

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<script setup lang="ts">
22
import type { DevframeCommandEntry, DevframeCommandKeybinding } from '@devframes/hub'
33
import type { DocksContext } from '@devframes/hub/client'
4+
import type { ShortcutRow } from '../../state/keybindings'
45
import DisplayKbd from '@antfu/design/components/Display/DisplayKbd.vue'
56
import { computed, nextTick, ref, watch } from 'vue'
6-
import { filterCommandsByWhen, findCommandDeep, formatKeybinding, isKeybindingOverrideDifferentFromDefault, isMac, KNOWN_BROWSER_SHORTCUTS, walkCommands } from '../../state/keybindings'
7+
import { filterCommandsByWhen, findCommandDeep, formatKeybinding, getShortcutRows, isKeybindingOverrideDifferentFromDefault, isMac, KNOWN_BROWSER_SHORTCUTS } from '../../state/keybindings'
78
import { useSettings } from '../../state/settings-defaults'
89
import DockIcon from '../dock/DockIcon.vue'
910
@@ -16,13 +17,6 @@ const settings = useSettings(props.context)
1617
const shortcutOverrides = computed(() => settings.value.commandShortcuts ?? {})
1718
const shortcutSearch = ref('')
1819
19-
interface ShortcutRow {
20-
command: DevframeCommandEntry
21-
parentTitle?: string
22-
/** Nesting level: 0 for a top-level command, +1 per ancestor. */
23-
depth: number
24-
}
25-
2620
// This page is only reachable with the dock open and the palette closed, so `when`
2721
// is evaluated against that context rather than the live one. `dockOpen`/`paletteOpen`
2822
// are transient dispatch state: `close-panel`'s `!paletteOpen` exists to hand Escape
@@ -35,26 +29,7 @@ const availableCommands = computed(() => filterCommandsByWhen(
3529
{ ...props.context.when.context, dockOpen: true, paletteOpen: false },
3630
))
3731
38-
/**
39-
* One row per command at every depth, in tree order, so anything the palette
40-
* can run can be given a shortcut here.
41-
*
42-
* Nesting runs deeper than a parent and its children: a dock group's members sit
43-
* two levels below the `Docks` command, and a devframe's own `children` go deeper
44-
* still.
45-
*/
46-
const shortcutRows = computed<ShortcutRow[]>(() => {
47-
const rows: ShortcutRow[] = []
48-
walkCommands(availableCommands.value, (cmd, ancestors) => {
49-
const parentTitle = ancestors.at(-1)?.title
50-
rows.push({
51-
command: cmd,
52-
...(parentTitle ? { parentTitle } : {}),
53-
depth: ancestors.length,
54-
})
55-
})
56-
return rows
57-
})
32+
const shortcutRows = computed(() => getShortcutRows(availableCommands.value))
5833
5934
const filteredShortcutRows = computed(() => {
6035
if (!shortcutSearch.value)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { DevframeServerCommandEntry } from '@devframes/hub'
2+
import type { DevframeRpcClient } from '@devframes/hub/client'
3+
import { DEFAULT_STATE_USER_SETTINGS, HUB_EVENTS } from '@devframes/hub/constants'
4+
import { createSharedState } from 'devframe/utils/shared-state'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
import { createCommandsContext } from './commands'
7+
8+
afterEach(() => vi.unstubAllGlobals())
9+
10+
describe('command shortcut eligibility', () => {
11+
it('ignores saved and default bindings while preserving explicit calls and palette shortcuts', async () => {
12+
const command = {
13+
id: 'tool:open-file',
14+
title: 'Open File',
15+
source: 'server',
16+
showInPalette: false,
17+
allowShortcuts: false,
18+
keybindings: [{ key: 'Alt+E' }],
19+
} satisfies DevframeServerCommandEntry
20+
const serverState = createSharedState<DevframeServerCommandEntry[]>({ initialValue: [command] })
21+
const settings = createSharedState({
22+
initialValue: {
23+
...DEFAULT_STATE_USER_SETTINGS(),
24+
commandShortcuts: { [command.id]: [{ key: 'Alt+Y' }] },
25+
},
26+
})
27+
const call = vi.fn()
28+
// eslint-disable-next-line slop/no-chained-type-assertions -- the command context only needs these two RPC APIs.
29+
const rpc = { sharedState: { get: async () => serverState }, call } as unknown as DevframeRpcClient
30+
const window = new EventTarget()
31+
vi.stubGlobal('window', window)
32+
const context = await createCommandsContext('embedded', rpc, settings)
33+
const openPalette = vi.fn()
34+
context.register({
35+
id: 'tool:palette',
36+
title: 'Toggle Palette',
37+
source: 'client',
38+
showInPalette: false,
39+
keybindings: [{ key: 'Alt+K' }],
40+
action: openPalette,
41+
})
42+
43+
const press = (key: string) => window.dispatchEvent(Object.assign(new Event('keydown', { cancelable: true }), {
44+
key,
45+
altKey: true,
46+
ctrlKey: false,
47+
metaKey: false,
48+
shiftKey: false,
49+
}))
50+
51+
const unhandled = press('y')
52+
expect(call).not.toHaveBeenCalled()
53+
expect(unhandled).toBe(true)
54+
expect(context.getKeybindings(command.id)).toEqual([])
55+
settings.mutate((state) => {
56+
delete state.commandShortcuts[command.id]
57+
})
58+
expect(press('e')).toBe(true)
59+
expect(call).not.toHaveBeenCalled()
60+
61+
press('k')
62+
expect(openPalette).toHaveBeenCalledOnce()
63+
await context.execute(command.id, 'src/main.ts')
64+
expect(call).toHaveBeenCalledExactlyOnceWith(HUB_EVENTS.rpc.commandsExecute, command.id, 'src/main.ts')
65+
})
66+
})

packages/hub-ui/src/client/state/commands.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,14 @@ export async function createCommandsContext(
106106
}
107107

108108
function getKeybindings(id: string): DevframeCommandKeybinding[] {
109+
const cmd = findCommandDeep(commands.value, id)
110+
if (cmd?.allowShortcuts === false)
111+
return []
112+
109113
const overrides = shortcutOverrides.value[id]
110114
if (overrides !== undefined)
111115
return overrides
112116

113-
const cmd = findCommandDeep(commands.value, id)
114117
return cmd?.keybindings ?? []
115118
}
116119

packages/hub-ui/src/client/state/keybindings.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
11
import type { DevframeCommandEntry, DevframeCommandKeybinding } from '@devframes/hub'
22
import type { WhenContext } from 'devframe/utils/when'
33
import { describe, expect, it } from 'vitest'
4-
import { collectAllKeybindings, filterCommandsByWhen, findCommandDeep, walkCommands } from './keybindings'
4+
import { collectAllKeybindings, filterCommandsByWhen, findCommandDeep, getShortcutRows, walkCommands } from './keybindings'
5+
6+
describe('getShortcutRows', () => {
7+
it('omits opted-out commands while retaining their bindable descendants and palette-hidden commands', () => {
8+
const commands: DevframeCommandEntry[] = [
9+
{ id: 'open-file', title: 'Open File', source: 'server', allowShortcuts: false },
10+
{
11+
id: 'tools',
12+
title: 'Tools',
13+
source: 'client',
14+
allowShortcuts: false,
15+
children: [
16+
{ id: 'open-selected', title: 'Open Selected', source: 'client', allowShortcuts: true },
17+
{ id: 'open-path', title: 'Open Path', source: 'client', allowShortcuts: false },
18+
],
19+
},
20+
{ id: 'palette', title: 'Palette', source: 'client', showInPalette: false },
21+
]
22+
expect(getShortcutRows(commands).map(row => ({ id: row.command.id, parentTitle: row.parentTitle, depth: row.depth }))).toEqual([
23+
{ id: 'open-selected', parentTitle: 'Tools', depth: 1 },
24+
{ id: 'palette', parentTitle: undefined, depth: 0 },
25+
])
26+
})
27+
})
528

629
/**
730
* Dock-navigation commands nest two deep (`Docks` › a group › its members), and

packages/hub-ui/src/client/state/keybindings.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,28 @@ export function findCommandDeep(
111111
return found
112112
}
113113

114+
export interface ShortcutRow {
115+
command: DevframeCommandEntry
116+
parentTitle?: string
117+
depth: number
118+
}
119+
120+
/** List bindable commands at every depth, retaining each command's ancestry. */
121+
export function getShortcutRows(commands: DevframeCommandEntry[]): ShortcutRow[] {
122+
const rows: ShortcutRow[] = []
123+
walkCommands(commands, (cmd, ancestors) => {
124+
if (cmd.allowShortcuts === false)
125+
return
126+
const parentTitle = ancestors.at(-1)?.title
127+
rows.push({
128+
command: cmd,
129+
...(parentTitle ? { parentTitle } : {}),
130+
depth: ancestors.length,
131+
})
132+
})
133+
return rows
134+
}
135+
114136
/**
115137
* Drop the commands whose `when` clause does not hold in the current context,
116138
* descendants included at every depth; `when` controls palette visibility at

packages/hub/src/node/__tests__/host-commands.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ import { DevframeCommandsHost } from '../host-commands'
77

88
type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> }
99

10+
describe('command shortcut metadata', () => {
11+
it('publishes eligibility for nested commands without preventing explicit execution', async () => {
12+
const host = new DevframeCommandsHost({} as DevframeHubContext)
13+
host.register({
14+
id: 'tool:files',
15+
title: 'Files',
16+
children: [{
17+
id: 'tool:open',
18+
title: 'Open',
19+
allowShortcuts: false,
20+
handler: (path: string) => path,
21+
}],
22+
})
23+
24+
expect(host.list()[0]?.children?.[0]).toMatchObject({
25+
id: 'tool:open',
26+
allowShortcuts: false,
27+
source: 'server',
28+
})
29+
await expect(host.execute('tool:open', 'src/main.ts')).resolves.toBe('src/main.ts')
30+
})
31+
})
32+
1033
describe('devframeCommandsHost command id validation', () => {
1134
it('rejects duplicate ids inside one command tree', () => {
1235
const host = new DevframeCommandsHost({} as DevframeHubContext)

packages/hub/src/types/commands.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ export interface DevframeCommandBase {
3838
* when the expression evaluates to true.
3939
*/
4040
when?: string
41+
/**
42+
* Whether keyboard shortcuts can invoke this command. Default: true.
43+
* Set to false for commands that require arguments from their caller.
44+
* The hub UI hides their shortcut settings and ignores default and saved bindings.
45+
* Explicit calls through `commands.execute(id, ...args)` remain available.
46+
*/
47+
allowShortcuts?: boolean
4148
/**
4249
* Default keyboard shortcut(s) for this command
4350
*/

tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export interface DevframeCommandBase {
5353
category?: string;
5454
showInPalette?: boolean | 'without-children';
5555
when?: string;
56+
allowShortcuts?: boolean;
5657
keybindings?: DevframeCommandKeybinding[];
5758
}
5859
export interface DevframeCommandHandle {

0 commit comments

Comments
 (0)