Skip to content

Commit f4fe278

Browse files
antfubotantfu
andauthored
feat(devtools): project each DevTools tab as its own Vite DevTools dock entry (#1035)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 1702c91 commit f4fe278

17 files changed

Lines changed: 585 additions & 303 deletions

File tree

‎package.json‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"scripts": {
1212
"build": "turbo run build",
13+
"watch": "turbo watch build",
1314
"dev": "pnpm run build && pnpm -C packages/devtools dev",
1415
"lint": "eslint --cache .",
1516
"release": "pnpm test && bumpp -r --all",
@@ -21,11 +22,11 @@
2122
"test:e2e:built": "pnpm test:e2e:prebuild && PW_PROJECT='*:built' playwright test --config tests/e2e/playwright.config.ts",
2223
"test:e2e:prebuild": "pnpm -C playgrounds/empty exec nuxt build && pnpm -C playgrounds/spa exec nuxt build && pnpm -C playgrounds/tab-pinia exec nuxt build && pnpm -C playgrounds/tab-seo exec nuxt build",
2324
"test:e2e:ui": "playwright test --config tests/e2e/playwright.config.ts --ui",
24-
"docs": "nuxi dev docs",
25-
"docs:build": "CI=true nuxi generate docs",
25+
"docs": "pnpm -C docs install && nuxi dev docs",
26+
"docs:build": "pnpm -C docs install && CI=true nuxi generate docs",
2627
"typecheck": "vue-tsc --noEmit",
27-
"postinstall": "simple-git-hooks && pnpm -C docs install && skills-npm",
28-
"prepare": "pnpm -r --filter=\"./packages/*\" run dev:prepare"
28+
"postinstall": "simple-git-hooks && skills-npm",
29+
"dev:prepare": "pnpm -r --filter=\"./packages/*\" run dev:prepare"
2930
},
3031
"devDependencies": {
3132
"@antfu/eslint-config": "catalog:cli",

‎packages/devtools-kit/src/_types/custom-tabs.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export interface ModuleBuiltinTab {
127127
title?: string
128128
path?: string
129129
category?: TabCategory
130+
defaultOrder?: number
130131
show?: () => MaybeRefOrGetter<any>
131132
badge?: () => MaybeRefOrGetter<number | string | undefined>
132133
onClick?: () => void

‎packages/devtools/client/app.vue‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useRoute } from '#app/composables/router'
55
import { useHead } from '#imports'
66
import { getColorMode, showConnectionWarning, useClient, useInjectionClient } from '~/composables/client'
77
import { useCopy } from '~/composables/editor'
8+
import { isEmbedded } from '~/composables/embed'
9+
import { setupFrameNav } from '~/composables/frame-nav'
810
import { WS_DEBOUNCE_TIME } from '~/composables/rpc'
911
import { registerCommands } from '~/composables/state-commands'
1012
import { splitScreenAvailable, splitScreenEnabled } from '~/composables/storage'
@@ -43,7 +45,10 @@ setupClientRPC()
4345
const client = useClient()
4446
const route = useRoute()
4547
const colorMode = getColorMode()
46-
const isUtilityView = computed(() => route.path.startsWith('/__') || route.path === '/')
48+
// When embedded as the shared-frame anchor (`?embed=1`), hide the app shell
49+
// (SideNav + split pane) so the iframe shows only the current tab; the dock's
50+
// per-tab members drive navigation via the frame-nav shim.
51+
const isUtilityView = computed(() => isEmbedded.value || route.path.startsWith('/__') || route.path === '/')
4752
const waiting = computed(() => !client.value && !showConnectionWarning.value)
4853
const showDisconnectIndicator = ref(false)
4954
@@ -87,6 +92,11 @@ const { scale, sidebarExpanded } = useDevToolsOptions('ui')
8792
const dataSchema = useSchemaInput()
8893
8994
onMounted(async () => {
95+
// As the shared-frame anchor, announce our tabs to the dock and answer
96+
// soft-navigation over postMessage.
97+
if (isEmbedded.value)
98+
setupFrameNav()
99+
90100
const injectClient = useInjectionClient()
91101
watchEffect(() => {
92102
window.__NUXT_DEVTOOLS__ = injectClient.value
@@ -148,12 +158,14 @@ registerCommands(() => [
148158
</NLoading>
149159
<div
150160
v-else
151-
:class="isUtilityView ? 'flex' : sidebarExpanded ? 'grid grid-cols-[250px_1fr]' : 'grid grid-cols-[50px_1fr]'"
161+
:class="isEmbedded ? 'grid grid-cols-[1fr]' : isUtilityView ? 'flex' : sidebarExpanded ? 'grid grid-cols-[250px_1fr]' : 'grid grid-cols-[50px_1fr]'"
152162
h-full h-screen of-hidden rounded-xl bg-base font-sans
153163
>
154164
<SideNav v-show="!isUtilityView" of-x-hidden of-y-auto />
155165
<NuxtLayout>
156-
<NSplitPane storage-key="devtools:split-screen-mode" :min-size="20">
166+
<!-- Embedded single-tab view: mount the tab content directly, no split pane -->
167+
<NuxtPage v-if="isEmbedded" />
168+
<NSplitPane v-else storage-key="devtools:split-screen-mode" :min-size="20">
157169
<template #left>
158170
<NuxtPage />
159171
</template>
@@ -163,7 +175,6 @@ registerCommands(() => [
163175
</NSplitPane>
164176
</NuxtLayout>
165177
<CommandPalette />
166-
<AuthConfirmDialog />
167178
</div>
168179
<DisconnectIndicator v-if="showDisconnectIndicator" />
169180
<RestartDialogs />

‎packages/devtools/client/components/AuthConfirmDialog.vue‎

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { ref } from 'vue'
2+
3+
const STORAGE_KEY = 'nuxt-devtools-embedded'
4+
5+
/**
6+
* "Embedded" mode: the client is loaded as the shared-frame **anchor** inside a
7+
* Vite DevTools dock iframe (`?embed=1`). In this mode the app shell — SideNav
8+
* and split pane — is hidden so the iframe shows only the current tab, and the
9+
* `devframe:frame-nav` shim drives tab navigation from the dock instead.
10+
*
11+
* The flag is latched into `sessionStorage` (isolated per iframe) so it
12+
* survives soft navigation that drops the query and any reloads.
13+
*/
14+
function detect(): boolean {
15+
if (typeof window === 'undefined')
16+
return false
17+
try {
18+
if (window.sessionStorage.getItem(STORAGE_KEY) === '1')
19+
return true
20+
}
21+
catch {}
22+
const embedded = new URLSearchParams(window.location.search).get('embed') === '1'
23+
if (embedded) {
24+
try {
25+
window.sessionStorage.setItem(STORAGE_KEY, '1')
26+
}
27+
catch {}
28+
}
29+
return embedded
30+
}
31+
32+
export const isEmbedded = ref(detect())
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { ModuleBuiltinTab, ModuleCustomTab } from '~/../src/types'
2+
import { computed, watch } from 'vue'
3+
import { useRouter } from '#app/composables/router'
4+
import { useEnabledTabs } from '~/composables/state-tabs'
5+
6+
// `devframe:frame-nav` — the embedded-app half of the shared-iframe soft-nav
7+
// protocol (devframe#128 / vitejs/devtools#464). The Vite DevTools dock owns
8+
// one kept-alive iframe (the anchor); this shim announces one member dock per
9+
// DevTools tab and switches views by client-side navigation, so no per-tab
10+
// iframe/reload is needed. The shim is transport-only: it takes no hub/RPC
11+
// dependency, just `postMessage`.
12+
//
13+
// The announced set comes from `useEnabledTabs()` — the same conditional list
14+
// the SideNav used — so a tab's `show()` condition, hidden/pinned settings and
15+
// experimental gating decide whether its dock appears, and the manifest is
16+
// re-announced whenever that set changes.
17+
const CHANNEL = 'devframe:frame-nav'
18+
const VERSION = 1
19+
// Must match the anchor's `frameId` registered in `module-main.ts`.
20+
const FRAME_ID = 'nuxt:devtools'
21+
22+
const ICON_CLASS_RE = /\s.*$/
23+
const FIRST_DASH_RE = /-/
24+
25+
interface FrameNavTab {
26+
id: string
27+
title: string
28+
icon?: string
29+
category?: string
30+
defaultOrder?: number
31+
navTarget: { path: string }
32+
}
33+
34+
/** The DevTools settings page, surfaced as its own dock member. */
35+
const SETTINGS_TAB: FrameNavTab = {
36+
id: 'settings',
37+
title: 'Settings',
38+
icon: 'carbon:settings',
39+
category: '~builtin',
40+
// sort last within its sub-category (higher `order` renders earlier)
41+
defaultOrder: 1000,
42+
navTarget: { path: '/settings' },
43+
}
44+
45+
/** Normalise a tab icon (UnoCSS `i-carbon-foo` / `carbon-foo`) to iconify `carbon:foo`. */
46+
function normalizeIcon(icon: string | undefined): string | undefined {
47+
if (!icon)
48+
return undefined
49+
let token = icon.replace(ICON_CLASS_RE, '')
50+
if (/^(?:https?:)?\/\//.test(token) || token.startsWith('/') || token.startsWith('data:'))
51+
return token
52+
if (token.startsWith('i-'))
53+
token = token.slice(2)
54+
if (!token.includes(':'))
55+
token = token.replace(FIRST_DASH_RE, ':')
56+
return token
57+
}
58+
59+
function tabPath(tab: ModuleBuiltinTab | ModuleCustomTab): string {
60+
return 'path' in tab && tab.path ? tab.path : `/modules/custom-${tab.name}`
61+
}
62+
63+
/**
64+
* Start the frame-nav shim. No-op unless running inside an iframe. Announces the
65+
* (conditional) tab manifest, answers `navigate` with client-side navigation,
66+
* and reports `navigated` so the dock highlight follows in-app navigation.
67+
*/
68+
export function setupFrameNav(): void {
69+
if (typeof window === 'undefined' || window.parent === window)
70+
return
71+
72+
const router = useRouter()
73+
const tabs = useEnabledTabs()
74+
75+
const manifest = computed<FrameNavTab[]>(() => [
76+
...tabs.value.map(tab => ({
77+
id: tab.name,
78+
title: tab.title ?? tab.name,
79+
icon: normalizeIcon(tab.icon),
80+
defaultOrder: 'defaultOrder' in tab ? tab.defaultOrder : undefined,
81+
// Mirror `getCategorizedTabs`: an uncategorised tab belongs to `app`, so
82+
// the `Nuxt` group's `categoryOrder` weights apply to it.
83+
category: tab.category || 'app',
84+
navTarget: { path: tabPath(tab) },
85+
})),
86+
SETTINGS_TAB,
87+
])
88+
89+
function currentTabId(): string | undefined {
90+
const path = router.currentRoute.value.path
91+
return manifest.value.find(entry => entry.navTarget.path === path)?.id
92+
}
93+
94+
function post(message: Record<string, unknown>) {
95+
window.parent.postMessage({ channel: CHANNEL, v: VERSION, frameId: FRAME_ID, from: 'frame', ...message }, '*')
96+
}
97+
98+
function announce(type: 'ready' | 'manifest') {
99+
post({ type, tabs: manifest.value, current: currentTabId() })
100+
}
101+
102+
window.addEventListener('message', (ev: MessageEvent) => {
103+
const data = ev.data
104+
if (!data || data.channel !== CHANNEL || data.v !== VERSION || data.frameId !== FRAME_ID || data.from !== 'host')
105+
return
106+
if (data.type === 'hello') {
107+
announce('ready')
108+
}
109+
else if (data.type === 'navigate') {
110+
const path = data.navTarget?.path
111+
if (typeof path === 'string')
112+
router.push(path).then(() => post({ type: 'navigated', tabId: data.tabId }))
113+
}
114+
})
115+
116+
// Announce proactively in case the host attached before this shim loaded.
117+
announce('ready')
118+
119+
// Re-announce when the conditional tab set changes (show()/settings/etc).
120+
watch(() => manifest.value.map(entry => entry.id).join(','), () => announce('manifest'))
121+
122+
// Report in-app navigation so the dock highlight follows.
123+
watch(() => router.currentRoute.value.path, () => {
124+
const id = currentTabId()
125+
if (id)
126+
post({ type: 'navigated', tabId: id })
127+
})
128+
}

‎packages/devtools/client/composables/state-tabs.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export function useAllTabs() {
2121
return {
2222
name: i.name as string,
2323
path: i.path,
24+
defaultOrder: i.meta.order as number | undefined,
2425
...i.meta,
2526
}
2627
}),

‎packages/devtools/client/middleware/route.global.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { defineNuxtRouteMiddleware, navigateTo } from '#imports'
2+
import { isEmbedded } from '~/composables/embed'
23
import { isFirstVisit } from '~/composables/storage'
34

45
export default defineNuxtRouteMiddleware((to) => {
6+
// Embedded anchor: skip the first-visit welcome; land on a real tab so the
7+
// frame-nav shim has something to report as the current view.
8+
if (isEmbedded.value) {
9+
if (to.path === '/')
10+
return navigateTo('/modules/overview')
11+
return
12+
}
13+
514
if (isFirstVisit.value) {
615
if (to.path !== '/')
716
return navigateTo('/')

‎packages/devtools/client/pages/settings.vue‎

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { watchEffect } from 'vue'
33
import { definePageMeta } from '#imports'
44
import { useClient } from '~/composables/client'
5+
import { isEmbedded } from '~/composables/embed'
56
import { rpc } from '~/composables/rpc'
67
import { getCategorizedTabs, useAllTabs } from '~/composables/state-tabs'
78
import { telemetryEnabled } from '~/composables/telemetry'
@@ -115,7 +116,8 @@ watchEffect(() => {
115116
text="DevTools Settings"
116117
/>
117118
<div grid="~ lg:cols-2 gap-x-10 gap-y-3" max-w-300>
118-
<div flex="~ col gap-2">
119+
<!-- Tab visibility/order is controlled by the Vite DevTools dock when embedded. -->
120+
<div v-if="!isEmbedded" flex="~ col gap-2">
119121
<h3 text-lg>
120122
Tabs
121123
</h3>
@@ -175,38 +177,45 @@ watchEffect(() => {
175177
Appearance
176178
</h3>
177179
<NCard p4 flex="~ col gap-2">
178-
<div>
179-
<NDarkToggle v-slot="{ toggle, isDark }">
180-
<NButton n="primary" @click="toggle">
181-
<div i-carbon-sun dark:i-carbon-moon translate-y--1px /> {{ isDark.value ? 'Dark' : 'Light' }}
182-
</NButton>
183-
</NDarkToggle>
184-
</div>
185-
<div mx--2 my1 h-1px border="b base" op75 />
180+
<!-- Color mode is driven by Vite DevTools when embedded. -->
181+
<template v-if="!isEmbedded">
182+
<div>
183+
<NDarkToggle v-slot="{ toggle, isDark }">
184+
<NButton n="primary" @click="toggle">
185+
<div i-carbon-sun dark:i-carbon-moon translate-y--1px /> {{ isDark.value ? 'Dark' : 'Light' }}
186+
</NButton>
187+
</NDarkToggle>
188+
</div>
189+
<div mx--2 my1 h-1px border="b base" op75 />
190+
</template>
186191
<p>UI Scale</p>
187192
<NSelect v-model="scale" n="primary">
188193
<option v-for="i of scaleOptions" :key="i[0]" :value="i[1]">
189194
{{ i[0] }}
190195
</option>
191196
</NSelect>
192-
<div mx--2 my1 h-1px border="b base" op75 />
193-
<NCheckbox v-model="sidebarExpanded" n-primary>
194-
<span>
195-
Expand Sidebar
196-
</span>
197-
</NCheckbox>
198-
<NCheckbox v-model="sidebarScrollable" :disabled="sidebarExpanded" n-primary>
199-
<span>
200-
Scrollable Sidebar
201-
</span>
202-
</NCheckbox>
197+
<!-- The SideNav is hidden when embedded, so its options are moot. -->
198+
<template v-if="!isEmbedded">
199+
<div mx--2 my1 h-1px border="b base" op75 />
200+
<NCheckbox v-model="sidebarExpanded" n-primary>
201+
<span>
202+
Expand Sidebar
203+
</span>
204+
</NCheckbox>
205+
<NCheckbox v-model="sidebarScrollable" :disabled="sidebarExpanded" n-primary>
206+
<span>
207+
Scrollable Sidebar
208+
</span>
209+
</NCheckbox>
210+
</template>
203211
</NCard>
204212

205213
<h3 mt2 text-lg>
206214
Features
207215
</h3>
208216
<NCard p4 flex="~ col gap-2">
209-
<NCheckbox v-model="interactionCloseOnOutsideClick" n-primary>
217+
<!-- Panel open/close behaviour is owned by the Vite DevTools dock when embedded. -->
218+
<NCheckbox v-if="!isEmbedded" v-model="interactionCloseOnOutsideClick" n-primary>
210219
<span>Close DevTools when clicking outside</span>
211220
</NCheckbox>
212221
<!-- <NCheckbox v-model="showExperimentalFeatures" n-primary>

‎packages/devtools/src/integrations/code-server.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,7 @@ export function setup(ctx: NuxtDevtoolsServerContext): void {
9696
return mountDevframe(kit as any, mountedDefinition, {
9797
dock: {
9898
groupId: NUXT_DEVTOOLS_GROUP_ID,
99-
category: 'framework',
100-
defaultOrder: -200,
99+
category: 'modules',
101100
},
102101
})
103102
}, nuxt)

0 commit comments

Comments
 (0)