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
18 changes: 17 additions & 1 deletion packages/hub-ui/src/client/components/dock/DockEmbedded.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -21,12 +21,28 @@ const props = defineProps<{
layout?: Partial<DockLayout>
}>()

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)
Expand Down
4 changes: 2 additions & 2 deletions packages/hub-ui/src/client/embedded/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -39,7 +39,7 @@ async function mountDock(): Promise<void> {
simpleAuth: false,
})

const state = useLocalStorage<DockPanelStorage>(
const state = useLocalStorage<HubDockPanelStorage>(
'devframes-dock-state',
DEFAULT_DOCK_PANEL_STORE(),
{ mergeDefaults: true },
Expand Down
53 changes: 49 additions & 4 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -21,7 +22,7 @@ const docksContextByRpc = new WeakMap<DevframeRpcClient, DocksContext>()
export async function createDocksContext(
clientType: 'embedded' | 'standalone',
rpc: DevframeRpcClient,
panelStore?: Ref<DockPanelStorage>,
panelStore?: Ref<HubDockPanelStorage>,
): Promise<DocksContext> {
if (docksContextByRpc.has(rpc)) {
return docksContextByRpc.get(rpc)!
Expand Down Expand Up @@ -73,13 +74,58 @@ export async function createDocksContext(
return [...base, BUILTIN_ENTRY_SETTINGS]
})

const selectedId = ref<string | null>(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<string | null>({
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<string, DockEntryState> = reactive(new Map())
watchEffect(() => {
for (const entry of entries.value) {
Expand Down Expand Up @@ -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<SharedState<HubDocksUserSettings>> | undefined
Expand Down
14 changes: 13 additions & 1 deletion packages/hub-ui/src/client/state/docks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,6 +26,7 @@ export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage {
position: 'bottom',
open: false,
inactiveTimeout: 3_000,
selectedId: null,
}
}

Expand Down
7 changes: 4 additions & 3 deletions packages/hub-ui/src/client/stories/mock-context.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<DockPanelStorage>
panel?: Partial<HubDockPanelStorage>
/** Overrides merged over the default user settings (hidden, pinned, order, ...). */
settings?: Partial<HubDocksUserSettings>
/** Entry id to pre-select (also opens the panel). */
Expand Down Expand Up @@ -117,7 +118,7 @@ export async function createMockDocksContext(
} = options

const rpc = createMockRpc(entries, settings, isTrusted)
const panelStore = ref<DockPanelStorage>({ ...DEFAULT_DOCK_PANEL_STORE(), ...panel })
const panelStore = ref<HubDockPanelStorage>({ ...DEFAULT_DOCK_PANEL_STORE(), ...panel })

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

Expand Down
83 changes: 83 additions & 0 deletions packages/hub-ui/test/dock-panel-restore.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
11 changes: 11 additions & 0 deletions packages/hub-ui/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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',
},
})
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading