Skip to content

Commit 1b4b9df

Browse files
antfubotantfu
andauthored
feat(hub): restore dock open/selection/route across reloads via a session store (#246)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent f4339bc commit 1b4b9df

16 files changed

Lines changed: 254 additions & 50 deletions

File tree

‎packages/devframe/src/utils/remote-assets.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { AddressInfo } from 'node:net'
22
import type { MockInstance } from 'vitest'
33
import type { RemoteAssets, RemoteAssetsErrorMessage, RemoteAssetsStore } from '../types/remote-assets'
4-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
4+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
55
import { createServer } from 'node:http'
66
import { tmpdir } from 'node:os'
77
import { join } from 'node:path'
@@ -12,8 +12,11 @@ import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE } from '../constants'
1212
import { resolveStaticAssetsSource } from './remote-assets'
1313
import { serveStaticHandler } from './serve-static'
1414

15+
// `realpathSync` because module resolution reports realpaths, while the system
16+
// temp dir is a symlink on macOS (`/var` → `/private/var`) — a path built from
17+
// the raw `mkdtempSync` result would never match a resolved one.
1518
function makeTmp(): string {
16-
return mkdtempSync(join(tmpdir(), 'devframe-remote-assets-'))
19+
return realpathSync(mkdtempSync(join(tmpdir(), 'devframe-remote-assets-')))
1720
}
1821

1922
/** A fake CDN over a flat `filePath -> contents` map, answering the jsDelivr listing API + per-file URLs. */

‎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.selectedDockRoute = 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')

0 commit comments

Comments
 (0)