Skip to content

Commit cb369f0

Browse files
committed
feat(hub-ui): open a scoped palette for groups without defaultChildId
A group has no view of its own, so activating one by id — its shortcut, a palette pick, an RPC activation — used to fall back to whichever member happened to come first. That picked for the user, and a group whose members are peers has no member worth picking. Activating a group now opens an unambiguous target directly: the author's `defaultChildId`, or a lone visible member. With several peer members it opens the command palette drilled into that group, so the choice stays with the user and the group is reachable by keyboard alone. Pressing the same shortcut again closes the palette; stepping back to the root list unscopes it, so the shortcut drills back in rather than toggling. `CommandsContext` gains `openPalette(atCommandId?)` and `paletteScopeId`. Members hang directly off their group in the command tree — the dock bar's sub-category dividers have no counterpart there, since a category is not something you can run. A group with no visible member and no reachable `defaultChildId` registers no command at all. `switchEntry` is unchanged, so boot restore and `hub:docks:activate` behave as before.
1 parent 1dbec7f commit cb369f0

10 files changed

Lines changed: 274 additions & 43 deletions

File tree

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

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

5252
`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.
5353

54+
## Nested commands
55+
56+
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.
57+
58+
```ts
59+
ctx.commands.register({
60+
id: 'app:cache',
61+
title: 'Cache',
62+
children: [
63+
{ id: 'app:cache:clear', title: 'Clear', keybindings: [{ key: 'Mod+Shift+K' }], handler: clearCache },
64+
],
65+
})
66+
```
67+
68+
Set `showInPalette: 'without-children'` on a parent to keep its whole subtree out of root search while leaving it reachable by drilling down.
69+
5470
## Cross-iframe dock activation
5571

5672
A mounted devframe's iframe uses `hub:docks:activate` to switch the active dock.
@@ -199,7 +215,7 @@ ctx.docks.register({
199215
title: 'Nuxt',
200216
icon: 'logos:nuxt-icon',
201217
category: 'framework',
202-
defaultChildId: 'nuxt:overview', // optional; popover-only when omitted
218+
defaultChildId: 'nuxt:overview', // optional; see "Activating a group" below
203219
})
204220

205221
ctx.docks.register({
@@ -212,7 +228,17 @@ ctx.docks.register({
212228
})
213229
```
214230

215-
Group and members stay independent top-level entries in `devframe:docks`; `defaultChildId` opens on activation. 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)).
231+
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)).
232+
233+
### Activating a group
234+
235+
Activating a group resolves to one of its members.
236+
237+
**Clicking** the dock-rail button opens `defaultChildId` when the group declares one, and reveals the member popover otherwise.
238+
239+
**By id** — a keyboard shortcut on the group, a command-palette pick, or a `hub:docks:activate` call — opens the member the group points at: `defaultChildId`, or the only visible member when there is exactly one. A group with several members and no `defaultChildId` opens the command palette listing just those members, so the choice stays with the user and the group remains reachable by keyboard alone. Pressing the same shortcut again closes that palette.
240+
241+
Declare `defaultChildId` when one member is the natural landing spot; leave it off when the members are peers.
216242

217243
### The dual role of `category`
218244

docs/content/1.guide/17.client-context.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ A second boot replaces the context and warns; `dispose()` tears down listeners a
4040
| `clientType` | `'embedded'` (inside the user app) or `'standalone'` (independent hub page). |
4141
| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, `register()` / `update()` for [client-only docks](#client-only-docks). |
4242
| `panel` | Dock panel state: position, size, drag/resize. |
43-
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`. |
43+
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`, `openPalette(atCommandId?)` — with an id, the palette opens drilled into that command's children and records it in `paletteScopeId` (how [activating a group](/guide/hub#activating-a-group) offers its members). |
4444
| `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`). |
4545
| `when` | The [when-clause](/references/when-clauses) context. |
4646
| `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/views-builtin/ViewBuiltinSettings.stories.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { DevframeViewBuiltin } from '@devframes/hub'
22
import type { Meta, StoryObj } from '@storybook/vue3-vite'
33
import { h } from 'vue'
4-
import { groupedEntries } from '../../stories/fixtures'
4+
import { groupedEntries, subcategorizedGroupEntries } from '../../stories/fixtures'
55
import { mountWithContext } from '../../stories/story-helpers'
66
import ViewBuiltinSettings from './ViewBuiltinSettings.vue'
77

@@ -51,3 +51,19 @@ export const Standalone: Story = {
5151
),
5252
}),
5353
}
54+
55+
/**
56+
* A group whose members split into in-group sub-categories — open the
57+
* **Shortcuts** tab to see them listed directly under their group (`Docks` ›
58+
* Tools › a member), each indented by nesting level and bindable like any other
59+
* command. The rail's sub-category dividers stay in the rail; every row here is
60+
* something you can actually run.
61+
*/
62+
export const DeeplyNestedShortcuts: Story = {
63+
render: () => ({
64+
setup: () => mountWithContext(
65+
{ entries: subcategorizedGroupEntries, clientType: 'embedded' },
66+
ctx => stage(h(ViewBuiltinSettings, { context: ctx, entry })),
67+
),
68+
}),
69+
}

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

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { ShallowRef } from 'vue'
66
import { evaluateWhen } from 'devframe/utils/when'
77
import { computed, markRaw, reactive, ref, watch } from 'vue'
88
import { sharedStateToRef } from './docks'
9-
import { collectAllKeybindings, filterCommandsByWhen, normalizeKeyEvent } from './keybindings'
9+
import { collectAllKeybindings, filterCommandsByWhen, findCommandDeep, normalizeKeyEvent } from './keybindings'
1010
import { useDockPopupWindow, useIsDockPopupOpen } from './popup'
1111

1212
const commandsContextByRpc = new WeakMap<DevframeRpcClient, CommandsContext>()
@@ -33,6 +33,9 @@ export async function createCommandsContext(
3333
const shortcutOverrides = computed(() => settings.value.commandShortcuts ?? {})
3434

3535
const paletteOpen = ref(false)
36+
// See `CommandsContext.paletteScopeId` for the contract; the palette owns
37+
// clearing it.
38+
const paletteScopeId = ref<string | null>(null)
3639
const isDockPopupOpen = useIsDockPopupOpen()
3740

3841
const getWhenContext = (): WhenContext => {
@@ -71,25 +74,13 @@ export async function createCommandsContext(
7174
}
7275
}
7376

74-
function findCommand(id: string): DevframeCommandEntry | undefined {
75-
// Search top-level
76-
const topLevel = commands.value.find(c => c.id === id)
77-
if (topLevel)
78-
return topLevel
79-
80-
// Search children
81-
for (const cmd of commands.value) {
82-
if (cmd.children) {
83-
const child = cmd.children.find(c => c.id === id)
84-
if (child)
85-
return child as DevframeCommandEntry
86-
}
87-
}
88-
return undefined
77+
function openPalette(atCommandId?: string): void {
78+
paletteScopeId.value = atCommandId ?? null
79+
paletteOpen.value = true
8980
}
9081

9182
async function execute(id: string, ...args: any[]): Promise<unknown> {
92-
const cmd = findCommand(id)
83+
const cmd = findCommandDeep(commands.value, id)
9384
if (!cmd) {
9485
throw new Error(`Command "${id}" not found`)
9586
}
@@ -119,7 +110,7 @@ export async function createCommandsContext(
119110
if (overrides !== undefined)
120111
return overrides
121112

122-
const cmd = findCommand(id)
113+
const cmd = findCommandDeep(commands.value, id)
123114
return cmd?.keybindings ?? []
124115
}
125116

@@ -136,6 +127,8 @@ export async function createCommandsContext(
136127
getKeybindings,
137128
settings: markRaw(settingsState),
138129
paletteOpen,
130+
paletteScopeId,
131+
openPalette,
139132
})
140133

141134
commandsContextByRpc.set(rpc, commandsContext)
@@ -162,9 +155,7 @@ function setupShortcutListener(
162155
if (keybinding.key !== pressed)
163156
continue
164157
// Check command-level when clause
165-
const cmd: DevframeCommandEntry | undefined
166-
= commands.value.find(c => c.id === id)
167-
?? commands.value.flatMap(c => c.children as DevframeCommandEntry[] ?? []).find(c => c.id === id)
158+
const cmd = findCommandDeep(commands.value, id)
168159
if (cmd?.when && !evaluateWhen(cmd.when, whenCtx))
169160
continue
170161

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

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,127 @@ describe('createDocksContext', () => {
152152
expect(session.value.open).toBe(false)
153153
})
154154
})
155+
156+
const groupEntries = [
157+
{ id: 'tools', type: 'group', title: 'Tools', icon: 'ph:wrench-duotone' },
158+
{ id: 'tools:a', type: 'iframe', title: 'A', icon: 'ph:file-duotone', url: '/a', groupId: 'tools' },
159+
{ id: 'tools:b', type: 'iframe', title: 'B', icon: 'ph:file-duotone', url: '/b', groupId: 'tools', category: 'app' },
160+
{ id: 'solo', type: 'group', title: 'Solo', icon: 'ph:circle-duotone' },
161+
{ id: 'solo:only', type: 'iframe', title: 'Only', icon: 'ph:file-duotone', url: '/only', groupId: 'solo' },
162+
{ id: 'defaulted', type: 'group', title: 'Defaulted', icon: 'ph:star-duotone', defaultChildId: 'defaulted:second' },
163+
{ id: 'defaulted:first', type: 'iframe', title: 'First', icon: 'ph:file-duotone', url: '/first', groupId: 'defaulted' },
164+
{ id: 'defaulted:second', type: 'iframe', title: 'Second', icon: 'ph:file-duotone', url: '/second', groupId: 'defaulted' },
165+
{ id: 'empty', type: 'group', title: 'Empty', icon: 'ph:prohibit-duotone' },
166+
] satisfies DevframeDockEntry[]
167+
168+
async function createGroupedContext() {
169+
const { rpc, sharedStates, trust } = createStubRpc()
170+
const context = await createDocksContext('embedded', rpc, undefined, ref<DockSessionStorage>({
171+
open: false,
172+
selectedDockId: null,
173+
selectedDockRoute: null,
174+
}))
175+
176+
trust()
177+
sharedStates.get('devframe:docks')!.push(groupEntries)
178+
sharedStates.get('devframe:dock-renderers')!.push({})
179+
await flushRestore()
180+
181+
return context
182+
}
183+
184+
/**
185+
* Activating a group by id — what a keyboard shortcut and a palette pick both
186+
* do — must never invent a member for the user.
187+
*/
188+
describe('dock group command activation', () => {
189+
it('opens the palette scoped to the group when no member is an obvious target', async () => {
190+
expect.assertions(3)
191+
192+
const context = await createGroupedContext()
193+
await context.commands.execute('devframes:docks:tools')
194+
195+
expect(context.commands.paletteOpen).toBe(true)
196+
expect(context.commands.paletteScopeId).toBe('devframes:docks:tools')
197+
// No member was picked on the user's behalf.
198+
expect(context.docks.selected).toBeNull()
199+
})
200+
201+
it('closes that palette again on a second activation while it stays scoped', async () => {
202+
expect.assertions(1)
203+
204+
const context = await createGroupedContext()
205+
await context.commands.execute('devframes:docks:tools')
206+
await context.commands.execute('devframes:docks:tools')
207+
208+
expect(context.commands.paletteOpen).toBe(false)
209+
})
210+
211+
it('re-scopes instead of closing once the palette has stepped back to the root', async () => {
212+
expect.assertions(2)
213+
214+
const context = await createGroupedContext()
215+
await context.commands.execute('devframes:docks:tools')
216+
// What the palette does when Escape or Backspace pops the last crumb: the
217+
// list is still open, but no longer showing the group.
218+
context.commands.paletteScopeId = null
219+
await context.commands.execute('devframes:docks:tools')
220+
221+
expect(context.commands.paletteOpen).toBe(true)
222+
expect(context.commands.paletteScopeId).toBe('devframes:docks:tools')
223+
})
224+
225+
it('opens the sole visible member directly instead of a one-item palette', async () => {
226+
expect.assertions(2)
227+
228+
const context = await createGroupedContext()
229+
await context.commands.execute('devframes:docks:solo')
230+
231+
expect(context.docks.selected?.id).toBe('solo:only')
232+
expect(context.commands.paletteOpen).toBe(false)
233+
})
234+
235+
it('honors `defaultChildId` over both the palette and member order', async () => {
236+
expect.assertions(2)
237+
238+
const context = await createGroupedContext()
239+
await context.commands.execute('devframes:docks:defaulted')
240+
241+
expect(context.docks.selected?.id).toBe('defaulted:second')
242+
expect(context.commands.paletteOpen).toBe(false)
243+
})
244+
245+
it('registers no command for a group with nothing to activate', async () => {
246+
expect.assertions(1)
247+
248+
const context = await createGroupedContext()
249+
250+
await expect(context.commands.execute('devframes:docks:empty')).rejects.toThrow(/not found/)
251+
})
252+
253+
it('executes a group member command nested below its group', async () => {
254+
expect.assertions(1)
255+
256+
const context = await createGroupedContext()
257+
await context.commands.execute('devframes:docks:tools:b')
258+
259+
expect(context.docks.selected?.id).toBe('tools:b')
260+
})
261+
262+
it('hangs members directly off their group even when sub-categories differ', async () => {
263+
expect.assertions(1)
264+
265+
const context = await createGroupedContext()
266+
const docks = context.commands.commands.find(c => c.id === 'devframes:docks')
267+
const tools = docks?.children?.find(c => c.id === 'devframes:docks:tools')
268+
269+
// `tools:a` (default) and `tools:b` (app) land in different in-group
270+
// sub-categories. The dock rail draws a divider between them; the command
271+
// tree keeps them siblings, so reaching one is a single step rather than
272+
// picking an inert category row first.
273+
expect(tools?.children?.map(c => c.id)).toEqual([
274+
'devframes:docks:tools:a',
275+
'devframes:docks:tools:b',
276+
])
277+
})
278+
})

0 commit comments

Comments
 (0)