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
34 changes: 34 additions & 0 deletions docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,42 @@ ctx.docks.register({

`groupId` lives on every entry kind, so iframes, launchers, custom-render views, and integration-contributed types (e.g. json-render panels) 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.

### The dual role of `category`

`category` decides an entry's outer bucket on the dock bar (ordered by `DEFAULT_CATEGORIES_ORDER`) — but its meaning shifts once an entry joins a group:

- **Ungrouped entry** — `category` is the entry's outer dock-bar bucket, defaulting to `'default'`.
- **Grouped entry** (a `groupId` that resolves to a registered group) — the outer bucket comes from the **group's** own `category` instead. The member's own `category` is reinterpreted as an **in-group sub-category** that sub-divides and sorts members inside the group's popover / sub-navigation.

The group's `category` is therefore the single outer bucket shared by the group button and all its members. Fallbacks keep the rule total:

- A **group** with no `category` buckets itself and its members under `'default'`.
- A grouped **member** with no `category` defaults its in-group sub-category to `'default'`.
- **Orphan tolerance** — a member whose `groupId` never resolves to a registered group renders as a normal top-level entry and uses its **own** `category` as the outer bucket, exactly as an ungrouped entry does.

In the example above, `nuxt:overview` renders under the `framework` bucket (from the `nuxt` group) even though it could carry its own `category`; that own `category` would only sort it against sibling members inside the Nuxt group.

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.

#### Known categories

`DEFAULT_CATEGORIES_ORDER` (exported from `@devframes/hub`, `@devframes/hub/node`, `@devframes/hub/client` and `@devframes/hub/constants`) names the buckets the hub orders by default, reading from "closest to your app" toward "peripheral":

| Category | Weight | Typical use |
|---|---|---|
| `framework` | `-100` | Framework internals (Vue / Nuxt / Vite). |
| `default` | `0` | Uncategorized entries. |
| `app` | `100` | The user's own app tools. |
| `ui` | `150` | Components, design system, styling. |
| `data` | `250` | State, storage, queries, database. |
| `web` | `300` | Network, platform, accessibility. |
| `performance` | `350` | Profiling, metrics, budgets. |
| `advanced` | `400` | Power-user / low-level tools. |
| `docs` | `500` | Documentation, references. |
| `~builtin` | `1000` | The viewer's own built-in views; always last. |

The gaps are intentional — a kit can interleave its own category ids (or override any weight) without editing this table. An unknown category sorts as weight `0`.

## The protocol — what the UI sees

A hub-aware UI doesn't import any hub classes; it reads three shared-state keys and one RPC method:
Expand Down
44 changes: 44 additions & 0 deletions packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ function iframeEntry(id: string, extra?: Record<string, unknown>): DevframeDockE
return { id, title: id, icon: 'ph:cube', type: 'iframe', url: `/__${id}/`, ...extra } as DevframeDockEntry
}

function groupEntry(id: string, extra?: Record<string, unknown>): DevframeDockEntry {
return { id, title: id, icon: 'ph:folder', type: 'group', ...extra } as DevframeDockEntry
}

describe('createDevframeClientHost', () => {
it('publishes the global client context with the full surface', async () => {
const { rpc } = createStubRpc()
Expand Down Expand Up @@ -95,6 +99,46 @@ describe('createDevframeClientHost', () => {
host.dispose()
})

it('groups entries by category — grouped members bucket under their group, orphans by their own', async () => {
const { rpc, states } = createStubRpc()
const host = await createDevframeClientHost({ rpc })

states.get('devframe:docks')!.push([
// (a) member with groupId + its own category → outer bucket = group's category ('framework'),
// and its own 'app' category is reinterpreted as an in-group sub-category.
groupEntry('nuxt', { category: 'framework' }),
iframeEntry('nuxt:overview', { groupId: 'nuxt', category: 'app' }),
// (b) group with no category → members bucket to 'default'.
groupEntry('misc'),
iframeEntry('misc:one', { groupId: 'misc', category: 'web' }),
// (c) orphan member (groupId with no registered group) → its own category ('web').
iframeEntry('orphan', { groupId: 'ghost', category: 'web' }),
// plain ungrouped entry keeps its own category.
iframeEntry('plain', { category: 'app' }),
])

const grouped = Object.fromEntries(
host.context.docks.groupedEntries.map(([cat, entries]) => [cat, entries.map(e => e.id)]),
)

// (a) grouped member lands under the group's category, alongside the group button.
expect(grouped.framework).toEqual(['nuxt', 'nuxt:overview'])
// (b) group without a category, and its member, bucket to 'default'.
expect(grouped.default).toEqual(['misc', 'misc:one'])
// (c) orphan + plain keep their own categories.
expect(grouped.web).toEqual(['orphan'])
expect(grouped.app).toEqual(['plain'])

// Categories sort by DEFAULT_CATEGORIES_ORDER — framework first.
expect(host.context.docks.groupedEntries.map(([cat]) => cat)).toEqual([
'framework',
'default',
'app',
'web',
])
host.dispose()
})

it('switches entries with activation/deactivation events and when-context updates', async () => {
const { rpc, states } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
Expand Down
15 changes: 14 additions & 1 deletion packages/hub/src/client/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,9 +479,22 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext {
}

function groupByCategory(entries: DevframeDockEntry[]): DevframeDockEntriesGrouped {
// Index registered groups so a member whose `groupId` resolves takes its
// OUTER bucket from the group's category, not its own (which becomes the
// member's in-group sub-category). Orphan members — a `groupId` with no
// registered group — fall back to their own category.
const groupsById = new Map<string, DevframeDockEntry>()
for (const entry of entries) {
if (entry.type === 'group')
groupsById.set(entry.id, entry)
}

const groups = new Map<string, DevframeDockEntry[]>()
for (const entry of entries) {
const category = entry.category ?? 'default'
const resolvedGroup = entry.groupId ? groupsById.get(entry.groupId) : undefined
const category = resolvedGroup
? resolvedGroup.category ?? 'default'
: entry.category ?? 'default'
let list = groups.get(category)
if (!list) {
list = []
Expand Down
3 changes: 3 additions & 0 deletions packages/hub/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Re-export the category-order table so client-side viewers can import the
// single source of truth from `@devframes/hub/client`.
export { DEFAULT_CATEGORIES_ORDER } from '../constants'
export * from './client-script'
export * from './context'
export * from './docks'
Expand Down
16 changes: 15 additions & 1 deletion packages/hub/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,26 @@ import type { DevframeDocksUserSettings } from './types/settings'

export * from 'devframe/constants'

/**
* The default ordering weight for each known dock category — lower sorts
* earlier. Downstream viewers (e.g. `@vitejs/devtools-kit`) import this as the
* single source of truth so the hub and its viewers agree on category order.
* `framework` sorts first; `~builtin` (the viewer's own built-in views) last.
*
* The buckets read from "closest to your app" → "platform / analysis" →
* "peripheral". Gaps between the weights are intentional: a kit can interleave
* its own categories (or override these) without editing this table.
*/
export const DEFAULT_CATEGORIES_ORDER: Record<string, number> = {
'framework': -100,
'default': 0,
'app': 100,
'framework': 200,
'ui': 150,
'data': 250,
'web': 300,
'performance': 350,
'advanced': 400,
'docs': 500,
'~builtin': 1000,
}

Expand Down
1 change: 1 addition & 0 deletions packages/hub/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { DEFAULT_CATEGORIES_ORDER } from './constants'
export * from './define'
export type * from './types'
3 changes: 3 additions & 0 deletions packages/hub/src/node/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Re-export the category-order table so node-side consumers can import the
// single source of truth from `@devframes/hub/node`.
export { DEFAULT_CATEGORIES_ORDER } from '../constants'
export * from './context'
export * from './host-commands'
export * from './host-docks'
Expand Down
47 changes: 38 additions & 9 deletions packages/hub/src/types/docks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,20 @@ export interface DevframeDocksActiveState {
activation: DevframeDockActivation | null
}

// Known categories the hub orders by default. Kits may pass their own
// category ids; `(string & {})` keeps autocomplete on the known set while
// Known categories the hub orders by default (see
// {@link import('../constants').DEFAULT_CATEGORIES_ORDER}). Kits may pass their
// own category ids; `(string & {})` keeps autocomplete on the known set while
// allowing arbitrary string values. `~builtin` is reserved for the viewer's
// own built-in views (see {@link DevframeViewBuiltin}) and always sorts last.
export type DevframeDockEntryCategory
= | 'app'
| 'framework'
| 'web'
| 'advanced'
= | 'framework' // framework internals (Vue / Nuxt / Vite)
| 'app' // the user's own app tools
| 'ui' // components, design system, styling
| 'data' // state, storage, queries, database
| 'web' // network, platform, accessibility
| 'performance' // profiling, metrics, budgets
| 'advanced' // power-user / low-level tools
| 'docs' // documentation, references
| 'default'
| '~builtin'
| (string & {})
Expand All @@ -76,7 +81,19 @@ export interface DevframeDockEntryBase {
*/
defaultOrder?: number
/**
* The category of the entry
* The category of the entry — a field with a dual role that depends on
* whether {@link groupId} resolves to a registered {@link DevframeViewGroup}:
*
* - **Ungrouped (or orphan) entry** — `category` is the entry's OUTER bucket
* on the dock bar, ordered by {@link import('../constants').DEFAULT_CATEGORIES_ORDER}.
* - **Grouped entry** (a `groupId` that resolves to a registered group) —
* the OUTER bucket is instead the group's own `category`, and this field is
* reinterpreted as the entry's IN-GROUP sub-category, used to sub-divide and
* sort members inside the group's popover / sub-navigation.
*
* Falls back to `'default'` when omitted — both as an outer bucket and, for a
* grouped member, as its in-group sub-bucket.
*
* @default 'default'
*/
category?: DevframeDockEntryCategory
Expand All @@ -102,8 +119,14 @@ export interface DevframeDockEntryBase {
*
* This is a flat pointer — membership, not containment. The entry stays an
* independently-registered, top-level entry; only its rendering is grouped
* downstream. If the referenced group is never registered, the entry renders
* as a normal top-level entry (orphan tolerance).
* downstream.
*
* When the referenced group **is** registered, it supplies the entry's OUTER
* dock-bar category (the group's own {@link category}), and this entry's own
* {@link category} is reinterpreted as its IN-GROUP sub-category. When the
* referenced group is **never** registered, the entry renders as a normal
* top-level entry and falls back to using its own {@link category} as the
* outer bucket (orphan tolerance).
*
* @see {@link DevframeViewGroup}
*/
Expand Down Expand Up @@ -277,6 +300,12 @@ export interface DevframeViewJsonRender extends DevframeDockEntryBase {
* through the same `register`/`update`/`values` machinery as every other entry,
* keyed by `id`.
*
* The group's `category` is the OUTER bucket for the group button itself AND
* for every one of its members — a member's own `category` no longer decides
* its outer bucket, but is reinterpreted as an in-group sub-category that
* sub-divides and sorts members inside this group. A group with no `category`
* buckets itself and its members under `'default'`.
*
* Grouping is one level deep: a group entry must not itself set `groupId`.
*/
export interface DevframeViewGroup extends DevframeDockEntryBase {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export * from "devframe/client";
// #endregion

// #region Other
export { DEFAULT_CATEGORIES_ORDER }
export { DevframeClientRpcHost }
export { RpcClientEvents }
// #endregion
4 changes: 4 additions & 0 deletions tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ export var CLIENT_CONTEXT_KEY /* const */

// #region Re-exports
export * from "devframe/client";
// #endregion

// #region Other
export { DEFAULT_CATEGORIES_ORDER }
// #endregion
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export declare const defineHubRpcFunction: <NAME extends string, TYPE extends im
export { ClientScriptEntry }
export { ConnectionMeta }
export { CreateHubContextOptions }
export { DEFAULT_CATEGORIES_ORDER }
export { DevframeCapabilities }
export { DevframeChildProcessExecuteOptions }
export { DevframeChildProcessOutput }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Generated by tsnapi — public API snapshot of `@devframes/hub`
*/
// #region Other
export { DEFAULT_CATEGORIES_ORDER }
export { defineCommand }
export { defineDockEntry }
export { defineHubRpcFunction }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,5 +276,6 @@ export declare const hubTerminalsWrite: {
// #region Other
export { createHubContext }
export { CreateHubContextOptions }
export { DEFAULT_CATEGORIES_ORDER }
export { DevframeHubContext }
// #endregion
4 changes: 4 additions & 0 deletions tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,8 @@ export var hubTerminalsResize /* const */
export var hubTerminalsRestart /* const */
export var hubTerminalsTerminate /* const */
export var hubTerminalsWrite /* const */
// #endregion

// #region Other
export { DEFAULT_CATEGORIES_ORDER }
// #endregion
Loading