Skip to content
Draft
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
26 changes: 26 additions & 0 deletions docs/guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions docs/kit/devtools-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const plugin: Plugin = {
| Property | Type | Description |
|----------|------|-------------|
| `ctx.docks` | `DocksHost` | Register and manage [dock entries](./dock-system) |
| `ctx.dockConfig` | `SharedState<DevToolsDockConfig>` | 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 |
Expand All @@ -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
Expand Down
83 changes: 82 additions & 1 deletion packages/core/src/client/inject/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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<DockPanelStorage> {
return ref<DockPanelStorage>({
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<DevToolsDockConfig>({ 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')
})
})
48 changes: 46 additions & 2 deletions packages/core/src/client/inject/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
/// <reference types="vite/client" />
/// <reference lib="dom" />

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`.
Expand All @@ -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<DockPanelStorage>): Promise<void> {
const configState = await rpc.sharedState.get<DevToolsDockConfig>('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<void> {
try {
await fetch(MODE_URL, {
Expand Down Expand Up @@ -60,8 +98,12 @@ async function mountDock(): Promise<void> {
],
})

// 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<DockPanelStorage>(
'vite-devtools-dock-state',
DOCK_STATE_STORAGE_KEY,
{
mode: 'float',
width: 80,
Expand All @@ -74,6 +116,8 @@ async function mountDock(): Promise<void> {
},
{ mergeDefaults: true },
)
if (!hasStoredDockState)
void seedWindowDefaultsOnce(rpc, state)

const context = await createDocksContext(
'embedded',
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/client/standalone/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { DocksContext } from '@vitejs/devtools-kit/client'
import type { DevToolsDocksContext } from '../webcomponents/state/context'
import { CLIENT_CONTEXT_KEY, getDevToolsRpcClient } from '@vitejs/devtools-kit/client'
import { watchEffect } from 'vue'
import DockStandalone from '../webcomponents/components/dock/DockStandalone.vue'
Expand All @@ -21,7 +21,7 @@ const rpc = await getDevToolsRpcClient()
// eslint-disable-next-line no-console
console.log('[VITE DEVTOOLS] RPC', rpc)

const context: DocksContext = await createDocksContext(
const context: DevToolsDocksContext = await createDocksContext(
'standalone',
rpc,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { DocksContext } from '@vitejs/devtools-kit/client'
import type { VueElementConstructor } from 'vue'
import type { DevToolsDocksContext } from '../state/context'
import { defineCustomElement } from 'vue'
import css from '../.generated/css'
import Component from './dock/DockEmbedded.vue'
Expand All @@ -11,7 +11,7 @@ export const DockEmbedded = defineCustomElement(
styles: [css],
},
) as VueElementConstructor<{
context: DocksContext
context: DevToolsDocksContext
}>

customElements.define('vite-devtools-dock-embedded', DockEmbedded)
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { DocksContext } from '@vitejs/devtools-kit/client'
import type { VueElementConstructor } from 'vue'
import type { DevToolsDocksContext } from '../state/context'
import { defineCustomElement } from 'vue'
import css from '../.generated/css'
import Component from './dock/DockStandalone.vue'
Expand All @@ -11,7 +11,7 @@ export const DockStandalone = defineCustomElement(
styles: [css],
},
) as VueElementConstructor<{
context: DocksContext
context: DevToolsDocksContext
}>

if (!customElements.get('vite-devtools-dock-standalone'))
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/client/webcomponents/components/dock/Dock.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DocksContext } from '@vitejs/devtools-kit/client'
import type { CSSProperties } from 'vue'
import type { DevToolsDocksContext } from '../../state/context'
import type { DockLayout } from './dock-layout'
import { useEventListener, useScreenSafeArea, whenever } from '@vueuse/core'
import { computed, onMounted, reactive, ref, useTemplateRef } from 'vue'
Expand All @@ -23,7 +23,7 @@ import DockEntriesWithCategories from './DockEntriesWithCategories.vue'
import DockOverflowButton from './DockOverflowButton.vue'

const props = defineProps<{
context: DocksContext
context: DevToolsDocksContext
/**
* Override individual dock layout tunables (bar height, item capacity,
* viewport margin, snapping, ...). Merged over `DEFAULT_DOCK_LAYOUT`.
Expand All @@ -34,7 +34,11 @@ 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/host-declared `maxVisibleItems` is the default; an explicit `layout` prop wins.
const layout = computed(() => resolveDockLayout({
maxVisibleItems: context.dockConfig.maxVisibleItems,
...props.layout,
}))

const isSafari = navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { DevToolsDockEntry } from '@vitejs/devtools-kit'
import type { DocksContext } from '@vitejs/devtools-kit/client'
import type { DevToolsDocksContext } from '../../state/context'
import { h } from 'vue'
import { setDockContextMenu } from '../../state/floating-tooltip'
import { isDockPopupSupported, requestDockPopupOpen, useIsDockPopupOpen } from '../../state/popup'
Expand All @@ -23,7 +23,7 @@ function renderMenuItem(item: DockMenuItem) {
])
}

function hideDock(context: DocksContext, entry: DevToolsDockEntry) {
function hideDock(context: DevToolsDocksContext, entry: DevToolsDockEntry) {
const settingsStore = context.docks.settings
const id = entry.id
settingsStore.mutate((state) => {
Expand All @@ -35,7 +35,7 @@ function hideDock(context: DocksContext, entry: DevToolsDockEntry) {
setDockContextMenu(null)
}

function refreshDock(context: DocksContext, entry: DevToolsDockEntry) {
function refreshDock(context: DevToolsDocksContext, entry: DevToolsDockEntry) {
const state = context.docks.getStateById(entry.id)
const iframe = state?.domElements.iframe
if (!iframe) {
Expand All @@ -48,7 +48,7 @@ function refreshDock(context: DocksContext, entry: DevToolsDockEntry) {
setDockContextMenu(null)
}

function canHide(context: DocksContext, entry: DevToolsDockEntry) {
function canHide(context: DevToolsDocksContext, entry: DevToolsDockEntry) {
if (entry.id === '~settings')
return false
return context.docks.entries.some(item => item.id === entry.id)
Expand All @@ -59,7 +59,7 @@ function canRefresh(entry: DevToolsDockEntry) {
}

export function openDockContextMenu(options: {
context: DocksContext
context: DevToolsDocksContext
entry: DevToolsDockEntry
el: HTMLElement
gap?: number
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DocksContext } from '@vitejs/devtools-kit/client'
import type { CSSProperties } from 'vue'
import type { DevToolsDocksContext } from '../../state/context'
import { computed, h, useTemplateRef } from 'vue'
import { getEntryGroup } from '../../state/dock-settings'
import { setEdgePositionDropdown, setFloatingTooltip, useEdgePositionDropdown } from '../../state/floating-tooltip'
Expand All @@ -11,7 +11,7 @@ import DockGroupSidebar from './DockGroupSidebar.vue'
import DockPanelResizer from './DockPanelResizer.vue'

const props = defineProps<{
context: DocksContext
context: DevToolsDocksContext
}>()

const context = props.context
Expand Down
Loading
Loading