diff --git a/packages/hub-ui/src/client/components/dock/DockEmbedded.vue b/packages/hub-ui/src/client/components/dock/DockEmbedded.vue index b3d94b9e..730d49af 100644 --- a/packages/hub-ui/src/client/components/dock/DockEmbedded.vue +++ b/packages/hub-ui/src/client/components/dock/DockEmbedded.vue @@ -2,7 +2,7 @@ import type { DocksContext } from '@devframes/hub/client' import type { DockLayout } from './dock-layout' import { useEventListener } from '@vueuse/core' -import { onUnmounted } from 'vue' +import { onUnmounted, watch } from 'vue' import { sharedStateToRef } from '../../state/docks' import { closeDockPopup, useIsDockPopupOpen } from '../../state/popup' import { useIsRpcTrusted } from '../../utils/useIsRpcTrusted' @@ -21,12 +21,28 @@ const props = defineProps<{ layout?: Partial }>() +const context = props.context + const isDockPopupOpen = useIsDockPopupOpen() const settings = sharedStateToRef(props.context.docks.settings) // Force float mode when unauthorized, regardless of store setting const isRpcTrusted = useIsRpcTrusted(props.context) +/** + * If the panel is open but nothing valid is selected (e.g. a restored + * `selectedId` didn't resolve to a real entry), fall back to the first + * available one — mirrors `DockStandalone`'s own boot guard. + */ +watch( + () => context.docks.entries, + () => { + if (context.panel.store.open) + context.docks.selectedId ||= context.docks.entries[0]?.id ?? null + }, + { immediate: true }, +) + // Close the dock when clicking outside of it useEventListener(window, 'mousedown', (e: MouseEvent) => { if (!settings.value.closeOnOutsideClick) diff --git a/packages/hub-ui/src/client/embedded/index.ts b/packages/hub-ui/src/client/embedded/index.ts index b069a6af..ff53bb7c 100644 --- a/packages/hub-ui/src/client/embedded/index.ts +++ b/packages/hub-ui/src/client/embedded/index.ts @@ -1,4 +1,4 @@ -import type { DockPanelStorage } from '@devframes/hub/client' +import type { HubDockPanelStorage } from '../state/docks' import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client' import { useLocalStorage } from '@vueuse/core' import { HUB_UI_HIDE_EVENT } from '../constants' @@ -39,7 +39,7 @@ async function mountDock(): Promise { simpleAuth: false, }) - const state = useLocalStorage( + const state = useLocalStorage( 'devframes-dock-state', DEFAULT_DOCK_PANEL_STORE(), { mergeDefaults: true }, diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 1df7ed40..31052144 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -1,9 +1,10 @@ import type { DevframeClientCommand, DevframeDockEntry, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub' -import type { CommandsContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext } from '@devframes/hub/client' +import type { CommandsContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockRegistration, DockRendererManifest, DocksContext } 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 { HubDocksUserSettings } from './dock-settings' +import type { HubDockPanelStorage } from './docks' import { attachFrameNavClient } from '@devframes/hub/client' import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY } from '@devframes/hub/constants' import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue' @@ -21,7 +22,7 @@ const docksContextByRpc = new WeakMap() export async function createDocksContext( clientType: 'embedded' | 'standalone', rpc: DevframeRpcClient, - panelStore?: Ref, + panelStore?: Ref, ): Promise { if (docksContextByRpc.has(rpc)) { return docksContextByRpc.get(rpc)! @@ -73,13 +74,58 @@ export async function createDocksContext( return [...base, BUILTIN_ENTRY_SETTINGS] }) - const selectedId = ref(null) + panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE()) + + /** + * `selectedId` lives in `panelStore` (localStorage in the embedded client), + * alongside `open`/mode/geometry — so it's restored across a reload and + * shared cross-tab like the rest of that value, instead of resetting to + * nothing every time the dock mounts. + */ + const selectedId = computed({ + get: () => panelStore.value.selectedId, + set: (value) => { panelStore.value.selectedId = value }, + }) const selected = computed( () => entries.value.find(entry => entry.id === selectedId.value) ?? BUILTIN_ENTRIES.find(entry => entry.id === selectedId.value) ?? null, ) + /** + * A restored `selectedId` may point at a non-selectable entry (a group, or + * a `subTabs` anchor) — `switchEntry` would fix that on click, but routing + * through it here would force `panelStore.value.open = true`, reopening a + * closed panel. So validate once, on boot, directly instead. Past boot, + * `switchEntry` may itself land `selectedId` on a group/anchor (e.g. + * mid-redirect, or a `subTabs` anchor with no live member yet) — that's not + * something to keep correcting. + */ + const isSelectableEntry = (id: string): boolean => { + if (BUILTIN_ENTRIES.some(entry => entry.id === id)) + return true + const entry = entries.value.find(e => e.id === id) + if (!entry) + return false + if (entry.type === 'group') + return false + if (entry.type === 'iframe' && entry.subTabs) + return false + return true + } + let bootRestoreChecked = false + watch( + entries, + () => { + if (bootRestoreChecked) + return + bootRestoreChecked = true + if (selectedId.value != null && !isSelectableEntry(selectedId.value)) + selectedId.value = null + }, + { immediate: true }, + ) + const dockEntryStateMap: Map = reactive(new Map()) watchEffect(() => { for (const entry of entries.value) { @@ -131,7 +177,6 @@ export async function createDocksContext( clientDocks.set(entry.id, entry as DevframeDockEntry) } - panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE()) let docksContext: DocksContext let _settingsStorePromise: Promise> | undefined diff --git a/packages/hub-ui/src/client/state/docks.ts b/packages/hub-ui/src/client/state/docks.ts index 88f70b6a..709fd6da 100644 --- a/packages/hub-ui/src/client/state/docks.ts +++ b/packages/hub-ui/src/client/state/docks.ts @@ -5,7 +5,18 @@ import type { Ref, ShallowRef } from 'vue' import { createEventEmitter } from 'devframe/utils/events' import { markRaw, reactive, shallowRef, watch } from 'vue' -export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage { +/** + * {@link DockPanelStorage} (hub's own type — geometry/mode/`open`) plus + * `selectedId`, which the hub has no concept of. Both persist in the same + * `devframes-dock-state` localStorage value (the embedded dock's own store), + * so both survive a reload and are shared cross-tab like the rest of that + * value — a dock left open/selected in one tab shows the same way in the next. + */ +export interface HubDockPanelStorage extends DockPanelStorage { + selectedId: string | null +} + +export function DEFAULT_DOCK_PANEL_STORE(): HubDockPanelStorage { return { mode: 'float', width: 80, @@ -15,6 +26,7 @@ export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage { position: 'bottom', open: false, inactiveTimeout: 3_000, + selectedId: null, } } diff --git a/packages/hub-ui/src/client/stories/mock-context.ts b/packages/hub-ui/src/client/stories/mock-context.ts index e4bc87d3..9e2f683c 100644 --- a/packages/hub-ui/src/client/stories/mock-context.ts +++ b/packages/hub-ui/src/client/stories/mock-context.ts @@ -1,5 +1,6 @@ import type { DevframeDockEntry } from '@devframes/hub' -import type { DevframeRpcClient, DockPanelStorage, DocksContext, RpcClientEvents } from '@devframes/hub/client' +import type { DevframeRpcClient, DocksContext, RpcClientEvents } from '@devframes/hub/client' +import type { HubDockPanelStorage } from '../state/docks' import type { HubDocksUserSettings } from '../types' import { DEFAULT_STATE_USER_SETTINGS } from '@devframes/hub/constants' import { createEventEmitter } from 'devframe/utils/events' @@ -25,7 +26,7 @@ export interface CreateMockContextOptions { /** Which client shell the context represents. */ clientType?: 'embedded' | 'standalone' /** Overrides merged over the default panel store (mode, position, open, ...). */ - panel?: Partial + panel?: Partial /** Overrides merged over the default user settings (hidden, pinned, order, ...). */ settings?: Partial /** Entry id to pre-select (also opens the panel). */ @@ -117,7 +118,7 @@ export async function createMockDocksContext( } = options const rpc = createMockRpc(entries, settings, isTrusted) - const panelStore = ref({ ...DEFAULT_DOCK_PANEL_STORE(), ...panel }) + const panelStore = ref({ ...DEFAULT_DOCK_PANEL_STORE(), ...panel }) const context = await createDocksContext(clientType, rpc, panelStore) diff --git a/packages/hub-ui/test/dock-panel-restore.test.ts b/packages/hub-ui/test/dock-panel-restore.test.ts new file mode 100644 index 00000000..e41620c4 --- /dev/null +++ b/packages/hub-ui/test/dock-panel-restore.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { iframe } from '../src/client/stories/fixtures' +import { createMockDocksContext } from '../src/client/stories/mock-context' + +/** + * `selectedId` lives on the same `panelStore` ref as `open`/mode/geometry + * (`state/docks.ts`'s `HubDockPanelStorage`) — restored from localStorage in + * the real embedded client, seeded here via `createMockDocksContext`'s + * `panel`/`selectedId` options instead of a separate session store. + */ +describe('restored dock panel state (selectedId on the shared panelStore)', () => { + it('keeps a restored selectedId that resolves to a real leaf entry', async () => { + const context = await createMockDocksContext({ + entries: [iframe('a', 'A', 'ph:cube-duotone')], + panel: { selectedId: 'a', open: true }, + }) + + expect(context.docks.selectedId).toBe('a') + expect(context.panel.store.open).toBe(true) + }) + + it('keeps a restored selectedId of a `~builtin` pseudo-entry (e.g. Settings)', async () => { + const context = await createMockDocksContext({ + entries: [], + panel: { selectedId: '~settings', open: true }, + }) + + expect(context.docks.selectedId).toBe('~settings') + }) + + it('clears a restored selectedId pointing at a group (not a selectable leaf)', async () => { + const context = await createMockDocksContext({ + entries: [{ id: 'nuxt', type: 'group', title: 'Nuxt', icon: 'ph:cube-duotone' } as any], + panel: { selectedId: 'nuxt' }, + }) + + expect(context.docks.selectedId).toBeNull() + }) + + it('clears a restored selectedId pointing at a subTabs anchor (not a selectable leaf)', async () => { + const context = await createMockDocksContext({ + entries: [iframe('nuxt', 'Nuxt', 'ph:cube-duotone', { subTabs: { protocol: 'postmessage' } } as any)], + panel: { selectedId: 'nuxt' }, + }) + + expect(context.docks.selectedId).toBeNull() + }) + + it('clears a restored selectedId that no longer resolves to any entry, without forcing the panel open', async () => { + const context = await createMockDocksContext({ + entries: [iframe('a', 'A', 'ph:cube-duotone')], + panel: { selectedId: 'gone', open: false }, + }) + + expect(context.docks.selectedId).toBeNull() + // Clearing an invalid restored id must not route through `switchEntry` + // (which would force `open = true`) — the panel stays exactly as restored. + expect(context.panel.store.open).toBe(false) + }) + + it('does not clear an id `switchEntry` itself legitimately selects later (a subTabs anchor with no live member yet)', async () => { + const context = await createMockDocksContext({ + entries: [iframe('nuxt', 'Nuxt', 'ph:cube-duotone', { subTabs: { protocol: 'postmessage' } } as any)], + }) + + await context.docks.switchEntry('nuxt') + + expect(context.docks.selectedId).toBe('nuxt') + }) + + it('sets selectedId and open on the same panel store that carries geometry (mode)', async () => { + const context = await createMockDocksContext({ + entries: [], + panel: { mode: 'float' }, + }) + + context.panel.store.open = true + context.docks.selectedId = null + + expect(context.panel.store.open).toBe(true) + expect(context.panel.store.mode).toBe('float') + }) +}) diff --git a/packages/hub-ui/vitest.config.ts b/packages/hub-ui/vitest.config.ts new file mode 100644 index 00000000..7cb85851 --- /dev/null +++ b/packages/hub-ui/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import { alias } from '../../alias' + +// The dock-context tests cross-import `@devframes/hub`'s types/constants — +// resolve them to source rather than the (possibly stale/unbuilt) `dist`. +export default defineConfig({ + resolve: { alias }, + test: { + name: '@devframes/hub-ui', + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts index fe703b86..1e58dff0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ projects: [ 'packages/devframe', 'packages/hub', + 'packages/hub-ui', 'packages/json-render', 'packages/json-render-ui', 'plugins/code-server',