Skip to content

Commit a5ed1aa

Browse files
committed
refactor(hub): emit panel state from client context
1 parent d9b0573 commit a5ed1aa

28 files changed

Lines changed: 166 additions & 364 deletions

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` for later changes:
42+
43+
```ts
44+
import type { DevframeDockPanelState } from '@devframes/hub'
45+
import type { DockClientScriptContext } from '@devframes/hub/client'
46+
import { HUB_EVENTS } from '@devframes/hub/constants'
47+
48+
export default function setup(context: DockClientScriptContext) {
49+
const reportPanelState = (panelState: DevframeDockPanelState) =>
50+
context.rpc.call('my-devframe:panel-state', panelState)
51+
52+
void reportPanelState(context.panel.state)
53+
context.panel.events.on(
54+
HUB_EVENTS.client.docksPanelStateChanged,
55+
panelState => void reportPanelState(panelState),
56+
)
57+
}
58+
```
59+
60+
The custom RPC keeps node-side reporting opt-in. Its handler can call `ctx.rpc.getCurrentRpcSession()` when it needs the reporting connection's identity.
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 & 6 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.
@@ -21,19 +31,15 @@ Each subsystem emits on `ctx.<subsystem>.events`, consumed **inside the same nod
2131
|---|---|---|---|
2232
| `docks:entry:updated` | `DocksHost.register` / `update` | context → `devframe:docks` shared state | `DevframeDockUserEntry` |
2333
| `docks:activate` | `DocksHost.activate()` | context → broadcast + `devframe:docks:active` | `DevframeDockActivation` |
24-
| `docks:panel:state` | viewer state reports and RPC disconnects | hub consumers | `DevframeDockPanelStateEvent` |
2534
| `terminals:session:updated` | `TerminalsHost` register / update / remove / status change | context → `devframe:terminals:updated`; the terminals devframe | `DevframeTerminalSession` |
2635
| `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; the messages devframe | entry / entry / id / — |
2736
| `commands:registered` / `commands:unregistered` | `CommandsHost` register / update / unregister | context → `devframe:commands` shared state | entry / id |
2837

29-
`docks:panel:state` emits `connected` with the first reported panel snapshot, `changed` when its state or selected dock changes, and `disconnected` when the reporting RPC connection closes. A snapshot contains `state: 'open' | 'closed' | 'hidden'` and an optional `selectedDockId`. Its numeric `sessionId` identifies that connection for the lifetime of the Node process. A reload or reconnect receives a new id.
30-
3138
### Server RPC methods — client → server
3239

3340
| Method | Signature | Purpose |
3441
|---|---|---|
3542
| `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the hub UI provider to switch its active dock — see [Deep Linking](/guide/deep-linking). |
36-
| `hub:docks:panel:state` | `(panelState: DevframeDockPanelState) => void` | Report this viewer connection's current dock-panel state and selected dock. |
3743
| `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. |
3844
| `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). |
3945
| `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. |

examples/hub-vite-minimal/vite.config.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
22
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
3-
import { styleText } from 'node:util'
43
import { createUi } from '@devframes/hub-ui'
5-
import { HUB_EVENTS } from '@devframes/hub/constants'
64
import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub'
75
import { createA11yDevframe } from '@devframes/plugin-a11y'
86
import { createAssetsDevframe } from '@devframes/plugin-assets'
@@ -80,10 +78,6 @@ export default defineConfig({
8078
// the same contract.
8179
renderers: [jsonRenderUiRenderer()],
8280
configure(ctx) {
83-
ctx.docks.events.on(HUB_EVENTS.bus.docksPanelState, (event) => {
84-
console.info(styleText('cyan', '🔄 [hub-vite-minimal]'), 'Dock panel state updated', event)
85-
})
86-
8781
ctx.docks.register(jsonRenderDock)
8882
},
8983
// Gate with devframe's interactive OTP (the default): the hub prints a

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ afterEach(() => {
77
})
88

99
describe('setupEmbeddedVisibility', () => {
10-
it('reports the initial hidden state and later reveal and conceal transitions', () => {
10+
it('handles the initial hidden state and later reveal and conceal transitions', () => {
1111
expect.assertions(5)
1212

1313
const listeners = new Map<string, EventListener>()

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

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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'
44
import { HUB_EVENTS } from '@devframes/hub/constants'
@@ -72,10 +72,12 @@ async function flushRestore(): Promise<void> {
7272
await Promise.resolve()
7373
await Promise.resolve()
7474
await nextTick()
75+
await Promise.resolve()
76+
await Promise.resolve()
7577
}
7678

7779
describe('createDocksContext', () => {
78-
it('reports restored, selected, hidden, and closed panel snapshots', async () => {
80+
it('exposes restored panel state and emits selected, hidden, and closed changes', async () => {
7981
expect.assertions(8)
8082

8183
const { rpc, sharedStates, trust } = createStubRpc()
@@ -85,7 +87,12 @@ describe('createDocksContext', () => {
8587
selectedDockId: 'git',
8688
selectedDockRoute: null,
8789
})
88-
await createDocksContext('embedded', rpc, undefined, session, panelVisible)
90+
const context = await createDocksContext('embedded', rpc, undefined, session, panelVisible)
91+
const panelStates: DevframeDockPanelState[] = []
92+
context.panel.events.on(
93+
HUB_EVENTS.client.docksPanelStateChanged,
94+
panelState => panelStates.push(panelState),
95+
)
8996

9097
trust()
9198
sharedStates.get('devframe:docks')!.push([gitEntry])
@@ -95,34 +102,30 @@ describe('createDocksContext', () => {
95102

96103
panelVisible.value = true
97104
await nextTick()
98-
await vi.waitFor(() => {
99-
if (vi.mocked(rpc.call).mock.calls.length !== 1)
100-
throw new Error('waiting for the restored panel state report')
101-
})
102-
expect(rpc.call).toHaveBeenCalledTimes(1)
103-
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' })
105+
expect(context.panel.state).toEqual({ state: 'open', selectedDockId: 'git' })
106+
expect(panelStates).toEqual([])
104107

105108
session.value.selectedDockId = '~settings'
106109
await nextTick()
107-
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: '~settings' })
110+
expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: '~settings' })
108111

109112
panelVisible.value = false
110113
await nextTick()
111-
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden', selectedDockId: '~settings' })
114+
expect(panelStates.at(-1)).toEqual({ state: 'hidden', selectedDockId: '~settings' })
112115

113116
session.value.open = false
114117
session.value.selectedDockId = null
115118
await nextTick()
116-
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden' })
119+
expect(panelStates.at(-1)).toEqual({ state: 'hidden' })
117120

118121
panelVisible.value = true
119122
await nextTick()
120-
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'closed' })
123+
expect(panelStates.at(-1)).toEqual({ state: 'closed' })
121124

122125
panelVisible.value = true
123126
session.value.open = false
124127
await nextTick()
125-
expect(rpc.call).toHaveBeenCalledTimes(5)
128+
expect(panelStates).toHaveLength(4)
126129
})
127130

128131
it('mounts a restored dock once after all initial server state arrives', async () => {

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

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { DevframeClientCommand, DevframeDockEntry, DevframeDockPanelState, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub'
2-
import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage } from '@devframes/hub/client'
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'
7-
import { attachFrameNavClient, createDockRenderersContext, reportDockPanelState } from '@devframes/hub/client'
7+
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'
@@ -193,6 +194,7 @@ export async function createDocksContext(
193194
}
194195

195196
panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE())
197+
const panelEvents = createEventEmitter<DocksPanelEvents>()
196198
let docksContext: DocksContext
197199

198200
let _settingsStorePromise: Promise<SharedState<DevframeDocksUserSettings>> | undefined
@@ -598,6 +600,14 @@ export async function createDocksContext(
598600

599601
docksContext = reactive({
600602
panel: {
603+
get state() {
604+
return createDockPanelState(
605+
panelVisible.value !== false,
606+
sessionStore.value.open,
607+
selectedDockId.value,
608+
)
609+
},
610+
events: markRaw(panelEvents),
601611
store: panelStore,
602612
session: sessionStore,
603613
isDragging: false,
@@ -692,20 +702,26 @@ export async function createDocksContext(
692702
initialRestorePending.value = false
693703
await switchEntry(restoreDockId)
694704
}
695-
const reportPanelStateAfterInitialization = async (): Promise<void> => {
696-
await restoreAfterInitialization()
705+
const startPanelStateEvents = (): void => {
706+
let previousPanelState = docksContext.panel.state
697707
watch(
698708
[panelVisible, () => sessionStore.value.open, selectedDockId],
699-
([visible, open, currentSelectedDockId]) => {
700-
if (visible === undefined)
709+
() => {
710+
const panelState = docksContext.panel.state
711+
if (
712+
panelState.state === previousPanelState.state
713+
&& panelState.selectedDockId === previousPanelState.selectedDockId
714+
) {
701715
return
702-
const panelState = createDockPanelState(visible, open, currentSelectedDockId)
703-
void reportDockPanelState(rpc, panelState).catch(() => {})
716+
}
717+
718+
previousPanelState = panelState
719+
panelEvents.emit(HUB_EVENTS.client.docksPanelStateChanged, panelState)
704720
},
705-
{ immediate: true },
721+
{ flush: 'post' },
706722
)
707723
}
708-
void reportPanelStateAfterInitialization()
724+
void restoreAfterInitialization().then(startPanelStateEvents, startPanelStateEvents)
709725

710726
docksContextByRpc.set(rpc, docksContext)
711727
return docksContext

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

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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'
66
import { HUB_EVENTS } from '../../events'
@@ -68,32 +68,39 @@ function groupEntry(id: string, extra?: Record<string, unknown>): DevframeDockEn
6868
}
6969

7070
describe('createDevframeClientRuntime', () => {
71-
it('reports complete panel snapshots and coalesces related session changes', async () => {
72-
expect.assertions(4)
71+
it('exposes panel state and emits coalesced changes', async () => {
72+
expect.assertions(7)
7373

7474
const { rpc, calls, states } = createStubRpc()
7575
const host = await createDevframeClientRuntime({ rpc, clientType: 'embedded' })
76+
const panelStates: DevframeDockPanelState[] = []
7677

77-
expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }]])
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+
)
7883

79-
states.get('devframe:docks')!.push([iframeEntry('one')])
84+
states.get('devframe:docks')!.push([iframeEntry('one'), iframeEntry('two')])
8085
host.context.panel.session.open = true
8186
const switched = host.context.docks.switchEntry('one')
8287
await switched
83-
expect(calls).toEqual([
84-
[HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }],
85-
[HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'one' }],
86-
])
88+
expect(panelStates).toEqual([{ state: 'open', selectedDockId: 'one' }])
89+
expect(host.context.panel.state).toEqual({ state: 'open', selectedDockId: 'one' })
8790

8891
host.context.panel.session.open = true
8992
host.context.panel.session.selectedDockId = 'one'
9093
await Promise.resolve()
91-
expect(calls).toHaveLength(2)
94+
expect(panelStates).toHaveLength(1)
95+
96+
await host.context.docks.switchEntry('two')
97+
expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: 'two' })
9298

9399
host.context.panel.session.open = false
94100
const cleared = host.context.docks.switchEntry(null)
95101
await cleared
96-
expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }])
102+
expect(panelStates.at(-1)).toEqual({ state: 'closed' })
103+
expect(calls).toEqual([])
97104
host.dispose()
98105
})
99106

packages/hub/src/client/__tests__/panel-state.test.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.

packages/hub/src/client/docks.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { EventEmitter } from 'devframe/types'
33
import type { SharedState } from 'devframe/utils/shared-state'
44
import type { WhenContext } from 'devframe/utils/when'
55
import type { DevframeClientCommand, DevframeCommandEntry, DevframeCommandKeybinding } from '../types/commands'
6-
import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeDockUserEntry } from '../types/docks'
6+
import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeDockPanelState, DevframeDockUserEntry } from '../types/docks'
77
import type { DevframeDocksUserSettings } from '../types/settings'
88
import type { DockRenderersContext } from './renderers'
99

@@ -125,7 +125,15 @@ export interface WhenClauseContext {
125125

126126
export type DevframeClientContext = DocksContext
127127

128+
export interface DocksPanelEvents {
129+
'panel:state:changed': (state: DevframeDockPanelState) => void
130+
}
131+
128132
export interface DocksPanelContext {
133+
/** The current panel state snapshot. */
134+
readonly state: DevframeDockPanelState
135+
/** Subscribe to panel state changes after the current snapshot. */
136+
readonly events: EventEmitter<DocksPanelEvents>
129137
store: DockPanelStorage
130138
/**
131139
* Per-tab session UI state — whether the panel is open, which dock is

0 commit comments

Comments
 (0)