From 25ed7008a55aef91d9323d856a5d284fd26e9540 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Fri, 21 Aug 2026 18:28:19 +0200 Subject: [PATCH 01/11] feat(hub): expose panel session lifecycle events --- docs/content/8.references/3.events.md | 4 ++ .../hub-ui/src/client/state/context.test.ts | 33 +++++++++ packages/hub-ui/src/client/state/context.ts | 15 ++++- .../hub/src/client/__tests__/host.test.ts | 21 ++++++ packages/hub/src/client/host.ts | 24 +++++-- packages/hub/src/client/index.ts | 1 + packages/hub/src/client/panel-state.ts | 10 +++ packages/hub/src/events.ts | 2 + .../hub/src/node/__tests__/host-docks.test.ts | 43 +++++++++++- .../hub/src/node/__tests__/initiate.test.ts | 67 ++++++++++++++++++- .../src/node/__tests__/rpc-builtins.test.ts | 27 ++++++++ packages/hub/src/node/context.ts | 10 +++ packages/hub/src/node/initiate.ts | 7 ++ packages/hub/src/node/panel-state.ts | 39 +++++++++++ packages/hub/src/node/rpc-builtins.ts | 15 +++++ packages/hub/src/types/docks.ts | 7 ++ .../@devframes/hub/client.snapshot.d.ts | 1 + .../tsnapi/@devframes/hub/client.snapshot.js | 1 + .../@devframes/hub/constants.snapshot.d.ts | 2 + .../tsnapi/@devframes/hub/index.snapshot.d.ts | 13 ++++ .../tsnapi/@devframes/hub/node.snapshot.d.ts | 15 +++++ .../tsnapi/@devframes/hub/node.snapshot.js | 1 + .../tsnapi/@devframes/hub/types.snapshot.d.ts | 1 + 23 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 packages/hub/src/client/panel-state.ts create mode 100644 packages/hub/src/node/panel-state.ts diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 473bc115..6ea1dec1 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -21,15 +21,19 @@ Each subsystem emits on `ctx..events`, consumed **inside the same nod |---|---|---|---| | `docks:entry:updated` | `DocksHost.register` / `update` | context → `devframe:docks` shared state | `DevframeDockUserEntry` | | `docks:activate` | `DocksHost.activate()` | context → broadcast + `devframe:docks:active` | `DevframeDockActivation` | +| `docks:panel:state` | viewer state reports and RPC disconnects | hub consumers | `DevframeDockPanelStateEvent` | | `terminals:session:updated` | `TerminalsHost` register / update / remove / status change | context → `devframe:terminals:updated`; the terminals devframe | `DevframeTerminalSession` | | `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; the messages devframe | entry / entry / id / — | | `commands:registered` / `commands:unregistered` | `CommandsHost` register / update / unregister | context → `devframe:commands` shared state | entry / id | +`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. + ### Server RPC methods — client → server | Method | Signature | Purpose | |---|---|---| | `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the hub UI provider to switch its active dock — see [Deep Linking](/guide/deep-linking). | +| `hub:docks:panel-state` | `(open) => void` | Report this viewer connection's current dock-panel state. | | `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. | | `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). | | `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. | diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index f22e5b7d..03b62193 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -1,6 +1,7 @@ import type { DevframeDockEntry } from '@devframes/hub' import type { DevframeRpcClient, DockSessionStorage } from '@devframes/hub/client' import type { SharedState } from 'devframe/utils/shared-state' +import { HUB_EVENTS } from '@devframes/hub/constants' import { DEVFRAME_EVENTS } from 'devframe/constants' import { createEventEmitter } from 'devframe/utils/events' import { createSharedState } from 'devframe/utils/shared-state' @@ -74,6 +75,38 @@ async function flushRestore(): Promise { } describe('createDocksContext', () => { + it('reports the restored panel state and later open-state transitions', async () => { + expect.assertions(4) + + const { rpc, sharedStates, trust } = createStubRpc() + const session = ref({ + open: true, + selectedDockId: 'git', + selectedDockRoute: null, + }) + await createDocksContext('embedded', rpc, undefined, session) + + trust() + sharedStates.get('devframe:docks')!.push([gitEntry]) + sharedStates.get('devframe:dock-renderers')!.push({}) + await flushRestore() + await vi.waitFor(() => { + if (vi.mocked(rpc.call).mock.calls.length !== 1) + throw new Error('waiting for the restored panel state report') + }) + + expect(rpc.call).toHaveBeenCalledTimes(1) + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, true) + + session.value.open = false + await nextTick() + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, false) + + session.value.open = false + await nextTick() + expect(rpc.call).toHaveBeenCalledTimes(2) + }) + it('mounts a restored dock once after all initial server state arrives', async () => { expect.assertions(7) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 6ac2b64d..bf48f815 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -4,7 +4,7 @@ import type { SharedState } from 'devframe/utils/shared-state' import type { WhenContext } from 'devframe/utils/when' import type { Ref } from 'vue' import type { DevframeDocksUserSettings } from './dock-settings' -import { attachFrameNavClient, createDockRenderersContext } from '@devframes/hub/client' +import { attachFrameNavClient, createDockRenderersContext, reportDockPanelState } from '@devframes/hub/client' import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants' import { DEVFRAME_EVENTS } from 'devframe/constants' import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue' @@ -648,12 +648,13 @@ export async function createDocksContext( // the captured session intent. // `switchEntry` then consumes the persisted iframe route when the view boots. const restoreAfterInitialization = async (): Promise => { + await waitUntilTrusted() + const restoreDockId = restoreIntent.selectedDockId if (!restoreIntent.open || restoreDockId == null) return await Promise.all([ - waitUntilTrusted(), dockEntriesInitialSyncComplete, rendererManifestInitialSyncComplete, ]) @@ -669,7 +670,15 @@ export async function createDocksContext( initialRestorePending.value = false await switchEntry(restoreDockId) } - void restoreAfterInitialization() + const reportPanelStateAfterInitialization = async (): Promise => { + await restoreAfterInitialization() + watch( + () => sessionStore.value.open, + open => void reportDockPanelState(rpc, open).catch(() => {}), + { immediate: true }, + ) + } + void reportPanelStateAfterInitialization() docksContextByRpc.set(rpc, docksContext) return docksContext diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index ff4c9aea..dfc8f1d0 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -3,6 +3,7 @@ import type { SharedState } from 'devframe/utils/shared-state' import type { DevframeDockEntry } from '../../types/docks' import { createEventEmitter } from 'devframe/utils/events' import { describe, expect, it, vi } from 'vitest' +import { HUB_EVENTS } from '../../events' import { getDevframeClientContext } from '../context' import { createDevframeClientRuntime } from '../host' @@ -67,6 +68,26 @@ function groupEntry(id: string, extra?: Record): DevframeDockEn } describe('createDevframeClientRuntime', () => { + it('reports its initial panel state and later open-state assignments', async () => { + expect.assertions(3) + + const { rpc, calls } = createStubRpc() + const host = await createDevframeClientRuntime({ rpc, clientType: 'embedded' }) + + expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, false]]) + + host.context.panel.session.open = true + host.context.panel.session.open = true + expect(calls).toEqual([ + [HUB_EVENTS.rpc.docksPanelState, false], + [HUB_EVENTS.rpc.docksPanelState, true], + ]) + + host.context.panel.session.open = false + expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, false]) + host.dispose() + }) + it('publishes the global client context with the full surface', async () => { const { rpc } = createStubRpc() const host = await createDevframeClientRuntime({ rpc }) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 1bc8b685..735553c7 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -32,6 +32,7 @@ import { HUB_EVENTS } from '../events' import { getDevframeClientContext, setDevframeClientContext } from './context' import { attachFrameNavClient } from './frame-nav' import { createMessagesClient } from './messages' +import { reportDockPanelState } from './panel-state' import { createDockRenderersContext } from './renderers' const DOCKS_STATE_KEY = HUB_EVENTS.sharedState.docks @@ -154,7 +155,10 @@ export async function createDevframeClientRuntime( ...options.categoryOrder, } - const panel = createPanelContext(clientType) + const sendPanelState = (open: boolean): void => { + void reportDockPanelState(rpc, open).catch(() => {}) + } + const panel = createPanelContext(clientType, sendPanelState) const docks = createDocksContext() const commands = createCommandsContext() const renderers = createRenderersContext() @@ -225,6 +229,7 @@ export async function createDevframeClientRuntime( ) } setDevframeClientContext(context) + sendPanelState(panel.session.open) const loadedScripts = new Set() if (loadScriptsEnabled) { @@ -549,7 +554,10 @@ export async function createDevframeClientRuntime( // ── shared helpers ───────────────────────────────────────────────────────── -function createPanelContext(clientType: DockClientType): DocksPanelContext { +function createPanelContext( + clientType: DockClientType, + onOpenChange: (open: boolean) => void, +): DocksPanelContext { const store: DocksPanelContext['store'] = { mode: 'edge', width: 480, @@ -559,9 +567,17 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext { position: 'right', inactiveTimeout: 0, } + let open = clientType === 'standalone' const session: DocksPanelContext['session'] = { - // A standalone runtime owns the page, so its "panel" is always open. - open: clientType === 'standalone', + get open() { + return open + }, + set open(nextOpen) { + if (nextOpen === open) + return + open = nextOpen + onOpenChange(open) + }, selectedDockId: null, selectedDockRoute: null, } diff --git a/packages/hub/src/client/index.ts b/packages/hub/src/client/index.ts index 78c06ac6..0f785ca2 100644 --- a/packages/hub/src/client/index.ts +++ b/packages/hub/src/client/index.ts @@ -7,6 +7,7 @@ export * from './frame-location' export * from './frame-nav' export * from './host' export * from './messages' +export * from './panel-state' export * from './remote' export * from './renderers' export * from 'devframe/client' diff --git a/packages/hub/src/client/panel-state.ts b/packages/hub/src/client/panel-state.ts new file mode 100644 index 00000000..130207ca --- /dev/null +++ b/packages/hub/src/client/panel-state.ts @@ -0,0 +1,10 @@ +import type { DevframeRpcClient } from 'devframe/client' +import { HUB_EVENTS } from '../events' + +/** Report this RPC connection's current dock-panel state to the hub. */ +export async function reportDockPanelState( + rpc: DevframeRpcClient, + open: boolean, +): Promise { + await rpc.call(HUB_EVENTS.rpc.docksPanelState, open) +} diff --git a/packages/hub/src/events.ts b/packages/hub/src/events.ts index a9fce268..86ad652d 100644 --- a/packages/hub/src/events.ts +++ b/packages/hub/src/events.ts @@ -23,6 +23,7 @@ export const HUB_EVENTS = { bus: { docksEntryUpdated: 'docks:entry:updated', docksActivate: 'docks:activate', + docksPanelState: 'docks:panel:state', terminalsSessionUpdated: 'terminals:session:updated', messagesAdded: 'messages:added', messagesUpdated: 'messages:updated', @@ -34,6 +35,7 @@ export const HUB_EVENTS = { /** Server RPC methods a connected client calls (client → server), `hub:` prefix. */ rpc: { docksActivate: 'hub:docks:activate', + docksPanelState: 'hub:docks:panel-state', commandsExecute: 'hub:commands:execute', messagesAdd: 'hub:messages:add', messagesUpdate: 'hub:messages:update', diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index c25c681d..e65973e9 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -1,4 +1,4 @@ -import type { DevframeViewLauncher } from '../../types/docks' +import type { DevframeDockPanelStateEvent, DevframeViewLauncher } from '../../types/docks' import type { DevframeHubContext } from '../context' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -7,7 +7,9 @@ import { REMOTE_CONNECTION_KEY } from 'devframe/constants' import { getInternalContext } from 'devframe/node/hub-internals' import { describe, expect, it, vi } from 'vitest' import { parseRemoteConnection } from '../../client/remote' +import { HUB_EVENTS } from '../../events' import { DevframeDocksHost } from '../host-docks' +import { disconnectDockPanelState, updateDockPanelState } from '../panel-state' function createContext(): DevframeHubContext { const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-docks-')) @@ -221,6 +223,45 @@ describe('devframeDockHost activate', () => { }) }) +describe('devframeDockHost panel state', () => { + it('emits the first report and changed values while suppressing duplicates', () => { + expect.assertions(1) + + const host = new DevframeDocksHost(createContext()) + const events: DevframeDockPanelStateEvent[] = [] + host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) + + updateDockPanelState(host, 11, false) + updateDockPanelState(host, 11, false) + updateDockPanelState(host, 11, true) + + expect(events).toEqual([ + { type: 'connected', sessionId: 11, open: false }, + { type: 'changed', sessionId: 11, open: true }, + ]) + }) + + it('tracks sessions independently and disconnects only reporting sessions', () => { + expect.assertions(1) + + const host = new DevframeDocksHost(createContext()) + const events: DevframeDockPanelStateEvent[] = [] + host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) + + updateDockPanelState(host, 11, true) + updateDockPanelState(host, 12, false) + disconnectDockPanelState(host, 99) + disconnectDockPanelState(host, 11) + disconnectDockPanelState(host, 11) + + expect(events).toEqual([ + { type: 'connected', sessionId: 11, open: true }, + { type: 'connected', sessionId: 12, open: false }, + { type: 'disconnected', sessionId: 11 }, + ]) + }) +}) + describe('devframeDockHost ~builtin category', () => { it('returns no docks until an integration registers one', () => { const host = new DevframeDocksHost(createContext()) diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index e1dd1032..6e01e782 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -1,4 +1,5 @@ import type { DevframeDefinition, DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' +import type { DevframeDockPanelStateEvent } from '../../types/docks' import { mkdtempSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { tmpdir } from 'node:os' @@ -6,8 +7,9 @@ import { join } from 'node:path' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { DOCK_RENDERERS_STATE_KEY } from '../../constants' +import { HUB_EVENTS } from '../../events' import { DEVFRAMES_HUB_BASE, initHub } from '../initiate' function makeDist(html: string): string { @@ -38,10 +40,12 @@ function makeFrame(id: string, distDir?: string): DevframeDefinition { } function connectWsClient(url: string) { - return createRpcClient( + const channel = createWsRpcChannel({ url }) + const client = createRpcClient( {} as DevframeRpcClientFunctions, - { channel: createWsRpcChannel({ url }) }, + { channel }, ) + return Object.assign(client, { close: channel.close }) } describe('initHub', () => { @@ -152,6 +156,63 @@ describe('initHub', () => { } }) + it('tracks panel state by RPC connection and emits disconnect separately from close', async () => { + expect.assertions(9) + + const host = '127.0.0.1' + const port = await getPort({ port: 18215, host }) + const hub = initHub({ + base: DEVFRAMES_HUB_BASE, + auth: false, + host, + ws: { port }, + devframes: [makeFrame('alpha')], + }) + const clients: ReturnType[] = [] + + try { + await hub.ready + const context = await hub.context + const lifecycleEvents: DevframeDockPanelStateEvent[] = [] + context.docks.events.on(HUB_EVENTS.bus.docksPanelState, event => lifecycleEvents.push(event)) + + const firstClient = connectWsClient(`ws://${host}:${port}/__ws`) + const secondClient = connectWsClient(`ws://${host}:${port}/__ws`) + clients.push(firstClient, secondClient) + + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, true) + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, true) + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, false) + await secondClient.$call(HUB_EVENTS.rpc.docksPanelState, false) + + expect(lifecycleEvents).toHaveLength(3) + expect(lifecycleEvents[0]).toMatchObject({ type: 'connected', open: true }) + expect(typeof lifecycleEvents[0]!.sessionId).toBe('number') + expect(lifecycleEvents[1]).toEqual({ type: 'changed', sessionId: lifecycleEvents[0]!.sessionId, open: false }) + expect(lifecycleEvents[2]).toMatchObject({ type: 'connected', open: false }) + expect(lifecycleEvents[2]!.sessionId).not.toBe(lifecycleEvents[0]!.sessionId) + + firstClient.close() + await vi.waitFor(() => { + if (lifecycleEvents.length !== 4) + throw new Error('waiting for the first client to disconnect') + }) + expect(lifecycleEvents[3]).toEqual({ type: 'disconnected', sessionId: lifecycleEvents[0]!.sessionId }) + + const reconnectedClient = connectWsClient(`ws://${host}:${port}/__ws`) + clients.push(reconnectedClient) + await reconnectedClient.$call(HUB_EVENTS.rpc.docksPanelState, true) + + expect(lifecycleEvents[4]).toMatchObject({ type: 'connected', open: true }) + expect([lifecycleEvents[0]!.sessionId, lifecycleEvents[2]!.sessionId]).not.toContain(lifecycleEvents[4]!.sessionId) + } + finally { + for (const client of clients) + client.close() + await hub.close() + } + }) + it('ui slot: viewer owns the root, embedded.js serves the entry, discovery still wins', async () => { const viewerDist = makeDist('hub viewer') const embeddedDir = mkdtempSync(join(tmpdir(), 'hub-embedded-')) diff --git a/packages/hub/src/node/__tests__/rpc-builtins.test.ts b/packages/hub/src/node/__tests__/rpc-builtins.test.ts index 5a9e2e0e..40cd54cf 100644 --- a/packages/hub/src/node/__tests__/rpc-builtins.test.ts +++ b/packages/hub/src/node/__tests__/rpc-builtins.test.ts @@ -1,7 +1,11 @@ +import type { DevframeDockPanelStateEvent } from '../../types/docks' import type { DevframeHubContext } from '../context' +import { createEventEmitter } from 'devframe/utils/events' import { describe, expect, it, vi } from 'vitest' +import { HUB_EVENTS } from '../../events' import { hubDocksActivate, + hubDocksPanelState, hubTerminalsRemove, hubTerminalsResize, hubTerminalsRestart, @@ -138,3 +142,26 @@ describe('hub docks activate RPC', () => { expect(activate).toHaveBeenCalledWith('devframes_plugin_messages', undefined) }) }) + +describe('hub docks panel-state RPC', () => { + it('derives the session id from the active RPC handler context', async () => { + expect.assertions(2) + + const events = createEventEmitter<{ + 'docks:panel:state': (event: DevframeDockPanelStateEvent) => void + }>() + const lifecycleEvents: DevframeDockPanelStateEvent[] = [] + events.on(HUB_EVENTS.bus.docksPanelState, event => lifecycleEvents.push(event)) + const getCurrentRpcSession = vi.fn(() => ({ meta: { id: 73 } })) + const ctx = { + docks: { events }, + rpc: { getCurrentRpcSession }, + } as unknown as DevframeHubContext + + const fn = await hubDocksPanelState.setup!(ctx) + await fn.handler!(true) + + expect(getCurrentRpcSession).toHaveBeenCalledOnce() + expect(lifecycleEvents).toEqual([{ type: 'connected', sessionId: 73, open: true }]) + }) +}) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 6aee0ffc..f38a7a21 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -56,6 +56,16 @@ declare module 'devframe/types' { * selection. Handled by {@link import('./rpc-builtins').hubDocksActivate}. */ 'hub:docks:activate': (input: { dockId: string, params?: Record }) => Promise + /** + * Report this viewer connection's current dock-panel state. The server + * resolves the connection's session id and emits the typed lifecycle event + * on `ctx.docks.events`. + * + * Use `reportDockPanelState()` from `@devframes/hub/client`. + * + * @internal + */ + 'hub:docks:panel-state': (open: boolean) => Promise /** * Invoke a registered server command by id; trailing args are forwarded to * the command's handler. Handled by diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 1905ec37..cd39fb65 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -22,6 +22,7 @@ import { DEVFRAMES_HUB_BASE, DOCK_RENDERERS_STATE_KEY, normalizeHubBase } from ' import { createHubContext } from './context' import { diagnostics } from './diagnostics' import { prepareDevframe } from './install-devframe' +import { disconnectDockPanelState } from './panel-state' /** A `devframes` entry with per-mount dock customization. */ export interface HubDevframeEntry { @@ -429,6 +430,7 @@ export function initHub(options: InitHubOptions): HubInstance { const cwd = options.cwd ?? process.cwd() const frames: { id: string, base: string, title: string }[] = [] const rendererRegistrations = resolveRendererRegistrations(options.renderers ?? []) + let initializedContext: DevframeHubContext | undefined const shell = createInstanceShell({ base, @@ -441,6 +443,10 @@ export function initHub(options: InitHubOptions): HubInstance { sse: options.sse, allowedOrigins: options.allowedOrigins, destroyUnmatchedUpgrades: options.destroyUnmatchedUpgrades, + onPeerDisconnect: (_connection, sessionMeta) => { + if (initializedContext) + disconnectDockPanelState(initializedContext.docks, sessionMeta.id) + }, register: resolveInstanceRegister(options.register, { id: options.name ?? 'devframes-hub', ...(options.name !== undefined ? { name: options.name } : {}), @@ -495,6 +501,7 @@ export function initHub(options: InitHubOptions): HubInstance { ...(options.rpcDeclarations ? { builtinRpcDeclarations: options.rpcDeclarations } : {}), }) } + initializedContext = ctx // Publish the host's bare-specifier resolution template before anything // registers a dock, so the docks host's bare-specifier capability check diff --git a/packages/hub/src/node/panel-state.ts b/packages/hub/src/node/panel-state.ts new file mode 100644 index 00000000..a0743f22 --- /dev/null +++ b/packages/hub/src/node/panel-state.ts @@ -0,0 +1,39 @@ +import type { DevframeDockPanelStateEvent, DevframeDocksHost } from '../types/docks' +import { HUB_EVENTS } from '../events' + +const dockPanelStates = new WeakMap>() + +export function updateDockPanelState( + docks: DevframeDocksHost, + sessionId: number, + open: boolean, +): void { + let sessionStates = dockPanelStates.get(docks) + if (!sessionStates) { + sessionStates = new Map() + dockPanelStates.set(docks, sessionStates) + } + + const previousOpen = sessionStates.get(sessionId) + if (previousOpen === open) + return + + sessionStates.set(sessionId, open) + const event: DevframeDockPanelStateEvent = previousOpen === undefined + ? { type: 'connected', sessionId, open } + : { type: 'changed', sessionId, open } + docks.events.emit(HUB_EVENTS.bus.docksPanelState, event) +} + +export function disconnectDockPanelState( + docks: DevframeDocksHost, + sessionId: number, +): void { + const sessionStates = dockPanelStates.get(docks) + if (!sessionStates?.delete(sessionId)) + return + + docks.events.emit(HUB_EVENTS.bus.docksPanelState, { type: 'disconnected', sessionId }) + if (sessionStates.size === 0) + dockPanelStates.delete(docks) +} diff --git a/packages/hub/src/node/rpc-builtins.ts b/packages/hub/src/node/rpc-builtins.ts index f8e4f384..cffc9ebe 100644 --- a/packages/hub/src/node/rpc-builtins.ts +++ b/packages/hub/src/node/rpc-builtins.ts @@ -7,6 +7,7 @@ import type { import { defineHubRpcFunction } from '../define' import { HUB_EVENTS } from '../events' import { diagnostics } from './diagnostics' +import { updateDockPanelState } from './panel-state' /** * Resolve an interactive (PTY) terminal session by id, or throw. Sessions @@ -220,6 +221,19 @@ export const hubDocksActivate = defineHubRpcFunction({ }), }) +/** Record the current viewer connection's dock-panel state. */ +export const hubDocksPanelState = defineHubRpcFunction({ + name: HUB_EVENTS.rpc.docksPanelState, + type: 'action', + setup: context => ({ + async handler(open: boolean): Promise { + const session = context.rpc.getCurrentRpcSession() + if (session) + updateDockPanelState(context.docks, session.meta.id, open) + }, + }), +}) + /** * Framework-neutral RPC declarations auto-registered by * {@link createHubContext}. Provide additional RPCs by passing your own @@ -229,6 +243,7 @@ export const hubDocksActivate = defineHubRpcFunction({ export const builtinHubRpcDeclarations: readonly RpcFunctionDefinitionAny[] = [ hubCommandsExecute, hubDocksActivate, + hubDocksPanelState, hubMessagesAdd, hubMessagesUpdate, hubMessagesRemove, diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index 93992520..d68f09bc 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -5,6 +5,7 @@ export interface DevframeDocksHost { readonly events: EventEmitter<{ 'docks:entry:updated': (entry: DevframeDockUserEntry) => void 'docks:activate': (activation: DevframeDockActivation) => void + 'docks:panel:state': (event: DevframeDockPanelStateEvent) => void }> register: (entry: T, force?: boolean) => { @@ -29,6 +30,12 @@ export interface DevframeDocksHost { activate: (dockId: string, params?: Record) => void } +/** Lifecycle event for one viewer's dock panel over an RPC connection. */ +export type DevframeDockPanelStateEvent + = | { type: 'connected', sessionId: number, open: boolean } + | { type: 'changed', sessionId: number, open: boolean } + | { type: 'disconnected', sessionId: number } + /** * A request to switch the active dock. `params` is an opaque, serializable * bag the target dock interprets — the terminals dock reads `params.sessionId` diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index 2bbbb5b6..3608413a 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -247,6 +247,7 @@ export declare function createDockRenderersContext(_: CreateDockRenderersContext export declare function createMessagesClient(_: DevframeRpcClient, _?: MessagesClientOptions): DevframeMessagesClient; export declare function getDevframeClientContext(): DevframeClientContext | undefined; export declare function parseRemoteConnection(_?: string): RemoteConnectionInfo | null; +export declare function reportDockPanelState(_: DevframeRpcClient, _: boolean): Promise; export declare function resolveClientModuleSpecifier(_: string, _?: { resolveClientModule?: (_: string) => string | undefined; template?: string; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js index 0f1b65ec..6f908298 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js @@ -10,6 +10,7 @@ export function createDockRenderersContext(_) {} export function createMessagesClient(_, _) {} export function getDevframeClientContext() {} export function parseRemoteConnection(_) {} +export async function reportDockPanelState(_, _) {} export function resolveDockIcon(_, _) {} export function resolveDockUrl(_, _) {} export function setDevframeClientContext(_) {} diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts index 4d2876e2..09ea96a7 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts @@ -14,6 +14,7 @@ export declare const HUB_EVENTS: { readonly bus: { readonly docksEntryUpdated: "docks:entry:updated"; readonly docksActivate: "docks:activate"; + readonly docksPanelState: "docks:panel:state"; readonly terminalsSessionUpdated: "terminals:session:updated"; readonly messagesAdded: "messages:added"; readonly messagesUpdated: "messages:updated"; @@ -24,6 +25,7 @@ export declare const HUB_EVENTS: { }; readonly rpc: { readonly docksActivate: "hub:docks:activate"; + readonly docksPanelState: "hub:docks:panel-state"; readonly commandsExecute: "hub:commands:execute"; readonly messagesAdd: "hub:messages:add"; readonly messagesUpdate: "hub:messages:update"; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 96010d69..bd665758 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -109,6 +109,7 @@ export interface DevframeDocksHost { readonly events: EventEmitter<{ 'docks:entry:updated': (entry: DevframeDockUserEntry) => void; 'docks:activate': (activation: DevframeDockActivation) => void; + 'docks:panel:state': (event: DevframeDockPanelStateEvent) => void; }>; register: (_: T, _?: boolean) => { update: (_: Partial) => void; @@ -346,6 +347,18 @@ export type DevframeDockEntryIcon = string | { light: string; dark: string; }; +export type DevframeDockPanelStateEvent = { + type: 'connected'; + sessionId: number; + open: boolean; +} | { + type: 'changed'; + sessionId: number; + open: boolean; +} | { + type: 'disconnected'; + sessionId: number; +}; export type DevframeDockUserEntry = DevframeDockEntryRegistry[keyof DevframeDockEntryRegistry]; export type DevframeMessageAction = DevframeMessageActivateAction | DevframeMessageCommandAction; export type DevframeMessageEntryFrom = 'server' | 'browser'; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 68ad102b..f77cbfb6 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -132,6 +132,21 @@ export declare const hubDocksActivate: { params?: Record; }], Promise>> | undefined; }; +export declare const hubDocksPanelState: { + name: "hub:docks:panel-state"; + type?: "action" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: DevframeHubContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((open: boolean) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[open: boolean], Promise, DevframeHubContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}; export declare const hubMessagesAdd: { name: "hub:messages:add"; type?: "action" | undefined; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index e8bd00c5..3c5c1bb3 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -14,6 +14,7 @@ export { DevframeMessagesHost } export { DevframeTerminalsHost } export { hubCommandsExecute } export { hubDocksActivate } +export { hubDocksPanelState } export { hubMessagesAdd } export { hubMessagesClear } export { hubMessagesRemove } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 2eb67965..39af4d73 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -29,6 +29,7 @@ export { DevframeDockEntryBase } export { DevframeDockEntryCategory } export { DevframeDockEntryIcon } export { DevframeDockEntryRegistry } +export { DevframeDockPanelStateEvent } export { DevframeDocksActiveState } export { DevframeDocksHost } export { DevframeDocksUserSettings } From ebdbcf6fa71bf83c74ebc1fb457ad17033b6ea27 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Sat, 22 Aug 2026 13:09:58 +0200 Subject: [PATCH 02/11] docs(hub-ui): explain dock restore trust gate --- packages/hub-ui/src/client/state/context.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index bf48f815..435eff00 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -648,6 +648,7 @@ export async function createDocksContext( // the captured session intent. // `switchEntry` then consumes the persisted iframe route when the view boots. const restoreAfterInitialization = async (): Promise => { + // The authorization gate can still clear the live session on reload, so restore only after it settles. await waitUntilTrusted() const restoreDockId = restoreIntent.selectedDockId From a5d5eb55298f26c0d6cd913335b0fb241c9569db Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Sat, 22 Aug 2026 13:22:37 +0200 Subject: [PATCH 03/11] fix(hub): align dock panel state event name --- docs/content/8.references/3.events.md | 2 +- packages/hub/src/events.ts | 2 +- packages/hub/src/node/context.ts | 2 +- .../__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts | 2 +- tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 6ea1dec1..055b658f 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -33,7 +33,7 @@ Each subsystem emits on `ctx..events`, consumed **inside the same nod | Method | Signature | Purpose | |---|---|---| | `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the hub UI provider to switch its active dock — see [Deep Linking](/guide/deep-linking). | -| `hub:docks:panel-state` | `(open) => void` | Report this viewer connection's current dock-panel state. | +| `hub:docks:panel:state` | `(open) => void` | Report this viewer connection's current dock-panel state. | | `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. | | `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). | | `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. | diff --git a/packages/hub/src/events.ts b/packages/hub/src/events.ts index 86ad652d..ed55eead 100644 --- a/packages/hub/src/events.ts +++ b/packages/hub/src/events.ts @@ -35,7 +35,7 @@ export const HUB_EVENTS = { /** Server RPC methods a connected client calls (client → server), `hub:` prefix. */ rpc: { docksActivate: 'hub:docks:activate', - docksPanelState: 'hub:docks:panel-state', + docksPanelState: 'hub:docks:panel:state', commandsExecute: 'hub:commands:execute', messagesAdd: 'hub:messages:add', messagesUpdate: 'hub:messages:update', diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index f38a7a21..4fe403d1 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -65,7 +65,7 @@ declare module 'devframe/types' { * * @internal */ - 'hub:docks:panel-state': (open: boolean) => Promise + 'hub:docks:panel:state': (open: boolean) => Promise /** * Invoke a registered server command by id; trailing args are forwarded to * the command's handler. Handled by diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts index 09ea96a7..fb4525ec 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts @@ -25,7 +25,7 @@ export declare const HUB_EVENTS: { }; readonly rpc: { readonly docksActivate: "hub:docks:activate"; - readonly docksPanelState: "hub:docks:panel-state"; + readonly docksPanelState: "hub:docks:panel:state"; readonly commandsExecute: "hub:commands:execute"; readonly messagesAdd: "hub:messages:add"; readonly messagesUpdate: "hub:messages:update"; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index f77cbfb6..667980df 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -133,7 +133,7 @@ export declare const hubDocksActivate: { }], Promise>> | undefined; }; export declare const hubDocksPanelState: { - name: "hub:docks:panel-state"; + name: "hub:docks:panel:state"; type?: "action" | undefined; cacheable?: boolean; args?: undefined; From 2ffa4803439f46c0744cd00d2a3e685df6a1f706 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 10:57:02 +0200 Subject: [PATCH 04/11] feat(hub): report extensible panel state --- docs/content/8.references/3.events.md | 4 +- packages/hub-ui/src/client/embedded/index.ts | 10 ++++- .../src/client/embedded/visibility.test.ts | 42 +++++++++++++++++++ .../hub-ui/src/client/embedded/visibility.ts | 10 +++-- .../hub-ui/src/client/state/context.test.ts | 32 ++++++++++---- packages/hub-ui/src/client/state/context.ts | 12 +++++- .../hub/src/client/__tests__/host.test.ts | 25 +++++++---- .../src/client/__tests__/panel-state.test.ts | 20 +++++++++ packages/hub/src/client/host.ts | 40 ++++++++++++++---- packages/hub/src/client/panel-state.ts | 5 ++- .../hub/src/node/__tests__/host-docks.test.ts | 20 +++++---- .../hub/src/node/__tests__/initiate.test.ts | 18 ++++---- .../src/node/__tests__/rpc-builtins.test.ts | 4 +- packages/hub/src/node/context.ts | 2 +- packages/hub/src/node/panel-state.ts | 26 ++++++++---- packages/hub/src/node/rpc-builtins.ts | 5 ++- packages/hub/src/types/docks.ts | 10 ++++- .../@devframes/hub/client.snapshot.d.ts | 2 +- .../tsnapi/@devframes/hub/index.snapshot.d.ts | 12 +++--- .../tsnapi/@devframes/hub/node.snapshot.d.ts | 10 ++--- .../tsnapi/@devframes/hub/types.snapshot.d.ts | 1 + 21 files changed, 232 insertions(+), 78 deletions(-) create mode 100644 packages/hub-ui/src/client/embedded/visibility.test.ts create mode 100644 packages/hub/src/client/__tests__/panel-state.test.ts diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 055b658f..d02e3a4a 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -26,14 +26,14 @@ Each subsystem emits on `ctx..events`, consumed **inside the same nod | `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; the messages devframe | entry / entry / id / — | | `commands:registered` / `commands:unregistered` | `CommandsHost` register / update / unregister | context → `devframe:commands` shared state | entry / id | -`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. +`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. ### Server RPC methods — client → server | Method | Signature | Purpose | |---|---|---| | `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the hub UI provider to switch its active dock — see [Deep Linking](/guide/deep-linking). | -| `hub:docks:panel:state` | `(open) => void` | Report this viewer connection's current dock-panel state. | +| `hub:docks:panel:state` | `(panelState: DevframeDockPanelState) => void` | Report this viewer connection's current dock-panel state and selected dock. | | `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. | | `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). | | `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. | diff --git a/packages/hub-ui/src/client/embedded/index.ts b/packages/hub-ui/src/client/embedded/index.ts index 26802d36..d42acb93 100644 --- a/packages/hub-ui/src/client/embedded/index.ts +++ b/packages/hub-ui/src/client/embedded/index.ts @@ -1,6 +1,7 @@ import type { DockPanelStorage, DockSessionStorage } from '@devframes/hub/client' import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client' import { useLocalStorage, useSessionStorage } from '@vueuse/core' +import { ref } from 'vue' import { applyPrimaryColor, setBranding } from '../state/branding' import { DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE } from '../state/docks' import { setupEmbeddedVisibility } from './visibility' @@ -74,8 +75,9 @@ async function mountDock(): Promise { // carried by the connection we just established above. const branding = setBranding(rpc.connectionMeta.configs?.ui?.branding || {}) + const panelVisible = ref() const { createDocksContext } = await import('../state/context') - const context = await createDocksContext('embedded', rpc, state, session) + const context = await createDocksContext('embedded', rpc, state, session, panelVisible) setDevframeClientContext(context) const { DockEmbedded } = await import('../components/DockEmbedded') @@ -97,8 +99,12 @@ async function mountDock(): Promise { show: () => { if (dockEl && !dockEl.isConnected) document.body.appendChild(dockEl) + panelVisible.value = true + }, + hide: () => { + dockEl?.remove() + panelVisible.value = false }, - hide: () => dockEl?.remove(), }, ) } diff --git a/packages/hub-ui/src/client/embedded/visibility.test.ts b/packages/hub-ui/src/client/embedded/visibility.test.ts new file mode 100644 index 00000000..ef0e60fc --- /dev/null +++ b/packages/hub-ui/src/client/embedded/visibility.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HUB_UI_HIDE_EVENT } from '../constants' +import { setupEmbeddedVisibility } from './visibility' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('setupEmbeddedVisibility', () => { + it('reports the initial hidden state and later reveal and conceal transitions', () => { + expect.assertions(5) + + const listeners = new Map() + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: EventListener) => { + listeners.set(type, listener) + }), + }) + const show = vi.fn() + const hide = vi.fn() + + setupEmbeddedVisibility('hidden', 'Devframe', { show, hide }) + + expect(hide).toHaveBeenCalledOnce() + expect(show).not.toHaveBeenCalled() + + const preventDefault = vi.fn() + listeners.get('keydown')!({ + shiftKey: true, + altKey: true, + ctrlKey: false, + metaKey: false, + code: 'KeyD', + preventDefault, + } as unknown as KeyboardEvent) + expect(preventDefault).toHaveBeenCalledOnce() + expect(show).toHaveBeenCalledOnce() + + listeners.get(HUB_UI_HIDE_EVENT)!({} as Event) + expect(hide).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/hub-ui/src/client/embedded/visibility.ts b/packages/hub-ui/src/client/embedded/visibility.ts index 7dd01403..211c7197 100644 --- a/packages/hub-ui/src/client/embedded/visibility.ts +++ b/packages/hub-ui/src/client/embedded/visibility.ts @@ -95,10 +95,14 @@ export function setupEmbeddedVisibility( handlers.hide() } - if (shown) + if (shown) { handlers.show() - else if (mode === 'passive') - printHint(label) + } + else { + handlers.hide() + if (mode === 'passive') + printHint(label) + } // Shift+Alt+D toggles the dock — the always-available "summon" chord. window.addEventListener('keydown', (e) => { diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index 03b62193..eee65b91 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -75,36 +75,54 @@ async function flushRestore(): Promise { } describe('createDocksContext', () => { - it('reports the restored panel state and later open-state transitions', async () => { - expect.assertions(4) + it('reports restored, selected, hidden, and closed panel snapshots', async () => { + expect.assertions(8) const { rpc, sharedStates, trust } = createStubRpc() + const panelVisible = ref() const session = ref({ open: true, selectedDockId: 'git', selectedDockRoute: null, }) - await createDocksContext('embedded', rpc, undefined, session) + await createDocksContext('embedded', rpc, undefined, session, panelVisible) trust() sharedStates.get('devframe:docks')!.push([gitEntry]) sharedStates.get('devframe:dock-renderers')!.push({}) await flushRestore() + expect(rpc.call).not.toHaveBeenCalled() + + panelVisible.value = true + await nextTick() await vi.waitFor(() => { if (vi.mocked(rpc.call).mock.calls.length !== 1) throw new Error('waiting for the restored panel state report') }) - expect(rpc.call).toHaveBeenCalledTimes(1) - expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, true) + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' }) + + session.value.selectedDockId = '~settings' + await nextTick() + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: '~settings' }) + + panelVisible.value = false + await nextTick() + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden', selectedDockId: '~settings' }) session.value.open = false + session.value.selectedDockId = null + await nextTick() + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden' }) + + panelVisible.value = true await nextTick() - expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, false) + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }) + panelVisible.value = true session.value.open = false await nextTick() - expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call).toHaveBeenCalledTimes(5) }) it('mounts a restored dock once after all initial server state arrives', async () => { diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 435eff00..b87e8776 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -23,6 +23,7 @@ export async function createDocksContext( rpc: DevframeRpcClient, panelStore?: Ref, sessionStore?: Ref, + panelVisible: Ref = ref(true), ): Promise { if (docksContextByRpc.has(rpc)) { return docksContextByRpc.get(rpc)! @@ -674,8 +675,15 @@ export async function createDocksContext( const reportPanelStateAfterInitialization = async (): Promise => { await restoreAfterInitialization() watch( - () => sessionStore.value.open, - open => void reportDockPanelState(rpc, open).catch(() => {}), + [panelVisible, () => sessionStore.value.open, selectedDockId], + ([visible, open, currentSelectedDockId]) => { + if (visible === undefined) + return + void reportDockPanelState(rpc, { + state: visible ? (open ? 'open' : 'closed') : 'hidden', + ...(currentSelectedDockId !== null ? { selectedDockId: currentSelectedDockId } : {}), + }).catch(() => {}) + }, { immediate: true }, ) } diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index dfc8f1d0..0a2262c8 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -68,23 +68,32 @@ function groupEntry(id: string, extra?: Record): DevframeDockEn } describe('createDevframeClientRuntime', () => { - it('reports its initial panel state and later open-state assignments', async () => { - expect.assertions(3) + it('reports complete panel snapshots and coalesces related session changes', async () => { + expect.assertions(4) - const { rpc, calls } = createStubRpc() + const { rpc, calls, states } = createStubRpc() const host = await createDevframeClientRuntime({ rpc, clientType: 'embedded' }) - expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, false]]) + expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }]]) + states.get('devframe:docks')!.push([iframeEntry('one')]) host.context.panel.session.open = true - host.context.panel.session.open = true + const switched = host.context.docks.switchEntry('one') + await switched expect(calls).toEqual([ - [HUB_EVENTS.rpc.docksPanelState, false], - [HUB_EVENTS.rpc.docksPanelState, true], + [HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }], + [HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'one' }], ]) + host.context.panel.session.open = true + host.context.panel.session.selectedDockId = 'one' + await Promise.resolve() + expect(calls).toHaveLength(2) + host.context.panel.session.open = false - expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, false]) + const cleared = host.context.docks.switchEntry(null) + await cleared + expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }]) host.dispose() }) diff --git a/packages/hub/src/client/__tests__/panel-state.test.ts b/packages/hub/src/client/__tests__/panel-state.test.ts new file mode 100644 index 00000000..91ed29f4 --- /dev/null +++ b/packages/hub/src/client/__tests__/panel-state.test.ts @@ -0,0 +1,20 @@ +import type { DevframeRpcClient } from 'devframe/client' +import { describe, expect, it, vi } from 'vitest' +import { HUB_EVENTS } from '../../events' +import { reportDockPanelState } from '../panel-state' + +describe('reportDockPanelState', () => { + it('reports the complete panel snapshot through the hub RPC', async () => { + expect.assertions(1) + + const call = vi.fn(async () => {}) + const rpc = { call } as unknown as DevframeRpcClient + + await reportDockPanelState(rpc, { state: 'open', selectedDockId: 'git' }) + + expect(call).toHaveBeenCalledWith( + HUB_EVENTS.rpc.docksPanelState, + { state: 'open', selectedDockId: 'git' }, + ) + }) +}) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 735553c7..b3257ec1 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -20,6 +20,7 @@ import type { DockClientType, DockEntryState, DocksEntriesContext, + DockSessionStorage, DocksPanelContext, WhenClauseContext, } from './docks' @@ -125,6 +126,7 @@ export async function createDevframeClientRuntime( ): Promise { const clientType: DockClientType = options.clientType ?? 'standalone' const rpc = options.rpc ?? await connectDevframe(options.connect) + let disposed = false // Set by createRenderersContext(); teardown disposes every live mount. let mountedRenderers: Set<() => void> | undefined @@ -155,10 +157,24 @@ export async function createDevframeClientRuntime( ...options.categoryOrder, } - const sendPanelState = (open: boolean): void => { - void reportDockPanelState(rpc, open).catch(() => {}) + const reportPanelState = (session: DockSessionStorage): void => { + void reportDockPanelState(rpc, { + state: session.open ? 'open' : 'closed', + ...(session.selectedDockId !== null ? { selectedDockId: session.selectedDockId } : {}), + }).catch(() => {}) } - const panel = createPanelContext(clientType, sendPanelState) + let panelStateReportPending = false + const schedulePanelStateReport = (session: DockSessionStorage): void => { + if (panelStateReportPending) + return + panelStateReportPending = true + queueMicrotask(() => { + panelStateReportPending = false + if (!disposed) + reportPanelState(session) + }) + } + const panel = createPanelContext(clientType, schedulePanelStateReport) const docks = createDocksContext() const commands = createCommandsContext() const renderers = createRenderersContext() @@ -229,7 +245,7 @@ export async function createDevframeClientRuntime( ) } setDevframeClientContext(context) - sendPanelState(panel.session.open) + reportPanelState(panel.session) const loadedScripts = new Set() if (loadScriptsEnabled) { @@ -240,6 +256,7 @@ export async function createDevframeClientRuntime( return { context, dispose() { + disposed = true for (const off of disposers.splice(0)) off() for (const disposeAdapter of frameNavAdapters.values()) disposeAdapter() frameNavAdapters.clear() @@ -556,7 +573,7 @@ export async function createDevframeClientRuntime( function createPanelContext( clientType: DockClientType, - onOpenChange: (open: boolean) => void, + onSessionChange: (session: DockSessionStorage) => void, ): DocksPanelContext { const store: DocksPanelContext['store'] = { mode: 'edge', @@ -568,6 +585,7 @@ function createPanelContext( inactiveTimeout: 0, } let open = clientType === 'standalone' + let selectedDockId: string | null = null const session: DocksPanelContext['session'] = { get open() { return open @@ -576,9 +594,17 @@ function createPanelContext( if (nextOpen === open) return open = nextOpen - onOpenChange(open) + onSessionChange(session) + }, + get selectedDockId() { + return selectedDockId + }, + set selectedDockId(nextSelectedDockId) { + if (nextSelectedDockId === selectedDockId) + return + selectedDockId = nextSelectedDockId + onSessionChange(session) }, - selectedDockId: null, selectedDockRoute: null, } return { diff --git a/packages/hub/src/client/panel-state.ts b/packages/hub/src/client/panel-state.ts index 130207ca..d0542830 100644 --- a/packages/hub/src/client/panel-state.ts +++ b/packages/hub/src/client/panel-state.ts @@ -1,10 +1,11 @@ import type { DevframeRpcClient } from 'devframe/client' +import type { DevframeDockPanelState } from '../types/docks' import { HUB_EVENTS } from '../events' /** Report this RPC connection's current dock-panel state to the hub. */ export async function reportDockPanelState( rpc: DevframeRpcClient, - open: boolean, + panelState: DevframeDockPanelState, ): Promise { - await rpc.call(HUB_EVENTS.rpc.docksPanelState, open) + await rpc.call(HUB_EVENTS.rpc.docksPanelState, panelState) } diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index e65973e9..197a2447 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -231,13 +231,15 @@ describe('devframeDockHost panel state', () => { const events: DevframeDockPanelStateEvent[] = [] host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) - updateDockPanelState(host, 11, false) - updateDockPanelState(host, 11, false) - updateDockPanelState(host, 11, true) + updateDockPanelState(host, 11, { state: 'closed' }) + updateDockPanelState(host, 11, { state: 'closed' }) + updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'git' }) + updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'inspect' }) expect(events).toEqual([ - { type: 'connected', sessionId: 11, open: false }, - { type: 'changed', sessionId: 11, open: true }, + { type: 'connected', sessionId: 11, state: 'closed' }, + { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'git' }, + { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'inspect' }, ]) }) @@ -248,15 +250,15 @@ describe('devframeDockHost panel state', () => { const events: DevframeDockPanelStateEvent[] = [] host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) - updateDockPanelState(host, 11, true) - updateDockPanelState(host, 12, false) + updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'git' }) + updateDockPanelState(host, 12, { state: 'hidden' }) disconnectDockPanelState(host, 99) disconnectDockPanelState(host, 11) disconnectDockPanelState(host, 11) expect(events).toEqual([ - { type: 'connected', sessionId: 11, open: true }, - { type: 'connected', sessionId: 12, open: false }, + { type: 'connected', sessionId: 11, state: 'open', selectedDockId: 'git' }, + { type: 'connected', sessionId: 12, state: 'hidden' }, { type: 'disconnected', sessionId: 11 }, ]) }) diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 6e01e782..b7cd0af9 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -180,16 +180,16 @@ describe('initHub', () => { const secondClient = connectWsClient(`ws://${host}:${port}/__ws`) clients.push(firstClient, secondClient) - await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, true) - await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, true) - await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, false) - await secondClient.$call(HUB_EVENTS.rpc.docksPanelState, false) + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' }) + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' }) + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }) + await secondClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden' }) expect(lifecycleEvents).toHaveLength(3) - expect(lifecycleEvents[0]).toMatchObject({ type: 'connected', open: true }) + expect(lifecycleEvents[0]).toMatchObject({ type: 'connected', state: 'open', selectedDockId: 'git' }) expect(typeof lifecycleEvents[0]!.sessionId).toBe('number') - expect(lifecycleEvents[1]).toEqual({ type: 'changed', sessionId: lifecycleEvents[0]!.sessionId, open: false }) - expect(lifecycleEvents[2]).toMatchObject({ type: 'connected', open: false }) + expect(lifecycleEvents[1]).toEqual({ type: 'changed', sessionId: lifecycleEvents[0]!.sessionId, state: 'closed' }) + expect(lifecycleEvents[2]).toMatchObject({ type: 'connected', state: 'hidden' }) expect(lifecycleEvents[2]!.sessionId).not.toBe(lifecycleEvents[0]!.sessionId) firstClient.close() @@ -201,9 +201,9 @@ describe('initHub', () => { const reconnectedClient = connectWsClient(`ws://${host}:${port}/__ws`) clients.push(reconnectedClient) - await reconnectedClient.$call(HUB_EVENTS.rpc.docksPanelState, true) + await reconnectedClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'open' }) - expect(lifecycleEvents[4]).toMatchObject({ type: 'connected', open: true }) + expect(lifecycleEvents[4]).toMatchObject({ type: 'connected', state: 'open' }) expect([lifecycleEvents[0]!.sessionId, lifecycleEvents[2]!.sessionId]).not.toContain(lifecycleEvents[4]!.sessionId) } finally { diff --git a/packages/hub/src/node/__tests__/rpc-builtins.test.ts b/packages/hub/src/node/__tests__/rpc-builtins.test.ts index 40cd54cf..cc348d40 100644 --- a/packages/hub/src/node/__tests__/rpc-builtins.test.ts +++ b/packages/hub/src/node/__tests__/rpc-builtins.test.ts @@ -159,9 +159,9 @@ describe('hub docks panel-state RPC', () => { } as unknown as DevframeHubContext const fn = await hubDocksPanelState.setup!(ctx) - await fn.handler!(true) + await fn.handler!({ state: 'open', selectedDockId: 'git' }) expect(getCurrentRpcSession).toHaveBeenCalledOnce() - expect(lifecycleEvents).toEqual([{ type: 'connected', sessionId: 73, open: true }]) + expect(lifecycleEvents).toEqual([{ type: 'connected', sessionId: 73, state: 'open', selectedDockId: 'git' }]) }) }) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 4fe403d1..4660b15c 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -65,7 +65,7 @@ declare module 'devframe/types' { * * @internal */ - 'hub:docks:panel:state': (open: boolean) => Promise + 'hub:docks:panel:state': (panelState: import('../types/docks').DevframeDockPanelState) => Promise /** * Invoke a registered server command by id; trailing args are forwarded to * the command's handler. Handled by diff --git a/packages/hub/src/node/panel-state.ts b/packages/hub/src/node/panel-state.ts index a0743f22..9b1ba270 100644 --- a/packages/hub/src/node/panel-state.ts +++ b/packages/hub/src/node/panel-state.ts @@ -1,12 +1,12 @@ -import type { DevframeDockPanelStateEvent, DevframeDocksHost } from '../types/docks' +import type { DevframeDockPanelState, DevframeDockPanelStateEvent, DevframeDocksHost } from '../types/docks' import { HUB_EVENTS } from '../events' -const dockPanelStates = new WeakMap>() +const dockPanelStates = new WeakMap>() export function updateDockPanelState( docks: DevframeDocksHost, sessionId: number, - open: boolean, + panelState: DevframeDockPanelState, ): void { let sessionStates = dockPanelStates.get(docks) if (!sessionStates) { @@ -14,14 +14,22 @@ export function updateDockPanelState( dockPanelStates.set(docks, sessionStates) } - const previousOpen = sessionStates.get(sessionId) - if (previousOpen === open) + const previousState = sessionStates.get(sessionId) + if ( + previousState?.state === panelState.state + && previousState.selectedDockId === panelState.selectedDockId + ) { return + } - sessionStates.set(sessionId, open) - const event: DevframeDockPanelStateEvent = previousOpen === undefined - ? { type: 'connected', sessionId, open } - : { type: 'changed', sessionId, open } + const currentState: DevframeDockPanelState = { + state: panelState.state, + ...(panelState.selectedDockId !== undefined ? { selectedDockId: panelState.selectedDockId } : {}), + } + sessionStates.set(sessionId, currentState) + const event: DevframeDockPanelStateEvent = previousState === undefined + ? { type: 'connected', sessionId, ...currentState } + : { type: 'changed', sessionId, ...currentState } docks.events.emit(HUB_EVENTS.bus.docksPanelState, event) } diff --git a/packages/hub/src/node/rpc-builtins.ts b/packages/hub/src/node/rpc-builtins.ts index cffc9ebe..c88431a5 100644 --- a/packages/hub/src/node/rpc-builtins.ts +++ b/packages/hub/src/node/rpc-builtins.ts @@ -1,4 +1,5 @@ import type { RpcFunctionDefinitionAny } from 'devframe/rpc' +import type { DevframeDockPanelState } from '../types/docks' import type { DevframeMessageEntry, DevframeMessageEntryInput } from '../types/messages' import type { DevframeChildProcessTerminalSession, @@ -226,10 +227,10 @@ export const hubDocksPanelState = defineHubRpcFunction({ name: HUB_EVENTS.rpc.docksPanelState, type: 'action', setup: context => ({ - async handler(open: boolean): Promise { + async handler(panelState: DevframeDockPanelState): Promise { const session = context.rpc.getCurrentRpcSession() if (session) - updateDockPanelState(context.docks, session.meta.id, open) + updateDockPanelState(context.docks, session.meta.id, panelState) }, }), }) diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index d68f09bc..cef050cf 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -30,10 +30,16 @@ export interface DevframeDocksHost { activate: (dockId: string, params?: Record) => void } +/** Current state reported by one dock-panel viewer. */ +export interface DevframeDockPanelState { + state: 'open' | 'closed' | 'hidden' + selectedDockId?: string +} + /** Lifecycle event for one viewer's dock panel over an RPC connection. */ export type DevframeDockPanelStateEvent - = | { type: 'connected', sessionId: number, open: boolean } - | { type: 'changed', sessionId: number, open: boolean } + = | ({ type: 'connected', sessionId: number } & DevframeDockPanelState) + | ({ type: 'changed', sessionId: number } & DevframeDockPanelState) | { type: 'disconnected', sessionId: number } /** diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index 3608413a..191e446f 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -247,7 +247,7 @@ export declare function createDockRenderersContext(_: CreateDockRenderersContext export declare function createMessagesClient(_: DevframeRpcClient, _?: MessagesClientOptions): DevframeMessagesClient; export declare function getDevframeClientContext(): DevframeClientContext | undefined; export declare function parseRemoteConnection(_?: string): RemoteConnectionInfo | null; -export declare function reportDockPanelState(_: DevframeRpcClient, _: boolean): Promise; +export declare function reportDockPanelState(_: DevframeRpcClient, _: DevframeDockPanelState): Promise; export declare function resolveClientModuleSpecifier(_: string, _?: { resolveClientModule?: (_: string) => string | undefined; template?: string; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index bd665758..8005cd17 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -101,6 +101,10 @@ export interface DevframeDockEntryRegistry { 'group': DevframeViewGroup; '~builtin': DevframeViewBuiltin; } +export interface DevframeDockPanelState { + state: 'open' | 'closed' | 'hidden'; + selectedDockId?: string; +} export interface DevframeDocksActiveState { activation: DevframeDockActivation | null; } @@ -347,15 +351,13 @@ export type DevframeDockEntryIcon = string | { light: string; dark: string; }; -export type DevframeDockPanelStateEvent = { +export type DevframeDockPanelStateEvent = ({ type: 'connected'; sessionId: number; - open: boolean; -} | { +} & DevframeDockPanelState) | ({ type: 'changed'; sessionId: number; - open: boolean; -} | { +} & DevframeDockPanelState) | { type: 'disconnected'; sessionId: number; }; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 667980df..c1859bc4 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -140,12 +140,12 @@ export declare const hubDocksPanelState: { returns?: undefined; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: DevframeHubContext) => import("devframe/rpc").Thenable>>) | undefined; - handler?: ((open: boolean) => Promise) | undefined; - dump?: import("devframe/rpc").RpcDump<[open: boolean], Promise, DevframeHubContext> | undefined; + setup?: ((context: DevframeHubContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((panelState: DevframeDockPanelState) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[panelState: DevframeDockPanelState], Promise, DevframeHubContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>>> | undefined; - __promise?: import("devframe/rpc").Thenable>> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }; export declare const hubMessagesAdd: { name: "hub:messages:add"; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 39af4d73..b496606d 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -29,6 +29,7 @@ export { DevframeDockEntryBase } export { DevframeDockEntryCategory } export { DevframeDockEntryIcon } export { DevframeDockEntryRegistry } +export { DevframeDockPanelState } export { DevframeDockPanelStateEvent } export { DevframeDocksActiveState } export { DevframeDocksHost } From 63e7a4a5e99266c2eead292391e7b6927545b98c Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 11:15:59 +0200 Subject: [PATCH 05/11] refactor(hub): clarify panel state snapshots --- packages/hub-ui/src/client/state/context.ts | 28 +++++++++++++++++---- packages/hub/src/client/host.ts | 15 ++++++++--- packages/hub/src/node/panel-state.ts | 5 +--- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index b87e8776..6a4213f4 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -1,4 +1,4 @@ -import type { DevframeClientCommand, DevframeDockEntry, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub' +import type { DevframeClientCommand, DevframeDockEntry, DevframeDockPanelState, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub' import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage } from '@devframes/hub/client' import type { SharedState } from 'devframe/utils/shared-state' import type { WhenContext } from 'devframe/utils/when' @@ -18,6 +18,26 @@ import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDo import { executeSetupScript } from './setup-script' const docksContextByRpc = new WeakMap() + +function createDockPanelState( + visible: boolean, + open: boolean, + selectedDockId: string | null, +): DevframeDockPanelState { + let state: DevframeDockPanelState['state'] + if (!visible) + state = 'hidden' + else if (open) + state = 'open' + else + state = 'closed' + + const panelState: DevframeDockPanelState = { state } + if (selectedDockId !== null) + panelState.selectedDockId = selectedDockId + return panelState +} + export async function createDocksContext( clientType: 'embedded' | 'standalone', rpc: DevframeRpcClient, @@ -679,10 +699,8 @@ export async function createDocksContext( ([visible, open, currentSelectedDockId]) => { if (visible === undefined) return - void reportDockPanelState(rpc, { - state: visible ? (open ? 'open' : 'closed') : 'hidden', - ...(currentSelectedDockId !== null ? { selectedDockId: currentSelectedDockId } : {}), - }).catch(() => {}) + const panelState = createDockPanelState(visible, open, currentSelectedDockId) + void reportDockPanelState(rpc, panelState).catch(() => {}) }, { immediate: true }, ) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index b3257ec1..124ac19e 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -10,6 +10,7 @@ import type { ClientScriptEntry, DevframeDockEntriesGrouped, DevframeDockEntry, + DevframeDockPanelState, DevframeViewIframe, } from '../types/docks' import type { DevframeDocksUserSettings } from '../types/settings' @@ -158,10 +159,7 @@ export async function createDevframeClientRuntime( } const reportPanelState = (session: DockSessionStorage): void => { - void reportDockPanelState(rpc, { - state: session.open ? 'open' : 'closed', - ...(session.selectedDockId !== null ? { selectedDockId: session.selectedDockId } : {}), - }).catch(() => {}) + void reportDockPanelState(rpc, createDockPanelState(session)).catch(() => {}) } let panelStateReportPending = false const schedulePanelStateReport = (session: DockSessionStorage): void => { @@ -618,6 +616,15 @@ function createPanelContext( } } +function createDockPanelState(session: DockSessionStorage): DevframeDockPanelState { + const panelState: DevframeDockPanelState = { + state: session.open ? 'open' : 'closed', + } + if (session.selectedDockId !== null) + panelState.selectedDockId = session.selectedDockId + return panelState +} + function groupByCategory(entries: DevframeDockEntry[], categoryOrder: Record): 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 diff --git a/packages/hub/src/node/panel-state.ts b/packages/hub/src/node/panel-state.ts index 9b1ba270..1d82a691 100644 --- a/packages/hub/src/node/panel-state.ts +++ b/packages/hub/src/node/panel-state.ts @@ -22,10 +22,7 @@ export function updateDockPanelState( return } - const currentState: DevframeDockPanelState = { - state: panelState.state, - ...(panelState.selectedDockId !== undefined ? { selectedDockId: panelState.selectedDockId } : {}), - } + const currentState: DevframeDockPanelState = { ...panelState } sessionStates.set(sessionId, currentState) const event: DevframeDockPanelStateEvent = previousState === undefined ? { type: 'connected', sessionId, ...currentState } From ae88cf6a224316702963878d4803e9c077e6e4cf Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 11:29:11 +0200 Subject: [PATCH 06/11] chore(examples): log dock panel state updates --- examples/hub-vite/vite.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/hub-vite/vite.config.ts b/examples/hub-vite/vite.config.ts index 301b9a85..4a83727b 100644 --- a/examples/hub-vite/vite.config.ts +++ b/examples/hub-vite/vite.config.ts @@ -1,5 +1,7 @@ import type { DevframeHubContext } from '@devframes/hub/node' +import { styleText } from 'node:util' import { defineHubRpcFunction } from '@devframes/hub' +import { HUB_EVENTS } from '@devframes/hub/constants' import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import createA11yDevframe from '@devframes/plugin-a11y' @@ -149,6 +151,10 @@ export default defineConfig({ // into this host's own bundle. renderers: [jsonRenderUiRenderer()], configure: async (context) => { + context.docks.events.on(HUB_EVENTS.bus.docksPanelState, (event) => { + console.info(styleText('cyan', '🔄 [hub-vite]'), 'Dock panel state updated', event) + }) + // Seed a sample command directly on the hub so the UI shows something // even without any plugged-in devframes. context.commands.register({ From 52e807410c3753bc086a32b7ba9a7a09b869bd4b Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 11:45:01 +0200 Subject: [PATCH 07/11] fix(examples): log embedded panel state updates --- examples/hub-vite-minimal/vite.config.ts | 6 ++++++ examples/hub-vite/vite.config.ts | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/hub-vite-minimal/vite.config.ts b/examples/hub-vite-minimal/vite.config.ts index 0259e8ae..eb99334a 100644 --- a/examples/hub-vite-minimal/vite.config.ts +++ b/examples/hub-vite-minimal/vite.config.ts @@ -1,6 +1,8 @@ import type { DevframeJsonRenderSpec } from '@devframes/json-render' import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' +import { styleText } from 'node:util' import { createUi } from '@devframes/hub-ui' +import { HUB_EVENTS } from '@devframes/hub/constants' import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import { createA11yDevframe } from '@devframes/plugin-a11y' import { createAssetsDevframe } from '@devframes/plugin-assets' @@ -78,6 +80,10 @@ export default defineConfig({ // the same contract. renderers: [jsonRenderUiRenderer()], configure(ctx) { + ctx.docks.events.on(HUB_EVENTS.bus.docksPanelState, (event) => { + console.info(styleText('cyan', '🔄 [hub-vite-minimal]'), 'Dock panel state updated', event) + }) + ctx.docks.register(jsonRenderDock) }, // Gate with devframe's interactive OTP (the default): the hub prints a diff --git a/examples/hub-vite/vite.config.ts b/examples/hub-vite/vite.config.ts index 4a83727b..301b9a85 100644 --- a/examples/hub-vite/vite.config.ts +++ b/examples/hub-vite/vite.config.ts @@ -1,7 +1,5 @@ import type { DevframeHubContext } from '@devframes/hub/node' -import { styleText } from 'node:util' import { defineHubRpcFunction } from '@devframes/hub' -import { HUB_EVENTS } from '@devframes/hub/constants' import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import createA11yDevframe from '@devframes/plugin-a11y' @@ -151,10 +149,6 @@ export default defineConfig({ // into this host's own bundle. renderers: [jsonRenderUiRenderer()], configure: async (context) => { - context.docks.events.on(HUB_EVENTS.bus.docksPanelState, (event) => { - console.info(styleText('cyan', '🔄 [hub-vite]'), 'Dock panel state updated', event) - }) - // Seed a sample command directly on the hub so the UI shows something // even without any plugged-in devframes. context.commands.register({ From d9b0573d1d7ef7971faa13f1f5136bd6ad69fdc3 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 13:56:39 +0200 Subject: [PATCH 08/11] fix(hub): normalize dock panel selection --- packages/hub/src/node/__tests__/host-docks.test.ts | 4 +++- packages/hub/src/node/panel-state.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index 197a2447..b2c367bc 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -231,15 +231,17 @@ describe('devframeDockHost panel state', () => { const events: DevframeDockPanelStateEvent[] = [] host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) - updateDockPanelState(host, 11, { state: 'closed' }) + updateDockPanelState(host, 11, { state: 'closed', selectedDockId: undefined }) updateDockPanelState(host, 11, { state: 'closed' }) updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'git' }) updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'inspect' }) + updateDockPanelState(host, 11, { state: 'open', selectedDockId: undefined }) expect(events).toEqual([ { type: 'connected', sessionId: 11, state: 'closed' }, { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'git' }, { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'inspect' }, + { type: 'changed', sessionId: 11, state: 'open' }, ]) }) diff --git a/packages/hub/src/node/panel-state.ts b/packages/hub/src/node/panel-state.ts index 1d82a691..9bcef2d1 100644 --- a/packages/hub/src/node/panel-state.ts +++ b/packages/hub/src/node/panel-state.ts @@ -8,6 +8,10 @@ export function updateDockPanelState( sessionId: number, panelState: DevframeDockPanelState, ): void { + const currentState: DevframeDockPanelState = typeof panelState.selectedDockId === 'string' + ? { state: panelState.state, selectedDockId: panelState.selectedDockId } + : { state: panelState.state } + let sessionStates = dockPanelStates.get(docks) if (!sessionStates) { sessionStates = new Map() @@ -16,13 +20,12 @@ export function updateDockPanelState( const previousState = sessionStates.get(sessionId) if ( - previousState?.state === panelState.state - && previousState.selectedDockId === panelState.selectedDockId + previousState?.state === currentState.state + && previousState.selectedDockId === currentState.selectedDockId ) { return } - const currentState: DevframeDockPanelState = { ...panelState } sessionStates.set(sessionId, currentState) const event: DevframeDockPanelStateEvent = previousState === undefined ? { type: 'connected', sessionId, ...currentState } From a5ed1aaf1f0b12e7b6a042fd0fcb3f64120a4047 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 23:16:40 +0200 Subject: [PATCH 09/11] refactor(hub): emit panel state from client context --- docs/content/1.guide/17.client-context.md | 23 +++++++ docs/content/8.references/3.events.md | 18 +++-- examples/hub-vite-minimal/vite.config.ts | 6 -- .../src/client/embedded/visibility.test.ts | 2 +- .../hub-ui/src/client/state/context.test.ts | 31 +++++---- packages/hub-ui/src/client/state/context.ts | 36 +++++++--- .../hub/src/client/__tests__/host.test.ts | 29 +++++--- .../src/client/__tests__/panel-state.test.ts | 20 ------ packages/hub/src/client/docks.ts | 10 ++- packages/hub/src/client/host.ts | 60 +++++++++++------ packages/hub/src/client/index.ts | 1 - packages/hub/src/client/panel-state.ts | 11 --- packages/hub/src/events.ts | 13 ++-- .../hub/src/node/__tests__/host-docks.test.ts | 47 +------------ .../hub/src/node/__tests__/initiate.test.ts | 67 +------------------ .../src/node/__tests__/rpc-builtins.test.ts | 27 -------- packages/hub/src/node/context.ts | 10 --- packages/hub/src/node/initiate.ts | 7 -- packages/hub/src/node/panel-state.ts | 47 ------------- packages/hub/src/node/rpc-builtins.ts | 16 ----- packages/hub/src/types/docks.ts | 9 +-- .../@devframes/hub/client.snapshot.d.ts | 6 +- .../tsnapi/@devframes/hub/client.snapshot.js | 1 - .../@devframes/hub/constants.snapshot.d.ts | 5 +- .../tsnapi/@devframes/hub/index.snapshot.d.ts | 11 --- .../tsnapi/@devframes/hub/node.snapshot.d.ts | 15 ----- .../tsnapi/@devframes/hub/node.snapshot.js | 1 - .../tsnapi/@devframes/hub/types.snapshot.d.ts | 1 - 28 files changed, 166 insertions(+), 364 deletions(-) delete mode 100644 packages/hub/src/client/__tests__/panel-state.test.ts delete mode 100644 packages/hub/src/client/panel-state.ts delete mode 100644 packages/hub/src/node/panel-state.ts diff --git a/docs/content/1.guide/17.client-context.md b/docs/content/1.guide/17.client-context.md index 7a73b7cb..45388791 100644 --- a/docs/content/1.guide/17.client-context.md +++ b/docs/content/1.guide/17.client-context.md @@ -36,6 +36,29 @@ The context carries the [RPC client](/guide/client) (`rpc`) and the page's `clie `getDevframeClientContext()` returns the context anywhere; `undefined` before boot. +### Tracking panel state + +`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: + +```ts +import type { DevframeDockPanelState } from '@devframes/hub' +import type { DockClientScriptContext } from '@devframes/hub/client' +import { HUB_EVENTS } from '@devframes/hub/constants' + +export default function setup(context: DockClientScriptContext) { + const reportPanelState = (panelState: DevframeDockPanelState) => + context.rpc.call('my-devframe:panel-state', panelState) + + void reportPanelState(context.panel.state) + context.panel.events.on( + HUB_EVENTS.client.docksPanelStateChanged, + panelState => void reportPanelState(panelState), + ) +} +``` + +The custom RPC keeps node-side reporting opt-in. Its handler can call `ctx.rpc.getCurrentRpcSession()` when it needs the reporting connection's identity. + ### Client-only docks 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)`. diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index d02e3a4a..e776cb9d 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -2,10 +2,10 @@ title: 'Events Reference' navigation: icon: i-lucide-radio-tower -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.' +description: 'Devframe carries change notifications through client contexts, node event buses, RPC, broadcasts, and shared state.' --- -Devframe carries change notifications across channels of differing **direction and reach**: a node event bus, server RPC, and server-pushed broadcasts and shared state. +Devframe carries change notifications through client contexts, node event buses, RPC, broadcasts, and shared state. 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`. @@ -13,6 +13,16 @@ Each name lives in code: [`HUB_EVENTS`](https://github.com/devframes/devframe/bl ## Hub events +### Client-context events + +Client scripts subscribe to these events on the client context inside the host page. + +| Event | Emitter | Payload | +|---|---|---| +| `panel:state:changed` | `ctx.panel.events` | `DevframeDockPanelState` | + +`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`. + ### Internal node event bus Each subsystem emits on `ctx..events`, consumed **inside the same node process** by `createHubContext`, which fans them onto the wire. @@ -21,19 +31,15 @@ Each subsystem emits on `ctx..events`, consumed **inside the same nod |---|---|---|---| | `docks:entry:updated` | `DocksHost.register` / `update` | context → `devframe:docks` shared state | `DevframeDockUserEntry` | | `docks:activate` | `DocksHost.activate()` | context → broadcast + `devframe:docks:active` | `DevframeDockActivation` | -| `docks:panel:state` | viewer state reports and RPC disconnects | hub consumers | `DevframeDockPanelStateEvent` | | `terminals:session:updated` | `TerminalsHost` register / update / remove / status change | context → `devframe:terminals:updated`; the terminals devframe | `DevframeTerminalSession` | | `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; the messages devframe | entry / entry / id / — | | `commands:registered` / `commands:unregistered` | `CommandsHost` register / update / unregister | context → `devframe:commands` shared state | entry / id | -`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. - ### Server RPC methods — client → server | Method | Signature | Purpose | |---|---|---| | `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the hub UI provider to switch its active dock — see [Deep Linking](/guide/deep-linking). | -| `hub:docks:panel:state` | `(panelState: DevframeDockPanelState) => void` | Report this viewer connection's current dock-panel state and selected dock. | | `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. | | `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). | | `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. | diff --git a/examples/hub-vite-minimal/vite.config.ts b/examples/hub-vite-minimal/vite.config.ts index eb99334a..0259e8ae 100644 --- a/examples/hub-vite-minimal/vite.config.ts +++ b/examples/hub-vite-minimal/vite.config.ts @@ -1,8 +1,6 @@ import type { DevframeJsonRenderSpec } from '@devframes/json-render' import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' -import { styleText } from 'node:util' import { createUi } from '@devframes/hub-ui' -import { HUB_EVENTS } from '@devframes/hub/constants' import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import { createA11yDevframe } from '@devframes/plugin-a11y' import { createAssetsDevframe } from '@devframes/plugin-assets' @@ -80,10 +78,6 @@ export default defineConfig({ // the same contract. renderers: [jsonRenderUiRenderer()], configure(ctx) { - ctx.docks.events.on(HUB_EVENTS.bus.docksPanelState, (event) => { - console.info(styleText('cyan', '🔄 [hub-vite-minimal]'), 'Dock panel state updated', event) - }) - ctx.docks.register(jsonRenderDock) }, // Gate with devframe's interactive OTP (the default): the hub prints a diff --git a/packages/hub-ui/src/client/embedded/visibility.test.ts b/packages/hub-ui/src/client/embedded/visibility.test.ts index ef0e60fc..08ab8b95 100644 --- a/packages/hub-ui/src/client/embedded/visibility.test.ts +++ b/packages/hub-ui/src/client/embedded/visibility.test.ts @@ -7,7 +7,7 @@ afterEach(() => { }) describe('setupEmbeddedVisibility', () => { - it('reports the initial hidden state and later reveal and conceal transitions', () => { + it('handles the initial hidden state and later reveal and conceal transitions', () => { expect.assertions(5) const listeners = new Map() diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index eee65b91..84a84a9c 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -1,4 +1,4 @@ -import type { DevframeDockEntry } from '@devframes/hub' +import type { DevframeDockEntry, DevframeDockPanelState } from '@devframes/hub' import type { DevframeRpcClient, DockSessionStorage } from '@devframes/hub/client' import type { SharedState } from 'devframe/utils/shared-state' import { HUB_EVENTS } from '@devframes/hub/constants' @@ -72,10 +72,12 @@ async function flushRestore(): Promise { await Promise.resolve() await Promise.resolve() await nextTick() + await Promise.resolve() + await Promise.resolve() } describe('createDocksContext', () => { - it('reports restored, selected, hidden, and closed panel snapshots', async () => { + it('exposes restored panel state and emits selected, hidden, and closed changes', async () => { expect.assertions(8) const { rpc, sharedStates, trust } = createStubRpc() @@ -85,7 +87,12 @@ describe('createDocksContext', () => { selectedDockId: 'git', selectedDockRoute: null, }) - await createDocksContext('embedded', rpc, undefined, session, panelVisible) + const context = await createDocksContext('embedded', rpc, undefined, session, panelVisible) + const panelStates: DevframeDockPanelState[] = [] + context.panel.events.on( + HUB_EVENTS.client.docksPanelStateChanged, + panelState => panelStates.push(panelState), + ) trust() sharedStates.get('devframe:docks')!.push([gitEntry]) @@ -95,34 +102,30 @@ describe('createDocksContext', () => { panelVisible.value = true await nextTick() - await vi.waitFor(() => { - if (vi.mocked(rpc.call).mock.calls.length !== 1) - throw new Error('waiting for the restored panel state report') - }) - expect(rpc.call).toHaveBeenCalledTimes(1) - expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' }) + expect(context.panel.state).toEqual({ state: 'open', selectedDockId: 'git' }) + expect(panelStates).toEqual([]) session.value.selectedDockId = '~settings' await nextTick() - expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: '~settings' }) + expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: '~settings' }) panelVisible.value = false await nextTick() - expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden', selectedDockId: '~settings' }) + expect(panelStates.at(-1)).toEqual({ state: 'hidden', selectedDockId: '~settings' }) session.value.open = false session.value.selectedDockId = null await nextTick() - expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden' }) + expect(panelStates.at(-1)).toEqual({ state: 'hidden' }) panelVisible.value = true await nextTick() - expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }) + expect(panelStates.at(-1)).toEqual({ state: 'closed' }) panelVisible.value = true session.value.open = false await nextTick() - expect(rpc.call).toHaveBeenCalledTimes(5) + expect(panelStates).toHaveLength(4) }) it('mounts a restored dock once after all initial server state arrives', async () => { diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 6a4213f4..25383277 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -1,12 +1,13 @@ import type { DevframeClientCommand, DevframeDockEntry, DevframeDockPanelState, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub' -import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage } from '@devframes/hub/client' +import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage, DocksPanelEvents } from '@devframes/hub/client' import type { SharedState } from 'devframe/utils/shared-state' import type { WhenContext } from 'devframe/utils/when' import type { Ref } from 'vue' import type { DevframeDocksUserSettings } from './dock-settings' -import { attachFrameNavClient, createDockRenderersContext, reportDockPanelState } from '@devframes/hub/client' +import { attachFrameNavClient, createDockRenderersContext } from '@devframes/hub/client' import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants' import { DEVFRAME_EVENTS } from 'devframe/constants' +import { createEventEmitter } from 'devframe/utils/events' import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue' import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants' import { useBranding } from './branding' @@ -193,6 +194,7 @@ export async function createDocksContext( } panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE()) + const panelEvents = createEventEmitter() let docksContext: DocksContext let _settingsStorePromise: Promise> | undefined @@ -598,6 +600,14 @@ export async function createDocksContext( docksContext = reactive({ panel: { + get state() { + return createDockPanelState( + panelVisible.value !== false, + sessionStore.value.open, + selectedDockId.value, + ) + }, + events: markRaw(panelEvents), store: panelStore, session: sessionStore, isDragging: false, @@ -692,20 +702,26 @@ export async function createDocksContext( initialRestorePending.value = false await switchEntry(restoreDockId) } - const reportPanelStateAfterInitialization = async (): Promise => { - await restoreAfterInitialization() + const startPanelStateEvents = (): void => { + let previousPanelState = docksContext.panel.state watch( [panelVisible, () => sessionStore.value.open, selectedDockId], - ([visible, open, currentSelectedDockId]) => { - if (visible === undefined) + () => { + const panelState = docksContext.panel.state + if ( + panelState.state === previousPanelState.state + && panelState.selectedDockId === previousPanelState.selectedDockId + ) { return - const panelState = createDockPanelState(visible, open, currentSelectedDockId) - void reportDockPanelState(rpc, panelState).catch(() => {}) + } + + previousPanelState = panelState + panelEvents.emit(HUB_EVENTS.client.docksPanelStateChanged, panelState) }, - { immediate: true }, + { flush: 'post' }, ) } - void reportPanelStateAfterInitialization() + void restoreAfterInitialization().then(startPanelStateEvents, startPanelStateEvents) docksContextByRpc.set(rpc, docksContext) return docksContext diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index 0a2262c8..026b83eb 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -1,6 +1,6 @@ import type { DevframeRpcClient } from 'devframe/client' import type { SharedState } from 'devframe/utils/shared-state' -import type { DevframeDockEntry } from '../../types/docks' +import type { DevframeDockEntry, DevframeDockPanelState } from '../../types/docks' import { createEventEmitter } from 'devframe/utils/events' import { describe, expect, it, vi } from 'vitest' import { HUB_EVENTS } from '../../events' @@ -68,32 +68,39 @@ function groupEntry(id: string, extra?: Record): DevframeDockEn } describe('createDevframeClientRuntime', () => { - it('reports complete panel snapshots and coalesces related session changes', async () => { - expect.assertions(4) + it('exposes panel state and emits coalesced changes', async () => { + expect.assertions(7) const { rpc, calls, states } = createStubRpc() const host = await createDevframeClientRuntime({ rpc, clientType: 'embedded' }) + const panelStates: DevframeDockPanelState[] = [] - expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }]]) + expect(host.context.panel.state).toEqual({ state: 'closed' }) + host.context.panel.events.on( + HUB_EVENTS.client.docksPanelStateChanged, + panelState => panelStates.push(panelState), + ) - states.get('devframe:docks')!.push([iframeEntry('one')]) + states.get('devframe:docks')!.push([iframeEntry('one'), iframeEntry('two')]) host.context.panel.session.open = true const switched = host.context.docks.switchEntry('one') await switched - expect(calls).toEqual([ - [HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }], - [HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'one' }], - ]) + expect(panelStates).toEqual([{ state: 'open', selectedDockId: 'one' }]) + expect(host.context.panel.state).toEqual({ state: 'open', selectedDockId: 'one' }) host.context.panel.session.open = true host.context.panel.session.selectedDockId = 'one' await Promise.resolve() - expect(calls).toHaveLength(2) + expect(panelStates).toHaveLength(1) + + await host.context.docks.switchEntry('two') + expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: 'two' }) host.context.panel.session.open = false const cleared = host.context.docks.switchEntry(null) await cleared - expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }]) + expect(panelStates.at(-1)).toEqual({ state: 'closed' }) + expect(calls).toEqual([]) host.dispose() }) diff --git a/packages/hub/src/client/__tests__/panel-state.test.ts b/packages/hub/src/client/__tests__/panel-state.test.ts deleted file mode 100644 index 91ed29f4..00000000 --- a/packages/hub/src/client/__tests__/panel-state.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { DevframeRpcClient } from 'devframe/client' -import { describe, expect, it, vi } from 'vitest' -import { HUB_EVENTS } from '../../events' -import { reportDockPanelState } from '../panel-state' - -describe('reportDockPanelState', () => { - it('reports the complete panel snapshot through the hub RPC', async () => { - expect.assertions(1) - - const call = vi.fn(async () => {}) - const rpc = { call } as unknown as DevframeRpcClient - - await reportDockPanelState(rpc, { state: 'open', selectedDockId: 'git' }) - - expect(call).toHaveBeenCalledWith( - HUB_EVENTS.rpc.docksPanelState, - { state: 'open', selectedDockId: 'git' }, - ) - }) -}) diff --git a/packages/hub/src/client/docks.ts b/packages/hub/src/client/docks.ts index eefc0943..75412ca1 100644 --- a/packages/hub/src/client/docks.ts +++ b/packages/hub/src/client/docks.ts @@ -3,7 +3,7 @@ import type { EventEmitter } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { WhenContext } from 'devframe/utils/when' import type { DevframeClientCommand, DevframeCommandEntry, DevframeCommandKeybinding } from '../types/commands' -import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeDockUserEntry } from '../types/docks' +import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeDockPanelState, DevframeDockUserEntry } from '../types/docks' import type { DevframeDocksUserSettings } from '../types/settings' import type { DockRenderersContext } from './renderers' @@ -125,7 +125,15 @@ export interface WhenClauseContext { export type DevframeClientContext = DocksContext +export interface DocksPanelEvents { + 'panel:state:changed': (state: DevframeDockPanelState) => void +} + export interface DocksPanelContext { + /** The current panel state snapshot. */ + readonly state: DevframeDockPanelState + /** Subscribe to panel state changes after the current snapshot. */ + readonly events: EventEmitter store: DockPanelStorage /** * Per-tab session UI state — whether the panel is open, which dock is diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 124ac19e..d304a243 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -23,6 +23,7 @@ import type { DocksEntriesContext, DockSessionStorage, DocksPanelContext, + DocksPanelEvents, WhenClauseContext, } from './docks' import type { DockRenderer, DockRendererManifest, DockRenderersContext } from './renderers' @@ -34,7 +35,6 @@ import { HUB_EVENTS } from '../events' import { getDevframeClientContext, setDevframeClientContext } from './context' import { attachFrameNavClient } from './frame-nav' import { createMessagesClient } from './messages' -import { reportDockPanelState } from './panel-state' import { createDockRenderersContext } from './renderers' const DOCKS_STATE_KEY = HUB_EVENTS.sharedState.docks @@ -158,21 +158,7 @@ export async function createDevframeClientRuntime( ...options.categoryOrder, } - const reportPanelState = (session: DockSessionStorage): void => { - void reportDockPanelState(rpc, createDockPanelState(session)).catch(() => {}) - } - let panelStateReportPending = false - const schedulePanelStateReport = (session: DockSessionStorage): void => { - if (panelStateReportPending) - return - panelStateReportPending = true - queueMicrotask(() => { - panelStateReportPending = false - if (!disposed) - reportPanelState(session) - }) - } - const panel = createPanelContext(clientType, schedulePanelStateReport) + const panel = createPanelContext(clientType, () => disposed) const docks = createDocksContext() const commands = createCommandsContext() const renderers = createRenderersContext() @@ -243,7 +229,6 @@ export async function createDevframeClientRuntime( ) } setDevframeClientContext(context) - reportPanelState(panel.session) const loadedScripts = new Set() if (loadScriptsEnabled) { @@ -571,7 +556,7 @@ export async function createDevframeClientRuntime( function createPanelContext( clientType: DockClientType, - onSessionChange: (session: DockSessionStorage) => void, + isDisposed: () => boolean, ): DocksPanelContext { const store: DocksPanelContext['store'] = { mode: 'edge', @@ -584,6 +569,33 @@ function createPanelContext( } let open = clientType === 'standalone' let selectedDockId: string | null = null + const events = createEventEmitter() + let panelStateChangePending = false + let previousPanelState: DevframeDockPanelState + let panelContext: DocksPanelContext + + function schedulePanelStateChange(): void { + if (panelStateChangePending) + return + panelStateChangePending = true + queueMicrotask(() => { + panelStateChangePending = false + if (isDisposed()) + return + + const panelState = panelContext.state + if ( + panelState.state === previousPanelState.state + && panelState.selectedDockId === previousPanelState.selectedDockId + ) { + return + } + + previousPanelState = panelState + events.emit(HUB_EVENTS.client.docksPanelStateChanged, panelState) + }) + } + const session: DocksPanelContext['session'] = { get open() { return open @@ -592,7 +604,7 @@ function createPanelContext( if (nextOpen === open) return open = nextOpen - onSessionChange(session) + schedulePanelStateChange() }, get selectedDockId() { return selectedDockId @@ -601,11 +613,15 @@ function createPanelContext( if (nextSelectedDockId === selectedDockId) return selectedDockId = nextSelectedDockId - onSessionChange(session) + schedulePanelStateChange() }, selectedDockRoute: null, } - return { + panelContext = { + get state() { + return createDockPanelState(session) + }, + events, store, session, isDragging: false, @@ -614,6 +630,8 @@ function createPanelContext( return store.position === 'left' || store.position === 'right' }, } + previousPanelState = panelContext.state + return panelContext } function createDockPanelState(session: DockSessionStorage): DevframeDockPanelState { diff --git a/packages/hub/src/client/index.ts b/packages/hub/src/client/index.ts index 0f785ca2..78c06ac6 100644 --- a/packages/hub/src/client/index.ts +++ b/packages/hub/src/client/index.ts @@ -7,7 +7,6 @@ export * from './frame-location' export * from './frame-nav' export * from './host' export * from './messages' -export * from './panel-state' export * from './remote' export * from './renderers' export * from 'devframe/client' diff --git a/packages/hub/src/client/panel-state.ts b/packages/hub/src/client/panel-state.ts deleted file mode 100644 index d0542830..00000000 --- a/packages/hub/src/client/panel-state.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { DevframeRpcClient } from 'devframe/client' -import type { DevframeDockPanelState } from '../types/docks' -import { HUB_EVENTS } from '../events' - -/** Report this RPC connection's current dock-panel state to the hub. */ -export async function reportDockPanelState( - rpc: DevframeRpcClient, - panelState: DevframeDockPanelState, -): Promise { - await rpc.call(HUB_EVENTS.rpc.docksPanelState, panelState) -} diff --git a/packages/hub/src/events.ts b/packages/hub/src/events.ts index ed55eead..b2903c2d 100644 --- a/packages/hub/src/events.ts +++ b/packages/hub/src/events.ts @@ -10,9 +10,10 @@ * instead of re-typing a literal. * * The `.events` EventEmitter maps in `types/{docks,terminals,messages,commands}.ts` - * and the RPC augmentation interfaces in `node/context.ts` declare these same - * names as type-level keys (a literal is unavoidable in a type position); those - * declarations mirror this map and move with it. + * and `client/docks.ts`, plus the RPC augmentation interfaces in + * `node/context.ts`, declare these same names as type-level keys (a literal is + * unavoidable in a type position); those declarations mirror this map and move + * with it. */ export const HUB_EVENTS = { /** @@ -23,7 +24,6 @@ export const HUB_EVENTS = { bus: { docksEntryUpdated: 'docks:entry:updated', docksActivate: 'docks:activate', - docksPanelState: 'docks:panel:state', terminalsSessionUpdated: 'terminals:session:updated', messagesAdded: 'messages:added', messagesUpdated: 'messages:updated', @@ -35,7 +35,6 @@ export const HUB_EVENTS = { /** Server RPC methods a connected client calls (client → server), `hub:` prefix. */ rpc: { docksActivate: 'hub:docks:activate', - docksPanelState: 'hub:docks:panel:state', commandsExecute: 'hub:commands:execute', messagesAdd: 'hub:messages:add', messagesUpdate: 'hub:messages:update', @@ -47,6 +46,10 @@ export const HUB_EVENTS = { terminalsRestart: 'hub:terminals:restart', terminalsRemove: 'hub:terminals:remove', }, + /** Client-context events emitted inside the host page. */ + client: { + docksPanelStateChanged: 'panel:state:changed', + }, /** Broadcast notifications the server pushes to clients (server → client), `devframe:` prefix. */ broadcast: { docksActivate: 'devframe:docks:activate', diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index b2c367bc..c25c681d 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -1,4 +1,4 @@ -import type { DevframeDockPanelStateEvent, DevframeViewLauncher } from '../../types/docks' +import type { DevframeViewLauncher } from '../../types/docks' import type { DevframeHubContext } from '../context' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -7,9 +7,7 @@ import { REMOTE_CONNECTION_KEY } from 'devframe/constants' import { getInternalContext } from 'devframe/node/hub-internals' import { describe, expect, it, vi } from 'vitest' import { parseRemoteConnection } from '../../client/remote' -import { HUB_EVENTS } from '../../events' import { DevframeDocksHost } from '../host-docks' -import { disconnectDockPanelState, updateDockPanelState } from '../panel-state' function createContext(): DevframeHubContext { const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-docks-')) @@ -223,49 +221,6 @@ describe('devframeDockHost activate', () => { }) }) -describe('devframeDockHost panel state', () => { - it('emits the first report and changed values while suppressing duplicates', () => { - expect.assertions(1) - - const host = new DevframeDocksHost(createContext()) - const events: DevframeDockPanelStateEvent[] = [] - host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) - - updateDockPanelState(host, 11, { state: 'closed', selectedDockId: undefined }) - updateDockPanelState(host, 11, { state: 'closed' }) - updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'git' }) - updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'inspect' }) - updateDockPanelState(host, 11, { state: 'open', selectedDockId: undefined }) - - expect(events).toEqual([ - { type: 'connected', sessionId: 11, state: 'closed' }, - { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'git' }, - { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'inspect' }, - { type: 'changed', sessionId: 11, state: 'open' }, - ]) - }) - - it('tracks sessions independently and disconnects only reporting sessions', () => { - expect.assertions(1) - - const host = new DevframeDocksHost(createContext()) - const events: DevframeDockPanelStateEvent[] = [] - host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) - - updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'git' }) - updateDockPanelState(host, 12, { state: 'hidden' }) - disconnectDockPanelState(host, 99) - disconnectDockPanelState(host, 11) - disconnectDockPanelState(host, 11) - - expect(events).toEqual([ - { type: 'connected', sessionId: 11, state: 'open', selectedDockId: 'git' }, - { type: 'connected', sessionId: 12, state: 'hidden' }, - { type: 'disconnected', sessionId: 11 }, - ]) - }) -}) - describe('devframeDockHost ~builtin category', () => { it('returns no docks until an integration registers one', () => { const host = new DevframeDocksHost(createContext()) diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index b7cd0af9..e1dd1032 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -1,5 +1,4 @@ import type { DevframeDefinition, DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' -import type { DevframeDockPanelStateEvent } from '../../types/docks' import { mkdtempSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { tmpdir } from 'node:os' @@ -7,9 +6,8 @@ import { join } from 'node:path' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { DOCK_RENDERERS_STATE_KEY } from '../../constants' -import { HUB_EVENTS } from '../../events' import { DEVFRAMES_HUB_BASE, initHub } from '../initiate' function makeDist(html: string): string { @@ -40,12 +38,10 @@ function makeFrame(id: string, distDir?: string): DevframeDefinition { } function connectWsClient(url: string) { - const channel = createWsRpcChannel({ url }) - const client = createRpcClient( + return createRpcClient( {} as DevframeRpcClientFunctions, - { channel }, + { channel: createWsRpcChannel({ url }) }, ) - return Object.assign(client, { close: channel.close }) } describe('initHub', () => { @@ -156,63 +152,6 @@ describe('initHub', () => { } }) - it('tracks panel state by RPC connection and emits disconnect separately from close', async () => { - expect.assertions(9) - - const host = '127.0.0.1' - const port = await getPort({ port: 18215, host }) - const hub = initHub({ - base: DEVFRAMES_HUB_BASE, - auth: false, - host, - ws: { port }, - devframes: [makeFrame('alpha')], - }) - const clients: ReturnType[] = [] - - try { - await hub.ready - const context = await hub.context - const lifecycleEvents: DevframeDockPanelStateEvent[] = [] - context.docks.events.on(HUB_EVENTS.bus.docksPanelState, event => lifecycleEvents.push(event)) - - const firstClient = connectWsClient(`ws://${host}:${port}/__ws`) - const secondClient = connectWsClient(`ws://${host}:${port}/__ws`) - clients.push(firstClient, secondClient) - - await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' }) - await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' }) - await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }) - await secondClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden' }) - - expect(lifecycleEvents).toHaveLength(3) - expect(lifecycleEvents[0]).toMatchObject({ type: 'connected', state: 'open', selectedDockId: 'git' }) - expect(typeof lifecycleEvents[0]!.sessionId).toBe('number') - expect(lifecycleEvents[1]).toEqual({ type: 'changed', sessionId: lifecycleEvents[0]!.sessionId, state: 'closed' }) - expect(lifecycleEvents[2]).toMatchObject({ type: 'connected', state: 'hidden' }) - expect(lifecycleEvents[2]!.sessionId).not.toBe(lifecycleEvents[0]!.sessionId) - - firstClient.close() - await vi.waitFor(() => { - if (lifecycleEvents.length !== 4) - throw new Error('waiting for the first client to disconnect') - }) - expect(lifecycleEvents[3]).toEqual({ type: 'disconnected', sessionId: lifecycleEvents[0]!.sessionId }) - - const reconnectedClient = connectWsClient(`ws://${host}:${port}/__ws`) - clients.push(reconnectedClient) - await reconnectedClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'open' }) - - expect(lifecycleEvents[4]).toMatchObject({ type: 'connected', state: 'open' }) - expect([lifecycleEvents[0]!.sessionId, lifecycleEvents[2]!.sessionId]).not.toContain(lifecycleEvents[4]!.sessionId) - } - finally { - for (const client of clients) - client.close() - await hub.close() - } - }) - it('ui slot: viewer owns the root, embedded.js serves the entry, discovery still wins', async () => { const viewerDist = makeDist('hub viewer') const embeddedDir = mkdtempSync(join(tmpdir(), 'hub-embedded-')) diff --git a/packages/hub/src/node/__tests__/rpc-builtins.test.ts b/packages/hub/src/node/__tests__/rpc-builtins.test.ts index cc348d40..5a9e2e0e 100644 --- a/packages/hub/src/node/__tests__/rpc-builtins.test.ts +++ b/packages/hub/src/node/__tests__/rpc-builtins.test.ts @@ -1,11 +1,7 @@ -import type { DevframeDockPanelStateEvent } from '../../types/docks' import type { DevframeHubContext } from '../context' -import { createEventEmitter } from 'devframe/utils/events' import { describe, expect, it, vi } from 'vitest' -import { HUB_EVENTS } from '../../events' import { hubDocksActivate, - hubDocksPanelState, hubTerminalsRemove, hubTerminalsResize, hubTerminalsRestart, @@ -142,26 +138,3 @@ describe('hub docks activate RPC', () => { expect(activate).toHaveBeenCalledWith('devframes_plugin_messages', undefined) }) }) - -describe('hub docks panel-state RPC', () => { - it('derives the session id from the active RPC handler context', async () => { - expect.assertions(2) - - const events = createEventEmitter<{ - 'docks:panel:state': (event: DevframeDockPanelStateEvent) => void - }>() - const lifecycleEvents: DevframeDockPanelStateEvent[] = [] - events.on(HUB_EVENTS.bus.docksPanelState, event => lifecycleEvents.push(event)) - const getCurrentRpcSession = vi.fn(() => ({ meta: { id: 73 } })) - const ctx = { - docks: { events }, - rpc: { getCurrentRpcSession }, - } as unknown as DevframeHubContext - - const fn = await hubDocksPanelState.setup!(ctx) - await fn.handler!({ state: 'open', selectedDockId: 'git' }) - - expect(getCurrentRpcSession).toHaveBeenCalledOnce() - expect(lifecycleEvents).toEqual([{ type: 'connected', sessionId: 73, state: 'open', selectedDockId: 'git' }]) - }) -}) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 4660b15c..6aee0ffc 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -56,16 +56,6 @@ declare module 'devframe/types' { * selection. Handled by {@link import('./rpc-builtins').hubDocksActivate}. */ 'hub:docks:activate': (input: { dockId: string, params?: Record }) => Promise - /** - * Report this viewer connection's current dock-panel state. The server - * resolves the connection's session id and emits the typed lifecycle event - * on `ctx.docks.events`. - * - * Use `reportDockPanelState()` from `@devframes/hub/client`. - * - * @internal - */ - 'hub:docks:panel:state': (panelState: import('../types/docks').DevframeDockPanelState) => Promise /** * Invoke a registered server command by id; trailing args are forwarded to * the command's handler. Handled by diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index cd39fb65..1905ec37 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -22,7 +22,6 @@ import { DEVFRAMES_HUB_BASE, DOCK_RENDERERS_STATE_KEY, normalizeHubBase } from ' import { createHubContext } from './context' import { diagnostics } from './diagnostics' import { prepareDevframe } from './install-devframe' -import { disconnectDockPanelState } from './panel-state' /** A `devframes` entry with per-mount dock customization. */ export interface HubDevframeEntry { @@ -430,7 +429,6 @@ export function initHub(options: InitHubOptions): HubInstance { const cwd = options.cwd ?? process.cwd() const frames: { id: string, base: string, title: string }[] = [] const rendererRegistrations = resolveRendererRegistrations(options.renderers ?? []) - let initializedContext: DevframeHubContext | undefined const shell = createInstanceShell({ base, @@ -443,10 +441,6 @@ export function initHub(options: InitHubOptions): HubInstance { sse: options.sse, allowedOrigins: options.allowedOrigins, destroyUnmatchedUpgrades: options.destroyUnmatchedUpgrades, - onPeerDisconnect: (_connection, sessionMeta) => { - if (initializedContext) - disconnectDockPanelState(initializedContext.docks, sessionMeta.id) - }, register: resolveInstanceRegister(options.register, { id: options.name ?? 'devframes-hub', ...(options.name !== undefined ? { name: options.name } : {}), @@ -501,7 +495,6 @@ export function initHub(options: InitHubOptions): HubInstance { ...(options.rpcDeclarations ? { builtinRpcDeclarations: options.rpcDeclarations } : {}), }) } - initializedContext = ctx // Publish the host's bare-specifier resolution template before anything // registers a dock, so the docks host's bare-specifier capability check diff --git a/packages/hub/src/node/panel-state.ts b/packages/hub/src/node/panel-state.ts deleted file mode 100644 index 9bcef2d1..00000000 --- a/packages/hub/src/node/panel-state.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { DevframeDockPanelState, DevframeDockPanelStateEvent, DevframeDocksHost } from '../types/docks' -import { HUB_EVENTS } from '../events' - -const dockPanelStates = new WeakMap>() - -export function updateDockPanelState( - docks: DevframeDocksHost, - sessionId: number, - panelState: DevframeDockPanelState, -): void { - const currentState: DevframeDockPanelState = typeof panelState.selectedDockId === 'string' - ? { state: panelState.state, selectedDockId: panelState.selectedDockId } - : { state: panelState.state } - - let sessionStates = dockPanelStates.get(docks) - if (!sessionStates) { - sessionStates = new Map() - dockPanelStates.set(docks, sessionStates) - } - - const previousState = sessionStates.get(sessionId) - if ( - previousState?.state === currentState.state - && previousState.selectedDockId === currentState.selectedDockId - ) { - return - } - - sessionStates.set(sessionId, currentState) - const event: DevframeDockPanelStateEvent = previousState === undefined - ? { type: 'connected', sessionId, ...currentState } - : { type: 'changed', sessionId, ...currentState } - docks.events.emit(HUB_EVENTS.bus.docksPanelState, event) -} - -export function disconnectDockPanelState( - docks: DevframeDocksHost, - sessionId: number, -): void { - const sessionStates = dockPanelStates.get(docks) - if (!sessionStates?.delete(sessionId)) - return - - docks.events.emit(HUB_EVENTS.bus.docksPanelState, { type: 'disconnected', sessionId }) - if (sessionStates.size === 0) - dockPanelStates.delete(docks) -} diff --git a/packages/hub/src/node/rpc-builtins.ts b/packages/hub/src/node/rpc-builtins.ts index c88431a5..f8e4f384 100644 --- a/packages/hub/src/node/rpc-builtins.ts +++ b/packages/hub/src/node/rpc-builtins.ts @@ -1,5 +1,4 @@ import type { RpcFunctionDefinitionAny } from 'devframe/rpc' -import type { DevframeDockPanelState } from '../types/docks' import type { DevframeMessageEntry, DevframeMessageEntryInput } from '../types/messages' import type { DevframeChildProcessTerminalSession, @@ -8,7 +7,6 @@ import type { import { defineHubRpcFunction } from '../define' import { HUB_EVENTS } from '../events' import { diagnostics } from './diagnostics' -import { updateDockPanelState } from './panel-state' /** * Resolve an interactive (PTY) terminal session by id, or throw. Sessions @@ -222,19 +220,6 @@ export const hubDocksActivate = defineHubRpcFunction({ }), }) -/** Record the current viewer connection's dock-panel state. */ -export const hubDocksPanelState = defineHubRpcFunction({ - name: HUB_EVENTS.rpc.docksPanelState, - type: 'action', - setup: context => ({ - async handler(panelState: DevframeDockPanelState): Promise { - const session = context.rpc.getCurrentRpcSession() - if (session) - updateDockPanelState(context.docks, session.meta.id, panelState) - }, - }), -}) - /** * Framework-neutral RPC declarations auto-registered by * {@link createHubContext}. Provide additional RPCs by passing your own @@ -244,7 +229,6 @@ export const hubDocksPanelState = defineHubRpcFunction({ export const builtinHubRpcDeclarations: readonly RpcFunctionDefinitionAny[] = [ hubCommandsExecute, hubDocksActivate, - hubDocksPanelState, hubMessagesAdd, hubMessagesUpdate, hubMessagesRemove, diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index cef050cf..f21c575e 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -5,7 +5,6 @@ export interface DevframeDocksHost { readonly events: EventEmitter<{ 'docks:entry:updated': (entry: DevframeDockUserEntry) => void 'docks:activate': (activation: DevframeDockActivation) => void - 'docks:panel:state': (event: DevframeDockPanelStateEvent) => void }> register: (entry: T, force?: boolean) => { @@ -30,18 +29,12 @@ export interface DevframeDocksHost { activate: (dockId: string, params?: Record) => void } -/** Current state reported by one dock-panel viewer. */ +/** Current state of one dock panel in a host page. */ export interface DevframeDockPanelState { state: 'open' | 'closed' | 'hidden' selectedDockId?: string } -/** Lifecycle event for one viewer's dock panel over an RPC connection. */ -export type DevframeDockPanelStateEvent - = | ({ type: 'connected', sessionId: number } & DevframeDockPanelState) - | ({ type: 'changed', sessionId: number } & DevframeDockPanelState) - | { type: 'disconnected', sessionId: number } - /** * A request to switch the active dock. `params` is an opaque, serializable * bag the target dock interprets — the terminals dock reads `params.sessionId` diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index 191e446f..6acdec65 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -114,6 +114,8 @@ export interface DockSessionStorage { groupLastChildIds?: Record; } export interface DocksPanelContext { + readonly state: DevframeDockPanelState; + readonly events: EventEmitter; store: DockPanelStorage; session: DockSessionStorage; isDragging: boolean; @@ -121,6 +123,9 @@ export interface DocksPanelContext { readonly isVertical: boolean; consumeBootRoute?: (_: string) => string | null; } +export interface DocksPanelEvents { + 'panel:state:changed': (_: DevframeDockPanelState) => void; +} export interface FrameLocationHistory { pushState: (..._: any[]) => void; replaceState: (..._: any[]) => void; @@ -247,7 +252,6 @@ export declare function createDockRenderersContext(_: CreateDockRenderersContext export declare function createMessagesClient(_: DevframeRpcClient, _?: MessagesClientOptions): DevframeMessagesClient; export declare function getDevframeClientContext(): DevframeClientContext | undefined; export declare function parseRemoteConnection(_?: string): RemoteConnectionInfo | null; -export declare function reportDockPanelState(_: DevframeRpcClient, _: DevframeDockPanelState): Promise; export declare function resolveClientModuleSpecifier(_: string, _?: { resolveClientModule?: (_: string) => string | undefined; template?: string; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js index 6f908298..0f1b65ec 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js @@ -10,7 +10,6 @@ export function createDockRenderersContext(_) {} export function createMessagesClient(_, _) {} export function getDevframeClientContext() {} export function parseRemoteConnection(_) {} -export async function reportDockPanelState(_, _) {} export function resolveDockIcon(_, _) {} export function resolveDockUrl(_, _) {} export function setDevframeClientContext(_) {} diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts index fb4525ec..bddf45b0 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts @@ -14,7 +14,6 @@ export declare const HUB_EVENTS: { readonly bus: { readonly docksEntryUpdated: "docks:entry:updated"; readonly docksActivate: "docks:activate"; - readonly docksPanelState: "docks:panel:state"; readonly terminalsSessionUpdated: "terminals:session:updated"; readonly messagesAdded: "messages:added"; readonly messagesUpdated: "messages:updated"; @@ -25,7 +24,6 @@ export declare const HUB_EVENTS: { }; readonly rpc: { readonly docksActivate: "hub:docks:activate"; - readonly docksPanelState: "hub:docks:panel:state"; readonly commandsExecute: "hub:commands:execute"; readonly messagesAdd: "hub:messages:add"; readonly messagesUpdate: "hub:messages:update"; @@ -37,6 +35,9 @@ export declare const HUB_EVENTS: { readonly terminalsRestart: "hub:terminals:restart"; readonly terminalsRemove: "hub:terminals:remove"; }; + readonly client: { + readonly docksPanelStateChanged: "panel:state:changed"; + }; readonly broadcast: { readonly docksActivate: "devframe:docks:activate"; readonly terminalsUpdated: "devframe:terminals:updated"; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 8005cd17..6fec7e31 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -113,7 +113,6 @@ export interface DevframeDocksHost { readonly events: EventEmitter<{ 'docks:entry:updated': (entry: DevframeDockUserEntry) => void; 'docks:activate': (activation: DevframeDockActivation) => void; - 'docks:panel:state': (event: DevframeDockPanelStateEvent) => void; }>; register: (_: T, _?: boolean) => { update: (_: Partial) => void; @@ -351,16 +350,6 @@ export type DevframeDockEntryIcon = string | { light: string; dark: string; }; -export type DevframeDockPanelStateEvent = ({ - type: 'connected'; - sessionId: number; -} & DevframeDockPanelState) | ({ - type: 'changed'; - sessionId: number; -} & DevframeDockPanelState) | { - type: 'disconnected'; - sessionId: number; -}; export type DevframeDockUserEntry = DevframeDockEntryRegistry[keyof DevframeDockEntryRegistry]; export type DevframeMessageAction = DevframeMessageActivateAction | DevframeMessageCommandAction; export type DevframeMessageEntryFrom = 'server' | 'browser'; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index c1859bc4..68ad102b 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -132,21 +132,6 @@ export declare const hubDocksActivate: { params?: Record; }], Promise>> | undefined; }; -export declare const hubDocksPanelState: { - name: "hub:docks:panel:state"; - type?: "action" | undefined; - cacheable?: boolean; - args?: undefined; - returns?: undefined; - jsonSerializable?: boolean; - agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: DevframeHubContext) => import("devframe/rpc").Thenable>>) | undefined; - handler?: ((panelState: DevframeDockPanelState) => Promise) | undefined; - dump?: import("devframe/rpc").RpcDump<[panelState: DevframeDockPanelState], Promise, DevframeHubContext> | undefined; - snapshot?: boolean; - __cache?: WeakMap>>> | undefined; - __promise?: import("devframe/rpc").Thenable>> | undefined; -}; export declare const hubMessagesAdd: { name: "hub:messages:add"; type?: "action" | undefined; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index 3c5c1bb3..e8bd00c5 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -14,7 +14,6 @@ export { DevframeMessagesHost } export { DevframeTerminalsHost } export { hubCommandsExecute } export { hubDocksActivate } -export { hubDocksPanelState } export { hubMessagesAdd } export { hubMessagesClear } export { hubMessagesRemove } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index b496606d..ca4778e5 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -30,7 +30,6 @@ export { DevframeDockEntryCategory } export { DevframeDockEntryIcon } export { DevframeDockEntryRegistry } export { DevframeDockPanelState } -export { DevframeDockPanelStateEvent } export { DevframeDocksActiveState } export { DevframeDocksHost } export { DevframeDocksUserSettings } From db3a5a3df965f6faf3da9c0d54cacc9a48496a65 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 23:37:44 +0200 Subject: [PATCH 10/11] refactor(hub): simplify panel state tracking --- docs/content/1.guide/17.client-context.md | 10 ++--- packages/hub-ui/src/client/embedded/index.ts | 7 +-- .../src/client/embedded/visibility.test.ts | 42 ------------------ .../hub-ui/src/client/embedded/visibility.ts | 16 +++---- .../hub-ui/src/client/state/context.test.ts | 10 ++--- packages/hub-ui/src/client/state/context.ts | 43 ++++++------------- .../hub/src/client/__tests__/host.test.ts | 5 +-- packages/hub/src/client/host.ts | 43 +++++++------------ packages/hub/src/events.ts | 8 ++-- 9 files changed, 55 insertions(+), 129 deletions(-) delete mode 100644 packages/hub-ui/src/client/embedded/visibility.test.ts diff --git a/docs/content/1.guide/17.client-context.md b/docs/content/1.guide/17.client-context.md index 45388791..88782755 100644 --- a/docs/content/1.guide/17.client-context.md +++ b/docs/content/1.guide/17.client-context.md @@ -41,23 +41,19 @@ The context carries the [RPC client](/guide/client) (`rpc`) and the page's `clie `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: ```ts -import type { DevframeDockPanelState } from '@devframes/hub' import type { DockClientScriptContext } from '@devframes/hub/client' import { HUB_EVENTS } from '@devframes/hub/constants' export default function setup(context: DockClientScriptContext) { - const reportPanelState = (panelState: DevframeDockPanelState) => - context.rpc.call('my-devframe:panel-state', panelState) - - void reportPanelState(context.panel.state) + void context.rpc.call('my-devframe:panel-state', context.panel.state) context.panel.events.on( HUB_EVENTS.client.docksPanelStateChanged, - panelState => void reportPanelState(panelState), + panelState => void context.rpc.call('my-devframe:panel-state', panelState), ) } ``` -The custom RPC keeps node-side reporting opt-in. Its handler can call `ctx.rpc.getCurrentRpcSession()` when it needs the reporting connection's identity. +The custom RPC keeps node-side reporting opt-in. ### Client-only docks diff --git a/packages/hub-ui/src/client/embedded/index.ts b/packages/hub-ui/src/client/embedded/index.ts index d42acb93..4f3551cb 100644 --- a/packages/hub-ui/src/client/embedded/index.ts +++ b/packages/hub-ui/src/client/embedded/index.ts @@ -4,7 +4,7 @@ import { useLocalStorage, useSessionStorage } from '@vueuse/core' import { ref } from 'vue' import { applyPrimaryColor, setBranding } from '../state/branding' import { DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE } from '../state/docks' -import { setupEmbeddedVisibility } from './visibility' +import { isEmbeddedDockInitiallyVisible, setupEmbeddedVisibility } from './visibility' /** * The floating-dock bootstrap the hub serves at `embedded.js` — load @@ -75,7 +75,8 @@ async function mountDock(): Promise { // carried by the connection we just established above. const branding = setBranding(rpc.connectionMeta.configs?.ui?.branding || {}) - const panelVisible = ref() + const embeddedVisibility = rpc.connectionMeta.configs?.ui?.embeddedVisibility ?? 'normal' + const panelVisible = ref(isEmbeddedDockInitiallyVisible(embeddedVisibility)) const { createDocksContext } = await import('../state/context') const context = await createDocksContext('embedded', rpc, state, session, panelVisible) setDevframeClientContext(context) @@ -93,7 +94,7 @@ async function mountDock(): Promise { // Reveal policy: `normal` appends now; `passive`/`hidden` wait for the // Shift+Alt+D reveal (the element is built and ready, just detached). setupEmbeddedVisibility( - rpc.connectionMeta.configs?.ui?.embeddedVisibility ?? 'normal', + embeddedVisibility, branding.productName, { show: () => { diff --git a/packages/hub-ui/src/client/embedded/visibility.test.ts b/packages/hub-ui/src/client/embedded/visibility.test.ts deleted file mode 100644 index 08ab8b95..00000000 --- a/packages/hub-ui/src/client/embedded/visibility.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { HUB_UI_HIDE_EVENT } from '../constants' -import { setupEmbeddedVisibility } from './visibility' - -afterEach(() => { - vi.unstubAllGlobals() -}) - -describe('setupEmbeddedVisibility', () => { - it('handles the initial hidden state and later reveal and conceal transitions', () => { - expect.assertions(5) - - const listeners = new Map() - vi.stubGlobal('window', { - addEventListener: vi.fn((type: string, listener: EventListener) => { - listeners.set(type, listener) - }), - }) - const show = vi.fn() - const hide = vi.fn() - - setupEmbeddedVisibility('hidden', 'Devframe', { show, hide }) - - expect(hide).toHaveBeenCalledOnce() - expect(show).not.toHaveBeenCalled() - - const preventDefault = vi.fn() - listeners.get('keydown')!({ - shiftKey: true, - altKey: true, - ctrlKey: false, - metaKey: false, - code: 'KeyD', - preventDefault, - } as unknown as KeyboardEvent) - expect(preventDefault).toHaveBeenCalledOnce() - expect(show).toHaveBeenCalledOnce() - - listeners.get(HUB_UI_HIDE_EVENT)!({} as Event) - expect(hide).toHaveBeenCalledTimes(2) - }) -}) diff --git a/packages/hub-ui/src/client/embedded/visibility.ts b/packages/hub-ui/src/client/embedded/visibility.ts index 211c7197..a16dc10f 100644 --- a/packages/hub-ui/src/client/embedded/visibility.ts +++ b/packages/hub-ui/src/client/embedded/visibility.ts @@ -60,6 +60,10 @@ export interface EmbeddedVisibilityHandlers { hide: () => void } +export function isEmbeddedDockInitiallyVisible(mode: EmbeddedVisibility): boolean { + return mode === 'normal' || (mode === 'passive' && readPersistedReveal()) +} + /** * Drive the embedded dock's reveal lifecycle for the resolved * {@link EmbeddedVisibility} mode: decide whether to show on boot, wire the @@ -73,7 +77,7 @@ export function setupEmbeddedVisibility( label: string, handlers: EmbeddedVisibilityHandlers, ): void { - let shown = mode === 'normal' || (mode === 'passive' && readPersistedReveal()) + let shown = isEmbeddedDockInitiallyVisible(mode) function reveal(): void { if (shown) @@ -95,14 +99,10 @@ export function setupEmbeddedVisibility( handlers.hide() } - if (shown) { + if (shown) handlers.show() - } - else { - handlers.hide() - if (mode === 'passive') - printHint(label) - } + else if (mode === 'passive') + printHint(label) // Shift+Alt+D toggles the dock — the always-available "summon" chord. window.addEventListener('keydown', (e) => { diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index 84a84a9c..4c0a78ad 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -81,7 +81,7 @@ describe('createDocksContext', () => { expect.assertions(8) const { rpc, sharedStates, trust } = createStubRpc() - const panelVisible = ref() + const panelVisible = ref(false) const session = ref({ open: true, selectedDockId: 'git', @@ -98,12 +98,12 @@ describe('createDocksContext', () => { sharedStates.get('devframe:docks')!.push([gitEntry]) sharedStates.get('devframe:dock-renderers')!.push({}) await flushRestore() - expect(rpc.call).not.toHaveBeenCalled() + expect(context.panel.state).toEqual({ state: 'hidden', selectedDockId: 'git' }) + expect(panelStates).toEqual([]) panelVisible.value = true await nextTick() - expect(context.panel.state).toEqual({ state: 'open', selectedDockId: 'git' }) - expect(panelStates).toEqual([]) + expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: 'git' }) session.value.selectedDockId = '~settings' await nextTick() @@ -125,7 +125,7 @@ describe('createDocksContext', () => { panelVisible.value = true session.value.open = false await nextTick() - expect(panelStates).toHaveLength(4) + expect(panelStates).toHaveLength(5) }) it('mounts a restored dock once after all initial server state arrives', async () => { diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 25383277..2a8e7cf9 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -19,32 +19,12 @@ import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDo import { executeSetupScript } from './setup-script' const docksContextByRpc = new WeakMap() - -function createDockPanelState( - visible: boolean, - open: boolean, - selectedDockId: string | null, -): DevframeDockPanelState { - let state: DevframeDockPanelState['state'] - if (!visible) - state = 'hidden' - else if (open) - state = 'open' - else - state = 'closed' - - const panelState: DevframeDockPanelState = { state } - if (selectedDockId !== null) - panelState.selectedDockId = selectedDockId - return panelState -} - export async function createDocksContext( clientType: 'embedded' | 'standalone', rpc: DevframeRpcClient, panelStore?: Ref, sessionStore?: Ref, - panelVisible: Ref = ref(true), + panelVisible: Ref = ref(true), ): Promise { if (docksContextByRpc.has(rpc)) { return docksContextByRpc.get(rpc)! @@ -601,11 +581,18 @@ export async function createDocksContext( docksContext = reactive({ panel: { get state() { - return createDockPanelState( - panelVisible.value !== false, - sessionStore.value.open, - selectedDockId.value, - ) + let state: DevframeDockPanelState['state'] + if (!panelVisible.value) + state = 'hidden' + else if (sessionStore.value.open) + state = 'open' + else + state = 'closed' + + const panelState: DevframeDockPanelState = { state } + if (selectedDockId.value !== null) + panelState.selectedDockId = selectedDockId.value + return panelState }, events: markRaw(panelEvents), store: panelStore, @@ -679,14 +666,12 @@ export async function createDocksContext( // the captured session intent. // `switchEntry` then consumes the persisted iframe route when the view boots. const restoreAfterInitialization = async (): Promise => { - // The authorization gate can still clear the live session on reload, so restore only after it settles. - await waitUntilTrusted() - const restoreDockId = restoreIntent.selectedDockId if (!restoreIntent.open || restoreDockId == null) return await Promise.all([ + waitUntilTrusted(), dockEntriesInitialSyncComplete, rendererManifestInitialSyncComplete, ]) diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index 026b83eb..2c10a9ce 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -69,9 +69,9 @@ function groupEntry(id: string, extra?: Record): DevframeDockEn describe('createDevframeClientRuntime', () => { it('exposes panel state and emits coalesced changes', async () => { - expect.assertions(7) + expect.assertions(6) - const { rpc, calls, states } = createStubRpc() + const { rpc, states } = createStubRpc() const host = await createDevframeClientRuntime({ rpc, clientType: 'embedded' }) const panelStates: DevframeDockPanelState[] = [] @@ -100,7 +100,6 @@ describe('createDevframeClientRuntime', () => { const cleared = host.context.docks.switchEntry(null) await cleared expect(panelStates.at(-1)).toEqual({ state: 'closed' }) - expect(calls).toEqual([]) host.dispose() }) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index d304a243..7a0ecaff 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -21,7 +21,6 @@ import type { DockClientType, DockEntryState, DocksEntriesContext, - DockSessionStorage, DocksPanelContext, DocksPanelEvents, WhenClauseContext, @@ -127,7 +126,6 @@ export async function createDevframeClientRuntime( ): Promise { const clientType: DockClientType = options.clientType ?? 'standalone' const rpc = options.rpc ?? await connectDevframe(options.connect) - let disposed = false // Set by createRenderersContext(); teardown disposes every live mount. let mountedRenderers: Set<() => void> | undefined @@ -158,7 +156,7 @@ export async function createDevframeClientRuntime( ...options.categoryOrder, } - const panel = createPanelContext(clientType, () => disposed) + const panel = createPanelContext(clientType) const docks = createDocksContext() const commands = createCommandsContext() const renderers = createRenderersContext() @@ -239,7 +237,6 @@ export async function createDevframeClientRuntime( return { context, dispose() { - disposed = true for (const off of disposers.splice(0)) off() for (const disposeAdapter of frameNavAdapters.values()) disposeAdapter() frameNavAdapters.clear() @@ -554,10 +551,7 @@ export async function createDevframeClientRuntime( // ── shared helpers ───────────────────────────────────────────────────────── -function createPanelContext( - clientType: DockClientType, - isDisposed: () => boolean, -): DocksPanelContext { +function createPanelContext(clientType: DockClientType): DocksPanelContext { const store: DocksPanelContext['store'] = { mode: 'edge', width: 480, @@ -571,8 +565,17 @@ function createPanelContext( let selectedDockId: string | null = null const events = createEventEmitter() let panelStateChangePending = false - let previousPanelState: DevframeDockPanelState - let panelContext: DocksPanelContext + + function createDockPanelState(): DevframeDockPanelState { + const panelState: DevframeDockPanelState = { + state: open ? 'open' : 'closed', + } + if (selectedDockId !== null) + panelState.selectedDockId = selectedDockId + return panelState + } + + let previousPanelState = createDockPanelState() function schedulePanelStateChange(): void { if (panelStateChangePending) @@ -580,10 +583,7 @@ function createPanelContext( panelStateChangePending = true queueMicrotask(() => { panelStateChangePending = false - if (isDisposed()) - return - - const panelState = panelContext.state + const panelState = createDockPanelState() if ( panelState.state === previousPanelState.state && panelState.selectedDockId === previousPanelState.selectedDockId @@ -617,9 +617,9 @@ function createPanelContext( }, selectedDockRoute: null, } - panelContext = { + return { get state() { - return createDockPanelState(session) + return createDockPanelState() }, events, store, @@ -630,17 +630,6 @@ function createPanelContext( return store.position === 'left' || store.position === 'right' }, } - previousPanelState = panelContext.state - return panelContext -} - -function createDockPanelState(session: DockSessionStorage): DevframeDockPanelState { - const panelState: DevframeDockPanelState = { - state: session.open ? 'open' : 'closed', - } - if (session.selectedDockId !== null) - panelState.selectedDockId = session.selectedDockId - return panelState } function groupByCategory(entries: DevframeDockEntry[], categoryOrder: Record): DevframeDockEntriesGrouped { diff --git a/packages/hub/src/events.ts b/packages/hub/src/events.ts index b2903c2d..3d3e3e60 100644 --- a/packages/hub/src/events.ts +++ b/packages/hub/src/events.ts @@ -9,11 +9,9 @@ * both places in the same change, and reference `HUB_EVENTS.*` from call sites * instead of re-typing a literal. * - * The `.events` EventEmitter maps in `types/{docks,terminals,messages,commands}.ts` - * and `client/docks.ts`, plus the RPC augmentation interfaces in - * `node/context.ts`, declare these same names as type-level keys (a literal is - * unavoidable in a type position); those declarations mirror this map and move - * with it. + * The `.events` EventEmitter maps and RPC augmentation interfaces declare these + * same names as type-level keys (a literal is unavoidable in a type position); + * those declarations mirror this map and move with it. */ export const HUB_EVENTS = { /** From 7df3648a271ca851430a6d3c5be8d53db969c44b Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 23:58:21 +0200 Subject: [PATCH 11/11] fix(hub): address panel state review feedback --- docs/content/1.guide/17.client-context.md | 10 +++-- docs/content/8.references/6.hub-api.md | 2 +- .../hub-ui/src/client/state/context.test.ts | 18 ++++++-- packages/hub-ui/src/client/state/context.ts | 43 +++++++++---------- packages/hub/src/client/docks.ts | 2 +- 5 files changed, 44 insertions(+), 31 deletions(-) diff --git a/docs/content/1.guide/17.client-context.md b/docs/content/1.guide/17.client-context.md index 88782755..fb8fd51e 100644 --- a/docs/content/1.guide/17.client-context.md +++ b/docs/content/1.guide/17.client-context.md @@ -38,17 +38,21 @@ The context carries the [RPC client](/guide/client) (`rpc`) and the page's `clie ### Tracking panel state -`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: +`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: ```ts import type { DockClientScriptContext } from '@devframes/hub/client' import { HUB_EVENTS } from '@devframes/hub/constants' export default function setup(context: DockClientScriptContext) { - void context.rpc.call('my-devframe:panel-state', context.panel.state) + const reportPanelState = (panelState: typeof context.panel.state) => { + void context.rpc.call('my-devframe:panel-state', panelState).catch(error => console.error(error)) + } + + reportPanelState(context.panel.state) context.panel.events.on( HUB_EVENTS.client.docksPanelStateChanged, - panelState => void context.rpc.call('my-devframe:panel-state', panelState), + reportPanelState, ) } ``` diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index 397c4941..849ee7a3 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -105,7 +105,7 @@ The properties of `DevframeClientContext` — [The client context](/guide/client | `rpc` | The [RPC client](/guide/client) — server/client functions, shared state. | | `clientType` | `'embedded'` (inside the user app) or `'standalone'` (independent hub page). | | `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, `register()` / `update()` for [client-only docks](/guide/client-context#client-only-docks). | -| `panel` | Dock panel state: position, size, drag/resize. | +| `panel` | Current `state`, local `events`, session, position, size, and drag/resize state for the dock panel. | | `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`. | | `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`). | | `when` | The [when-clause](/references/when-clauses) context. | diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index 4c0a78ad..63b2fcfa 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -72,13 +72,11 @@ async function flushRestore(): Promise { await Promise.resolve() await Promise.resolve() await nextTick() - await Promise.resolve() - await Promise.resolve() } describe('createDocksContext', () => { it('exposes restored panel state and emits selected, hidden, and closed changes', async () => { - expect.assertions(8) + expect.assertions(9) const { rpc, sharedStates, trust } = createStubRpc() const panelVisible = ref(false) @@ -94,6 +92,13 @@ describe('createDocksContext', () => { panelState => panelStates.push(panelState), ) + panelVisible.value = true + await nextTick() + expect(panelStates).toEqual([{ state: 'open', selectedDockId: 'git' }]) + panelVisible.value = false + await nextTick() + panelStates.length = 0 + trust() sharedStates.get('devframe:docks')!.push([gitEntry]) sharedStates.get('devframe:dock-renderers')!.push({}) @@ -129,11 +134,15 @@ describe('createDocksContext', () => { }) it('mounts a restored dock once after all initial server state arrives', async () => { - expect.assertions(7) + expect.assertions(8) const { rpc, sharedStates, trust } = createStubRpc() const executeSetupScriptMock = vi.mocked(executeSetupScript) executeSetupScriptMock.mockClear() + let setupPanelState: DevframeDockPanelState | undefined + executeSetupScriptMock.mockImplementationOnce(async (_dockEntry, scriptContext) => { + setupPanelState = scriptContext.panel.state + }) const session = ref({ open: true, selectedDockId: 'git', @@ -159,6 +168,7 @@ describe('createDocksContext', () => { expect(context.docks.selected?.id).toBe('git') expect(session.value.open).toBe(true) + expect(setupPanelState).toEqual({ state: 'open', selectedDockId: 'git' }) expect(executeSetupScriptMock).toHaveBeenCalledOnce() }) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 2a8e7cf9..0fb29bba 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -272,6 +272,10 @@ export async function createDocksContext( return false } + initialRestorePending.value = false + selectedDockId.value = entry.id + sessionStore.value.open = true + // If has import script, run it if ( (entry.type === 'action') @@ -302,9 +306,6 @@ export async function createDocksContext( if (entry.groupId) (sessionStore.value.groupLastChildIds ??= {})[entry.groupId] = entry.id - initialRestorePending.value = false - selectedDockId.value = entry.id - sessionStore.value.open = true // Only an iframe dock owns an address-bar route; ViewIframe keeps // `session.selectedDockRoute` current for it. Clear it for anything else so a stale // route from a previous iframe isn't persisted against a non-iframe dock. @@ -687,26 +688,24 @@ export async function createDocksContext( initialRestorePending.value = false await switchEntry(restoreDockId) } - const startPanelStateEvents = (): void => { - let previousPanelState = docksContext.panel.state - watch( - [panelVisible, () => sessionStore.value.open, selectedDockId], - () => { - const panelState = docksContext.panel.state - if ( - panelState.state === previousPanelState.state - && panelState.selectedDockId === previousPanelState.selectedDockId - ) { - return - } + let previousPanelState = docksContext.panel.state + watch( + [panelVisible, () => sessionStore.value.open, selectedDockId], + () => { + const panelState = docksContext.panel.state + if ( + panelState.state === previousPanelState.state + && panelState.selectedDockId === previousPanelState.selectedDockId + ) { + return + } - previousPanelState = panelState - panelEvents.emit(HUB_EVENTS.client.docksPanelStateChanged, panelState) - }, - { flush: 'post' }, - ) - } - void restoreAfterInitialization().then(startPanelStateEvents, startPanelStateEvents) + previousPanelState = panelState + panelEvents.emit(HUB_EVENTS.client.docksPanelStateChanged, panelState) + }, + { flush: 'post' }, + ) + void restoreAfterInitialization() docksContextByRpc.set(rpc, docksContext) return docksContext diff --git a/packages/hub/src/client/docks.ts b/packages/hub/src/client/docks.ts index 75412ca1..fe5a919d 100644 --- a/packages/hub/src/client/docks.ts +++ b/packages/hub/src/client/docks.ts @@ -126,7 +126,7 @@ export interface WhenClauseContext { export type DevframeClientContext = DocksContext export interface DocksPanelEvents { - 'panel:state:changed': (state: DevframeDockPanelState) => void + 'panel:state:changed': (panelState: DevframeDockPanelState) => void } export interface DocksPanelContext {