Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ A hub-aware UI doesn't import any hub classes; it reads three shared-state keys

| Channel | Type | What it carries |
|---|---|---|
| `devframe:docks` shared state | `DevframeDockEntry[]` | The full dock list, including the hub's `~terminals` / `~messages` / `~settings` builtins. |
| `devframe:docks` shared state | `DevframeDockEntry[]` | Every dock entry the mounted integrations registered. |
| `devframe:commands` shared state | `DevframeServerCommandEntry[]` | Serializable command list (handlers stripped). |
| `devframe:user-settings` shared state | `DevframeDocksUserSettings` | Persisted per-workspace hub settings. |
| `hub:commands:execute` RPC | `(id, ...args) => unknown` | Server-side command dispatch. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ export async function minimalNextDevframeHub(
handler: () => 'pong',
})

// The hub no longer synthesizes built-in docks — a high-level integration
// registers the viewer's native views it wants. `~builtin` views default
// their category to `~builtin`, so this Settings tab sorts last on its own.
context.docks.register({
type: '~builtin',
id: '~settings',
title: 'Settings',
icon: 'ph:gear-duotone',
})

// Demo devframes alongside the dogfooded built-in plugin packages.
const devframes = options.devframes
?? [demoDevframe, demoDevframeB, ...await loadBuiltinPlugins()]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,18 @@ describe('minimal-next-devframe-hub (example)', () => {
})
})

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

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

Expand Down
20 changes: 2 additions & 18 deletions packages/hub/src/node/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,15 @@ function createHost(storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-contex
}

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

const docks = await context.rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks')
expect(docks.value().map(dock => dock.id)).toEqual([
'~terminals',
'~messages',
'~settings',
])
})

it('suppresses built-ins gated off via builtinDocks', async () => {
const context = await createHubContext({
cwd: process.cwd(),
mode: 'build',
host: createHost(),
builtinDocks: { terminals: false, messages: false },
})

const docks = await context.rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks')
expect(docks.value().map(dock => dock.id)).toEqual(['~settings'])
expect(docks.value()).toEqual([])
})
})

Expand Down
69 changes: 33 additions & 36 deletions packages/hub/src/node/__tests__/host-docks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ function createContext(): DevframeHubContext {
resolveOrigin: () => 'http://localhost:5173',
getStorageDir: () => storageDir,
},
// Minimal stubs so the built-in `~terminals`/`~messages` getters
// (`when`/`badge`) can be evaluated without a full context.
terminals: { sessions: new Map() },
messages: { entries: new Map() },
} as unknown as DevframeHubContext
}

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

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

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

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

expect(host.views.has('nuxt')).toBe(true)
expect(emitted).toEqual(['nuxt'])
const entry = host.values({ includeBuiltin: false })[0]
const entry = host.values()[0]
expect(entry.type).toBe('group')
expect(entry).toMatchObject({ id: 'nuxt', defaultChildId: 'nuxt:overview' })
})
Expand All @@ -117,7 +113,7 @@ describe('devframeDockHost grouping', () => {
groupId: 'nuxt',
})

const entry = host.values({ includeBuiltin: false })[0]
const entry = host.values()[0]
expect(entry.groupId).toBe('nuxt')
})

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

const ids = host.values({ includeBuiltin: false }).map(entry => entry.id)
const ids = host.values().map(entry => entry.id)
expect(ids).toEqual(['nuxt:overview', 'nuxt'])
})

Expand Down Expand Up @@ -194,33 +190,40 @@ describe('devframeDockHost grouping', () => {
})
})

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

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

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

it('keeps an explicitly-enabled built-in and drops an omitted-as-false sibling', () => {
const host = new DevframeDocksHost(createContext(), { terminals: true, settings: false })
const ids = host.values().map(entry => entry.id)
expect(ids).toEqual(['~terminals', '~messages'])
it('preserves an explicit category on a ~builtin view', () => {
const host = new DevframeDocksHost(createContext())
host.register({
type: '~builtin',
id: '~settings',
title: 'Settings',
icon: 'ph:gear-duotone',
category: 'app',
})

expect(host.values()[0].category).toBe('app')
})

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

const ids = host.values().map(entry => entry.id)
expect(ids).toEqual(['app:overview', '~terminals'])
})

it('drops every built-in when includeBuiltin is false, regardless of gating', () => {
const host = new DevframeDocksHost(createContext(), { terminals: true, messages: true, settings: true })
expect(host.values({ includeBuiltin: false })).toEqual([])
expect(host.values()[0].category).toBeUndefined()
})
})
23 changes: 8 additions & 15 deletions packages/hub/src/node/context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CreateHostContextOptions } from 'devframe/node'
import type { DevframeHost, DevframeNodeContext } from 'devframe/types'
import type { DevframeCommandsHost } from '../types/commands'
import type { BuiltinDocksOptions, DevframeDocksHost } from '../types/docks'
import type { DevframeDocksHost } from '../types/docks'
import type { JsonRenderer, JsonRenderSpec } from '../types/json-render'
import type { DevframeMessagesHost } from '../types/messages'
import type { DevframeTerminalsHost } from '../types/terminals'
Expand Down Expand Up @@ -57,19 +57,12 @@ export interface DevframeHubContext extends DevframeNodeContext {
createJsonRenderer: (spec: JsonRenderSpec) => JsonRenderer
}

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

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

const docks = new DocksHostImpl(context, options.builtinDocks)
const docks = new DocksHostImpl(context)
const terminals = new TerminalsHostImpl(context)
const messages = new MessagesHostImpl(context)
const commands = new CommandsHostImpl(context)
Expand Down
83 changes: 20 additions & 63 deletions packages/hub/src/node/host-docks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { DevframeNodeContext } from 'devframe/types'
import type { SharedState } from 'devframe/utils/shared-state'
import type {
BuiltinDocksOptions,
DevframeDockEntry,
DevframeDocksHost as DevframeDocksHostType,
DevframeDockUserEntry,
DevframeViewBuiltin,
DevframeViewIframe,
RemoteConnectionInfo,
RemoteDockOptions,
Expand Down Expand Up @@ -93,15 +91,7 @@ export class DevframeDocksHost implements DevframeDocksHostType {

constructor(
public readonly context: DevframeHubContext,
/**
* Per-entry toggles for the synthesized built-in dock entries. An omitted
* (or `undefined`) flag keeps that built-in; only an explicit `false`
* suppresses it.
*/
private readonly builtinDocks: BuiltinDocksOptions = {},
) {

}
) {}

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

values({
includeBuiltin = true,
}: {
includeBuiltin?: boolean
} = {}): DevframeDockEntry[] {
const context = this.context
// An omitted flag keeps the built-in; only an explicit `false` drops it.
const { terminals = true, messages = true, settings = true } = this.builtinDocks
const builtinDocksEntries: DevframeViewBuiltin[] = []
if (terminals) {
builtinDocksEntries.push({
type: '~builtin',
id: '~terminals',
title: 'Terminals',
icon: 'ph:terminal-duotone',
category: '~builtin',
get when() {
return context.terminals.sessions.size === 0 ? 'false' : undefined
},
})
}
if (messages) {
builtinDocksEntries.push({
type: '~builtin',
id: '~messages',
title: 'Messages & Notifications',
icon: 'ph:notification-duotone',
category: '~builtin',
get badge() {
const size = context.messages.entries.size
return size > 0 ? String(size) : undefined
},
})
}
if (settings) {
builtinDocksEntries.push({
type: '~builtin',
id: '~settings',
title: 'Settings',
category: '~builtin',
icon: 'ph:gear-duotone',
})
}

return [
...Array.from(this.views.values(), view => this.projectView(view)),
...(includeBuiltin ? builtinDocksEntries : []),
]
values(): DevframeDockEntry[] {
return Array.from(this.views.values(), view => this.projectView(view))
}

private projectView(view: DevframeDockUserEntry): DevframeDockUserEntry {
Expand Down Expand Up @@ -195,8 +139,9 @@ export class DevframeDocksHost implements DevframeDocksHostType {
}
this.validateGroupMembership(view)
this.prepareRemoteRegistration(view)
this.views.set(view.id, view)
this.events.emit('dock:entry:updated', view)
const entry = this.withBuiltinCategory(view)
this.views.set(entry.id, entry)
this.events.emit('dock:entry:updated', entry)

return {
update: (patch) => {
Expand All @@ -214,8 +159,20 @@ export class DevframeDocksHost implements DevframeDocksHostType {
}
this.validateGroupMembership(view)
this.prepareRemoteRegistration(view)
this.views.set(view.id, view)
this.events.emit('dock:entry:updated', view)
const entry = this.withBuiltinCategory(view)
this.views.set(entry.id, entry)
this.events.emit('dock:entry:updated', entry)
}

/**
* `~builtin` views default their category to `~builtin` so the viewer's
* native views group together and sort last — a high-level integration
* registering one needn't repeat the category.
*/
private withBuiltinCategory<T extends DevframeDockUserEntry>(view: T): T {
if (view.type === '~builtin' && view.category === undefined)
return { ...view, category: '~builtin' }
return view
}

private validateGroupMembership(view: DevframeDockUserEntry): void {
Expand Down
Loading
Loading