Skip to content
Open
23 changes: 23 additions & 0 deletions docs/content/1.guide/17.client-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down
14 changes: 12 additions & 2 deletions docs/content/8.references/3.events.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,27 @@
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`.

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

## 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.<subsystem>.events`, consumed **inside the same node process** by `createHubContext`, which fans them onto the wire.
Expand Down
10 changes: 8 additions & 2 deletions packages/hub-ui/src/client/embedded/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -74,8 +75,9 @@ async function mountDock(): Promise<void> {
// carried by the connection we just established above.
const branding = setBranding(rpc.connectionMeta.configs?.ui?.branding || {})

const panelVisible = ref<boolean>()
Comment thread
dvcolomban marked this conversation as resolved.
Outdated
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')
Expand All @@ -97,8 +99,12 @@ async function mountDock(): Promise<void> {
show: () => {
if (dockEl && !dockEl.isConnected)
document.body.appendChild(dockEl)
panelVisible.value = true
},
hide: () => {
dockEl?.remove()
panelVisible.value = false
},
hide: () => dockEl?.remove(),
},
)
}
Expand Down
42 changes: 42 additions & 0 deletions packages/hub-ui/src/client/embedded/visibility.test.ts
Original file line number Diff line number Diff line change
@@ -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('handles the initial hidden state and later reveal and conceal transitions', () => {
expect.assertions(5)

const listeners = new Map<string, EventListener>()
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)
})
})
10 changes: 7 additions & 3 deletions packages/hub-ui/src/client/embedded/visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
56 changes: 55 additions & 1 deletion packages/hub-ui/src/client/state/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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'
import { DEVFRAME_EVENTS } from 'devframe/constants'
import { createEventEmitter } from 'devframe/utils/events'
import { createSharedState } from 'devframe/utils/shared-state'
Expand Down Expand Up @@ -71,9 +72,62 @@ async function flushRestore(): Promise<void> {
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)

const { rpc, sharedStates, trust } = createStubRpc()
const panelVisible = ref<boolean>()
const session = ref<DockSessionStorage>({
open: true,
selectedDockId: 'git',
selectedDockRoute: null,
})
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])
sharedStates.get('devframe:dock-renderers')!.push({})
await flushRestore()
expect(rpc.call).not.toHaveBeenCalled()

panelVisible.value = true
await nextTick()
expect(context.panel.state).toEqual({ state: 'open', selectedDockId: 'git' })
expect(panelStates).toEqual([])

session.value.selectedDockId = '~settings'
await nextTick()
expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: '~settings' })

panelVisible.value = false
await nextTick()
expect(panelStates.at(-1)).toEqual({ state: 'hidden', selectedDockId: '~settings' })

session.value.open = false
session.value.selectedDockId = null
await nextTick()
expect(panelStates.at(-1)).toEqual({ state: 'hidden' })

panelVisible.value = true
await nextTick()
expect(panelStates.at(-1)).toEqual({ state: 'closed' })

panelVisible.value = true
session.value.open = false
await nextTick()
expect(panelStates).toHaveLength(4)
})

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

Expand Down
60 changes: 56 additions & 4 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { DevframeClientCommand, DevframeDockEntry, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub'
import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage } from '@devframes/hub/client'
import type { DevframeClientCommand, DevframeDockEntry, DevframeDockPanelState, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub'
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 } 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'
Expand All @@ -18,11 +19,32 @@ import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDo
import { executeSetupScript } from './setup-script'

const docksContextByRpc = new WeakMap<DevframeRpcClient, DocksContext>()

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<DockPanelStorage>,
sessionStore?: Ref<DockSessionStorage>,
panelVisible: Ref<boolean | undefined> = ref(true),
): Promise<DocksContext> {
if (docksContextByRpc.has(rpc)) {
return docksContextByRpc.get(rpc)!
Expand Down Expand Up @@ -172,6 +194,7 @@ export async function createDocksContext(
}

panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE())
const panelEvents = createEventEmitter<DocksPanelEvents>()
let docksContext: DocksContext

let _settingsStorePromise: Promise<SharedState<DevframeDocksUserSettings>> | undefined
Expand Down Expand Up @@ -577,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,
Expand Down Expand Up @@ -648,12 +679,14 @@ export async function createDocksContext(
// the captured session intent.
// `switchEntry` then consumes the persisted iframe route when the view boots.
const restoreAfterInitialization = async (): Promise<void> => {
// 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
Comment thread
dvcolomban marked this conversation as resolved.
Outdated

await Promise.all([
waitUntilTrusted(),
dockEntriesInitialSyncComplete,
rendererManifestInitialSyncComplete,
])
Expand All @@ -669,7 +702,26 @@ export async function createDocksContext(
initialRestorePending.value = false
await switchEntry(restoreDockId)
}
void restoreAfterInitialization()
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
}

previousPanelState = panelState
panelEvents.emit(HUB_EVENTS.client.docksPanelStateChanged, panelState)
},
{ flush: 'post' },
)
}
void restoreAfterInitialization().then(startPanelStateEvents, startPanelStateEvents)

docksContextByRpc.set(rpc, docksContext)
return docksContext
Expand Down
39 changes: 38 additions & 1 deletion packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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'
import { getDevframeClientContext } from '../context'
import { createDevframeClientRuntime } from '../host'

Expand Down Expand Up @@ -67,6 +68,42 @@ function groupEntry(id: string, extra?: Record<string, unknown>): DevframeDockEn
}

describe('createDevframeClientRuntime', () => {
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(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'), iframeEntry('two')])
host.context.panel.session.open = true
const switched = host.context.docks.switchEntry('one')
await switched
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(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(panelStates.at(-1)).toEqual({ state: 'closed' })
expect(calls).toEqual([])
host.dispose()
})

it('publishes the global client context with the full surface', async () => {
const { rpc } = createStubRpc()
const host = await createDevframeClientRuntime({ rpc })
Expand Down
Loading
Loading