Skip to content

Commit 1f97131

Browse files
SaKaNa-Yantfubot
andauthored
feat(hub-ui): assign keyboard shortcuts to dock group members (#282)
Co-authored-by: Anthony Fu (via agent) <reg-github-bot@antfu.me>
1 parent 76a1153 commit 1f97131

18 files changed

Lines changed: 1061 additions & 145 deletions

File tree

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,22 @@ ctx.commands.register({
4646

4747
`args` takes positional [Standard Schema](https://standardschema.dev/) schemas (a single `v.object(...)` unwraps into the input); omit for zero-arg. `safety` defaults to `'action'`; `when` clauses are unenforced for agent calls.
4848

49+
## Nested commands
50+
51+
A command's `children` nest arbitrarily deep. The palette drills into each level, and every command in the tree is bindable at any depth: a shortcut assigned to a leaf several levels down fires as directly as one on a top-level command, and each appears as its own row under **Settings → Shortcuts**, indented by nesting level.
52+
53+
```ts
54+
ctx.commands.register({
55+
id: 'app:cache',
56+
title: 'Cache',
57+
children: [
58+
{ id: 'app:cache:clear', title: 'Clear', keybindings: [{ key: 'Mod+Shift+K' }], handler: clearCache },
59+
],
60+
})
61+
```
62+
63+
Set `showInPalette: 'without-children'` on a parent to keep its whole subtree out of root search while leaving it reachable by drilling down.
64+
4965
## Cross-iframe dock activation
5066

5167
A mounted devframe's iframe uses `hub:docks:activate` to switch the active dock.
@@ -181,7 +197,7 @@ ctx.docks.register({
181197
title: 'Nuxt',
182198
icon: 'logos:nuxt-icon',
183199
category: 'framework',
184-
defaultChildId: 'nuxt:overview', // optional; popover-only when omitted
200+
defaultChildId: 'nuxt:overview', // optional; see "Activating a group" below
185201
})
186202

187203
ctx.docks.register({
@@ -194,7 +210,19 @@ ctx.docks.register({
194210
})
195211
```
196212

197-
Group and members stay independent top-level entries in `devframe:docks`. Activating the group reopens the member last opened in it (remembered per tab), and `defaultChildId` before any member has been opened. Grouping affects the dock rail, not iframes; to share **one** soft-navigated iframe, give docks a shared `frameId` and mark the anchor with `subTabs` ([Shared-iframe soft navigation](/guide/client-context#shared-iframe-soft-navigation)).
213+
Group and members stay independent top-level entries in `devframe:docks`. Grouping affects the dock rail, not iframes; to share **one** soft-navigated iframe, give docks a shared `frameId` and mark the anchor with `subTabs` ([Shared-iframe soft navigation](/guide/client-context#shared-iframe-soft-navigation)).
214+
215+
### Activating a group
216+
217+
Activating a group resolves to one of its members.
218+
219+
The dock rail button reopens the member last opened in the group (remembered per tab), then `defaultChildId`; with neither, it reveals the member popover.
220+
221+
A group command, activated by its keyboard shortcut or a command-palette pick, opens that same remembered or default member, then the only visible member when there is exactly one. With several visible peers and no preferred member, it opens the command palette scoped to those members, so the choice stays with the user. Pressing the same shortcut again closes that palette.
222+
223+
`hub:docks:activate` follows programmatic dock switching: it opens the remembered or default member, then the first registered member.
224+
225+
Declare `defaultChildId` when one member is the natural landing spot; leave it off when the members are peers.
198226

199227
### The dual role of `category`
200228

‎docs/content/8.references/6.hub-api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ The properties of `DevframeClientContext`: [The client context](/guide/client-co
106106
| `clientType` | `'embedded'` (inside the user app) or `'standalone'` (independent hub page). |
107107
| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, `register()` / `update()` for [client-only docks](/guide/client-context#client-only-docks). |
108108
| `panel` | Current `state`, local `events`, session, position, size, and drag/resize state for the dock panel. |
109-
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`. |
109+
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`, `paletteOpen`, `paletteScopeId`, `openPalette(atCommandId?)`. Passing an id opens the palette drilled into that command's children ([Activating a group](/guide/hub#activating-a-group)). |
110110
| `renderers` | Dock-renderer registry: `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a renderer (local boot or the hub's [manifest](/guide/hub-initiate#renderer-modules); local wins). `mount()` resolves a `status`: `mounted` (with `dispose`), `missing-renderer`, or `load-error` (with `error`). |
111111
| `when` | The [when-clause](/references/when-clauses) context. |
112112
| `connection` | Live [connection status](/guide/client#handling-connection-and-auth-errors): `status`, `error`, `events`. |

‎packages/hub-ui/src/client/components/command-palette/CommandPalette.stories.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,25 @@ export const Open: Story = {
4545
),
4646
}),
4747
}
48+
49+
/**
50+
* The palette opened *scoped* to a dock group, listing only that group's
51+
* members, what activating a group with no `defaultChildId` does, so a group
52+
* stays reachable by keyboard with the choice of member left to the user.
53+
* Backspace or Escape steps back out to the root list.
54+
*/
55+
export const ScopedToGroup: Story = {
56+
render: () => ({
57+
setup: () => mountWithContext(
58+
{ entries: groupedEntries },
59+
ctx => h(defineComponent({
60+
setup() {
61+
onMounted(() => {
62+
ctx.commands.openPalette('devframes:docks:playground')
63+
})
64+
return () => h(CommandPalette, { context: ctx })
65+
},
66+
})),
67+
),
68+
}),
69+
}

‎packages/hub-ui/src/client/components/command-palette/CommandPalette.vue‎

Lines changed: 71 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<script setup lang="ts">
2-
import type { DevframeClientCommand, DevframeCommandEntry } from '@devframes/hub'
2+
import type { DevframeClientCommand } from '@devframes/hub'
33
import type { DocksContext } from '@devframes/hub/client'
4+
import type { PaletteCrumb, PaletteFlatItem } from '../../state/palette'
45
import Fuse from 'fuse.js'
56
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
7+
import { flattenPaletteCommands, paletteActionKeepsOpen, paletteScopeTrail, paletteTrailScopeId, reconcilePaletteTrail, resolvePaletteSelection } from '../../state/palette'
68
import BrandWordmark from '../icons/BrandWordmark.vue'
79
import CommandPaletteItem from './CommandPaletteItem.vue'
810
@@ -23,36 +25,14 @@ const listContainer = useTemplateRef<HTMLElement>('listContainer')
2325
const visible = ref(false)
2426
2527
// Breadcrumb stack for sub-command drill-down
26-
const breadcrumb = ref<Array<{ title: string, items: DevframeCommandEntry[] }>>([])
28+
const breadcrumb = ref<PaletteCrumb[]>([])
2729
28-
// Flattened items for top-level search (includes children with parent prefix)
29-
interface FlatItem {
30-
entry: DevframeCommandEntry
31-
parentTitle?: string
32-
searchTitle: string
33-
}
34-
35-
const flattenedItems = computed<FlatItem[]>(() => {
36-
const result: FlatItem[] = []
37-
for (const cmd of commandsCtx.value.paletteCommands) {
38-
result.push({ entry: cmd, searchTitle: cmd.title })
39-
if (cmd.children && cmd.showInPalette !== 'without-children') {
40-
for (const child of cmd.children) {
41-
if (child.showInPalette === false)
42-
continue
43-
result.push({
44-
entry: child as DevframeCommandEntry,
45-
parentTitle: cmd.title,
46-
searchTitle: `${cmd.title} > ${child.title}`,
47-
})
48-
}
49-
}
50-
}
51-
return result
52-
})
30+
const flattenedItems = computed<PaletteFlatItem[]>(
31+
() => flattenPaletteCommands(commandsCtx.value.paletteCommands),
32+
)
5333
5434
// Current items: either drilled-down sub-items or root items
55-
const currentFlatItems = computed<FlatItem[]>(() => {
35+
const currentFlatItems = computed<PaletteFlatItem[]>(() => {
5636
if (breadcrumb.value.length > 0) {
5737
const current = breadcrumb.value.at(-1)!
5838
return current.items.map(entry => ({ entry, searchTitle: entry.title }))
@@ -62,7 +42,7 @@ const currentFlatItems = computed<FlatItem[]>(() => {
6242
6343
// Dynamic sub-items from action() return
6444
const dynamicItems = ref<DevframeClientCommand[] | undefined>()
65-
const activeItems = computed<FlatItem[]>(() => {
45+
const activeItems = computed<PaletteFlatItem[]>(() => {
6646
if (dynamicItems.value) {
6747
return dynamicItems.value.map(entry => ({ entry, searchTitle: entry.title }))
6848
}
@@ -85,12 +65,20 @@ watch(search, () => {
8565
selectedIndex.value = 0
8666
})
8767
68+
/** Show the rows at `scopeId`'s level, from a fresh search. */
69+
function showScope(scopeId: string | null) {
70+
search.value = ''
71+
selectedIndex.value = 0
72+
dynamicItems.value = undefined
73+
const next = paletteScopeTrail(commandsCtx.value.paletteCommands, scopeId)
74+
breadcrumb.value = next
75+
if (scopeId != null && !next.some(crumb => crumb.id === scopeId))
76+
commandsCtx.value.paletteScopeId = null
77+
}
78+
8879
watch(show, (v) => {
8980
if (v) {
90-
search.value = ''
91-
selectedIndex.value = 0
92-
breadcrumb.value = []
93-
dynamicItems.value = undefined
81+
showScope(commandsCtx.value.paletteScopeId)
9482
// Trigger enter animation
9583
requestAnimationFrame(() => {
9684
visible.value = true
@@ -99,9 +87,37 @@ watch(show, (v) => {
9987
}
10088
else {
10189
visible.value = false
90+
// Every close path funnels through `show`: Escape, the backdrop, running a
91+
// command, and a bare `paletteOpen` toggle, so the scope is dropped here
92+
// once rather than in each of them. A later Mod+K then opens at the root
93+
// instead of resurrecting the group it was last scoped to.
94+
commandsCtx.value.paletteScopeId = null
10295
}
10396
})
10497
98+
// A scope also arrives while the palette is already open, activating a dock
99+
// group picked from the root list, say. `show` stays `true` throughout, so the
100+
// drill-down follows the scope itself rather than the open transition.
101+
watch(() => commandsCtx.value.paletteScopeId, (scopeId) => {
102+
if (show.value)
103+
showScope(scopeId)
104+
})
105+
106+
// A command tree can change while the palette is open (dock registration,
107+
// `when` context, or a client command update). Rebuild each crumb by id so the
108+
// rendered rows and their actions always come from the live tree.
109+
watch(() => commandsCtx.value.paletteCommands, (commands) => {
110+
if (!show.value || breadcrumb.value.length === 0)
111+
return
112+
const scopeId = commandsCtx.value.paletteScopeId
113+
const scopeWasActive = scopeId != null && breadcrumb.value.some(crumb => crumb.id === scopeId)
114+
const next = reconcilePaletteTrail(commands, breadcrumb.value, scopeId)
115+
breadcrumb.value = next
116+
selectedIndex.value = Math.min(selectedIndex.value, Math.max(filtered.value.length - 1, 0))
117+
if (scopeWasActive && scopeId != null && !next.some(crumb => crumb.id === scopeId))
118+
commandsCtx.value.paletteScopeId = null
119+
})
120+
105121
function moveSelected(delta: number) {
106122
const len = filtered.value.length
107123
if (len === 0)
@@ -121,18 +137,17 @@ function scrollToItem() {
121137
122138
const loadingId = ref<string | null>(null)
123139
124-
async function enterItem(flatItem: FlatItem) {
125-
const entry = flatItem.entry
140+
async function enterItem(flatItem: PaletteFlatItem) {
141+
// The row may have been rendered just before the command tree changed. Look
142+
// it up again so a removed entry no-ops and a replacement runs its new action.
143+
const entry = activeItems.value.find(item => item.entry.id === flatItem.entry.id)?.entry
144+
if (!entry)
145+
return
126146
127-
// If has static children, drill down
128-
if (entry.children && entry.children.length > 0) {
129-
breadcrumb.value.push({
130-
title: entry.title,
131-
items: entry.children as DevframeCommandEntry[],
132-
})
133-
search.value = ''
134-
selectedIndex.value = 0
135-
dynamicItems.value = undefined
147+
// Ordinary command parents drill down. Dock groups are actionable parents:
148+
// their action opens a preferred member or scopes the palette for a choice.
149+
if (resolvePaletteSelection(entry, props.context.docks.entries) === 'drill') {
150+
commandsCtx.value.paletteScopeId = entry.id
136151
return
137152
}
138153
@@ -151,6 +166,8 @@ async function enterItem(flatItem: FlatItem) {
151166
catch (err) {
152167
console.error(`[@devframes/hub-ui] Command "${entry.id}" failed:`, err)
153168
}
169+
if (paletteActionKeepsOpen(entry, props.context.docks.entries, commandsCtx.value.paletteOpen, commandsCtx.value.paletteScopeId))
170+
return
154171
close()
155172
return
156173
}
@@ -195,13 +212,22 @@ function goBack() {
195212
}
196213
if (breadcrumb.value.length > 0) {
197214
breadcrumb.value.pop()
215+
commandsCtx.value.paletteScopeId = paletteTrailScopeId(breadcrumb.value)
198216
search.value = ''
199217
selectedIndex.value = 0
200218
return
201219
}
202220
close()
203221
}
204222
223+
/** Jump to the level the crumb at `index` sits above. */
224+
function goToCrumb(index: number) {
225+
breadcrumb.value.splice(index)
226+
commandsCtx.value.paletteScopeId = paletteTrailScopeId(breadcrumb.value)
227+
search.value = ''
228+
selectedIndex.value = 0
229+
}
230+
205231
function onKeyDown(e: KeyboardEvent) {
206232
if (e.key === 'Backspace' && !search.value && (breadcrumb.value.length > 0 || dynamicItems.value)) {
207233
e.preventDefault()
@@ -275,7 +301,7 @@ function getKeybindings(id: string) {
275301
v-for="(crumb, i) in breadcrumb"
276302
:key="i"
277303
class="text-xs op60 hover:op80 mr-1 flex items-center gap-0.5"
278-
@click="breadcrumb.splice(i); search = ''; selectedIndex = 0"
304+
@click="goToCrumb(i)"
279305
>
280306
{{ crumb.title }}
281307
<span class="op40">&rsaquo;</span>

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

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { DevframeCommandEntry, DevframeCommandKeybinding } from '@devframes
33
import type { DocksContext } from '@devframes/hub/client'
44
import DisplayKbd from '@antfu/design/components/Display/DisplayKbd.vue'
55
import { computed, nextTick, ref, watch } from 'vue'
6-
import { filterCommandsByWhen, formatKeybinding, isKeybindingOverrideDifferentFromDefault, isMac, KNOWN_BROWSER_SHORTCUTS } from '../../state/keybindings'
6+
import { filterCommandsByWhen, findCommandDeep, formatKeybinding, isKeybindingOverrideDifferentFromDefault, isMac, KNOWN_BROWSER_SHORTCUTS, walkCommands } from '../../state/keybindings'
77
import { useSettings } from '../../state/settings-defaults'
88
import DockIcon from '../dock/DockIcon.vue'
99
@@ -19,7 +19,8 @@ const shortcutSearch = ref('')
1919
interface ShortcutRow {
2020
command: DevframeCommandEntry
2121
parentTitle?: string
22-
indent: boolean
22+
/** Nesting level: 0 for a top-level command, +1 per ancestor. */
23+
depth: number
2324
}
2425
2526
// This page is only reachable with the dock open and the palette closed, so `when`
@@ -34,20 +35,24 @@ const availableCommands = computed(() => filterCommandsByWhen(
3435
{ ...props.context.when.context, dockOpen: true, paletteOpen: false },
3536
))
3637
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+
*/
3746
const shortcutRows = computed<ShortcutRow[]>(() => {
3847
const rows: ShortcutRow[] = []
39-
for (const cmd of availableCommands.value) {
40-
rows.push({ command: cmd, indent: false })
41-
if (cmd.children) {
42-
for (const child of cmd.children) {
43-
rows.push({
44-
command: child as DevframeCommandEntry,
45-
parentTitle: cmd.title,
46-
indent: true,
47-
})
48-
}
49-
}
50-
}
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+
})
5156
return rows
5257
})
5358
@@ -66,6 +71,16 @@ function getEffectiveKeybindings(id: string): DevframeCommandKeybinding[] {
6671
return commandsCtx.getKeybindings(id)
6772
}
6873
74+
/**
75+
* Indent one step per nesting level. An inline style rather than a class, since
76+
* the depth is only known at runtime and UnoCSS generates utilities from source:
77+
* a computed `ml-${depth * 6}` would never be emitted. One step is `ml-6`
78+
* worth of space.
79+
*/
80+
function rowIndentStyle(row: ShortcutRow): Record<string, string> {
81+
return row.depth > 0 ? { marginLeft: `${row.depth * 1.5}rem` } : {}
82+
}
83+
6984
function isExecutable(command: DevframeCommandEntry): boolean {
7085
return command.source === 'server' || !!command.action
7186
}
@@ -75,16 +90,7 @@ function isOverridden(id: string): boolean {
7590
}
7691
7792
function getDefaultKeybindings(id: string): DevframeCommandKeybinding[] {
78-
for (const cmd of commandsCtx.commands) {
79-
if (cmd.id === id)
80-
return cmd.keybindings ?? []
81-
if (cmd.children) {
82-
const child = cmd.children.find(c => c.id === id)
83-
if (child)
84-
return child.keybindings ?? []
85-
}
86-
}
87-
return []
93+
return findCommandDeep(commandsCtx.commands, id)?.keybindings ?? []
8894
}
8995
9096
function clearShortcut(commandId: string) {
@@ -281,9 +287,9 @@ watch(editorOpen, async (v) => {
281287
v-if="row.command.icon"
282288
:icon="row.command.icon"
283289
class="w-4 h-4 shrink-0 op60"
284-
:class="{ 'ml-6': row.indent }"
290+
:style="rowIndentStyle(row)"
285291
/>
286-
<div v-else :class="{ 'ml-6': row.indent }" class="w-4 h-4 shrink-0" />
292+
<div v-else :style="rowIndentStyle(row)" class="w-4 h-4 shrink-0" />
287293
<div class="flex-1 min-w-0">
288294
<div class="flex items-center gap-1.5">
289295
<span class="truncate text-sm">{{ row.command.title }}</span>

0 commit comments

Comments
 (0)