Skip to content

Commit 078fbc0

Browse files
committed
feat(hub): report extensible panel state
1 parent d0c651a commit 078fbc0

21 files changed

Lines changed: 232 additions & 78 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ Each subsystem emits on `ctx.<subsystem>.events`, consumed **inside the same nod
2626
| `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; the messages devframe | entry / entry / id / — |
2727
| `commands:registered` / `commands:unregistered` | `CommandsHost` register / update / unregister | context → `devframe:commands` shared state | entry / id |
2828

29-
`docks:panel:state` emits `connected` with the first reported `open` value, `changed` when that value changes, and `disconnected` when the reporting RPC connection closes. Its numeric `sessionId` identifies that connection for the lifetime of the Node process. A reload or reconnect receives a new id.
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.
3030

3131
### Server RPC methods — client → server
3232

3333
| Method | Signature | Purpose |
3434
|---|---|---|
3535
| `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` | `(open) => void` | Report this viewer connection's current dock-panel state. |
36+
| `hub:docks:panel:state` | `(panelState: DevframeDockPanelState) => void` | Report this viewer connection's current dock-panel state and selected dock. |
3737
| `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. |
3838
| `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). |
3939
| `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. |

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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'
67
import { setupEmbeddedVisibility } from './visibility'
@@ -74,8 +75,9 @@ 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 panelVisible = ref<boolean>()
7779
const { createDocksContext } = await import('../state/context')
78-
const context = await createDocksContext('embedded', rpc, state, session)
80+
const context = await createDocksContext('embedded', rpc, state, session, panelVisible)
7981
setDevframeClientContext(context)
8082

8183
const { DockEmbedded } = await import('../components/DockEmbedded')
@@ -97,8 +99,12 @@ async function mountDock(): Promise<void> {
9799
show: () => {
98100
if (dockEl && !dockEl.isConnected)
99101
document.body.appendChild(dockEl)
102+
panelVisible.value = true
103+
},
104+
hide: () => {
105+
dockEl?.remove()
106+
panelVisible.value = false
100107
},
101-
hide: () => dockEl?.remove(),
102108
},
103109
)
104110
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { HUB_UI_HIDE_EVENT } from '../constants'
3+
import { setupEmbeddedVisibility } from './visibility'
4+
5+
afterEach(() => {
6+
vi.unstubAllGlobals()
7+
})
8+
9+
describe('setupEmbeddedVisibility', () => {
10+
it('reports the initial hidden state and later reveal and conceal transitions', () => {
11+
expect.assertions(5)
12+
13+
const listeners = new Map<string, EventListener>()
14+
vi.stubGlobal('window', {
15+
addEventListener: vi.fn((type: string, listener: EventListener) => {
16+
listeners.set(type, listener)
17+
}),
18+
})
19+
const show = vi.fn()
20+
const hide = vi.fn()
21+
22+
setupEmbeddedVisibility('hidden', 'Devframe', { show, hide })
23+
24+
expect(hide).toHaveBeenCalledOnce()
25+
expect(show).not.toHaveBeenCalled()
26+
27+
const preventDefault = vi.fn()
28+
listeners.get('keydown')!({
29+
shiftKey: true,
30+
altKey: true,
31+
ctrlKey: false,
32+
metaKey: false,
33+
code: 'KeyD',
34+
preventDefault,
35+
} as unknown as KeyboardEvent)
36+
expect(preventDefault).toHaveBeenCalledOnce()
37+
expect(show).toHaveBeenCalledOnce()
38+
39+
listeners.get(HUB_UI_HIDE_EVENT)!({} as Event)
40+
expect(hide).toHaveBeenCalledTimes(2)
41+
})
42+
})

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,14 @@ export function setupEmbeddedVisibility(
9595
handlers.hide()
9696
}
9797

98-
if (shown)
98+
if (shown) {
9999
handlers.show()
100-
else if (mode === 'passive')
101-
printHint(label)
100+
}
101+
else {
102+
handlers.hide()
103+
if (mode === 'passive')
104+
printHint(label)
105+
}
102106

103107
// Shift+Alt+D toggles the dock — the always-available "summon" chord.
104108
window.addEventListener('keydown', (e) => {

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

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,36 +75,54 @@ async function flushRestore(): Promise<void> {
7575
}
7676

7777
describe('createDocksContext', () => {
78-
it('reports the restored panel state and later open-state transitions', async () => {
79-
expect.assertions(4)
78+
it('reports restored, selected, hidden, and closed panel snapshots', async () => {
79+
expect.assertions(8)
8080

8181
const { rpc, sharedStates, trust } = createStubRpc()
82+
const panelVisible = ref<boolean>()
8283
const session = ref<DockSessionStorage>({
8384
open: true,
8485
selectedDockId: 'git',
8586
selectedDockRoute: null,
8687
})
87-
await createDocksContext('embedded', rpc, undefined, session)
88+
await createDocksContext('embedded', rpc, undefined, session, panelVisible)
8889

8990
trust()
9091
sharedStates.get('devframe:docks')!.push([gitEntry])
9192
sharedStates.get('devframe:dock-renderers')!.push({})
9293
await flushRestore()
94+
expect(rpc.call).not.toHaveBeenCalled()
95+
96+
panelVisible.value = true
97+
await nextTick()
9398
await vi.waitFor(() => {
9499
if (vi.mocked(rpc.call).mock.calls.length !== 1)
95100
throw new Error('waiting for the restored panel state report')
96101
})
97-
98102
expect(rpc.call).toHaveBeenCalledTimes(1)
99-
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, true)
103+
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' })
104+
105+
session.value.selectedDockId = '~settings'
106+
await nextTick()
107+
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: '~settings' })
108+
109+
panelVisible.value = false
110+
await nextTick()
111+
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden', selectedDockId: '~settings' })
100112

101113
session.value.open = false
114+
session.value.selectedDockId = null
115+
await nextTick()
116+
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden' })
117+
118+
panelVisible.value = true
102119
await nextTick()
103-
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, false)
120+
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'closed' })
104121

122+
panelVisible.value = true
105123
session.value.open = false
106124
await nextTick()
107-
expect(rpc.call).toHaveBeenCalledTimes(2)
125+
expect(rpc.call).toHaveBeenCalledTimes(5)
108126
})
109127

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

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export async function createDocksContext(
2323
rpc: DevframeRpcClient,
2424
panelStore?: Ref<DockPanelStorage>,
2525
sessionStore?: Ref<DockSessionStorage>,
26+
panelVisible: Ref<boolean | undefined> = ref(true),
2627
): Promise<DocksContext> {
2728
if (docksContextByRpc.has(rpc)) {
2829
return docksContextByRpc.get(rpc)!
@@ -666,8 +667,15 @@ export async function createDocksContext(
666667
const reportPanelStateAfterInitialization = async (): Promise<void> => {
667668
await restoreAfterInitialization()
668669
watch(
669-
() => sessionStore.value.open,
670-
open => void reportDockPanelState(rpc, open).catch(() => {}),
670+
[panelVisible, () => sessionStore.value.open, selectedDockId],
671+
([visible, open, currentSelectedDockId]) => {
672+
if (visible === undefined)
673+
return
674+
void reportDockPanelState(rpc, {
675+
state: visible ? (open ? 'open' : 'closed') : 'hidden',
676+
...(currentSelectedDockId !== null ? { selectedDockId: currentSelectedDockId } : {}),
677+
}).catch(() => {})
678+
},
671679
{ immediate: true },
672680
)
673681
}

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,23 +68,32 @@ function groupEntry(id: string, extra?: Record<string, unknown>): DevframeDockEn
6868
}
6969

7070
describe('createDevframeClientRuntime', () => {
71-
it('reports its initial panel state and later open-state assignments', async () => {
72-
expect.assertions(3)
71+
it('reports complete panel snapshots and coalesces related session changes', async () => {
72+
expect.assertions(4)
7373

74-
const { rpc, calls } = createStubRpc()
74+
const { rpc, calls, states } = createStubRpc()
7575
const host = await createDevframeClientRuntime({ rpc, clientType: 'embedded' })
7676

77-
expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, false]])
77+
expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }]])
7878

79+
states.get('devframe:docks')!.push([iframeEntry('one')])
7980
host.context.panel.session.open = true
80-
host.context.panel.session.open = true
81+
const switched = host.context.docks.switchEntry('one')
82+
await switched
8183
expect(calls).toEqual([
82-
[HUB_EVENTS.rpc.docksPanelState, false],
83-
[HUB_EVENTS.rpc.docksPanelState, true],
84+
[HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }],
85+
[HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'one' }],
8486
])
8587

88+
host.context.panel.session.open = true
89+
host.context.panel.session.selectedDockId = 'one'
90+
await Promise.resolve()
91+
expect(calls).toHaveLength(2)
92+
8693
host.context.panel.session.open = false
87-
expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, false])
94+
const cleared = host.context.docks.switchEntry(null)
95+
await cleared
96+
expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }])
8897
host.dispose()
8998
})
9099

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { DevframeRpcClient } from 'devframe/client'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { HUB_EVENTS } from '../../events'
4+
import { reportDockPanelState } from '../panel-state'
5+
6+
describe('reportDockPanelState', () => {
7+
it('reports the complete panel snapshot through the hub RPC', async () => {
8+
expect.assertions(1)
9+
10+
const call = vi.fn(async () => {})
11+
const rpc = { call } as unknown as DevframeRpcClient
12+
13+
await reportDockPanelState(rpc, { state: 'open', selectedDockId: 'git' })
14+
15+
expect(call).toHaveBeenCalledWith(
16+
HUB_EVENTS.rpc.docksPanelState,
17+
{ state: 'open', selectedDockId: 'git' },
18+
)
19+
})
20+
})

packages/hub/src/client/host.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
DockClientType,
2121
DockEntryState,
2222
DocksEntriesContext,
23+
DockSessionStorage,
2324
DocksPanelContext,
2425
WhenClauseContext,
2526
} from './docks'
@@ -125,6 +126,7 @@ export async function createDevframeClientRuntime(
125126
): Promise<DevframeClientRuntime> {
126127
const clientType: DockClientType = options.clientType ?? 'standalone'
127128
const rpc = options.rpc ?? await connectDevframe(options.connect)
129+
let disposed = false
128130
// Set by createRenderersContext(); teardown disposes every live mount.
129131
let mountedRenderers: Set<() => void> | undefined
130132

@@ -155,10 +157,24 @@ export async function createDevframeClientRuntime(
155157
...options.categoryOrder,
156158
}
157159

158-
const sendPanelState = (open: boolean): void => {
159-
void reportDockPanelState(rpc, open).catch(() => {})
160+
const reportPanelState = (session: DockSessionStorage): void => {
161+
void reportDockPanelState(rpc, {
162+
state: session.open ? 'open' : 'closed',
163+
...(session.selectedDockId !== null ? { selectedDockId: session.selectedDockId } : {}),
164+
}).catch(() => {})
160165
}
161-
const panel = createPanelContext(clientType, sendPanelState)
166+
let panelStateReportPending = false
167+
const schedulePanelStateReport = (session: DockSessionStorage): void => {
168+
if (panelStateReportPending)
169+
return
170+
panelStateReportPending = true
171+
queueMicrotask(() => {
172+
panelStateReportPending = false
173+
if (!disposed)
174+
reportPanelState(session)
175+
})
176+
}
177+
const panel = createPanelContext(clientType, schedulePanelStateReport)
162178
const docks = createDocksContext()
163179
const commands = createCommandsContext()
164180
const renderers = createRenderersContext()
@@ -229,7 +245,7 @@ export async function createDevframeClientRuntime(
229245
)
230246
}
231247
setDevframeClientContext(context)
232-
sendPanelState(panel.session.open)
248+
reportPanelState(panel.session)
233249

234250
const loadedScripts = new Set<string>()
235251
if (loadScriptsEnabled) {
@@ -240,6 +256,7 @@ export async function createDevframeClientRuntime(
240256
return {
241257
context,
242258
dispose() {
259+
disposed = true
243260
for (const off of disposers.splice(0)) off()
244261
for (const disposeAdapter of frameNavAdapters.values()) disposeAdapter()
245262
frameNavAdapters.clear()
@@ -556,7 +573,7 @@ export async function createDevframeClientRuntime(
556573

557574
function createPanelContext(
558575
clientType: DockClientType,
559-
onOpenChange: (open: boolean) => void,
576+
onSessionChange: (session: DockSessionStorage) => void,
560577
): DocksPanelContext {
561578
const store: DocksPanelContext['store'] = {
562579
mode: 'edge',
@@ -568,6 +585,7 @@ function createPanelContext(
568585
inactiveTimeout: 0,
569586
}
570587
let open = clientType === 'standalone'
588+
let selectedDockId: string | null = null
571589
const session: DocksPanelContext['session'] = {
572590
get open() {
573591
return open
@@ -576,9 +594,17 @@ function createPanelContext(
576594
if (nextOpen === open)
577595
return
578596
open = nextOpen
579-
onOpenChange(open)
597+
onSessionChange(session)
598+
},
599+
get selectedDockId() {
600+
return selectedDockId
601+
},
602+
set selectedDockId(nextSelectedDockId) {
603+
if (nextSelectedDockId === selectedDockId)
604+
return
605+
selectedDockId = nextSelectedDockId
606+
onSessionChange(session)
580607
},
581-
selectedDockId: null,
582608
selectedDockRoute: null,
583609
}
584610
return {
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { DevframeRpcClient } from 'devframe/client'
2+
import type { DevframeDockPanelState } from '../types/docks'
23
import { HUB_EVENTS } from '../events'
34

45
/** Report this RPC connection's current dock-panel state to the hub. */
56
export async function reportDockPanelState(
67
rpc: DevframeRpcClient,
7-
open: boolean,
8+
panelState: DevframeDockPanelState,
89
): Promise<void> {
9-
await rpc.call(HUB_EVENTS.rpc.docksPanelState, open)
10+
await rpc.call(HUB_EVENTS.rpc.docksPanelState, panelState)
1011
}

0 commit comments

Comments
 (0)