diff --git a/docs/guide/index.md b/docs/guide/index.md index a04cfcc58..ed4236095 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -114,6 +114,32 @@ export default defineConfig({ }) ``` +#### Dock defaults + +`dock` sets host-wide dock defaults — category order, the float bar's inline +capacity, and the initial window placement: + +```ts [vite.config.ts] twoslash +import { DevTools } from '@vitejs/devtools' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + DevTools({ + dock: { + categoryOrder: { web: -60, advanced: -50, app: -40 }, + maxVisibleItems: 8, + defaultMode: 'edge', + defaultPosition: 'left', + }, + }), + ], +}) +``` + +A plugin may additionally declare its own `devtools.dock.categoryOrder` to +weigh the categories it contributes — see [Dock config](/kit/devtools-plugin#dock-config). + #### Projects without an HTML entry For apps where Vite doesn't serve the HTML (JS-only entries, backend integration, middleware mode), import the client injector from a browser entry instead. One entry per visibility mode — import whichever one you want: diff --git a/docs/kit/devtools-plugin.md b/docs/kit/devtools-plugin.md index 9221f3ccd..de63ff9bd 100644 --- a/docs/kit/devtools-plugin.md +++ b/docs/kit/devtools-plugin.md @@ -85,6 +85,7 @@ const plugin: Plugin = { | Property | Type | Description | |----------|------|-------------| | `ctx.docks` | `DocksHost` | Register and manage [dock entries](./dock-system) | +| `ctx.dockConfig` | `SharedState` | Merged dock config — see [Dock config](#dock-config) below | | `ctx.views` | `ViewsHost` | Host static files for your DevTools UI | | `ctx.rpc` | `RpcHost` | Register [RPC functions](./rpc) and broadcast to clients | | `ctx.viteConfig` | `ResolvedConfig` | The resolved Vite configuration | @@ -93,6 +94,38 @@ const plugin: Plugin = { | `ctx.cwd` | `string` | Current working directory | | `ctx.workspaceRoot` | `string` | Workspace root directory | +### Dock config + +A plugin can declare `devtools.dock.categoryOrder` alongside `setup()` to weigh +the categories its own entries fall into — it shallow-merges with every other +plugin's declaration: + +```ts +const plugin: Plugin = { + devtools: { + dock: { + categoryOrder: { 'my-plugin': -40 }, + }, + setup(ctx) { + // ... + }, + }, +} +``` + +`maxVisibleItems`, `defaultMode`, and `defaultPosition` are properties of the +one shared dock bar, not of any single plugin, so they're set once by the host +via `DevTools({ dock })` instead (see the [Getting Started guide](/guide/#the-devtools-plugin)). + +The merged result is `ctx.dockConfig` — a `SharedState`, synced to every +connected client, that a plugin can `mutate()` to reconfigure them live: + +```ts +ctx.dockConfig.mutate((config) => { + config.maxVisibleItems = 8 +}) +``` + ### Example: accessing Vite config ```ts diff --git a/packages/core/src/client/inject/runtime.test.ts b/packages/core/src/client/inject/runtime.test.ts index d653aaf5e..563b596c4 100644 --- a/packages/core/src/client/inject/runtime.test.ts +++ b/packages/core/src/client/inject/runtime.test.ts @@ -1,5 +1,10 @@ +import type { DevToolsDockConfig } from '@vitejs/devtools-kit' +import type { DevToolsRpcClient, DockPanelStorage } from '@vitejs/devtools-kit/client' +import type { Ref } from 'vue' +import { createSharedState } from 'devframe/utils/shared-state' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { startDevTools } from './runtime' +import { ref } from 'vue' +import { seedWindowDefaultsOnce, startDevTools } from './runtime' const mocks = vi.hoisted(() => ({ getDevToolsRpcClient: vi.fn( @@ -51,3 +56,79 @@ describe('injected DevTools runtime', () => { ) }) }) + +describe('seedWindowDefaultsOnce', () => { + function mockRpc(initialValue: DevToolsDockConfig) { + const state = createSharedState({ initialValue, enablePatches: false }) + return { + sharedState: { + get: async () => state, + }, + } as unknown as DevToolsRpcClient + } + + function panelState(): Ref { + return ref({ + mode: 'float', + width: 80, + height: 80, + top: 0, + left: 0, + position: 'left', + open: false, + inactiveTimeout: 3_000, + }) + } + + it('applies an already-available default mode and position', async () => { + const state = panelState() + await seedWindowDefaultsOnce(mockRpc({ defaultMode: 'edge', defaultPosition: 'right' }), state) + + expect(state.value.mode).toBe('edge') + expect(state.value.position).toBe('right') + }) + + it('leaves the fallback untouched when nothing is declared', async () => { + const state = panelState() + await seedWindowDefaultsOnce(mockRpc({}), state) + + expect(state.value.mode).toBe('float') + expect(state.value.position).toBe('left') + }) + + it('applies only the declared field, leaving the other at its fallback', async () => { + const state = panelState() + await seedWindowDefaultsOnce(mockRpc({ defaultMode: 'edge' }), state) + + expect(state.value.mode).toBe('edge') + expect(state.value.position).toBe('left') + }) + + it('applies the default once it arrives later, and only once', async () => { + const sharedState = createSharedState({ initialValue: {}, enablePatches: false }) + const rpc = { + sharedState: { + get: async () => sharedState, + }, + } as unknown as DevToolsRpcClient + const state = panelState() + + const seeded = seedWindowDefaultsOnce(rpc, state) + sharedState.mutate((config) => { + config.defaultMode = 'edge' + config.defaultPosition = 'right' + }) + await seeded + + expect(state.value.mode).toBe('edge') + expect(state.value.position).toBe('right') + + // A later reconfiguration (e.g. `ctx.dockConfig.mutate()`) must not keep + // repositioning a dock the developer has since moved. + state.value.mode = 'float' + sharedState.mutate((config) => { + config.defaultMode = 'edge' + }) + expect(state.value.mode).toBe('float') + }) +}) diff --git a/packages/core/src/client/inject/runtime.ts b/packages/core/src/client/inject/runtime.ts index 0a5d2c520..d262921e5 100644 --- a/packages/core/src/client/inject/runtime.ts +++ b/packages/core/src/client/inject/runtime.ts @@ -1,13 +1,17 @@ /// /// -import type { DockPanelStorage } from '@vitejs/devtools-kit/client' +import type { DevToolsDockConfig } from '@vitejs/devtools-kit' +import type { DevToolsRpcClient, DockPanelStorage } from '@vitejs/devtools-kit/client' +import type { Ref } from 'vue' import { CLIENT_CONTEXT_KEY, getDevToolsRpcClient } from '@vitejs/devtools-kit/client' import { DEVTOOLS_MOUNT_PATH } from '@vitejs/devtools-kit/constants' import { useLocalStorage } from '@vueuse/core' import { DEVTOOLS_HIDE_EVENT, DEVTOOLS_MODE_FILENAME } from '../../constants' import { createDocksContext } from '../webcomponents/state/context' +const DOCK_STATE_STORAGE_KEY = 'vite-devtools-dock-state' + export type InjectMode = 'passive' | 'normal' | 'hidden' // Persistence endpoint the node middleware serves next to `__connection.json`. @@ -33,6 +37,40 @@ function matchesActivation(event: KeyboardEvent): boolean { && (event.code === 'KeyD' || event.key === 'd' || event.key === 'D') } +/** + * Seeds `state.mode`/`.position` from the host's declared `defaultMode`/ + * `defaultPosition`, once, for a developer with no stored preference yet. + * + * The client's `sharedState.get()` resolves immediately with just + * `initialValue` while the connection is still untrusted — the normal + * first-load case — so the declared config usually lands after `state` (and + * the dock) already exist. Applying it retroactively here, exactly once, + * avoids depending on `useLocalStorage`'s `mergeDefaults`, which would have + * already persisted the fallback `float`/`left` and made the seed a no-op on + * every later load too. + */ +export async function seedWindowDefaultsOnce(rpc: DevToolsRpcClient, state: Ref): Promise { + const configState = await rpc.sharedState.get('devtools:dock-config', { initialValue: {} }) + + const applyOnce = (config: DevToolsDockConfig): boolean => { + if (config.defaultMode == null && config.defaultPosition == null) + return false + if (config.defaultMode != null) + state.value.mode = config.defaultMode + if (config.defaultPosition != null) + state.value.position = config.defaultPosition + return true + } + + if (applyOnce(configState.value())) + return + + const unsubscribe = configState.on('updated', (config) => { + if (applyOnce(config)) + unsubscribe() + }) +} + async function persistNormalMode(enabled: boolean): Promise { try { await fetch(MODE_URL, { @@ -60,8 +98,12 @@ async function mountDock(): Promise { ], }) + // Read before `useLocalStorage` creates/touches the entry below — the write + // it does on every load would otherwise erase the "nothing stored yet" + // signal `seedWindowDefaultsOnce` needs. + const hasStoredDockState = localStorage.getItem(DOCK_STATE_STORAGE_KEY) !== null const state = useLocalStorage( - 'vite-devtools-dock-state', + DOCK_STATE_STORAGE_KEY, { mode: 'float', width: 80, @@ -74,6 +116,8 @@ async function mountDock(): Promise { }, { mergeDefaults: true }, ) + if (!hasStoredDockState) + void seedWindowDefaultsOnce(rpc, state) const context = await createDocksContext( 'embedded', diff --git a/packages/core/src/client/standalone/App.vue b/packages/core/src/client/standalone/App.vue index 043d85e54..0813ee8ce 100644 --- a/packages/core/src/client/standalone/App.vue +++ b/packages/core/src/client/standalone/App.vue @@ -1,5 +1,5 @@