diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 473bc115..d02e3a4a 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 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 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/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 7df400d9..5741255f 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,56 @@ async function flushRestore(): Promise { } describe('createDocksContext', () => { + 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, 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, { 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, { state: 'closed' }) + + panelVisible.value = true + session.value.open = false + await nextTick() + expect(rpc.call).toHaveBeenCalledTimes(5) + }) + 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 e40743d7..bf54cc3c 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -1,10 +1,10 @@ -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' 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' @@ -18,11 +18,32 @@ 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), ): Promise { if (docksContextByRpc.has(rpc)) { return docksContextByRpc.get(rpc)! @@ -640,12 +661,14 @@ 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, ]) @@ -661,7 +684,20 @@ export async function createDocksContext( initialRestorePending.value = false await switchEntry(restoreDockId) } - void restoreAfterInitialization() + const reportPanelStateAfterInitialization = async (): Promise => { + await restoreAfterInitialization() + watch( + [panelVisible, () => sessionStore.value.open, selectedDockId], + ([visible, open, currentSelectedDockId]) => { + if (visible === undefined) + return + const panelState = createDockPanelState(visible, open, currentSelectedDockId) + void reportDockPanelState(rpc, panelState).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..0a2262c8 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,35 @@ function groupEntry(id: string, extra?: Record): DevframeDockEn } describe('createDevframeClientRuntime', () => { + it('reports complete panel snapshots and coalesces related session changes', async () => { + expect.assertions(4) + + const { rpc, calls, states } = createStubRpc() + const host = await createDevframeClientRuntime({ rpc, clientType: 'embedded' }) + + expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }]]) + + states.get('devframe:docks')!.push([iframeEntry('one')]) + 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' }], + ]) + + 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 + const cleared = host.context.docks.switchEntry(null) + await cleared + expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }]) + 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/__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 1bc8b685..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' @@ -20,6 +21,7 @@ import type { DockClientType, DockEntryState, DocksEntriesContext, + DockSessionStorage, DocksPanelContext, WhenClauseContext, } from './docks' @@ -32,6 +34,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 @@ -124,6 +127,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 @@ -154,7 +158,21 @@ export async function createDevframeClientRuntime( ...options.categoryOrder, } - const panel = createPanelContext(clientType) + 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 docks = createDocksContext() const commands = createCommandsContext() const renderers = createRenderersContext() @@ -225,6 +243,7 @@ export async function createDevframeClientRuntime( ) } setDevframeClientContext(context) + reportPanelState(panel.session) const loadedScripts = new Set() if (loadScriptsEnabled) { @@ -235,6 +254,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() @@ -549,7 +569,10 @@ export async function createDevframeClientRuntime( // ── shared helpers ───────────────────────────────────────────────────────── -function createPanelContext(clientType: DockClientType): DocksPanelContext { +function createPanelContext( + clientType: DockClientType, + onSessionChange: (session: DockSessionStorage) => void, +): DocksPanelContext { const store: DocksPanelContext['store'] = { mode: 'edge', width: 480, @@ -559,10 +582,27 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext { position: 'right', inactiveTimeout: 0, } + let open = clientType === 'standalone' + let selectedDockId: string | null = null const session: DocksPanelContext['session'] = { - // A standalone runtime owns the page, so its "panel" is always open. - open: clientType === 'standalone', - selectedDockId: null, + get open() { + return open + }, + set open(nextOpen) { + if (nextOpen === open) + return + open = nextOpen + onSessionChange(session) + }, + get selectedDockId() { + return selectedDockId + }, + set selectedDockId(nextSelectedDockId) { + if (nextSelectedDockId === selectedDockId) + return + selectedDockId = nextSelectedDockId + onSessionChange(session) + }, selectedDockRoute: null, } return { @@ -576,6 +616,15 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext { } } +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/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..d0542830 --- /dev/null +++ b/packages/hub/src/client/panel-state.ts @@ -0,0 +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, + 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 a9fce268..ed55eead 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..197a2447 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,47 @@ 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' }) + 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, state: 'closed' }, + { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'git' }, + { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'inspect' }, + ]) + }) + + 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 e1dd1032..b7cd0af9 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, { 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 5a9e2e0e..cc348d40 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!({ 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 6aee0ffc..4660b15c 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': (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 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..1d82a691 --- /dev/null +++ b/packages/hub/src/node/panel-state.ts @@ -0,0 +1,44 @@ +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 { + let sessionStates = dockPanelStates.get(docks) + if (!sessionStates) { + sessionStates = new Map() + dockPanelStates.set(docks, sessionStates) + } + + const previousState = sessionStates.get(sessionId) + if ( + previousState?.state === panelState.state + && previousState.selectedDockId === panelState.selectedDockId + ) { + return + } + + const currentState: DevframeDockPanelState = { ...panelState } + 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 f8e4f384..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, @@ -7,6 +8,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 +222,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(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 @@ -229,6 +244,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..cef050cf 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,18 @@ 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 } & 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 42fca8c4..d843f0da 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -246,6 +246,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, _: 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 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..fb4525ec 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..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; } @@ -109,6 +113,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 +351,16 @@ 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 68ad102b..c1859bc4 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?: ((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 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..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,8 @@ export { DevframeDockEntryBase } export { DevframeDockEntryCategory } export { DevframeDockEntryIcon } export { DevframeDockEntryRegistry } +export { DevframeDockPanelState } +export { DevframeDockPanelStateEvent } export { DevframeDocksActiveState } export { DevframeDocksHost } export { DevframeDocksUserSettings }