Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/content/1.guide/20.events.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@ Each subsystem host emits on `ctx.<subsystem>.events`, consumed **inside the sam
|---|---|---|---|
| `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`; terminals plugin | `DevframeTerminalSession` |
| `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; messages plugin | 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 viewer 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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ async function mount(): Promise<void> {
}
}

onMounted(mount)
onMounted(() => {
void mount()
})
watch(() => props.entry.id, () => {
void mount()
})
Expand Down
186 changes: 186 additions & 0 deletions packages/hub-ui/src/client/state/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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 { createEventEmitter } from 'devframe/utils/events'
import { createSharedState } from 'devframe/utils/shared-state'
import { describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { createDocksContext } from './context'
import { executeSetupScript } from './setup-script'

vi.mock('./setup-script', () => ({
executeSetupScript: vi.fn(async () => {}),
}))

const gitEntry = {
id: 'git',
type: 'custom-render',
title: 'Git',
icon: 'ph:git-branch-duotone',
renderer: { importFrom: '/git-client.js' },
} satisfies DevframeDockEntry

interface StubSharedState<Value extends object> extends SharedState<Value> {
push: (value: Value) => void
}

function createStubSharedState<Value extends object>(initialValue: Value): StubSharedState<Value> {
const state = createSharedState({ initialValue }) as StubSharedState<Value>
state.push = value => state.mutate(() => value)
return state
}

function createStubRpc() {
let isTrusted = false
const events = createEventEmitter<any>()
const sharedStates = new Map<string, StubSharedState<any>>()
const rpc = {
get isTrusted() {
return isTrusted
},
status: 'connected',
connectionError: null,
connectionMeta: { backend: 'live', configs: {} },
connection: {},
events,
sharedState: {
async get(key: string, options?: { initialValue?: object }) {
if (!sharedStates.has(key))
sharedStates.set(key, createStubSharedState(options?.initialValue ?? {}))
return sharedStates.get(key)!
},
},
client: {
register: vi.fn(),
},
call: vi.fn(),
} as unknown as DevframeRpcClient

return {
rpc,
sharedStates,
trust() {
isTrusted = true
events.emit('rpc:is-trusted:updated', true)
},
}
}

async function flushRestore(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
await nextTick()
}

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<DockSessionStorage>({
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)

const { rpc, sharedStates, trust } = createStubRpc()
const executeSetupScriptMock = vi.mocked(executeSetupScript)
executeSetupScriptMock.mockClear()
const session = ref<DockSessionStorage>({
open: true,
selectedDockId: 'git',
selectedDockRoute: null,
})
const context = await createDocksContext('embedded', rpc, undefined, session)

/** Mirrors the authorization gate temporarily closing the panel on reload. */
session.value.open = false
trust()

expect(session.value.open).toBe(false)

sharedStates.get('devframe:docks')!.push([gitEntry])
await flushRestore()

expect(context.docks.selected).toBeNull()
expect(session.value.open).toBe(false)
expect(executeSetupScriptMock).not.toHaveBeenCalled()

sharedStates.get('devframe:dock-renderers')!.push({})
await flushRestore()

expect(context.docks.selected?.id).toBe('git')
expect(session.value.open).toBe(true)
expect(executeSetupScriptMock).toHaveBeenCalledOnce()
})

it('keeps navigation performed before the initial server registry arrives', async () => {
expect.assertions(2)

const { rpc, sharedStates, trust } = createStubRpc()
const session = ref<DockSessionStorage>({
open: true,
selectedDockId: 'git',
selectedDockRoute: null,
})
const context = await createDocksContext('embedded', rpc, undefined, session)

session.value.open = false
trust()
await context.docks.switchEntry('~settings')

sharedStates.get('devframe:docks')!.push([gitEntry])
sharedStates.get('devframe:dock-renderers')!.push({})
await flushRestore()

expect(context.docks.selected?.id).toBe('~settings')
expect(session.value.open).toBe(true)
})

it('keeps a dock closed when the user closes it before initialization finishes', async () => {
expect.assertions(2)

const { rpc, sharedStates, trust } = createStubRpc()
const session = ref<DockSessionStorage>({
open: true,
selectedDockId: 'git',
selectedDockRoute: null,
})
const context = await createDocksContext('embedded', rpc, undefined, session)

trust()
await context.docks.switchEntry(null)
sharedStates.get('devframe:docks')!.push([gitEntry])
sharedStates.get('devframe:dock-renderers')!.push({})
await flushRestore()

expect(context.docks.selected).toBeNull()
expect(session.value.open).toBe(false)
})
})
97 changes: 70 additions & 27 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ 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 { 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'
import { createCommandsContext } from './commands'
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupDefaultChild } from './dock-settings'
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries } from './docks'
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries, waitForInitialSharedStateSync } from './docks'
import { createClientMessagesClient } from './messages-client'
import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
import { executeSetupScript } from './setup-script'
Expand All @@ -27,16 +27,22 @@ export async function createDocksContext(
return docksContextByRpc.get(rpc)!
}

const dockEntries = await useDocksEntries(rpc)
const { entries: dockEntries, initialSyncComplete: dockEntriesInitialSyncComplete } = await useDocksEntries(rpc)

// The hub's renderer manifest (`initHub({ renderers })`): dock type →
// prebuilt renderer-module entry. The registry below lazy-imports a module
// the first time a dock of its type mounts; locally-registered renderers win.
/** Identity marker replaced by the first server response, including an empty manifest. */
const pendingRendererManifest: DockRendererManifest = {}
const rendererManifestState = await rpc.sharedState.get<DockRendererManifest>(
DOCK_RENDERERS_STATE_KEY,
{ initialValue: {} },
{ initialValue: pendingRendererManifest },
)
const rendererManifest = sharedStateToRef(rendererManifestState)
const rendererManifestInitialSyncComplete = waitForInitialSharedStateSync(
rendererManifestState,
pendingRendererManifest,
)

// Client-only dock registry (0.7.10 `DocksEntriesContext` API). Docks
// registered here live in this page only, merged over the server-provided
Expand Down Expand Up @@ -85,6 +91,10 @@ export async function createDocksContext(
const restoreIntent = {
...sessionStore.value,
}
/** Keep the persisted view unmounted until its server-backed registries are ready. */
const initialRestorePending = ref(
restoreIntent.open && restoreIntent.selectedDockId != null,
)

// `selectedDockId` is backed by the session store so the current selection both
// drives the UI and persists across reloads through one source of truth.
Expand All @@ -101,11 +111,13 @@ export async function createDocksContext(
},
})

const selected = computed(
() => entries.value.find(entry => entry.id === selectedDockId.value)
const selected = computed(() => {
if (initialRestorePending.value)
return null
return entries.value.find(entry => entry.id === selectedDockId.value)
?? BUILTIN_ENTRIES.find(entry => entry.id === selectedDockId.value)
?? null,
)
?? null
})

const dockEntryStateMap: Map<string, DockEntryState> = reactive(new Map())
watchEffect(() => {
Expand Down Expand Up @@ -200,12 +212,14 @@ export async function createDocksContext(

const switchEntry = async (id: string | null = null) => {
if (id == null) {
initialRestorePending.value = false
selectedDockId.value = null
sessionStore.value.open = false
sessionStore.value.selectedDockRoute = null
return true
}
if (id === '~client-auth-notice') {
initialRestorePending.value = false
selectedDockId.value = id
sessionStore.value.open = true
return true
Expand Down Expand Up @@ -273,6 +287,7 @@ export async function createDocksContext(
if (entry.type === 'iframe' && entry.frameId && !entry.subTabs)
frameNavCurrentMember.set(entry.frameId, entry.id)

initialRestorePending.value = false
selectedDockId.value = entry.id
sessionStore.value.open = true
// Only an iframe dock owns an address-bar route; ViewIframe keeps
Expand Down Expand Up @@ -602,28 +617,56 @@ export async function createDocksContext(
return switchEntry(entry.id)
})

// Restore the persisted selection once the RPC is trusted. A reload starts
// untrusted, and Dock.vue force-closes the panel during that window (and a
// revocation clears the selection), so the durable intent captured in
// `restoreIntent` is re-applied here after the handshake — re-running the
// dock's setup script and re-opening the panel on the dock the developer left
// open. `switchEntry` reads `session.selectedDockRoute` back through `consumeBootRoute`
// when the restored iframe boots.
const applyRestore = (): void => {
if (restoreIntent.open && restoreIntent.selectedDockId != null)
void switchEntry(restoreIntent.selectedDockId)
const waitUntilTrusted = async (): Promise<void> => {
if (rpc.isTrusted)
return
await new Promise<void>((resolve) => {
const stopListening = rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
if (!isTrusted)
return
stopListening()
resolve()
})
})
}
if (rpc.isTrusted) {
applyRestore()

// A reload starts untrusted, and Dock.vue temporarily closes the panel during
// that window. The trust event precedes the asynchronous `devframe:docks`
// and renderer-manifest responses, so wait for all three before re-applying
// the captured session intent.
// `switchEntry` then consumes the persisted iframe route when the view boots.
const restoreAfterInitialization = async (): Promise<void> => {
await waitUntilTrusted()

const restoreDockId = restoreIntent.selectedDockId
if (!restoreIntent.open || restoreDockId == null)
return

await Promise.all([
dockEntriesInitialSyncComplete,
rendererManifestInitialSyncComplete,
])

if (!initialRestorePending.value)
return

if (selectedDockId.value !== restoreDockId) {
initialRestorePending.value = false
return
}

initialRestorePending.value = false
await switchEntry(restoreDockId)
}
else {
const off = rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
if (!isTrusted)
return
off()
applyRestore()
})
const reportPanelStateAfterInitialization = async (): Promise<void> => {
await restoreAfterInitialization()
watch(
() => sessionStore.value.open,
open => void reportDockPanelState(rpc, open).catch(() => {}),
{ immediate: true },
)
}
void reportPanelStateAfterInitialization()

docksContextByRpc.set(rpc, docksContext)
return docksContext
Expand Down
Loading
Loading