Skip to content

Commit c3c24f4

Browse files
committed
feat(hub): add dock grouping (groupId + type:'group')
Let a hub collapse related dock entries under a single dock-bar button when many integrations share one UI. Grouping only makes sense once a hub combines tools, so the data model and host validation land here while the hub continues to ship no UI — downstream kits derive the visual collapse from the existing 'devframe:docks' shared state. - Add an optional `groupId` pointer to every dock entry (membership, not containment) and a new `type:'group'` entry (`DevframeViewGroup`) with an optional `defaultChildId`. This is a flat pointer model: members stay independently-registered top-level entries, so register/update/values, the views map, settings, and shared-state sync all keep working unchanged. - Validate in the host: reject self-grouping (DF8103) and nested groups (DF8104, one level only). Orphan members whose group is unregistered are tolerated and render as normal top-level entries, keeping registration order-independent.
1 parent 524c6b6 commit c3c24f4

11 files changed

Lines changed: 231 additions & 2 deletions

File tree

docs/errors/DF8103.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8103: Dock Entry Cannot Group Itself
6+
7+
## Message
8+
9+
> Dock entry "`{id}`" cannot set groupId to its own id
10+
11+
## Cause
12+
13+
A dock entry registered with `groupId` pointing at its own `id`. `groupId` is a pointer to a *different* group entry the entry belongs to, so a self-reference would describe an entry that collapses under itself.
14+
15+
## Fix
16+
17+
- Point `groupId` at the `id` of a `type: 'group'` entry, such as `groupId: 'nuxt'`.
18+
- Omit `groupId` entirely to keep the entry as a normal top-level dock entry.
19+
20+
## Source
21+
22+
- [`packages/hub/src/node/host-docks.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-docks.ts)`DevframeDocksHost.register()` and `update()` throw this when `view.groupId === view.id`.

docs/errors/DF8104.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8104: Nested Dock Groups Unsupported
6+
7+
## Message
8+
9+
> Dock group "`{id}`" cannot itself belong to a group (nested groups are unsupported)
10+
11+
## Cause
12+
13+
A `type: 'group'` entry was registered with `groupId` set. Dock grouping is one level deep: a group collects member entries, but a group cannot itself be a member of another group.
14+
15+
## Fix
16+
17+
- Remove `groupId` from the group entry so it stays a top-level dock-bar button.
18+
- Keep members one level under their group; place each member's `groupId` on the leaf entry, not on another group.
19+
20+
## Source
21+
22+
- [`packages/hub/src/node/host-docks.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-docks.ts)`DevframeDocksHost.register()` and `update()` throw this when `view.type === 'group'` and `view.groupId` is set.

docs/guide/hub.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` wi
1515

1616
| Subsystem | Surface | Purpose |
1717
|---|---|---|
18-
| `ctx.docks` | `register / update / values` | Multi-tool dock entries (iframes, launchers, json-render, custom-render). |
18+
| `ctx.docks` | `register / update / values` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. |
1919
| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. |
2020
| `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). |
2121
| `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. |
@@ -43,6 +43,34 @@ await mountDevframe(ctx, myDevframe)
4343

4444
Framework kits typically wrap this in a plugin shell. `@vitejs/devtools-kit`'s `createPluginFromDevframe` returns a Vite `Plugin` whose `devtools.setup` calls into `mountDevframe`.
4545

46+
## Grouping dock entries
47+
48+
When a hub combines many integrations, related dock entries can collapse under a single dock-bar button. A `type: 'group'` entry is that button; any entry pointing its `groupId` at the group's `id` becomes a member.
49+
50+
```ts
51+
ctx.docks.register({
52+
type: 'group',
53+
id: 'nuxt',
54+
title: 'Nuxt',
55+
icon: 'logos:nuxt-icon',
56+
category: 'framework',
57+
defaultChildId: 'nuxt:overview', // optional; popover-only when omitted
58+
})
59+
60+
ctx.docks.register({
61+
type: 'iframe',
62+
id: 'nuxt:overview',
63+
title: 'Overview',
64+
icon: 'ph:gauge-duotone',
65+
url: '/__nuxt-overview/',
66+
groupId: 'nuxt', // joins the group above
67+
})
68+
```
69+
70+
`groupId` lives on every entry kind, so iframes, launchers, json-render panels, and custom-render views all join groups the same way. The group and its members stay independent top-level entries in `devframe:docks`; a downstream UI derives the visual collapse by matching each member's `groupId` to the group's `id` and renders members in a popover or sub-navigation. `defaultChildId` names the member opened when the group button is activated.
71+
72+
Grouping is one level deep: members join a group, and a group is always a top-level button. A member whose group is never registered renders as a normal top-level entry, so registration order is free.
73+
4674
## The protocol — what the UI sees
4775

4876
A hub-aware UI doesn't import any hub classes; it reads three shared-state keys and one RPC method:

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,102 @@ describe('devframeDockHost remote URL enrichment', () => {
7676
expect(parseRemoteConnection(url)?.websocket).toBe('ws://localhost:4173')
7777
})
7878
})
79+
80+
describe('devframeDockHost grouping', () => {
81+
it('registers a group entry: stored, projected, and emitted', () => {
82+
const host = new DevframeDocksHost(createContext())
83+
const emitted: string[] = []
84+
host.events.on('dock:entry:updated', entry => emitted.push(entry.id))
85+
86+
host.register({
87+
type: 'group',
88+
id: 'nuxt',
89+
title: 'Nuxt',
90+
icon: 'logos:nuxt-icon',
91+
category: 'framework',
92+
defaultChildId: 'nuxt:overview',
93+
})
94+
95+
expect(host.views.has('nuxt')).toBe(true)
96+
expect(emitted).toEqual(['nuxt'])
97+
const entry = host.values({ includeBuiltin: false })[0]
98+
expect(entry.type).toBe('group')
99+
expect(entry).toMatchObject({ id: 'nuxt', defaultChildId: 'nuxt:overview' })
100+
})
101+
102+
it('round-trips a member groupId through values()', () => {
103+
const host = new DevframeDocksHost(createContext())
104+
host.register({
105+
type: 'iframe',
106+
id: 'nuxt:overview',
107+
title: 'Overview',
108+
icon: 'ph:gauge-duotone',
109+
url: '/__nuxt-overview/',
110+
groupId: 'nuxt',
111+
})
112+
113+
const entry = host.values({ includeBuiltin: false })[0]
114+
expect(entry.groupId).toBe('nuxt')
115+
})
116+
117+
it('tolerates a member registered before its group (orphan tolerance)', () => {
118+
const host = new DevframeDocksHost(createContext())
119+
host.register({
120+
type: 'iframe',
121+
id: 'nuxt:overview',
122+
title: 'Overview',
123+
icon: 'ph:gauge-duotone',
124+
url: '/__nuxt-overview/',
125+
groupId: 'nuxt',
126+
})
127+
host.register({
128+
type: 'group',
129+
id: 'nuxt',
130+
title: 'Nuxt',
131+
icon: 'logos:nuxt-icon',
132+
})
133+
134+
const ids = host.values({ includeBuiltin: false }).map(entry => entry.id)
135+
expect(ids).toEqual(['nuxt:overview', 'nuxt'])
136+
})
137+
138+
it('rejects an entry that groups itself (DF8103)', () => {
139+
const host = new DevframeDocksHost(createContext())
140+
expect(() => host.register({
141+
type: 'iframe',
142+
id: 'self',
143+
title: 'Self',
144+
icon: 'ph:gauge-duotone',
145+
url: '/__self/',
146+
groupId: 'self',
147+
})).toThrow('cannot set groupId to its own id')
148+
})
149+
150+
it('rejects a nested group (DF8104)', () => {
151+
const host = new DevframeDocksHost(createContext())
152+
expect(() => host.register({
153+
type: 'group',
154+
id: 'child-group',
155+
title: 'Child Group',
156+
icon: 'logos:nuxt-icon',
157+
groupId: 'parent-group',
158+
})).toThrow('nested groups are unsupported')
159+
})
160+
161+
it('updates a group entry while preserving type and rejecting id change', () => {
162+
const host = new DevframeDocksHost(createContext())
163+
const handle = host.register({
164+
type: 'group',
165+
id: 'nuxt',
166+
title: 'Nuxt',
167+
icon: 'logos:nuxt-icon',
168+
})
169+
170+
handle.update({ title: 'Nuxt DevTools' })
171+
const entry = host.views.get('nuxt')!
172+
expect(entry.type).toBe('group')
173+
expect(entry.title).toBe('Nuxt DevTools')
174+
175+
expect(() => handle.update({ id: 'other' })).toThrow('Cannot change the id of a dock')
176+
})
177+
})

packages/hub/src/node/diagnostics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ export const diagnostics = defineDiagnostics({
2424
DF8102: {
2525
why: (p: { id: string }) => `Dock with id "${p.id}" is not registered. Use register() to add new docks.`,
2626
},
27+
DF8103: {
28+
why: (p: { id: string }) => `Dock entry "${p.id}" cannot set groupId to its own id`,
29+
fix: 'Point groupId at a different group entry, or omit it.',
30+
},
31+
DF8104: {
32+
why: (p: { id: string }) => `Dock group "${p.id}" cannot itself belong to a group (nested groups are unsupported)`,
33+
fix: 'Remove groupId from the group entry; nest members one level only.',
34+
},
2735
DF8200: {
2836
why: (p: { id: string }) => `Terminal session with id "${p.id}" already registered`,
2937
},

packages/hub/src/node/host-docks.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ export class DevframeDocksHost implements DevframeDocksHostType {
179179
if (this.views.has(view.id) && !force) {
180180
throw diagnostics.DF8100({ id: view.id })
181181
}
182+
this.validateGroupMembership(view)
182183
this.prepareRemoteRegistration(view)
183184
this.views.set(view.id, view)
184185
this.events.emit('dock:entry:updated', view)
@@ -197,11 +198,21 @@ export class DevframeDocksHost implements DevframeDocksHostType {
197198
if (!this.views.has(view.id)) {
198199
throw diagnostics.DF8102({ id: view.id })
199200
}
201+
this.validateGroupMembership(view)
200202
this.prepareRemoteRegistration(view)
201203
this.views.set(view.id, view)
202204
this.events.emit('dock:entry:updated', view)
203205
}
204206

207+
private validateGroupMembership(view: DevframeDockUserEntry): void {
208+
if (view.groupId === undefined)
209+
return
210+
if (view.groupId === view.id)
211+
throw diagnostics.DF8103({ id: view.id })
212+
if (view.type === 'group')
213+
throw diagnostics.DF8104({ id: view.id })
214+
}
215+
205216
private prepareRemoteRegistration(view: DevframeDockUserEntry): void {
206217
const internal = getInternalContext(this.context as DevframeNodeContext)
207218
// Always revoke any previously allocated token for this dock id — covers

packages/hub/src/types/docks.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ export interface DevframeDockEntryBase {
5858
* Badge text to display on the dock icon (e.g., unread count)
5959
*/
6060
badge?: string
61+
/**
62+
* Id of the group this entry belongs to. When set, hosts collapse this entry
63+
* under the matching group's button instead of showing it directly on the
64+
* dock bar.
65+
*
66+
* This is a flat pointer — membership, not containment. The entry stays an
67+
* independently-registered, top-level entry; only its rendering is grouped
68+
* downstream. If the referenced group is never registered, the entry renders
69+
* as a normal top-level entry (orphan tolerance).
70+
*
71+
* @see {@link DevframeViewGroup}
72+
*/
73+
groupId?: string
6174
}
6275

6376
export interface ClientScriptEntry {
@@ -169,7 +182,29 @@ export interface DevframeViewJsonRender extends DevframeDockEntryBase {
169182
ui: JsonRenderer
170183
}
171184

172-
export type DevframeDockUserEntry = DevframeViewIframe | DevframeViewAction | DevframeViewCustomRender | DevframeViewLauncher | DevframeViewJsonRender
185+
/**
186+
* A dock group: a single dock-bar button that collapses every entry whose
187+
* {@link DevframeDockEntryBase.groupId} matches this group's `id`.
188+
*
189+
* A group carries its own `title`/`icon`/`category`/`defaultOrder`/`when`
190+
* (inherited from {@link DevframeDockEntryBase}) and has no view payload of its
191+
* own — hosts render its members in a popover / sub-navigation. It flows
192+
* through the same `register`/`update`/`values` machinery as every other entry,
193+
* keyed by `id`.
194+
*
195+
* Grouping is one level deep: a group entry must not itself set `groupId`.
196+
*/
197+
export interface DevframeViewGroup extends DevframeDockEntryBase {
198+
type: 'group'
199+
/**
200+
* Member id auto-opened when the group button is activated. When unset,
201+
* activating the group only reveals its members (popover-only); no view
202+
* opens until a member is chosen.
203+
*/
204+
defaultChildId?: string
205+
}
206+
207+
export type DevframeDockUserEntry = DevframeViewIframe | DevframeViewAction | DevframeViewCustomRender | DevframeViewLauncher | DevframeViewJsonRender | DevframeViewGroup
173208

174209
export type DevframeDockEntry = DevframeDockUserEntry | DevframeViewBuiltin
175210

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export { DevframeTerminalStatus }
6565
export { DevframeViewAction }
6666
export { DevframeViewBuiltin }
6767
export { DevframeViewCustomRender }
68+
export { DevframeViewGroup }
6869
export { DevframeViewHost }
6970
export { DevframeViewIframe }
7071
export { DevframeViewJsonRender }

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export declare class DevframeDocksHost implements DevframeDocksHost$1 {
4040
update: (_: Partial<T>) => void;
4141
};
4242
update(_: DevframeDockUserEntry): void;
43+
private validateGroupMembership;
4344
private prepareRemoteRegistration;
4445
}
4546
export declare class DevframeMessagesHost implements DevframeMessagesHost$1 {

tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class DevframeDocksHost {
2727
resolveDevServerOrigin() {}
2828
register(_, _) {}
2929
update(_) {}
30+
validateGroupMembership(_) {}
3031
prepareRemoteRegistration(_) {}
3132
}
3233
export class DevframeMessagesHost {

0 commit comments

Comments
 (0)