Skip to content

Commit 55b2431

Browse files
antfubotdvcolomban
andcommitted
feat(hub-ui): restore the active dock across a reload
`selectedId` used to reset to nothing every time the embedded dock mounted. It now lives on the same localStorage-backed `panelStore` as `open`/mode/geometry, so a dev-server restart or HMR full-reload keeps the dock open on whatever was selected. A restored id that no longer resolves to a selectable entry (removed, or pointing at a group/subTabs anchor) is cleared once on boot without forcing the panel open; `DockEmbedded` then falls back to the first available entry if the panel is open with nothing selected, mirroring `DockStandalone`'s existing boot guard. Adds `packages/hub-ui`'s first vitest suite (registered in the root `vitest.config.ts`) covering the restore/validation behavior via the existing `createMockDocksContext` story helper. Co-authored-by: dvcolomban <90617742+dvcolomban@users.noreply.github.com>
1 parent 2dc9b90 commit 55b2431

8 files changed

Lines changed: 180 additions & 11 deletions

File tree

packages/hub-ui/src/client/components/dock/DockEmbedded.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { DocksContext } from '@devframes/hub/client'
33
import type { DockLayout } from './dock-layout'
44
import { useEventListener } from '@vueuse/core'
5-
import { onUnmounted } from 'vue'
5+
import { onUnmounted, watch } from 'vue'
66
import { sharedStateToRef } from '../../state/docks'
77
import { closeDockPopup, useIsDockPopupOpen } from '../../state/popup'
88
import { useIsRpcTrusted } from '../../utils/useIsRpcTrusted'
@@ -21,12 +21,28 @@ const props = defineProps<{
2121
layout?: Partial<DockLayout>
2222
}>()
2323
24+
const context = props.context
25+
2426
const isDockPopupOpen = useIsDockPopupOpen()
2527
const settings = sharedStateToRef(props.context.docks.settings)
2628
2729
// Force float mode when unauthorized, regardless of store setting
2830
const isRpcTrusted = useIsRpcTrusted(props.context)
2931
32+
/**
33+
* If the panel is open but nothing valid is selected (e.g. a restored
34+
* `selectedId` didn't resolve to a real entry), fall back to the first
35+
* available one — mirrors `DockStandalone`'s own boot guard.
36+
*/
37+
watch(
38+
() => context.docks.entries,
39+
() => {
40+
if (context.panel.store.open)
41+
context.docks.selectedId ||= context.docks.entries[0]?.id ?? null
42+
},
43+
{ immediate: true },
44+
)
45+
3046
// Close the dock when clicking outside of it
3147
useEventListener(window, 'mousedown', (e: MouseEvent) => {
3248
if (!settings.value.closeOnOutsideClick)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DockPanelStorage } from '@devframes/hub/client'
1+
import type { HubDockPanelStorage } from '../state/docks'
22
import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
33
import { useLocalStorage } from '@vueuse/core'
44
import { HUB_UI_HIDE_EVENT } from '../constants'
@@ -39,7 +39,7 @@ async function mountDock(): Promise<void> {
3939
simpleAuth: false,
4040
})
4141

42-
const state = useLocalStorage<DockPanelStorage>(
42+
const state = useLocalStorage<HubDockPanelStorage>(
4343
'devframes-dock-state',
4444
DEFAULT_DOCK_PANEL_STORE(),
4545
{ mergeDefaults: true },

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

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { DevframeClientCommand, DevframeDockEntry, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub'
2-
import type { CommandsContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext } from '@devframes/hub/client'
2+
import type { CommandsContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockRegistration, DockRendererManifest, DocksContext } from '@devframes/hub/client'
33
import type { SharedState } from 'devframe/utils/shared-state'
44
import type { WhenContext } from 'devframe/utils/when'
55
import type { Ref } from 'vue'
66
import type { HubDocksUserSettings } from './dock-settings'
7+
import type { HubDockPanelStorage } from './docks'
78
import { attachFrameNavClient } from '@devframes/hub/client'
89
import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY } from '@devframes/hub/constants'
910
import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue'
@@ -21,7 +22,7 @@ const docksContextByRpc = new WeakMap<DevframeRpcClient, DocksContext>()
2122
export async function createDocksContext(
2223
clientType: 'embedded' | 'standalone',
2324
rpc: DevframeRpcClient,
24-
panelStore?: Ref<DockPanelStorage>,
25+
panelStore?: Ref<HubDockPanelStorage>,
2526
): Promise<DocksContext> {
2627
if (docksContextByRpc.has(rpc)) {
2728
return docksContextByRpc.get(rpc)!
@@ -73,13 +74,58 @@ export async function createDocksContext(
7374
return [...base, BUILTIN_ENTRY_SETTINGS]
7475
})
7576

76-
const selectedId = ref<string | null>(null)
77+
panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE())
78+
79+
/**
80+
* `selectedId` lives in `panelStore` (localStorage in the embedded client),
81+
* alongside `open`/mode/geometry — so it's restored across a reload and
82+
* shared cross-tab like the rest of that value, instead of resetting to
83+
* nothing every time the dock mounts.
84+
*/
85+
const selectedId = computed<string | null>({
86+
get: () => panelStore.value.selectedId,
87+
set: (value) => { panelStore.value.selectedId = value },
88+
})
7789
const selected = computed(
7890
() => entries.value.find(entry => entry.id === selectedId.value)
7991
?? BUILTIN_ENTRIES.find(entry => entry.id === selectedId.value)
8092
?? null,
8193
)
8294

95+
/**
96+
* A restored `selectedId` may point at a non-selectable entry (a group, or
97+
* a `subTabs` anchor) — `switchEntry` would fix that on click, but routing
98+
* through it here would force `panelStore.value.open = true`, reopening a
99+
* closed panel. So validate once, on boot, directly instead. Past boot,
100+
* `switchEntry` may itself land `selectedId` on a group/anchor (e.g.
101+
* mid-redirect, or a `subTabs` anchor with no live member yet) — that's not
102+
* something to keep correcting.
103+
*/
104+
const isSelectableEntry = (id: string): boolean => {
105+
if (BUILTIN_ENTRIES.some(entry => entry.id === id))
106+
return true
107+
const entry = entries.value.find(e => e.id === id)
108+
if (!entry)
109+
return false
110+
if (entry.type === 'group')
111+
return false
112+
if (entry.type === 'iframe' && entry.subTabs)
113+
return false
114+
return true
115+
}
116+
let bootRestoreChecked = false
117+
watch(
118+
entries,
119+
() => {
120+
if (bootRestoreChecked)
121+
return
122+
bootRestoreChecked = true
123+
if (selectedId.value != null && !isSelectableEntry(selectedId.value))
124+
selectedId.value = null
125+
},
126+
{ immediate: true },
127+
)
128+
83129
const dockEntryStateMap: Map<string, DockEntryState> = reactive(new Map())
84130
watchEffect(() => {
85131
for (const entry of entries.value) {
@@ -131,7 +177,6 @@ export async function createDocksContext(
131177
clientDocks.set(entry.id, entry as DevframeDockEntry)
132178
}
133179

134-
panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE())
135180
let docksContext: DocksContext
136181

137182
let _settingsStorePromise: Promise<SharedState<HubDocksUserSettings>> | undefined

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,18 @@ import type { Ref, ShallowRef } from 'vue'
55
import { createEventEmitter } from 'devframe/utils/events'
66
import { markRaw, reactive, shallowRef, watch } from 'vue'
77

8-
export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage {
8+
/**
9+
* {@link DockPanelStorage} (hub's own type — geometry/mode/`open`) plus
10+
* `selectedId`, which the hub has no concept of. Both persist in the same
11+
* `devframes-dock-state` localStorage value (the embedded dock's own store),
12+
* so both survive a reload and are shared cross-tab like the rest of that
13+
* value — a dock left open/selected in one tab shows the same way in the next.
14+
*/
15+
export interface HubDockPanelStorage extends DockPanelStorage {
16+
selectedId: string | null
17+
}
18+
19+
export function DEFAULT_DOCK_PANEL_STORE(): HubDockPanelStorage {
920
return {
1021
mode: 'float',
1122
width: 80,
@@ -15,6 +26,7 @@ export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage {
1526
position: 'bottom',
1627
open: false,
1728
inactiveTimeout: 3_000,
29+
selectedId: null,
1830
}
1931
}
2032

packages/hub-ui/src/client/stories/mock-context.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DevframeDockEntry } from '@devframes/hub'
2-
import type { DevframeRpcClient, DockPanelStorage, DocksContext, RpcClientEvents } from '@devframes/hub/client'
2+
import type { DevframeRpcClient, DocksContext, RpcClientEvents } from '@devframes/hub/client'
3+
import type { HubDockPanelStorage } from '../state/docks'
34
import type { HubDocksUserSettings } from '../types'
45
import { DEFAULT_STATE_USER_SETTINGS } from '@devframes/hub/constants'
56
import { createEventEmitter } from 'devframe/utils/events'
@@ -25,7 +26,7 @@ export interface CreateMockContextOptions {
2526
/** Which client shell the context represents. */
2627
clientType?: 'embedded' | 'standalone'
2728
/** Overrides merged over the default panel store (mode, position, open, ...). */
28-
panel?: Partial<DockPanelStorage>
29+
panel?: Partial<HubDockPanelStorage>
2930
/** Overrides merged over the default user settings (hidden, pinned, order, ...). */
3031
settings?: Partial<HubDocksUserSettings>
3132
/** Entry id to pre-select (also opens the panel). */
@@ -117,7 +118,7 @@ export async function createMockDocksContext(
117118
} = options
118119

119120
const rpc = createMockRpc(entries, settings, isTrusted)
120-
const panelStore = ref<DockPanelStorage>({ ...DEFAULT_DOCK_PANEL_STORE(), ...panel })
121+
const panelStore = ref<HubDockPanelStorage>({ ...DEFAULT_DOCK_PANEL_STORE(), ...panel })
121122

122123
const context = await createDocksContext(clientType, rpc, panelStore)
123124

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { iframe } from '../src/client/stories/fixtures'
3+
import { createMockDocksContext } from '../src/client/stories/mock-context'
4+
5+
/**
6+
* `selectedId` lives on the same `panelStore` ref as `open`/mode/geometry
7+
* (`state/docks.ts`'s `HubDockPanelStorage`) — restored from localStorage in
8+
* the real embedded client, seeded here via `createMockDocksContext`'s
9+
* `panel`/`selectedId` options instead of a separate session store.
10+
*/
11+
describe('restored dock panel state (selectedId on the shared panelStore)', () => {
12+
it('keeps a restored selectedId that resolves to a real leaf entry', async () => {
13+
const context = await createMockDocksContext({
14+
entries: [iframe('a', 'A', 'ph:cube-duotone')],
15+
panel: { selectedId: 'a', open: true },
16+
})
17+
18+
expect(context.docks.selectedId).toBe('a')
19+
expect(context.panel.store.open).toBe(true)
20+
})
21+
22+
it('keeps a restored selectedId of a `~builtin` pseudo-entry (e.g. Settings)', async () => {
23+
const context = await createMockDocksContext({
24+
entries: [],
25+
panel: { selectedId: '~settings', open: true },
26+
})
27+
28+
expect(context.docks.selectedId).toBe('~settings')
29+
})
30+
31+
it('clears a restored selectedId pointing at a group (not a selectable leaf)', async () => {
32+
const context = await createMockDocksContext({
33+
entries: [{ id: 'nuxt', type: 'group', title: 'Nuxt', icon: 'ph:cube-duotone' } as any],
34+
panel: { selectedId: 'nuxt' },
35+
})
36+
37+
expect(context.docks.selectedId).toBeNull()
38+
})
39+
40+
it('clears a restored selectedId pointing at a subTabs anchor (not a selectable leaf)', async () => {
41+
const context = await createMockDocksContext({
42+
entries: [iframe('nuxt', 'Nuxt', 'ph:cube-duotone', { subTabs: { protocol: 'postmessage' } } as any)],
43+
panel: { selectedId: 'nuxt' },
44+
})
45+
46+
expect(context.docks.selectedId).toBeNull()
47+
})
48+
49+
it('clears a restored selectedId that no longer resolves to any entry, without forcing the panel open', async () => {
50+
const context = await createMockDocksContext({
51+
entries: [iframe('a', 'A', 'ph:cube-duotone')],
52+
panel: { selectedId: 'gone', open: false },
53+
})
54+
55+
expect(context.docks.selectedId).toBeNull()
56+
// Clearing an invalid restored id must not route through `switchEntry`
57+
// (which would force `open = true`) — the panel stays exactly as restored.
58+
expect(context.panel.store.open).toBe(false)
59+
})
60+
61+
it('does not clear an id `switchEntry` itself legitimately selects later (a subTabs anchor with no live member yet)', async () => {
62+
const context = await createMockDocksContext({
63+
entries: [iframe('nuxt', 'Nuxt', 'ph:cube-duotone', { subTabs: { protocol: 'postmessage' } } as any)],
64+
})
65+
66+
await context.docks.switchEntry('nuxt')
67+
68+
expect(context.docks.selectedId).toBe('nuxt')
69+
})
70+
71+
it('sets selectedId and open on the same panel store that carries geometry (mode)', async () => {
72+
const context = await createMockDocksContext({
73+
entries: [],
74+
panel: { mode: 'float' },
75+
})
76+
77+
context.panel.store.open = true
78+
context.docks.selectedId = null
79+
80+
expect(context.panel.store.open).toBe(true)
81+
expect(context.panel.store.mode).toBe('float')
82+
})
83+
})

packages/hub-ui/vitest.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineConfig } from 'vitest/config'
2+
import { alias } from '../../alias'
3+
4+
// The dock-context tests cross-import `@devframes/hub`'s types/constants —
5+
// resolve them to source rather than the (possibly stale/unbuilt) `dist`.
6+
export default defineConfig({
7+
resolve: { alias },
8+
test: {
9+
name: '@devframes/hub-ui',
10+
},
11+
})

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default defineConfig({
1515
projects: [
1616
'packages/devframe',
1717
'packages/hub',
18+
'packages/hub-ui',
1819
'packages/json-render',
1920
'packages/json-render-ui',
2021
'plugins/code-server',

0 commit comments

Comments
 (0)