Skip to content

Commit 43f1198

Browse files
committed
feat(hub): restore dock open/selection/route across reloads via session store
Introduce a per-tab `DockSessionStorage` (open, selectedId, route) distinct from the browser-shared `DockPanelStorage` geometry. `open` moves out of the panel store into this session store, joined by the selected dock id and the selected iframe dock's live address-bar route. The embedded and standalone bootstraps persist it to `sessionStorage`, and the docks context re-applies the persisted selection once the RPC handshake makes the client trusted — surviving the pre-handshake untrusted window that force-closes the panel. A restored iframe boots deep-linked to its saved route via a one-shot `consumeBootRoute`. Created with the help of an agent.
1 parent f95a3a9 commit 43f1198

15 files changed

Lines changed: 217 additions & 37 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const isRpcTrusted = useIsRpcTrusted(context, (isTrusted) => {
8686
else if (!isTrusted) {
8787
// On revocation: close current tab and panel
8888
context.docks.switchEntry(null)
89-
context.panel.store.open = false
89+
context.panel.session.open = false
9090
}
9191
})
9292
@@ -183,7 +183,7 @@ const isMinimized = computed(() => {
183183
// @ts-expect-error compatibility
184184
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0
185185
return !context.panel.isDragging
186-
&& !context.panel.store.open
186+
&& !context.panel.session.open
187187
&& !isHovering.value
188188
&& !isTouchDevice
189189
&& context.panel.store.inactiveTimeout
@@ -218,8 +218,8 @@ whenever(isMinimized, () => {
218218
})
219219
220220
onMounted(() => {
221-
if (context.panel.store.open && !isRpcTrusted.value)
222-
context.panel.store.open = false
221+
if (context.panel.session.open && !isRpcTrusted.value)
222+
context.panel.session.open = false
223223
if (isRpcTrusted.value)
224224
bringUp()
225225
recalculateCounter.value++

packages/hub-ui/src/client/components/dock/DockEdge.stories.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ function edgeStory(position: 'top' | 'right' | 'bottom' | 'left', open = true) {
3838
{
3939
entries: categorizedEntries,
4040
selectedId: open ? 'overview' : null,
41-
panel: { mode: 'edge', position, open, height: 40, width: 30 },
41+
panel: { mode: 'edge', position, height: 40, width: 30 },
42+
session: { open },
4243
},
4344
ctx => [
4445
h(DockEdge, { context: ctx }, { view: ({ entry }: any) => body(entry) }),
@@ -75,7 +76,8 @@ export const CollapsedIdle: Story = {
7576
{
7677
entries: categorizedEntries,
7778
selectedId: null,
78-
panel: { mode: 'edge', position: 'bottom', open: false, inactiveTimeout: 0 },
79+
panel: { mode: 'edge', position: 'bottom', inactiveTimeout: 0 },
80+
session: { open: false },
7981
settings: { autoCollapseEdgeToolbar: true },
8082
},
8183
ctx => [
@@ -93,7 +95,7 @@ export const WithGroup: Story = {
9395
{
9496
entries: groupedEntries,
9597
selectedId: 'nuxt:overview',
96-
panel: { mode: 'edge', position: 'bottom', open: true, height: 45 },
98+
panel: { mode: 'edge', position: 'bottom', height: 45 },
9799
},
98100
ctx => [
99101
h(DockEdge, { context: ctx }, { view: ({ entry }: any) => body(entry) }),

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const selectedEntry = computed(() => context.docks.selected)
3737
const activeGroup = computed(() => getEntryGroup(context.docks.entries, selectedEntry.value))
3838
const hasPanelContent = computed(() => {
3939
const entry = selectedEntry.value
40-
return context.panel.store.open
40+
return context.panel.session.open
4141
&& !!entry
4242
&& entry.type !== 'action'
4343
})

packages/hub-ui/src/client/components/dock/DockEmbedded.stories.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export const FloatReducedOverlap: Story = {
6969
export const Edge: Story = {
7070
render: () => ({
7171
setup: () => mountWithContext(
72-
{ entries: blankEntries, selectedId: 'overview', panel: { mode: 'edge', position: 'bottom', open: true, height: 45 } },
72+
{ entries: blankEntries, selectedId: 'overview', panel: { mode: 'edge', position: 'bottom', height: 45 } },
7373
ctx => h(DockEmbedded, { context: ctx }),
7474
),
7575
}),

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ useEventListener(window, 'mousedown', (e: MouseEvent) => {
3333
return
3434
if (isDockPopupOpen.value)
3535
return
36-
if (!props.context.panel.store.open || props.context.panel.isDragging || props.context.panel.isResizing)
36+
if (!props.context.panel.session.open || props.context.panel.isDragging || props.context.panel.isResizing)
3737
return
3838
3939
const matched = e.composedPath().find((_el) => {

packages/hub-ui/src/client/components/views/ViewIframe.vue

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,19 +224,37 @@ let onIframeLoad: (() => void) | undefined
224224
225225
onMounted(() => {
226226
const existed = props.panes.has(paneKey.value)
227+
// Restore the address-bar route persisted before the last reload: the dock
228+
// that was selected then boots deep-linked to where the developer left it,
229+
// instead of the entry's default url. `consumeBootRoute` hands the saved URL
230+
// back only for that dock, and only once, so a later switch can't reuse it.
231+
const bootUrl = props.context.panel.consumeBootRoute?.(props.entry.id) ?? props.entry.url
232+
if (!existed && bootUrl !== currentUrl.value) {
233+
currentUrl.value = bootUrl
234+
editingUrl.value = bootUrl
235+
}
227236
// `src` is only assigned when the pane is first created, so re-mounting an
228237
// existing iframe (tab switch) preserves its navigation/scroll/JS state. For
229238
// a shared frame this is also the boot deep-link: the first member (or the
230239
// anchor) to become visible seeds the src, and every later switch soft-navs.
231240
const pane = props.panes.ensure(paneKey.value, {
232-
src: props.entry.url,
241+
src: bootUrl,
233242
style: { boxShadow: 'none', outline: 'none' },
234243
})
235244
const iframe = pane.iframe
236245
237246
if (existed)
238247
updateCurrentUrl()
239248
249+
// Persist this dock's live route while it is the selected one, so the next
250+
// reload can restore it. Only the selected dock writes, so switching docks
251+
// never overwrites another's saved route.
252+
const panelSession = props.context.panel.session
253+
watchEffect(() => {
254+
if (props.context.docks.selectedId === props.entry.id)
255+
panelSession.route = currentUrl.value
256+
})
257+
240258
// Listen for iframe load events
241259
onIframeLoad = () => {
242260
isIframeLoading.value = false

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { DockPanelStorage } from '@devframes/hub/client'
1+
import type { DockPanelStorage, DockSessionStorage } from '@devframes/hub/client'
22
import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
3-
import { useLocalStorage } from '@vueuse/core'
3+
import { useLocalStorage, useSessionStorage } from '@vueuse/core'
44
import { applyPrimaryColor, setBranding } from '../state/branding'
5-
import { DEFAULT_DOCK_PANEL_STORE } from '../state/docks'
5+
import { DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE } from '../state/docks'
66
import { setupEmbeddedVisibility } from './visibility'
77

88
/**
@@ -59,13 +59,23 @@ async function mountDock(): Promise<void> {
5959
{ mergeDefaults: true },
6060
)
6161

62+
// Per-tab session UI state (open dock + its route). `sessionStorage`, not
63+
// `localStorage`: selection is per-tab navigation state, so two tabs against
64+
// the same server keep their own rather than fighting over a shared one. It
65+
// survives a reload and is restored after the auth handshake.
66+
const session = useSessionStorage<DockSessionStorage>(
67+
'devframes-dock-session',
68+
DEFAULT_DOCK_SESSION_STORE(),
69+
{ mergeDefaults: true },
70+
)
71+
6272
// Resolve branding before the dock exists so the primary color and logo are
6373
// in place on the first paint. Read from `ConnectionMeta.configs.ui.branding`,
6474
// carried by the connection we just established above.
6575
const branding = setBranding(rpc.connectionMeta.configs?.ui?.branding || {})
6676

6777
const { createDocksContext } = await import('../state/context')
68-
const context = await createDocksContext('embedded', rpc, state)
78+
const context = await createDocksContext('embedded', rpc, state, session)
6979
setDevframeClientContext(context)
7080

7181
const { DockEmbedded } = await import('../components/DockEmbedded')

packages/hub-ui/src/client/standalone/main.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import type { DockSessionStorage } from '@devframes/hub/client'
12
import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
3+
import { useSessionStorage } from '@vueuse/core'
24
import { watchEffect } from 'vue'
35
import { applyDocumentHead, applyPrimaryColor, setBranding } from '../state/branding'
46
import { isDark } from '../state/color-mode'
7+
import { DEFAULT_DOCK_SESSION_STORE } from '../state/docks'
58

69
// The standalone viewer — a vanilla shell served at the hub base itself
710
// (`DevframeHubUi.viewer`): resolve the shared connection, build the docks
@@ -37,8 +40,16 @@ async function main(): Promise<void> {
3740
const branding = setBranding(rpc.connectionMeta.configs?.ui?.branding || {})
3841
applyDocumentHead(document, branding)
3942

43+
// Per-tab session UI state (which dock is open + its route). `sessionStorage`
44+
// so a reload restores the selection after the auth handshake, per-tab.
45+
const session = useSessionStorage<DockSessionStorage>(
46+
'devframes-dock-session',
47+
DEFAULT_DOCK_SESSION_STORE(),
48+
{ mergeDefaults: true },
49+
)
50+
4051
const { createDocksContext } = await import('../state/context')
41-
const context = await createDocksContext('standalone', rpc)
52+
const context = await createDocksContext('standalone', rpc, undefined, session)
4253
setDevframeClientContext(context)
4354

4455
const { DockStandalone } = await import('../components/DockStandalone')

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

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DevframeClientCommand, DevframeDockEntry, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub'
2-
import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext } from '@devframes/hub/client'
2+
import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage } 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'
@@ -11,7 +11,7 @@ import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_
1111
import { useBranding } from './branding'
1212
import { createCommandsContext } from './commands'
1313
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupDefaultChild } from './dock-settings'
14-
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, sharedStateToRef, useDocksEntries } from './docks'
14+
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries } from './docks'
1515
import { createClientMessagesClient } from './messages-client'
1616
import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
1717
import { executeSetupScript } from './setup-script'
@@ -21,6 +21,7 @@ export async function createDocksContext(
2121
clientType: 'embedded' | 'standalone',
2222
rpc: DevframeRpcClient,
2323
panelStore?: Ref<DockPanelStorage>,
24+
sessionStore?: Ref<DockSessionStorage>,
2425
): Promise<DocksContext> {
2526
if (docksContextByRpc.has(rpc)) {
2627
return docksContextByRpc.get(rpc)!
@@ -72,7 +73,29 @@ export async function createDocksContext(
7273
return [...base, BUILTIN_ENTRY_SETTINGS]
7374
})
7475

75-
const selectedId = ref<string | null>(null)
76+
// Per-tab session UI state (open/selectedId/route). A caller (the embedded and
77+
// standalone bootstraps) passes a `sessionStorage`-backed ref so it survives a
78+
// reload; stories and the default path fall back to an in-memory ref.
79+
sessionStore ||= ref(DEFAULT_DOCK_SESSION_STORE())
80+
81+
// Snapshot the persisted intent up front, before the pre-handshake untrusted
82+
// window (Dock.vue's `open`-gate, a revocation `switchEntry(null)`) can clear
83+
// the live session state. Re-applied once the RPC becomes trusted so a reload
84+
// lands back on the same dock — see the restore effect near the end.
85+
const restoreIntent = {
86+
open: sessionStore.value.open,
87+
selectedId: sessionStore.value.selectedId,
88+
route: sessionStore.value.route,
89+
}
90+
91+
// `selectedId` is backed by the session store so the current selection both
92+
// drives the UI and persists across reloads through one source of truth.
93+
const selectedId = computed<string | null>({
94+
get: () => sessionStore.value.selectedId,
95+
set: (id) => {
96+
sessionStore.value.selectedId = id
97+
},
98+
})
7699
const selected = computed(
77100
() => entries.value.find(entry => entry.id === selectedId.value)
78101
?? BUILTIN_ENTRIES.find(entry => entry.id === selectedId.value)
@@ -154,7 +177,7 @@ export async function createDocksContext(
154177
const isDockPopupOpen = useIsDockPopupOpen()
155178
const getWhenContext = (): WhenContext => ({
156179
clientType,
157-
dockOpen: panelStore.value.open,
180+
dockOpen: sessionStore.value.open,
158181
paletteOpen: commandsContext?.paletteOpen ?? false,
159182
dockSelectedId: selectedId.value ?? '',
160183
popupOpen: isDockPopupOpen.value,
@@ -173,12 +196,13 @@ export async function createDocksContext(
173196
const switchEntry = async (id: string | null = null) => {
174197
if (id == null) {
175198
selectedId.value = null
176-
panelStore.value.open = false
199+
sessionStore.value.open = false
200+
sessionStore.value.route = null
177201
return true
178202
}
179203
if (id === '~client-auth-notice') {
180204
selectedId.value = id
181-
panelStore.value.open = true
205+
sessionStore.value.open = true
182206
return true
183207
}
184208
const entry = entries.value.find(e => e.id === id)
@@ -245,7 +269,12 @@ export async function createDocksContext(
245269
frameNavCurrentMember.set(entry.frameId, entry.id)
246270

247271
selectedId.value = entry.id
248-
panelStore.value.open = true
272+
sessionStore.value.open = true
273+
// Only an iframe dock owns an address-bar route; ViewIframe keeps
274+
// `session.route` current for it. Clear it for anything else so a stale
275+
// route from a previous iframe isn't persisted against a non-iframe dock.
276+
if (entry.type !== 'iframe')
277+
sessionStore.value.route = null
249278
return true
250279
}
251280

@@ -385,7 +414,7 @@ export async function createDocksContext(
385414
when: 'dockOpen && !paletteOpen',
386415
keybindings: [{ key: 'Escape' }],
387416
action: () => {
388-
panelStore.value.open = false
417+
sessionStore.value.open = false
389418
selectedId.value = null
390419
},
391420
},
@@ -501,12 +530,27 @@ export async function createDocksContext(
501530
}
502531
})
503532

533+
// One-shot boot route (see `DocksPanelContext.consumeBootRoute`): the persisted
534+
// address-bar URL is handed back to exactly the iframe dock that was selected
535+
// before the reload, once.
536+
let bootRoute: string | null = restoreIntent.selectedId != null ? restoreIntent.route : null
537+
const consumeBootRoute = (id: string): string | null => {
538+
if (bootRoute != null && id === restoreIntent.selectedId) {
539+
const route = bootRoute
540+
bootRoute = null
541+
return route
542+
}
543+
return null
544+
}
545+
504546
docksContext = reactive({
505547
panel: {
506548
store: panelStore,
549+
session: sessionStore,
507550
isDragging: false,
508551
isResizing: false,
509552
isVertical: computed(() => panelStore.value.position === 'left' || panelStore.value.position === 'right'),
553+
consumeBootRoute,
510554
},
511555
docks: {
512556
selectedId,
@@ -552,6 +596,29 @@ export async function createDocksContext(
552596
return switchEntry(entry.id)
553597
})
554598

599+
// Restore the persisted selection once the RPC is trusted. A reload starts
600+
// untrusted, and Dock.vue force-closes the panel during that window (and a
601+
// revocation clears the selection), so the durable intent captured in
602+
// `restoreIntent` is re-applied here after the handshake — re-running the
603+
// dock's setup script and re-opening the panel on the dock the developer left
604+
// open. `switchEntry` reads `session.route` back through `consumeBootRoute`
605+
// when the restored iframe boots.
606+
const applyRestore = (): void => {
607+
if (restoreIntent.open && restoreIntent.selectedId != null)
608+
void switchEntry(restoreIntent.selectedId)
609+
}
610+
if (rpc.isTrusted) {
611+
applyRestore()
612+
}
613+
else {
614+
const off = rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
615+
if (!isTrusted)
616+
return
617+
off()
618+
applyRestore()
619+
})
620+
}
621+
555622
docksContextByRpc.set(rpc, docksContext)
556623
return docksContext
557624
}

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DevframeDockEntry } from '@devframes/hub'
2-
import type { DevframeRpcClient, DockEntryState, DockEntryStateEvents, DockPanelStorage } from '@devframes/hub/client'
2+
import type { DevframeRpcClient, DockEntryState, DockEntryStateEvents, DockPanelStorage, DockSessionStorage } from '@devframes/hub/client'
33
import type { SharedState } from 'devframe/utils/shared-state'
44
import type { Ref, ShallowRef } from 'vue'
55
import { createEventEmitter } from 'devframe/utils/events'
@@ -13,11 +13,25 @@ export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage {
1313
top: 0,
1414
left: 10,
1515
position: 'bottom',
16-
open: false,
1716
inactiveTimeout: 3_000,
1817
}
1918
}
2019

20+
/**
21+
* The per-tab session UI state seed — `open`/`selectedId`/`route`. Persisted to
22+
* `sessionStorage` by the embedded and standalone bootstraps so a reload (and
23+
* the RPC auth handshake that follows one) restores the panel to the dock and
24+
* route the developer left open. Distinct from {@link DEFAULT_DOCK_PANEL_STORE}
25+
* (browser-shared `localStorage` geometry), because selection is per-tab.
26+
*/
27+
export function DEFAULT_DOCK_SESSION_STORE(): DockSessionStorage {
28+
return {
29+
open: false,
30+
selectedId: null,
31+
route: null,
32+
}
33+
}
34+
2135
export function createDockEntryState(
2236
entry: DevframeDockEntry,
2337
selected: Ref<DevframeDockEntry | null>,

0 commit comments

Comments
 (0)