Skip to content

Commit 07d580d

Browse files
authored
feat(hub): expose panel state through client context (#281)
1 parent 1cefacd commit 07d580d

16 files changed

Lines changed: 290 additions & 24 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,29 @@ The context carries the [RPC client](/guide/client) (`rpc`) and the page's `clie
3636

3737
`getDevframeClientContext()` returns the context anywhere; `undefined` before boot.
3838

39+
### Tracking panel state
40+
41+
`ctx.panel.state` is the current dock panel snapshot. It contains `state: 'open' | 'closed' | 'hidden'` and includes `selectedDockId` while a dock is selected. Subscribe to [`ctx.panel.events`](/references/events#client-context-events) for later changes:
42+
43+
```ts
44+
import type { DockClientScriptContext } from '@devframes/hub/client'
45+
import { HUB_EVENTS } from '@devframes/hub/constants'
46+
47+
export default function setup(context: DockClientScriptContext) {
48+
const reportPanelState = (panelState: typeof context.panel.state) => {
49+
void context.rpc.call('my-devframe:panel-state', panelState).catch(error => console.error(error))
50+
}
51+
52+
reportPanelState(context.panel.state)
53+
context.panel.events.on(
54+
HUB_EVENTS.client.docksPanelStateChanged,
55+
reportPanelState,
56+
)
57+
}
58+
```
59+
60+
The custom RPC keeps node-side reporting opt-in.
61+
3962
### Client-only docks
4063

4164
A client runtime can register a dock local to the host page (unlike [node hub context](/guide/hub) docks synced via `devframe:docks`). `ctx.docks.register(entry)` — e.g. `type: 'custom-render'` with `renderer: { importFrom }` — returns a handle whose `update({ badge })` patches in place (id immutable) and `dispose()` removes it. One sharing a server dock's id overrides it locally; re-registering an owned id throws unless you pass `register(entry, true)`.

‎docs/content/8.references/3.events.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,27 @@
22
title: 'Events Reference'
33
navigation:
44
icon: i-lucide-radio-tower
5-
description: 'Devframe carries change notifications across channels of differing direction and reach: a node event bus, server RPC, and server-pushed broadcasts and shared state.'
5+
description: 'Devframe carries change notifications through client contexts, node event buses, RPC, broadcasts, and shared state.'
66
---
77

8-
Devframe carries change notifications across channels of differing **direction and reach**: a node event bus, server RPC, and server-pushed broadcasts and shared state.
8+
Devframe carries change notifications through client contexts, node event buses, RPC, broadcasts, and shared state.
99

1010
Two prefixes mark the wire protocol: `hub:` for hub-layer server RPC (client → server), `devframe:` for the client-facing protocol (server → client). The internal event bus mirrors the subsystem vocabulary (`docks`, `terminals`, `messages`, `commands`) — `docks:activate` fans out to `devframe:docks:activate`.
1111

1212
Each name lives in code: [`HUB_EVENTS`](https://github.com/devframes/devframe/blob/main/packages/hub/src/events.ts) (`@devframes/hub/constants`) backs the hub tables, [`DEVFRAME_EVENTS`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/events.ts) (`devframe/constants`) the core ones.
1313

1414
## Hub events
1515

16+
### Client-context events
17+
18+
Client scripts subscribe to these events on the client context inside the host page.
19+
20+
| Event | Emitter | Payload |
21+
|---|---|---|
22+
| `panel:state:changed` | `ctx.panel.events` | `DevframeDockPanelState` |
23+
24+
`ctx.panel.state` supplies the current snapshot when a client script loads. Later open, close, dock selection, and hub UI provider visibility changes emit `panel:state:changed`. The snapshot contains `state: 'open' | 'closed' | 'hidden'` and an optional `selectedDockId`.
25+
1626
### Internal node event bus
1727

1828
Each subsystem emits on `ctx.<subsystem>.events`, consumed **inside the same node process** by `createHubContext`, which fans them onto the wire.

‎docs/content/8.references/6.hub-api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ The properties of `DevframeClientContext` — [The client context](/guide/client
105105
| `rpc` | The [RPC client](/guide/client) — server/client functions, shared state. |
106106
| `clientType` | `'embedded'` (inside the user app) or `'standalone'` (independent hub page). |
107107
| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, `register()` / `update()` for [client-only docks](/guide/client-context#client-only-docks). |
108-
| `panel` | Dock panel state: position, size, drag/resize. |
108+
| `panel` | Current `state`, local `events`, session, position, size, and drag/resize state for the dock panel. |
109109
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`. |
110110
| `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`). |
111111
| `when` | The [when-clause](/references/when-clauses) context. |

‎packages/hub-ui/src/client/embedded/index.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { DockPanelStorage, DockSessionStorage } from '@devframes/hub/client'
22
import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
33
import { useLocalStorage, useSessionStorage } from '@vueuse/core'
4+
import { ref } from 'vue'
45
import { applyPrimaryColor, setBranding } from '../state/branding'
56
import { DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE } from '../state/docks'
6-
import { setupEmbeddedVisibility } from './visibility'
7+
import { isEmbeddedDockInitiallyVisible, setupEmbeddedVisibility } from './visibility'
78

89
/**
910
* The floating-dock bootstrap the hub serves at `<base>embedded.js` — load
@@ -74,8 +75,10 @@ async function mountDock(): Promise<void> {
7475
// carried by the connection we just established above.
7576
const branding = setBranding(rpc.connectionMeta.configs?.ui?.branding || {})
7677

78+
const embeddedVisibility = rpc.connectionMeta.configs?.ui?.embeddedVisibility ?? 'normal'
79+
const panelVisible = ref(isEmbeddedDockInitiallyVisible(embeddedVisibility))
7780
const { createDocksContext } = await import('../state/context')
78-
const context = await createDocksContext('embedded', rpc, state, session)
81+
const context = await createDocksContext('embedded', rpc, state, session, panelVisible)
7982
setDevframeClientContext(context)
8083

8184
const { DockEmbedded } = await import('../components/DockEmbedded')
@@ -91,14 +94,18 @@ async function mountDock(): Promise<void> {
9194
// Reveal policy: `normal` appends now; `passive`/`hidden` wait for the
9295
// Shift+Alt+D reveal (the element is built and ready, just detached).
9396
setupEmbeddedVisibility(
94-
rpc.connectionMeta.configs?.ui?.embeddedVisibility ?? 'normal',
97+
embeddedVisibility,
9598
branding.productName,
9699
{
97100
show: () => {
98101
if (dockEl && !dockEl.isConnected)
99102
document.body.appendChild(dockEl)
103+
panelVisible.value = true
104+
},
105+
hide: () => {
106+
dockEl?.remove()
107+
panelVisible.value = false
100108
},
101-
hide: () => dockEl?.remove(),
102109
},
103110
)
104111
}

‎packages/hub-ui/src/client/embedded/visibility.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export interface EmbeddedVisibilityHandlers {
6060
hide: () => void
6161
}
6262

63+
export function isEmbeddedDockInitiallyVisible(mode: EmbeddedVisibility): boolean {
64+
return mode === 'normal' || (mode === 'passive' && readPersistedReveal())
65+
}
66+
6367
/**
6468
* Drive the embedded dock's reveal lifecycle for the resolved
6569
* {@link EmbeddedVisibility} mode: decide whether to show on boot, wire the
@@ -73,7 +77,7 @@ export function setupEmbeddedVisibility(
7377
label: string,
7478
handlers: EmbeddedVisibilityHandlers,
7579
): void {
76-
let shown = mode === 'normal' || (mode === 'passive' && readPersistedReveal())
80+
let shown = isEmbeddedDockInitiallyVisible(mode)
7781

7882
function reveal(): void {
7983
if (shown)

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

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { DevframeDockEntry } from '@devframes/hub'
1+
import type { DevframeDockEntry, DevframeDockPanelState } from '@devframes/hub'
22
import type { DevframeRpcClient, DockSessionStorage } from '@devframes/hub/client'
33
import type { SharedState } from 'devframe/utils/shared-state'
4+
import { HUB_EVENTS } from '@devframes/hub/constants'
45
import { DEVFRAME_EVENTS } from 'devframe/constants'
56
import { createEventEmitter } from 'devframe/utils/events'
67
import { createSharedState } from 'devframe/utils/shared-state'
@@ -74,12 +75,74 @@ async function flushRestore(): Promise<void> {
7475
}
7576

7677
describe('createDocksContext', () => {
78+
it('exposes restored panel state and emits selected, hidden, and closed changes', async () => {
79+
expect.assertions(9)
80+
81+
const { rpc, sharedStates, trust } = createStubRpc()
82+
const panelVisible = ref(false)
83+
const session = ref<DockSessionStorage>({
84+
open: true,
85+
selectedDockId: 'git',
86+
selectedDockRoute: null,
87+
})
88+
const context = await createDocksContext('embedded', rpc, undefined, session, panelVisible)
89+
const panelStates: DevframeDockPanelState[] = []
90+
context.panel.events.on(
91+
HUB_EVENTS.client.docksPanelStateChanged,
92+
panelState => panelStates.push(panelState),
93+
)
94+
95+
panelVisible.value = true
96+
await nextTick()
97+
expect(panelStates).toEqual([{ state: 'open', selectedDockId: 'git' }])
98+
panelVisible.value = false
99+
await nextTick()
100+
panelStates.length = 0
101+
102+
trust()
103+
sharedStates.get('devframe:docks')!.push([gitEntry])
104+
sharedStates.get('devframe:dock-renderers')!.push({})
105+
await flushRestore()
106+
expect(context.panel.state).toEqual({ state: 'hidden', selectedDockId: 'git' })
107+
expect(panelStates).toEqual([])
108+
109+
panelVisible.value = true
110+
await nextTick()
111+
expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: 'git' })
112+
113+
session.value.selectedDockId = '~settings'
114+
await nextTick()
115+
expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: '~settings' })
116+
117+
panelVisible.value = false
118+
await nextTick()
119+
expect(panelStates.at(-1)).toEqual({ state: 'hidden', selectedDockId: '~settings' })
120+
121+
session.value.open = false
122+
session.value.selectedDockId = null
123+
await nextTick()
124+
expect(panelStates.at(-1)).toEqual({ state: 'hidden' })
125+
126+
panelVisible.value = true
127+
await nextTick()
128+
expect(panelStates.at(-1)).toEqual({ state: 'closed' })
129+
130+
panelVisible.value = true
131+
session.value.open = false
132+
await nextTick()
133+
expect(panelStates).toHaveLength(5)
134+
})
135+
77136
it('mounts a restored dock once after all initial server state arrives', async () => {
78-
expect.assertions(7)
137+
expect.assertions(8)
79138

80139
const { rpc, sharedStates, trust } = createStubRpc()
81140
const executeSetupScriptMock = vi.mocked(executeSetupScript)
82141
executeSetupScriptMock.mockClear()
142+
let setupPanelState: DevframeDockPanelState | undefined
143+
executeSetupScriptMock.mockImplementationOnce(async (_dockEntry, scriptContext) => {
144+
setupPanelState = scriptContext.panel.state
145+
})
83146
const session = ref<DockSessionStorage>({
84147
open: true,
85148
selectedDockId: 'git',
@@ -105,6 +168,7 @@ describe('createDocksContext', () => {
105168

106169
expect(context.docks.selected?.id).toBe('git')
107170
expect(session.value.open).toBe(true)
171+
expect(setupPanelState).toEqual({ state: 'open', selectedDockId: 'git' })
108172
expect(executeSetupScriptMock).toHaveBeenCalledOnce()
109173
})
110174

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

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import type { DevframeClientCommand, DevframeDockEntry, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub'
2-
import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage } from '@devframes/hub/client'
1+
import type { DevframeClientCommand, DevframeDockEntry, DevframeDockPanelState, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub'
2+
import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage, DocksPanelEvents } from '@devframes/hub/client'
33
import type { SharedState } from 'devframe/utils/shared-state'
44
import type { WhenContext } from 'devframe/utils/when'
55
import type { Ref } from 'vue'
66
import type { DevframeDocksUserSettings } from './dock-settings'
77
import { attachFrameNavClient, createDockRenderersContext } from '@devframes/hub/client'
88
import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants'
99
import { DEVFRAME_EVENTS } from 'devframe/constants'
10+
import { createEventEmitter } from 'devframe/utils/events'
1011
import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue'
1112
import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants'
1213
import { useBranding } from './branding'
@@ -23,6 +24,7 @@ export async function createDocksContext(
2324
rpc: DevframeRpcClient,
2425
panelStore?: Ref<DockPanelStorage>,
2526
sessionStore?: Ref<DockSessionStorage>,
27+
panelVisible: Ref<boolean> = ref(true),
2628
): Promise<DocksContext> {
2729
if (docksContextByRpc.has(rpc)) {
2830
return docksContextByRpc.get(rpc)!
@@ -172,6 +174,7 @@ export async function createDocksContext(
172174
}
173175

174176
panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE())
177+
const panelEvents = createEventEmitter<DocksPanelEvents>()
175178
let docksContext: DocksContext
176179

177180
let _settingsStorePromise: Promise<SharedState<DevframeDocksUserSettings>> | undefined
@@ -269,6 +272,10 @@ export async function createDocksContext(
269272
return false
270273
}
271274

275+
initialRestorePending.value = false
276+
selectedDockId.value = entry.id
277+
sessionStore.value.open = true
278+
272279
// If has import script, run it
273280
if (
274281
(entry.type === 'action')
@@ -299,9 +306,6 @@ export async function createDocksContext(
299306
if (entry.groupId)
300307
(sessionStore.value.groupLastChildIds ??= {})[entry.groupId] = entry.id
301308

302-
initialRestorePending.value = false
303-
selectedDockId.value = entry.id
304-
sessionStore.value.open = true
305309
// Only an iframe dock owns an address-bar route; ViewIframe keeps
306310
// `session.selectedDockRoute` current for it. Clear it for anything else so a stale
307311
// route from a previous iframe isn't persisted against a non-iframe dock.
@@ -577,6 +581,21 @@ export async function createDocksContext(
577581

578582
docksContext = reactive({
579583
panel: {
584+
get state() {
585+
let state: DevframeDockPanelState['state']
586+
if (!panelVisible.value)
587+
state = 'hidden'
588+
else if (sessionStore.value.open)
589+
state = 'open'
590+
else
591+
state = 'closed'
592+
593+
const panelState: DevframeDockPanelState = { state }
594+
if (selectedDockId.value !== null)
595+
panelState.selectedDockId = selectedDockId.value
596+
return panelState
597+
},
598+
events: markRaw(panelEvents),
580599
store: panelStore,
581600
session: sessionStore,
582601
isDragging: false,
@@ -669,6 +688,23 @@ export async function createDocksContext(
669688
initialRestorePending.value = false
670689
await switchEntry(restoreDockId)
671690
}
691+
let previousPanelState = docksContext.panel.state
692+
watch(
693+
[panelVisible, () => sessionStore.value.open, selectedDockId],
694+
() => {
695+
const panelState = docksContext.panel.state
696+
if (
697+
panelState.state === previousPanelState.state
698+
&& panelState.selectedDockId === previousPanelState.selectedDockId
699+
) {
700+
return
701+
}
702+
703+
previousPanelState = panelState
704+
panelEvents.emit(HUB_EVENTS.client.docksPanelStateChanged, panelState)
705+
},
706+
{ flush: 'post' },
707+
)
672708
void restoreAfterInitialization()
673709

674710
docksContextByRpc.set(rpc, docksContext)

‎packages/hub/src/client/__tests__/host.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { DevframeRpcClient } from 'devframe/client'
22
import type { SharedState } from 'devframe/utils/shared-state'
3-
import type { DevframeDockEntry } from '../../types/docks'
3+
import type { DevframeDockEntry, DevframeDockPanelState } from '../../types/docks'
44
import { createEventEmitter } from 'devframe/utils/events'
55
import { describe, expect, it, vi } from 'vitest'
6+
import { HUB_EVENTS } from '../../events'
67
import { getDevframeClientContext } from '../context'
78
import { createDevframeClientRuntime } from '../host'
89

@@ -67,6 +68,41 @@ function groupEntry(id: string, extra?: Record<string, unknown>): DevframeDockEn
6768
}
6869

6970
describe('createDevframeClientRuntime', () => {
71+
it('exposes panel state and emits coalesced changes', async () => {
72+
expect.assertions(6)
73+
74+
const { rpc, states } = createStubRpc()
75+
const host = await createDevframeClientRuntime({ rpc, clientType: 'embedded' })
76+
const panelStates: DevframeDockPanelState[] = []
77+
78+
expect(host.context.panel.state).toEqual({ state: 'closed' })
79+
host.context.panel.events.on(
80+
HUB_EVENTS.client.docksPanelStateChanged,
81+
panelState => panelStates.push(panelState),
82+
)
83+
84+
states.get('devframe:docks')!.push([iframeEntry('one'), iframeEntry('two')])
85+
host.context.panel.session.open = true
86+
const switched = host.context.docks.switchEntry('one')
87+
await switched
88+
expect(panelStates).toEqual([{ state: 'open', selectedDockId: 'one' }])
89+
expect(host.context.panel.state).toEqual({ state: 'open', selectedDockId: 'one' })
90+
91+
host.context.panel.session.open = true
92+
host.context.panel.session.selectedDockId = 'one'
93+
await Promise.resolve()
94+
expect(panelStates).toHaveLength(1)
95+
96+
await host.context.docks.switchEntry('two')
97+
expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: 'two' })
98+
99+
host.context.panel.session.open = false
100+
const cleared = host.context.docks.switchEntry(null)
101+
await cleared
102+
expect(panelStates.at(-1)).toEqual({ state: 'closed' })
103+
host.dispose()
104+
})
105+
70106
it('publishes the global client context with the full surface', async () => {
71107
const { rpc } = createStubRpc()
72108
const host = await createDevframeClientRuntime({ rpc })

0 commit comments

Comments
 (0)