Skip to content

Commit b227bfc

Browse files
authored
fix(hub-ui): allow commands to opt out of keyboard shortcuts (#395)
1 parent 39fec9c commit b227bfc

9 files changed

Lines changed: 147 additions & 32 deletions

File tree

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

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

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

82+
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.
83+
8284
## Cross-iframe dock activation
8385

8486
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,10 +1,11 @@
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'
67
import { t } from '../../i18n'
7-
import { filterCommandsByWhen, findCommandDeep, formatKeybinding, isKeybindingOverrideDifferentFromDefault, isMac, KNOWN_BROWSER_SHORTCUTS, walkCommands } from '../../state/keybindings'
8+
import { filterCommandsByWhen, findCommandDeep, formatKeybinding, getShortcutRows, isKeybindingOverrideDifferentFromDefault, isMac, KNOWN_BROWSER_SHORTCUTS } from '../../state/keybindings'
89
import { useSettings } from '../../state/settings-defaults'
910
import DockIcon from '../dock/DockIcon.vue'
1011
@@ -17,13 +18,6 @@ const settings = useSettings(props.context)
1718
const shortcutOverrides = computed(() => settings.value.commandShortcuts ?? {})
1819
const shortcutSearch = ref('')
1920
20-
interface ShortcutRow {
21-
command: DevframeCommandEntry
22-
parentTitle?: string
23-
/** Nesting level: 0 for a top-level command, +1 per ancestor. */
24-
depth: number
25-
}
26-
2721
// This page is only reachable with the dock open and the palette closed, so `when`
2822
// is evaluated against that context rather than the live one. `dockOpen`/`paletteOpen`
2923
// are transient dispatch state: `close-panel`'s `!paletteOpen` exists to hand Escape
@@ -36,26 +30,7 @@ const availableCommands = computed(() => filterCommandsByWhen(
3630
{ ...props.context.when.context, dockOpen: true, paletteOpen: false },
3731
))
3832
39-
/**
40-
* One row per command at every depth, in tree order, so anything the palette
41-
* can run can be given a shortcut here.
42-
*
43-
* Nesting runs deeper than a parent and its children: a dock group's members sit
44-
* two levels below the `Docks` command, and a devframe's own `children` go deeper
45-
* still.
46-
*/
47-
const shortcutRows = computed<ShortcutRow[]>(() => {
48-
const rows: ShortcutRow[] = []
49-
walkCommands(availableCommands.value, (cmd, ancestors) => {
50-
const parentTitle = ancestors.at(-1)?.title
51-
rows.push({
52-
command: cmd,
53-
...(parentTitle ? { parentTitle } : {}),
54-
depth: ancestors.length,
55-
})
56-
})
57-
return rows
58-
})
33+
const shortcutRows = computed(() => getShortcutRows(availableCommands.value))
5934
6035
const filteredShortcutRows = computed(() => {
6136
if (!shortcutSearch.value)

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

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { DevframeDocksUserSettings } from '@devframes/hub'
1+
import type { DevframeDocksUserSettings, DevframeServerCommandEntry } from '@devframes/hub'
22
import type { DevframeRpcClient } from '@devframes/hub/client'
3+
import { DEFAULT_STATE_USER_SETTINGS, HUB_EVENTS } from '@devframes/hub/constants'
34
import { createSharedState } from 'devframe/utils/shared-state'
4-
import { afterEach, expect, it, vi } from 'vitest'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
56
import { effectScope, nextTick, shallowRef } from 'vue'
67
import { createCommandsContext } from './commands'
78
import { isMac } from './keybindings'
@@ -65,3 +66,61 @@ it.each(['standalone', 'shadow-root', 'popup'] as const)('leaves recording keyst
6566
scope.stop()
6667
}
6768
})
69+
70+
describe('command shortcut eligibility', () => {
71+
it('ignores saved and default bindings while preserving explicit calls and palette shortcuts', async () => {
72+
const command = {
73+
id: 'tool:open-file',
74+
title: 'Open File',
75+
source: 'server',
76+
showInPalette: false,
77+
allowShortcuts: false,
78+
keybindings: [{ key: 'Alt+E' }],
79+
} satisfies DevframeServerCommandEntry
80+
const serverState = createSharedState<DevframeServerCommandEntry[]>({ initialValue: [command] })
81+
const settings = createSharedState({
82+
initialValue: {
83+
...DEFAULT_STATE_USER_SETTINGS(),
84+
commandShortcuts: { [command.id]: [{ key: 'Alt+Y' }] },
85+
},
86+
})
87+
const call = vi.fn()
88+
// eslint-disable-next-line slop/no-chained-type-assertions -- the command context only needs these two RPC APIs.
89+
const rpc = { sharedState: { get: async () => serverState }, call } as unknown as DevframeRpcClient
90+
const window = new EventTarget()
91+
vi.stubGlobal('window', window)
92+
const context = await createCommandsContext('embedded', rpc, settings)
93+
const openPalette = vi.fn()
94+
context.register({
95+
id: 'tool:palette',
96+
title: 'Toggle Palette',
97+
source: 'client',
98+
showInPalette: false,
99+
keybindings: [{ key: 'Alt+K' }],
100+
action: openPalette,
101+
})
102+
103+
const press = (key: string) => window.dispatchEvent(Object.assign(new Event('keydown', { cancelable: true }), {
104+
key,
105+
altKey: true,
106+
ctrlKey: false,
107+
metaKey: false,
108+
shiftKey: false,
109+
}))
110+
111+
const unhandled = press('y')
112+
expect(call).not.toHaveBeenCalled()
113+
expect(unhandled).toBe(true)
114+
expect(context.getKeybindings(command.id)).toEqual([])
115+
settings.mutate((state) => {
116+
delete state.commandShortcuts[command.id]
117+
})
118+
expect(press('e')).toBe(true)
119+
expect(call).not.toHaveBeenCalled()
120+
121+
press('k')
122+
expect(openPalette).toHaveBeenCalledOnce()
123+
await context.execute(command.id, 'src/main.ts')
124+
expect(call).toHaveBeenCalledExactlyOnceWith(HUB_EVENTS.rpc.commandsExecute, command.id, 'src/main.ts')
125+
})
126+
})

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

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

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

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

‎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
@@ -41,6 +41,13 @@ export interface DevframeCommandBase {
4141
* when the expression evaluates to true.
4242
*/
4343
when?: string
44+
/**
45+
* Whether keyboard shortcuts can invoke this command. Default: true.
46+
* Set to false for commands that require arguments from their caller.
47+
* The hub UI hides their shortcut settings and ignores default and saved bindings.
48+
* Explicit calls through `commands.execute(id, ...args)` remain available.
49+
*/
50+
allowShortcuts?: boolean
4451
/**
4552
* Default keyboard shortcut(s) for this command
4653
*/

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

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

0 commit comments

Comments
 (0)