diff --git a/packages/core/src/client/inject/runtime.ts b/packages/core/src/client/inject/runtime.ts index 0a5d2c52..04ee98ad 100644 --- a/packages/core/src/client/inject/runtime.ts +++ b/packages/core/src/client/inject/runtime.ts @@ -1,7 +1,7 @@ /// /// -import type { DockPanelStorage } from '@vitejs/devtools-kit/client' +import type { DevToolsDockPanelStorage } from '../webcomponents/state/docks' import { CLIENT_CONTEXT_KEY, getDevToolsRpcClient } from '@vitejs/devtools-kit/client' import { DEVTOOLS_MOUNT_PATH } from '@vitejs/devtools-kit/constants' import { useLocalStorage } from '@vueuse/core' @@ -60,7 +60,12 @@ async function mountDock(): Promise { ], }) - const state = useLocalStorage( + /** + * Defaults here are this injected client's own — deliberately different + * from `DEFAULT_DOCK_PANEL_STORE()`'s fallback used elsewhere, not a shared + * literal. See `DevToolsDockPanelStorage` for what's persisted and why. + */ + const state = useLocalStorage( 'vite-devtools-dock-state', { mode: 'float', @@ -71,6 +76,7 @@ async function mountDock(): Promise { position: 'left', open: false, inactiveTimeout: 3_000, + selectedId: null, }, { mergeDefaults: true }, ) diff --git a/packages/core/src/client/webcomponents/components/dock/DockEmbedded.vue b/packages/core/src/client/webcomponents/components/dock/DockEmbedded.vue index 14277351..14605107 100644 --- a/packages/core/src/client/webcomponents/components/dock/DockEmbedded.vue +++ b/packages/core/src/client/webcomponents/components/dock/DockEmbedded.vue @@ -2,7 +2,7 @@ import type { DocksContext } from '@vitejs/devtools-kit/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/core/src/client/webcomponents/components/views/ViewJsonRender.vue b/packages/core/src/client/webcomponents/components/views/ViewJsonRender.vue index 723e93d5..f9350dcf 100644 --- a/packages/core/src/client/webcomponents/components/views/ViewJsonRender.vue +++ b/packages/core/src/client/webcomponents/components/views/ViewJsonRender.vue @@ -3,7 +3,9 @@ import type { Spec } from '@json-render/core' import type { DevToolsViewJsonRender } from '@vitejs/devtools-kit' import type { DocksContext } from '@vitejs/devtools-kit/client' import { JSONUIProvider, Renderer } from '@json-render/vue' -import { computed, markRaw, onMounted, ref, shallowRef, watch } from 'vue' +import { useDebounceFn, useSessionStorage } from '@vueuse/core' +import { computed, markRaw, onMounted, provide, ref, shallowRef, useTemplateRef, watch } from 'vue' +import { DOCK_ENTRY_ID_KEY } from '../../json-render/composables/dock-entry-id' import { devtoolsRegistry, UnsupportedComponent } from '../../json-render/registry' const props = defineProps<{ @@ -11,10 +13,32 @@ const props = defineProps<{ entry: DevToolsViewJsonRender }>() +/** + * Descendants (e.g. `useUncontrolledValue`) `inject()` this to scope + * session-persisted state to "this dock" — the entry's own id, stable for + * this component instance's lifetime (`ViewEntry` re-keys on entry change). + */ +provide(DOCK_ENTRY_ID_KEY, props.entry.id) + const spec = shallowRef(null) const isLoading = ref(true) const error = ref(null) +/** + * Restores/persists the scroll position of this dock's own view, per tab, + * across a reload — keyed by the dock entry id so switching docks doesn't + * bleed one dock's scroll into another's. + */ +const scrollContainer = useTemplateRef('scrollContainer') +const scrollTop = useSessionStorage(`vite-devtools-scroll:${props.entry.id}`, 0) +onMounted(() => { + if (scrollContainer.value) + scrollContainer.value.scrollTop = scrollTop.value +}) +const persistScrollTop = useDebounceFn(() => { + scrollTop.value = scrollContainer.value?.scrollTop ?? 0 +}, 200) + // Resolve spec from entry.ui._stateKey async function loadSpec() { try { @@ -87,7 +111,7 @@ watch(() => props.entry.ui?._stateKey, loadSpec) - + Loading... diff --git a/packages/core/src/client/webcomponents/json-render/components/Select.ts b/packages/core/src/client/webcomponents/json-render/components/Select.ts index ea74d1e8..f6abc822 100644 --- a/packages/core/src/client/webcomponents/json-render/components/Select.ts +++ b/packages/core/src/client/webcomponents/json-render/components/Select.ts @@ -2,6 +2,7 @@ import { useBoundProp } from '@json-render/vue' import { defineComponent, h, ref, useId, useTemplateRef, watch } from 'vue' import DockIcon from '../../components/dock/DockIcon.vue' import FloatingPopover from '../../components/floating/FloatingPopover' +import { useUncontrolledValue } from '../composables/useUncontrolledValue' import { bg, borderInput, borderSolid, surfaceSubtle } from './tokens' import { registryProps } from './types' @@ -42,6 +43,10 @@ export const Select = defineComponent({ const activeIndex = ref(0) const listboxId = useId() + /** Local, session-persisted fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */ + const uncontrolledValue = useUncontrolledValue(ctx, 'value', ctx.element.props.value) + const controlled = ctx.bindings?.value != null + const close = (options: { refocus?: boolean } = {}) => { open.value = false if (options.refocus) @@ -57,14 +62,21 @@ export const Select = defineComponent({ return query.value = '' const options = (ctx.element.props.options ?? []).map(normalizeOption) - const index = options.findIndex(option => option.value === ctx.element.props.value) + const currentValue = controlled ? ctx.element.props.value : uncontrolledValue.value + const index = options.findIndex(option => option.value === currentValue) activeIndex.value = index >= 0 ? index : 0 }) return () => { const { placeholder, label, disabled, searchable } = ctx.element.props const options = (ctx.element.props.options ?? []).map(normalizeOption) - const [value, setValue] = useBoundProp(ctx.element.props.value, ctx.bindings?.value) + const [boundValue, setBoundValue] = useBoundProp(ctx.element.props.value, ctx.bindings?.value) + const value = controlled ? boundValue : uncontrolledValue.value + const setValue = (next: string) => { + if (controlled) + setBoundValue(next) + else uncontrolledValue.value = next + } const change = ctx.on('change') const filtered = searchable && query.value diff --git a/packages/core/src/client/webcomponents/json-render/components/Switch.ts b/packages/core/src/client/webcomponents/json-render/components/Switch.ts index 177e1ecf..d733b5ff 100644 --- a/packages/core/src/client/webcomponents/json-render/components/Switch.ts +++ b/packages/core/src/client/webcomponents/json-render/components/Switch.ts @@ -1,5 +1,6 @@ import { useBoundProp } from '@json-render/vue' import { defineComponent, h } from 'vue' +import { useUncontrolledValue } from '../composables/useUncontrolledValue' import { primary, surfaceSubtle } from './tokens' import { registryProps } from './types' @@ -14,9 +15,19 @@ export const Switch = defineComponent({ name: 'JrSwitch', props: registryProps<'Switch', SwitchProps>(), setup(ctx) { + /** Local, session-persisted fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */ + const uncontrolledValue = useUncontrolledValue(ctx, 'value', ctx.element.props.value) + const controlled = ctx.bindings?.value != null + return () => { const { label, disabled } = ctx.element.props - const [value, setValue] = useBoundProp(ctx.element.props.value, ctx.bindings?.value) + const [boundValue, setBoundValue] = useBoundProp(ctx.element.props.value, ctx.bindings?.value) + const value = controlled ? boundValue : uncontrolledValue.value + const setValue = (next: boolean) => { + if (controlled) + setBoundValue(next) + else uncontrolledValue.value = next + } const change = ctx.on('change') const checked = !!value diff --git a/packages/core/src/client/webcomponents/json-render/components/Tabs.ts b/packages/core/src/client/webcomponents/json-render/components/Tabs.ts index f3145325..10dbecc8 100644 --- a/packages/core/src/client/webcomponents/json-render/components/Tabs.ts +++ b/packages/core/src/client/webcomponents/json-render/components/Tabs.ts @@ -1,6 +1,7 @@ import { useBoundProp } from '@json-render/vue' import { defineComponent, h, ref, watchEffect } from 'vue' import { getIconifySvg } from '../../utils/iconify' +import { useUncontrolledValue } from '../composables/useUncontrolledValue' import { colors, primary, surfaceSubtle } from './tokens' import { registryProps } from './types' @@ -26,8 +27,8 @@ export const Tabs = defineComponent({ name: 'JrTabs', props: registryProps<'Tabs', TabsProps>(), setup(ctx, { slots }) { - /** Local fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */ - const uncontrolledValue = ref(ctx.element.props.defaultValue ?? ctx.element.props.tabs?.[0]?.value) + /** Local, session-persisted fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */ + const uncontrolledValue = useUncontrolledValue(ctx, 'value', ctx.element.props.defaultValue ?? ctx.element.props.tabs?.[0]?.value) /** Icon SVGs keyed by name, resolved like `Icon.ts` — one tab's icon changing shouldn't refetch the others. */ const iconSvgs = ref>({}) diff --git a/packages/core/src/client/webcomponents/json-render/components/TextInput.ts b/packages/core/src/client/webcomponents/json-render/components/TextInput.ts index a56dfcd2..f2d1b0ee 100644 --- a/packages/core/src/client/webcomponents/json-render/components/TextInput.ts +++ b/packages/core/src/client/webcomponents/json-render/components/TextInput.ts @@ -1,6 +1,7 @@ import { useBoundProp } from '@json-render/vue' import { defineComponent, h } from 'vue' import DockIcon from '../../components/dock/DockIcon.vue' +import { useUncontrolledValue } from '../composables/useUncontrolledValue' import { borderInput, borderSolid } from './tokens' import { registryProps } from './types' @@ -19,9 +20,19 @@ export const TextInput = defineComponent({ name: 'JrTextInput', props: registryProps<'TextInput', TextInputProps>(), setup(ctx) { + /** Local, session-persisted fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */ + const uncontrolledValue = useUncontrolledValue(ctx, 'value', ctx.element.props.value) + const controlled = ctx.bindings?.value != null + return () => { const { placeholder, label, type = 'text', disabled, loading } = ctx.element.props - const [value, setValue] = useBoundProp(ctx.element.props.value, ctx.bindings?.value) + const [boundValue, setBoundValue] = useBoundProp(ctx.element.props.value, ctx.bindings?.value) + const value = controlled ? boundValue : uncontrolledValue.value + const setValue = (next: string) => { + if (controlled) + setBoundValue(next) + else uncontrolledValue.value = next + } const change = ctx.on('change') const input = h('input', { diff --git a/packages/core/src/client/webcomponents/json-render/composables/dock-entry-id.ts b/packages/core/src/client/webcomponents/json-render/composables/dock-entry-id.ts new file mode 100644 index 00000000..03d3f002 --- /dev/null +++ b/packages/core/src/client/webcomponents/json-render/composables/dock-entry-id.ts @@ -0,0 +1,10 @@ +import type { InjectionKey } from 'vue' + +/** + * Injection key for the current dock entry's id. `ViewJsonRender.vue` + * `provide()`s it once per dock; any json-render component or composable that + * needs an identity scoped to "this dock" — e.g. {@link useUncontrolledValue}'s + * session-persistence key, or a per-dock scroll position — `inject()`s it + * instead of threading the id through every registry component's props. + */ +export const DOCK_ENTRY_ID_KEY: InjectionKey = Symbol('vite-devtools:dock-entry-id') diff --git a/packages/core/src/client/webcomponents/json-render/composables/useUncontrolledValue.ts b/packages/core/src/client/webcomponents/json-render/composables/useUncontrolledValue.ts new file mode 100644 index 00000000..11670685 --- /dev/null +++ b/packages/core/src/client/webcomponents/json-render/composables/useUncontrolledValue.ts @@ -0,0 +1,32 @@ +import type { Ref } from 'vue' +import type { RegistryComponentProps } from '../components/types' +import { useSessionStorage } from '@vueuse/core' +import { inject } from 'vue' +import { DOCK_ENTRY_ID_KEY } from './dock-entry-id' + +/** + * Session-persisted fallback for a json-render element's own *uncontrolled* + * value — the local state `Tabs`/`Select`/`TextInput`/`Switch` fall back to + * when `value` has no `$bindState` binding. On by default: calling this + * instead of a plain `ref(default)` survives a reload within the same tab. + * + * The key is derived rather than passed in, since `UIElement` carries no id + * of its own: it combines the current dock's id ({@link DOCK_ENTRY_ID_KEY}) + * with a signature of the element's own static props (minus the bound prop). + * A shape change yields a different key, so persistence falls back to + * `defaultValue` instead of restoring a stale value for a different element — + * intended, not a bug. + */ +export function useUncontrolledValue, Prop extends keyof Props>( + ctx: RegistryComponentProps, + prop: Prop, + defaultValue: Props[Prop], +): Ref { + const dockEntryId = inject(DOCK_ENTRY_ID_KEY, undefined) + + const staticShape: Record = { ...ctx.element.props } + delete staticShape[prop as string] + + const key = `vite-devtools-uncontrolled:${dockEntryId ?? '~'}:${ctx.element.type}:${JSON.stringify(staticShape)}` + return useSessionStorage(key, defaultValue) +} diff --git a/packages/core/src/client/webcomponents/state/__tests__/dock-session.test.ts b/packages/core/src/client/webcomponents/state/__tests__/dock-session.test.ts new file mode 100644 index 00000000..8a2e5942 --- /dev/null +++ b/packages/core/src/client/webcomponents/state/__tests__/dock-session.test.ts @@ -0,0 +1,135 @@ +import type { DevToolsDockEntry } from '@vitejs/devtools-kit' +import type { DevToolsRpcClient } from '@vitejs/devtools-kit/client' +import type { Ref } from 'vue' +import type { DevToolsDockPanelStorage } from '../docks' +import { DEFAULT_STATE_USER_SETTINGS } from '@vitejs/devtools-kit/constants' +import { createSharedState } from 'devframe/utils/shared-state' +import { describe, expect, it } from 'vitest' +import { ref } from 'vue' +import { createDocksContext } from '../context' +import { DEFAULT_DOCK_PANEL_STORE } from '../docks' + +function createMockRpc(entries: DevToolsDockEntry[] = []): DevToolsRpcClient { + const docksState = createSharedState({ initialValue: entries, enablePatches: false }) + const settingsState = createSharedState({ initialValue: DEFAULT_STATE_USER_SETTINGS(), enablePatches: false }) + const commandsState = createSharedState({ initialValue: [] as any[], enablePatches: false }) + + return { + client: { register: () => () => {} }, + sharedState: { + get: async (key: string) => { + if (key === 'devframe:docks') + return docksState as any + if (key === 'devframe:user-settings') + return settingsState as any + if (key === 'devframe:commands') + return commandsState as any + throw new Error(`Unexpected shared state key: ${key}`) + }, + }, + } as unknown as DevToolsRpcClient +} + +function iframe(id: string, extra: Partial = {}): DevToolsDockEntry { + return { id, type: 'iframe', url: '/', title: id, icon: 'i', ...extra } as DevToolsDockEntry +} + +function group(id: string): DevToolsDockEntry { + return { id, type: 'group', title: id, icon: 'i' } as DevToolsDockEntry +} + +/** + * `open`/`selectedId` are now plain fields on the same `panelStore` ref + * `createDocksContext` is handed (`vite-devtools-dock-state`, localStorage in + * the real client) — no separate session store to mock, seed the ref directly. + */ +function panelStore(selectedId: string | null, open = true): Ref { + return ref({ ...DEFAULT_DOCK_PANEL_STORE(), open, selectedId }) +} + +describe('restored dock panel state (selectedId/open on the same panelStore ref)', () => { + it('keeps a restored selectedId that resolves to a real leaf entry', async () => { + expect.assertions(2) + + const context = await createDocksContext('embedded', createMockRpc([iframe('a')]), panelStore('a')) + + expect(context.docks.selectedId).toBe('a') + expect(context.panel.store.open).toBe(true) + }) + + it('keeps a restored selectedId of the ~client-auth-notice pseudo-entry', async () => { + expect.assertions(1) + + const context = await createDocksContext('embedded', createMockRpc([]), panelStore('~client-auth-notice')) + + expect(context.docks.selectedId).toBe('~client-auth-notice') + }) + + it('clears a restored selectedId pointing at a group (not a selectable leaf)', async () => { + expect.assertions(1) + + const context = await createDocksContext('embedded', createMockRpc([group('nuxt-group')]), panelStore('nuxt-group')) + + expect(context.docks.selectedId).toBeNull() + }) + + it('clears a restored selectedId pointing at a subTabs anchor (not a selectable leaf)', async () => { + expect.assertions(1) + + const context = await createDocksContext( + 'embedded', + createMockRpc([iframe('nuxt', { subTabs: { protocol: 'postmessage' } })]), + panelStore('nuxt'), + ) + + expect(context.docks.selectedId).toBeNull() + }) + + it('clears a restored selectedId that no longer resolves to any entry, without forcing the panel open', async () => { + expect.assertions(2) + + const context = await createDocksContext('embedded', createMockRpc([iframe('a')]), panelStore('gone', 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 () => { + expect.assertions(1) + + const context = await createDocksContext( + 'embedded', + createMockRpc([iframe('nuxt', { subTabs: { protocol: 'postmessage' } })]), + ) + + await context.docks.switchEntry('nuxt') + + expect(context.docks.selectedId).toBe('nuxt') + }) + + it('routes selectedId/open through the same panelStore ref passed in, alongside geometry', async () => { + expect.assertions(3) + + const store = ref({ ...DEFAULT_DOCK_PANEL_STORE(), mode: 'float' as const }) + const context = await createDocksContext('embedded', createMockRpc([]), store) + + context.panel.store.open = true + context.docks.selectedId = null + + expect(context.panel.store.open).toBe(true) + expect(store.value.open).toBe(true) + expect(store.value.mode).toBe('float') + }) + + it('still routes panel.store geometry fields (e.g. mode) through the same ref', async () => { + expect.assertions(1) + + const store = ref(DEFAULT_DOCK_PANEL_STORE()) + const context = await createDocksContext('embedded', createMockRpc([]), store) + + context.panel.store.mode = 'edge' + + expect(store.value.mode).toBe('edge') + }) +}) diff --git a/packages/core/src/client/webcomponents/state/context.ts b/packages/core/src/client/webcomponents/state/context.ts index 52060b2c..ce6b715d 100644 --- a/packages/core/src/client/webcomponents/state/context.ts +++ b/packages/core/src/client/webcomponents/state/context.ts @@ -1,9 +1,10 @@ import type { DevToolsClientCommand, DevToolsDockEntry, DevToolsDockUserEntry, DevToolsRpcClientFunctions, DevToolsViewIframe } from '@vitejs/devtools-kit' -import type { CommandsContext, DevToolsRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DocksContext } from '@vitejs/devtools-kit/client' +import type { CommandsContext, DevToolsRpcClient, DockClientScriptContext, DockEntryState, DockRegistration, DocksContext } from '@vitejs/devtools-kit/client' import type { SharedState } from 'devframe/utils/shared-state' import type { WhenContext } from 'devframe/utils/when' import type { Ref } from 'vue' import type { DevToolsDocksUserSettings } from './dock-settings' +import type { DevToolsDockPanelStorage } from './docks' import { attachDevToolsFrameNav } from '@vitejs/devtools-kit/client' import { DEFAULT_STATE_USER_SETTINGS, DEVTOOLS_MOUNT_PATH } from '@vitejs/devtools-kit/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: DevToolsRpcClient, - panelStore?: Ref, + panelStore?: Ref, ): Promise { if (docksContextByRpc.has(rpc)) { return docksContextByRpc.get(rpc)! @@ -52,13 +53,56 @@ export async function createDocksContext( return merged }) - const selectedId = ref(null) + panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE()) + + /** + * `open`/`selectedId` both live in `panelStore` (localStorage), so both are + * restored across a reload and shared cross-tab, like the rest of that value. + */ + 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 — waiting for + * `entries` to load rather than clearing a still-legitimate selection. Past + * boot, `switchEntry` may itself land `selectedId` on a group/anchor + * (e.g. mid-redirect); that's not something to keep correcting. + */ + const isSelectableEntry = (id: string): boolean => { + if (id === '~client-auth-notice') + 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, + (list) => { + if (bootRestoreChecked || list.length === 0) + 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) { @@ -110,7 +154,6 @@ export async function createDocksContext( clientDocks.set(entry.id, entry as DevToolsDockEntry) } - panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE()) let docksContext: DocksContext let _settingsStorePromise: Promise> | undefined diff --git a/packages/core/src/client/webcomponents/state/docks.ts b/packages/core/src/client/webcomponents/state/docks.ts index b6dfa2f5..3dff1059 100644 --- a/packages/core/src/client/webcomponents/state/docks.ts +++ b/packages/core/src/client/webcomponents/state/docks.ts @@ -5,7 +5,17 @@ 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 + * `vite-devtools-dock-state` localStorage value, so both are cross-tab — + * a dock left open/selected in one tab shows the same way in the next. + */ +export interface DevToolsDockPanelStorage extends DockPanelStorage { + selectedId: string | null +} + +export function DEFAULT_DOCK_PANEL_STORE(): DevToolsDockPanelStorage { return { mode: 'float', width: 80, @@ -15,6 +25,7 @@ export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage { position: 'bottom', open: false, inactiveTimeout: 3_000, + selectedId: null, } } diff --git a/packages/core/src/client/webcomponents/stories/mock-context.ts b/packages/core/src/client/webcomponents/stories/mock-context.ts index ec5c664a..9947c6aa 100644 --- a/packages/core/src/client/webcomponents/stories/mock-context.ts +++ b/packages/core/src/client/webcomponents/stories/mock-context.ts @@ -1,5 +1,6 @@ import type { DevToolsDockEntry, DevToolsDocksUserSettings } from '@vitejs/devtools-kit' -import type { DevToolsRpcClient, DockPanelStorage, DocksContext, RpcClientEvents } from '@vitejs/devtools-kit/client' +import type { DevToolsRpcClient, DocksContext, RpcClientEvents } from '@vitejs/devtools-kit/client' +import type { DevToolsDockPanelStorage } from '../state/docks' import { DEFAULT_STATE_USER_SETTINGS } from '@vitejs/devtools-kit/constants' import { createEventEmitter } from 'devframe/utils/events' import { createSharedState } from 'devframe/utils/shared-state' @@ -24,7 +25,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). */ @@ -113,7 +114,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/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts b/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts index a26256ea..e67513ac 100644 --- a/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts @@ -38,6 +38,9 @@ export interface DataTableProps { rows?: Record[]; height?: number; } +export interface DevToolsDockPanelStorage extends DockPanelStorage { + selectedId: string | null; +} export interface DividerProps { label?: string; } @@ -135,7 +138,7 @@ export interface TreeProps { // #region Functions export declare function createDockEntryState(_: DevToolsDockEntry, _: Ref): DockEntryState; -export declare function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage; +export declare function DEFAULT_DOCK_PANEL_STORE(): DevToolsDockPanelStorage; export declare function sharedStateToRef(_: SharedState): ShallowRef; export declare function useDocksEntries(_: DevToolsRpcClient): Promise>; // #endregion