Skip to content

Commit c0e5b27

Browse files
authored
refactor(hub)!: drop synthesized built-in docks for integration-owned ~builtin views (#92)
1 parent 36e193c commit c0e5b27

12 files changed

Lines changed: 102 additions & 180 deletions

File tree

docs/guide/hub.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ A hub-aware UI doesn't import any hub classes; it reads three shared-state keys
133133

134134
| Channel | Type | What it carries |
135135
|---|---|---|
136-
| `devframe:docks` shared state | `DevframeDockEntry[]` | The full dock list, including the hub's `~terminals` / `~messages` / `~settings` builtins. |
136+
| `devframe:docks` shared state | `DevframeDockEntry[]` | Every dock entry the mounted integrations registered. |
137137
| `devframe:commands` shared state | `DevframeServerCommandEntry[]` | Serializable command list (handlers stripped). |
138138
| `devframe:user-settings` shared state | `DevframeDocksUserSettings` | Persisted per-workspace hub settings. |
139139
| `hub:commands:execute` RPC | `(id, ...args) => unknown` | Server-side command dispatch. |

examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,16 @@ export async function minimalNextDevframeHub(
204204
handler: () => 'pong',
205205
})
206206

207+
// The hub no longer synthesizes built-in docks — a high-level integration
208+
// registers the viewer's native views it wants. `~builtin` views default
209+
// their category to `~builtin`, so this Settings tab sorts last on its own.
210+
context.docks.register({
211+
type: '~builtin',
212+
id: '~settings',
213+
title: 'Settings',
214+
icon: 'ph:gear-duotone',
215+
})
216+
207217
// Demo devframes alongside the dogfooded built-in plugin packages.
208218
const devframes = options.devframes
209219
?? [demoDevframe, demoDevframeB, ...await loadBuiltinPlugins()]

examples/minimal-next-devframe-hub/tests/minimal-next-devframe-hub.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,18 @@ describe('minimal-next-devframe-hub (example)', () => {
2828
})
2929
})
3030

31-
it('registers hub built-in docks and the mounted demo devframe', async () => {
31+
it('registers a hub-owned settings dock and the mounted plugin docks', async () => {
3232
server = await minimalNextDevframeHub({ host: '127.0.0.1' })
3333

34-
const dockIds = server.context.docks.values().map(d => d.id)
34+
const docks = server.context.docks.values()
35+
const dockIds = docks.map(d => d.id)
3536
expect(dockIds).toContain('next-demo-tool')
36-
expect(dockIds).toContain('~terminals')
37-
expect(dockIds).toContain('~messages')
37+
// The hub synthesizes no built-in docks; the integration registers the
38+
// settings tab itself, and `~builtin` views default to the `~builtin` category.
3839
expect(dockIds).toContain('~settings')
40+
expect(docks.find(d => d.id === '~settings')?.category).toBe('~builtin')
3941
// The dogfooded built-in plugin packages mount their own docks.
42+
expect(dockIds).toContain('devframes-plugin-terminals')
4043
expect(dockIds).toContain('devframes-plugin-messages')
4144
})
4245

packages/hub/src/node/__tests__/context.test.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,15 @@ function createHost(storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-contex
1616
}
1717

1818
describe('createHubContext shared state', () => {
19-
it('seeds built-in docks immediately', async () => {
19+
it('seeds an empty dock list — the hub synthesizes no built-in docks', async () => {
2020
const context = await createHubContext({
2121
cwd: process.cwd(),
2222
mode: 'build',
2323
host: createHost(),
2424
})
2525

2626
const docks = await context.rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks')
27-
expect(docks.value().map(dock => dock.id)).toEqual([
28-
'~terminals',
29-
'~messages',
30-
'~settings',
31-
])
32-
})
33-
34-
it('suppresses built-ins gated off via builtinDocks', async () => {
35-
const context = await createHubContext({
36-
cwd: process.cwd(),
37-
mode: 'build',
38-
host: createHost(),
39-
builtinDocks: { terminals: false, messages: false },
40-
})
41-
42-
const docks = await context.rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks')
43-
expect(docks.value().map(dock => dock.id)).toEqual(['~settings'])
27+
expect(docks.value()).toEqual([])
4428
})
4529
})
4630

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

Lines changed: 33 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@ function createContext(): DevframeHubContext {
1616
resolveOrigin: () => 'http://localhost:5173',
1717
getStorageDir: () => storageDir,
1818
},
19-
// Minimal stubs so the built-in `~terminals`/`~messages` getters
20-
// (`when`/`badge`) can be evaluated without a full context.
21-
terminals: { sessions: new Map() },
22-
messages: { entries: new Map() },
2319
} as unknown as DevframeHubContext
2420
}
2521

@@ -38,7 +34,7 @@ describe('devframeDockHost remote URL enrichment', () => {
3834
remote: true,
3935
})
4036

41-
const first = host.values({ includeBuiltin: false })[0]
37+
const first = host.values()[0]
4238
expect(first.type).toBe('iframe')
4339
const firstUrl = first.type === 'iframe' ? first.url : ''
4440
expect(firstUrl).toContain(`#/inspect?tab=state&${REMOTE_CONNECTION_KEY}=`)
@@ -57,7 +53,7 @@ describe('devframeDockHost remote URL enrichment', () => {
5753
remote: true,
5854
})
5955

60-
const second = host.values({ includeBuiltin: false })[0]
56+
const second = host.values()[0]
6157
const secondUrl = second.type === 'iframe' ? second.url : ''
6258
expect(secondUrl.match(new RegExp(REMOTE_CONNECTION_KEY, 'g'))).toHaveLength(1)
6359
expect(secondUrl).toContain('#/inspect?tab=state&')
@@ -77,7 +73,7 @@ describe('devframeDockHost remote URL enrichment', () => {
7773
remote: true,
7874
})
7975

80-
const entry = host.values({ includeBuiltin: false })[0]
76+
const entry = host.values()[0]
8177
const url = entry.type === 'iframe' ? entry.url : ''
8278
expect(url).toContain(`#section&${REMOTE_CONNECTION_KEY}=`)
8379
expect(parseRemoteConnection(url)?.websocket).toBe('ws://localhost:4173')
@@ -101,7 +97,7 @@ describe('devframeDockHost grouping', () => {
10197

10298
expect(host.views.has('nuxt')).toBe(true)
10399
expect(emitted).toEqual(['nuxt'])
104-
const entry = host.values({ includeBuiltin: false })[0]
100+
const entry = host.values()[0]
105101
expect(entry.type).toBe('group')
106102
expect(entry).toMatchObject({ id: 'nuxt', defaultChildId: 'nuxt:overview' })
107103
})
@@ -117,7 +113,7 @@ describe('devframeDockHost grouping', () => {
117113
groupId: 'nuxt',
118114
})
119115

120-
const entry = host.values({ includeBuiltin: false })[0]
116+
const entry = host.values()[0]
121117
expect(entry.groupId).toBe('nuxt')
122118
})
123119

@@ -138,7 +134,7 @@ describe('devframeDockHost grouping', () => {
138134
icon: 'logos:nuxt-icon',
139135
})
140136

141-
const ids = host.values({ includeBuiltin: false }).map(entry => entry.id)
137+
const ids = host.values().map(entry => entry.id)
142138
expect(ids).toEqual(['nuxt:overview', 'nuxt'])
143139
})
144140

@@ -194,33 +190,40 @@ describe('devframeDockHost grouping', () => {
194190
})
195191
})
196192

197-
describe('devframeDockHost built-in gating', () => {
198-
it('includes all three built-ins by default', () => {
193+
describe('devframeDockHost ~builtin category default', () => {
194+
it('returns no docks until an integration registers one', () => {
199195
const host = new DevframeDocksHost(createContext())
200-
const ids = host.values().map(entry => entry.id)
201-
expect(ids).toEqual(['~terminals', '~messages', '~settings'])
196+
expect(host.values()).toEqual([])
202197
})
203198

204-
it('treats an empty builtinDocks map as all-enabled', () => {
205-
const host = new DevframeDocksHost(createContext(), {})
206-
const ids = host.values().map(entry => entry.id)
207-
expect(ids).toEqual(['~terminals', '~messages', '~settings'])
208-
})
199+
it('defaults a ~builtin view without a category to the ~builtin category', () => {
200+
const host = new DevframeDocksHost(createContext())
201+
host.register({
202+
type: '~builtin',
203+
id: '~settings',
204+
title: 'Settings',
205+
icon: 'ph:gear-duotone',
206+
})
209207

210-
it('omits the built-ins gated with `false`, keeping the rest', () => {
211-
const host = new DevframeDocksHost(createContext(), { terminals: false, messages: false })
212-
const ids = host.values().map(entry => entry.id)
213-
expect(ids).toEqual(['~settings'])
208+
const entry = host.values()[0]
209+
expect(entry).toMatchObject({ id: '~settings', type: '~builtin', category: '~builtin' })
214210
})
215211

216-
it('keeps an explicitly-enabled built-in and drops an omitted-as-false sibling', () => {
217-
const host = new DevframeDocksHost(createContext(), { terminals: true, settings: false })
218-
const ids = host.values().map(entry => entry.id)
219-
expect(ids).toEqual(['~terminals', '~messages'])
212+
it('preserves an explicit category on a ~builtin view', () => {
213+
const host = new DevframeDocksHost(createContext())
214+
host.register({
215+
type: '~builtin',
216+
id: '~settings',
217+
title: 'Settings',
218+
icon: 'ph:gear-duotone',
219+
category: 'app',
220+
})
221+
222+
expect(host.values()[0].category).toBe('app')
220223
})
221224

222-
it('keeps user views ahead of gated built-ins', () => {
223-
const host = new DevframeDocksHost(createContext(), { messages: false, settings: false })
225+
it('leaves a non-builtin view without a category untouched', () => {
226+
const host = new DevframeDocksHost(createContext())
224227
host.register({
225228
type: 'iframe',
226229
id: 'app:overview',
@@ -229,12 +232,6 @@ describe('devframeDockHost built-in gating', () => {
229232
url: '/__app/',
230233
})
231234

232-
const ids = host.values().map(entry => entry.id)
233-
expect(ids).toEqual(['app:overview', '~terminals'])
234-
})
235-
236-
it('drops every built-in when includeBuiltin is false, regardless of gating', () => {
237-
const host = new DevframeDocksHost(createContext(), { terminals: true, messages: true, settings: true })
238-
expect(host.values({ includeBuiltin: false })).toEqual([])
235+
expect(host.values()[0].category).toBeUndefined()
239236
})
240237
})

packages/hub/src/node/context.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { CreateHostContextOptions } from 'devframe/node'
22
import type { DevframeHost, DevframeNodeContext } from 'devframe/types'
33
import type { DevframeCommandsHost } from '../types/commands'
4-
import type { BuiltinDocksOptions, DevframeDocksHost } from '../types/docks'
4+
import type { DevframeDocksHost } from '../types/docks'
55
import type { JsonRenderer, JsonRenderSpec } from '../types/json-render'
66
import type { DevframeMessagesHost } from '../types/messages'
77
import type { DevframeTerminalsHost } from '../types/terminals'
@@ -57,19 +57,12 @@ export interface DevframeHubContext extends DevframeNodeContext {
5757
createJsonRenderer: (spec: JsonRenderSpec) => JsonRenderer
5858
}
5959

60-
export interface CreateHubContextOptions extends CreateHostContextOptions {
61-
/**
62-
* Gate the hub's synthesized built-in dock entries (`~terminals`,
63-
* `~messages`, `~settings`). Each entry defaults to present; set one to
64-
* `false` to suppress it — e.g. when mounting `@devframes/plugin-terminals`
65-
* or `@devframes/plugin-messages`, which replace the built-in tabs.
66-
*
67-
* Omitting this option keeps all three built-ins.
68-
*
69-
* @default { terminals: true, messages: true, settings: true }
70-
*/
71-
builtinDocks?: BuiltinDocksOptions
72-
}
60+
/**
61+
* Options for {@link createHubContext} — devframe's
62+
* {@link CreateHostContextOptions} plus any hub-level additions kits layer on
63+
* through declaration merging.
64+
*/
65+
export interface CreateHubContextOptions extends CreateHostContextOptions {}
7366

7467
/**
7568
* Create a hub-level node context: wraps devframe's `createHostContext`,
@@ -87,7 +80,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis
8780
})
8881
const context = baseContext as DevframeHubContext
8982

90-
const docks = new DocksHostImpl(context, options.builtinDocks)
83+
const docks = new DocksHostImpl(context)
9184
const terminals = new TerminalsHostImpl(context)
9285
const messages = new MessagesHostImpl(context)
9386
const commands = new CommandsHostImpl(context)

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

Lines changed: 20 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import type { DevframeNodeContext } from 'devframe/types'
22
import type { SharedState } from 'devframe/utils/shared-state'
33
import type {
4-
BuiltinDocksOptions,
54
DevframeDockEntry,
65
DevframeDocksHost as DevframeDocksHostType,
76
DevframeDockUserEntry,
8-
DevframeViewBuiltin,
97
DevframeViewIframe,
108
RemoteConnectionInfo,
119
RemoteDockOptions,
@@ -93,15 +91,7 @@ export class DevframeDocksHost implements DevframeDocksHostType {
9391

9492
constructor(
9593
public readonly context: DevframeHubContext,
96-
/**
97-
* Per-entry toggles for the synthesized built-in dock entries. An omitted
98-
* (or `undefined`) flag keeps that built-in; only an explicit `false`
99-
* suppresses it.
100-
*/
101-
private readonly builtinDocks: BuiltinDocksOptions = {},
102-
) {
103-
104-
}
94+
) {}
10595

10696
async init() {
10797
this.userSettings = await this.context.rpc.sharedState.get('devframe:user-settings', {
@@ -112,54 +102,8 @@ export class DevframeDocksHost implements DevframeDocksHostType {
112102
})
113103
}
114104

115-
values({
116-
includeBuiltin = true,
117-
}: {
118-
includeBuiltin?: boolean
119-
} = {}): DevframeDockEntry[] {
120-
const context = this.context
121-
// An omitted flag keeps the built-in; only an explicit `false` drops it.
122-
const { terminals = true, messages = true, settings = true } = this.builtinDocks
123-
const builtinDocksEntries: DevframeViewBuiltin[] = []
124-
if (terminals) {
125-
builtinDocksEntries.push({
126-
type: '~builtin',
127-
id: '~terminals',
128-
title: 'Terminals',
129-
icon: 'ph:terminal-duotone',
130-
category: '~builtin',
131-
get when() {
132-
return context.terminals.sessions.size === 0 ? 'false' : undefined
133-
},
134-
})
135-
}
136-
if (messages) {
137-
builtinDocksEntries.push({
138-
type: '~builtin',
139-
id: '~messages',
140-
title: 'Messages & Notifications',
141-
icon: 'ph:notification-duotone',
142-
category: '~builtin',
143-
get badge() {
144-
const size = context.messages.entries.size
145-
return size > 0 ? String(size) : undefined
146-
},
147-
})
148-
}
149-
if (settings) {
150-
builtinDocksEntries.push({
151-
type: '~builtin',
152-
id: '~settings',
153-
title: 'Settings',
154-
category: '~builtin',
155-
icon: 'ph:gear-duotone',
156-
})
157-
}
158-
159-
return [
160-
...Array.from(this.views.values(), view => this.projectView(view)),
161-
...(includeBuiltin ? builtinDocksEntries : []),
162-
]
105+
values(): DevframeDockEntry[] {
106+
return Array.from(this.views.values(), view => this.projectView(view))
163107
}
164108

165109
private projectView(view: DevframeDockUserEntry): DevframeDockUserEntry {
@@ -195,8 +139,9 @@ export class DevframeDocksHost implements DevframeDocksHostType {
195139
}
196140
this.validateGroupMembership(view)
197141
this.prepareRemoteRegistration(view)
198-
this.views.set(view.id, view)
199-
this.events.emit('dock:entry:updated', view)
142+
const entry = this.withBuiltinCategory(view)
143+
this.views.set(entry.id, entry)
144+
this.events.emit('dock:entry:updated', entry)
200145

201146
return {
202147
update: (patch) => {
@@ -214,8 +159,20 @@ export class DevframeDocksHost implements DevframeDocksHostType {
214159
}
215160
this.validateGroupMembership(view)
216161
this.prepareRemoteRegistration(view)
217-
this.views.set(view.id, view)
218-
this.events.emit('dock:entry:updated', view)
162+
const entry = this.withBuiltinCategory(view)
163+
this.views.set(entry.id, entry)
164+
this.events.emit('dock:entry:updated', entry)
165+
}
166+
167+
/**
168+
* `~builtin` views default their category to `~builtin` so the viewer's
169+
* native views group together and sort last — a high-level integration
170+
* registering one needn't repeat the category.
171+
*/
172+
private withBuiltinCategory<T extends DevframeDockUserEntry>(view: T): T {
173+
if (view.type === '~builtin' && view.category === undefined)
174+
return { ...view, category: '~builtin' }
175+
return view
219176
}
220177

221178
private validateGroupMembership(view: DevframeDockUserEntry): void {

0 commit comments

Comments
 (0)