From 93473aae3e49cee052c9cd2daf9366435c4ebb90 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Tue, 4 Aug 2026 22:38:54 +0200 Subject: [PATCH 1/3] feat(kit,core): plugin-declared dock ordering, layout, and window defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unblocks a downstream Vite plugin that could not control dock-bar category order, float-mode capacity, or first-run window placement. - `DevToolsPluginOptions.dock` (`DevToolsDockConfig`): a plugin declares `categoryOrder`, `maxVisibleItems`, `defaultMode`, `defaultPosition` alongside its `setup()`. Collected across every plugin during the Vite plugin scan (last plugin wins per scalar key, `categoryOrder` shallow-merges), stashed on `ViteDevToolsNodeContext.dockConfig`, and handed to every client once via `ConnectionMeta.dockConfig` β€” riding the connection handshake every client already does, since the value is fixed for the life of the dev server. - New `docksCategoriesOrder` user setting: category headers in the Settings dock panel are now drag-and-droppable, reusing the same machinery entry rows already use. The outer bar's sort pre-merges the plugin's `categoryOrder` beneath the user's own drag order into the `categoryOrderOverride` slot `docksGroupByCategories` already had for a group's own ordering β€” no new parameters needed there. - `Dock.vue` folds a declared `maxVisibleItems` into the resolved float layout (an explicit `layout` prop still wins); `resolveDockLayout` clamps it to `>= 1`. - The injected overlay seeds its `vite-devtools-dock-state` localStorage defaults (`mode`/`position`) from a declared `defaultMode`/ `defaultPosition` β€” only for a developer with no stored preference yet. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/core/src/client/inject/runtime.ts | 9 +- .../webcomponents/components/dock/Dock.vue | 12 ++- .../components/dock/dock-layout.ts | 6 +- .../views-builtin/SettingsAdvanced.vue | 1 + .../views-builtin/SettingsDocks.vue | 88 ++++++++++++++++++- .../state/__tests__/context-cache.test.ts | 1 + .../state/__tests__/frame-nav.test.ts | 1 + .../src/client/webcomponents/state/context.ts | 14 ++- .../webcomponents/state/dock-settings.ts | 23 +++-- .../webcomponents/stories/mock-context.ts | 1 + packages/core/src/node/context.ts | 18 +++- packages/core/src/node/rpc/index.ts | 12 ++- packages/core/src/node/ws.ts | 1 + packages/kit/src/types/settings.ts | 8 ++ packages/kit/src/types/vite-plugin.ts | 33 +++++++ .../@vitejs/devtools-kit/index.snapshot.d.ts | 8 ++ 16 files changed, 215 insertions(+), 21 deletions(-) diff --git a/packages/core/src/client/inject/runtime.ts b/packages/core/src/client/inject/runtime.ts index 0a5d2c520..14f38d4f8 100644 --- a/packages/core/src/client/inject/runtime.ts +++ b/packages/core/src/client/inject/runtime.ts @@ -60,15 +60,20 @@ async function mountDock(): Promise { ], }) + // A plugin-declared `defaultMode`/`defaultPosition` (`ctx.docks` config, + // handed to us once via `rpc.connectionMeta.dockConfig`) only seeds these + // two defaults β€” `mergeDefaults` below fills them in solely for a developer + // with no stored preference yet, and never overwrites one who already has. + const dockConfig = rpc.connectionMeta.dockConfig const state = useLocalStorage( 'vite-devtools-dock-state', { - mode: 'float', + mode: dockConfig?.defaultMode ?? 'float', width: 80, height: 80, top: 0, left: 0, - position: 'left', + position: dockConfig?.defaultPosition ?? 'left', open: false, inactiveTimeout: 3_000, }, diff --git a/packages/core/src/client/webcomponents/components/dock/Dock.vue b/packages/core/src/client/webcomponents/components/dock/Dock.vue index 5935b1747..d90deb1be 100644 --- a/packages/core/src/client/webcomponents/components/dock/Dock.vue +++ b/packages/core/src/client/webcomponents/components/dock/Dock.vue @@ -34,7 +34,17 @@ const props = defineProps<{ // Here we directly destructure is as we don't expect context to be changed const context = props.context -const layout = computed(() => resolveDockLayout(props.layout)) +// A plugin-declared `maxVisibleItems` (`rpc.connectionMeta.dockConfig`) sets +// the default; an explicit `layout` prop (Storybook, embedders) still wins. +// Only spread the config override when it's actually set β€” an own `undefined` +// key would otherwise beat `DEFAULT_DOCK_LAYOUT`'s value in the merge below. +const layout = computed(() => { + const configuredMaxVisibleItems = context.rpc.connectionMeta.dockConfig?.maxVisibleItems + return resolveDockLayout({ + ...(configuredMaxVisibleItems !== undefined && { maxVisibleItems: configuredMaxVisibleItems }), + ...props.layout, + }) +}) const isSafari = navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome') diff --git a/packages/core/src/client/webcomponents/components/dock/dock-layout.ts b/packages/core/src/client/webcomponents/components/dock/dock-layout.ts index 3f32bb93e..74ca28602 100644 --- a/packages/core/src/client/webcomponents/components/dock/dock-layout.ts +++ b/packages/core/src/client/webcomponents/components/dock/dock-layout.ts @@ -90,11 +90,13 @@ export const DEFAULT_DOCK_LAYOUT: DockLayout = Object.freeze({ edgeZoneHeight: 70, }) -/** Merge partial overrides over {@link DEFAULT_DOCK_LAYOUT}. */ +/** Merge partial overrides over {@link DEFAULT_DOCK_LAYOUT}. `maxVisibleItems` is clamped to `>= 1` β€” a whole overflow button for zero inline items has nothing to anchor to. */ export function resolveDockLayout(overrides?: Partial): DockLayout { if (!overrides) return DEFAULT_DOCK_LAYOUT - return { ...DEFAULT_DOCK_LAYOUT, ...overrides } + const merged = { ...DEFAULT_DOCK_LAYOUT, ...overrides } + merged.maxVisibleItems = Math.max(1, merged.maxVisibleItems) + return merged } /** diff --git a/packages/core/src/client/webcomponents/components/views-builtin/SettingsAdvanced.vue b/packages/core/src/client/webcomponents/components/views-builtin/SettingsAdvanced.vue index 0ab084af2..ce79bdfee 100644 --- a/packages/core/src/client/webcomponents/components/views-builtin/SettingsAdvanced.vue +++ b/packages/core/src/client/webcomponents/components/views-builtin/SettingsAdvanced.vue @@ -46,6 +46,7 @@ async function resetDocks() { state.docksHidden = defaults.docksHidden state.docksCategoriesHidden = defaults.docksCategoriesHidden state.docksCustomOrder = defaults.docksCustomOrder + state.docksCategoriesOrder = {} state.docksPinned = defaults.docksPinned }) } diff --git a/packages/core/src/client/webcomponents/components/views-builtin/SettingsDocks.vue b/packages/core/src/client/webcomponents/components/views-builtin/SettingsDocks.vue index b83cf65fd..a8f14ca01 100644 --- a/packages/core/src/client/webcomponents/components/views-builtin/SettingsDocks.vue +++ b/packages/core/src/client/webcomponents/components/views-builtin/SettingsDocks.vue @@ -21,10 +21,18 @@ const settings = sharedStateToRef(props.settingsStore) // group button. A group's members are split by their in-group sub-category, and // each sub-category is its own reorderable container (`grp::::`); // category containers are keyed `cat:`. +const configCategoryOrder = computed(() => props.context.rpc.connectionMeta.dockConfig?.categoryOrder) + +// The outer bar's `categoryOrderOverride` pre-merges the plugin-declared +// config beneath the user's own drag order (user wins on a shared key) β€” the +// same one-slot convention `dock-settings.ts` documents for `docksGroupByCategories`. +const categoryOrderOverride = computed(() => ({ ...configCategoryOrder.value, ...settings.value.docksCategoriesOrder })) + const categories = computed(() => { return docksGroupByCategories(props.context.docks.entries, settings.value, { includeHidden: true, collapseGroups: true, + categoryOrderOverride: categoryOrderOverride.value, }) }) @@ -44,6 +52,23 @@ const GROUP_SUBCAT_SEPARATOR = ':::' const CATEGORY_CONTAINER = (category: string) => `cat:${category}` const GROUP_SUBCAT_CONTAINER = (groupId: string, subcategory: string) => `grp:${groupId}${GROUP_SUBCAT_SEPARATOR}${subcategory}` +// The category *headers* are themselves a reorderable container, keyed by +// this fixed id (distinct from a `cat:` per-category entry container). +// Its "items" are pseudo dock-entries β€” one per hideable category β€” whose id +// is prefixed to keep it out of the real dock-id namespace `entryEls`/drag +// state share with every other container (a category name could otherwise +// collide with an actual dock id). +const CATEGORY_ORDER_CONTAINER = 'cat-order' +const CATEGORY_ORDER_ID_PREFIX = 'cat-order:' +const categoryOrderId = (category: string) => `${CATEGORY_ORDER_ID_PREFIX}${category}` +const categoryFromOrderId = (id: string) => id.slice(CATEGORY_ORDER_ID_PREFIX.length) + +function categoryPseudoEntries(cats: DevToolsDockEntriesGrouped): DevToolsDockEntry[] { + return cats + .filter(([category]) => isCategoryHideable(category)) + .map(([category]) => ({ id: categoryOrderId(category), title: getCategoryLabel(category) }) as DevToolsDockEntry) +} + /** Split a `grp::::` container back into its group id + sub-category. */ function parseGroupContainer(container: string): { groupId: string, subcategory: string } { const rest = container.slice(4) @@ -55,6 +80,8 @@ function parseGroupContainer(container: string): { groupId: string, subcategory: } function itemsOfContainer(container: string): DevToolsDockEntry[] { + if (container === CATEGORY_ORDER_CONTAINER) + return categoryPseudoEntries(categories.value) if (container.startsWith('grp:')) { const { groupId, subcategory } = parseGroupContainer(container) return subcategoriesOf(groupId).find(([cat]) => cat === subcategory)?.[1] ?? [] @@ -64,6 +91,15 @@ function itemsOfContainer(container: string): DevToolsDockEntry[] { } function defaultItemsOfContainer(container: string): DevToolsDockEntry[] { + if (container === CATEGORY_ORDER_CONTAINER) { + // "Default" for the category-order container omits the user's own drag + // order (unlike `categories` above) β€” it's what dragging back to would reset to. + return categoryPseudoEntries(docksGroupByCategories(props.context.docks.entries, settings.value, { + includeHidden: true, + collapseGroups: true, + categoryOrderOverride: configCategoryOrder.value, + })) + } const noCustomOrder = { ...settings.value, docksCustomOrder: {} } if (container.startsWith('grp:')) { const { groupId, subcategory } = parseGroupContainer(container) @@ -106,6 +142,19 @@ function isInteractiveElement(el: HTMLElement | null): boolean { function applyOrder(container: string, items: DevToolsDockEntry[]) { const def = defaultItemsOfContainer(container).map(i => i.id) const isDefault = items.length === def.length && items.every((item, i) => item.id === def[i]) + if (container === CATEGORY_ORDER_CONTAINER) { + props.settingsStore.mutate((state) => { + state.docksCategoriesOrder ??= {} + items.forEach((item, index) => { + const category = categoryFromOrderId(item.id) + if (isDefault) + delete state.docksCategoriesOrder![category] + else + state.docksCategoriesOrder![category] = index + }) + }) + return + } props.settingsStore.mutate((state) => { items.forEach((item, index) => { if (isDefault) @@ -273,6 +322,14 @@ function doesContainerHaveCustomOrder(container: string): boolean { function resetCustomOrderForContainer(container: string) { const ids = customOrderIdsForContainer(container) + if (container === CATEGORY_ORDER_CONTAINER) { + props.settingsStore.mutate((state) => { + ids.forEach((id) => { + delete state.docksCategoriesOrder?.[categoryFromOrderId(id)] + }) + }) + return + } props.settingsStore.mutate((state) => { ids.forEach((id) => { delete state.docksCustomOrder[id] @@ -282,9 +339,21 @@ function resetCustomOrderForContainer(container: string) {