diff --git a/package.json b/package.json index db7baa8d62..700f834c1c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "build": "turbo run build", + "watch": "turbo watch build", "dev": "pnpm run build && pnpm -C packages/devtools dev", "lint": "eslint --cache .", "release": "pnpm test && bumpp -r --all", @@ -21,11 +22,11 @@ "test:e2e:built": "pnpm test:e2e:prebuild && PW_PROJECT='*:built' playwright test --config tests/e2e/playwright.config.ts", "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", "test:e2e:ui": "playwright test --config tests/e2e/playwright.config.ts --ui", - "docs": "nuxi dev docs", - "docs:build": "CI=true nuxi generate docs", + "docs": "pnpm -C docs install && nuxi dev docs", + "docs:build": "pnpm -C docs install && CI=true nuxi generate docs", "typecheck": "vue-tsc --noEmit", - "postinstall": "simple-git-hooks && pnpm -C docs install && skills-npm", - "prepare": "pnpm -r --filter=\"./packages/*\" run dev:prepare" + "postinstall": "simple-git-hooks && skills-npm", + "dev:prepare": "pnpm -r --filter=\"./packages/*\" run dev:prepare" }, "devDependencies": { "@antfu/eslint-config": "catalog:cli", diff --git a/packages/devtools-kit/src/_types/custom-tabs.ts b/packages/devtools-kit/src/_types/custom-tabs.ts index e1fd8b312a..f6ac88794e 100644 --- a/packages/devtools-kit/src/_types/custom-tabs.ts +++ b/packages/devtools-kit/src/_types/custom-tabs.ts @@ -127,6 +127,7 @@ export interface ModuleBuiltinTab { title?: string path?: string category?: TabCategory + defaultOrder?: number show?: () => MaybeRefOrGetter badge?: () => MaybeRefOrGetter onClick?: () => void diff --git a/packages/devtools/client/app.vue b/packages/devtools/client/app.vue index 867e4f9eef..a446a113c9 100644 --- a/packages/devtools/client/app.vue +++ b/packages/devtools/client/app.vue @@ -5,6 +5,8 @@ import { useRoute } from '#app/composables/router' import { useHead } from '#imports' import { getColorMode, showConnectionWarning, useClient, useInjectionClient } from '~/composables/client' import { useCopy } from '~/composables/editor' +import { isEmbedded } from '~/composables/embed' +import { setupFrameNav } from '~/composables/frame-nav' import { WS_DEBOUNCE_TIME } from '~/composables/rpc' import { registerCommands } from '~/composables/state-commands' import { splitScreenAvailable, splitScreenEnabled } from '~/composables/storage' @@ -43,7 +45,10 @@ setupClientRPC() const client = useClient() const route = useRoute() const colorMode = getColorMode() -const isUtilityView = computed(() => route.path.startsWith('/__') || route.path === '/') +// When embedded as the shared-frame anchor (`?embed=1`), hide the app shell +// (SideNav + split pane) so the iframe shows only the current tab; the dock's +// per-tab members drive navigation via the frame-nav shim. +const isUtilityView = computed(() => isEmbedded.value || route.path.startsWith('/__') || route.path === '/') const waiting = computed(() => !client.value && !showConnectionWarning.value) const showDisconnectIndicator = ref(false) @@ -87,6 +92,11 @@ const { scale, sidebarExpanded } = useDevToolsOptions('ui') const dataSchema = useSchemaInput() onMounted(async () => { + // As the shared-frame anchor, announce our tabs to the dock and answer + // soft-navigation over postMessage. + if (isEmbedded.value) + setupFrameNav() + const injectClient = useInjectionClient() watchEffect(() => { window.__NUXT_DEVTOOLS__ = injectClient.value @@ -148,12 +158,14 @@ registerCommands(() => [
- + + + @@ -163,7 +175,6 @@ registerCommands(() => [ -
diff --git a/packages/devtools/client/components/AuthConfirmDialog.vue b/packages/devtools/client/components/AuthConfirmDialog.vue deleted file mode 100644 index 04d85e1bdb..0000000000 --- a/packages/devtools/client/components/AuthConfirmDialog.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/packages/devtools/client/composables/embed.ts b/packages/devtools/client/composables/embed.ts new file mode 100644 index 0000000000..f307e9d46d --- /dev/null +++ b/packages/devtools/client/composables/embed.ts @@ -0,0 +1,32 @@ +import { ref } from 'vue' + +const STORAGE_KEY = 'nuxt-devtools-embedded' + +/** + * "Embedded" mode: the client is loaded as the shared-frame **anchor** inside a + * Vite DevTools dock iframe (`?embed=1`). In this mode the app shell — SideNav + * and split pane — is hidden so the iframe shows only the current tab, and the + * `devframe:frame-nav` shim drives tab navigation from the dock instead. + * + * The flag is latched into `sessionStorage` (isolated per iframe) so it + * survives soft navigation that drops the query and any reloads. + */ +function detect(): boolean { + if (typeof window === 'undefined') + return false + try { + if (window.sessionStorage.getItem(STORAGE_KEY) === '1') + return true + } + catch {} + const embedded = new URLSearchParams(window.location.search).get('embed') === '1' + if (embedded) { + try { + window.sessionStorage.setItem(STORAGE_KEY, '1') + } + catch {} + } + return embedded +} + +export const isEmbedded = ref(detect()) diff --git a/packages/devtools/client/composables/frame-nav.ts b/packages/devtools/client/composables/frame-nav.ts new file mode 100644 index 0000000000..b6b3969d10 --- /dev/null +++ b/packages/devtools/client/composables/frame-nav.ts @@ -0,0 +1,128 @@ +import type { ModuleBuiltinTab, ModuleCustomTab } from '~/../src/types' +import { computed, watch } from 'vue' +import { useRouter } from '#app/composables/router' +import { useEnabledTabs } from '~/composables/state-tabs' + +// `devframe:frame-nav` — the embedded-app half of the shared-iframe soft-nav +// protocol (devframe#128 / vitejs/devtools#464). The Vite DevTools dock owns +// one kept-alive iframe (the anchor); this shim announces one member dock per +// DevTools tab and switches views by client-side navigation, so no per-tab +// iframe/reload is needed. The shim is transport-only: it takes no hub/RPC +// dependency, just `postMessage`. +// +// The announced set comes from `useEnabledTabs()` — the same conditional list +// the SideNav used — so a tab's `show()` condition, hidden/pinned settings and +// experimental gating decide whether its dock appears, and the manifest is +// re-announced whenever that set changes. +const CHANNEL = 'devframe:frame-nav' +const VERSION = 1 +// Must match the anchor's `frameId` registered in `module-main.ts`. +const FRAME_ID = 'nuxt:devtools' + +const ICON_CLASS_RE = /\s.*$/ +const FIRST_DASH_RE = /-/ + +interface FrameNavTab { + id: string + title: string + icon?: string + category?: string + defaultOrder?: number + navTarget: { path: string } +} + +/** The DevTools settings page, surfaced as its own dock member. */ +const SETTINGS_TAB: FrameNavTab = { + id: 'settings', + title: 'Settings', + icon: 'carbon:settings', + category: '~builtin', + // sort last within its sub-category (higher `order` renders earlier) + defaultOrder: 1000, + navTarget: { path: '/settings' }, +} + +/** Normalise a tab icon (UnoCSS `i-carbon-foo` / `carbon-foo`) to iconify `carbon:foo`. */ +function normalizeIcon(icon: string | undefined): string | undefined { + if (!icon) + return undefined + let token = icon.replace(ICON_CLASS_RE, '') + if (/^(?:https?:)?\/\//.test(token) || token.startsWith('/') || token.startsWith('data:')) + return token + if (token.startsWith('i-')) + token = token.slice(2) + if (!token.includes(':')) + token = token.replace(FIRST_DASH_RE, ':') + return token +} + +function tabPath(tab: ModuleBuiltinTab | ModuleCustomTab): string { + return 'path' in tab && tab.path ? tab.path : `/modules/custom-${tab.name}` +} + +/** + * Start the frame-nav shim. No-op unless running inside an iframe. Announces the + * (conditional) tab manifest, answers `navigate` with client-side navigation, + * and reports `navigated` so the dock highlight follows in-app navigation. + */ +export function setupFrameNav(): void { + if (typeof window === 'undefined' || window.parent === window) + return + + const router = useRouter() + const tabs = useEnabledTabs() + + const manifest = computed(() => [ + ...tabs.value.map(tab => ({ + id: tab.name, + title: tab.title ?? tab.name, + icon: normalizeIcon(tab.icon), + defaultOrder: 'defaultOrder' in tab ? tab.defaultOrder : undefined, + // Mirror `getCategorizedTabs`: an uncategorised tab belongs to `app`, so + // the `Nuxt` group's `categoryOrder` weights apply to it. + category: tab.category || 'app', + navTarget: { path: tabPath(tab) }, + })), + SETTINGS_TAB, + ]) + + function currentTabId(): string | undefined { + const path = router.currentRoute.value.path + return manifest.value.find(entry => entry.navTarget.path === path)?.id + } + + function post(message: Record) { + window.parent.postMessage({ channel: CHANNEL, v: VERSION, frameId: FRAME_ID, from: 'frame', ...message }, '*') + } + + function announce(type: 'ready' | 'manifest') { + post({ type, tabs: manifest.value, current: currentTabId() }) + } + + window.addEventListener('message', (ev: MessageEvent) => { + const data = ev.data + if (!data || data.channel !== CHANNEL || data.v !== VERSION || data.frameId !== FRAME_ID || data.from !== 'host') + return + if (data.type === 'hello') { + announce('ready') + } + else if (data.type === 'navigate') { + const path = data.navTarget?.path + if (typeof path === 'string') + router.push(path).then(() => post({ type: 'navigated', tabId: data.tabId })) + } + }) + + // Announce proactively in case the host attached before this shim loaded. + announce('ready') + + // Re-announce when the conditional tab set changes (show()/settings/etc). + watch(() => manifest.value.map(entry => entry.id).join(','), () => announce('manifest')) + + // Report in-app navigation so the dock highlight follows. + watch(() => router.currentRoute.value.path, () => { + const id = currentTabId() + if (id) + post({ type: 'navigated', tabId: id }) + }) +} diff --git a/packages/devtools/client/composables/state-tabs.ts b/packages/devtools/client/composables/state-tabs.ts index d54c582e4a..aff769ff20 100644 --- a/packages/devtools/client/composables/state-tabs.ts +++ b/packages/devtools/client/composables/state-tabs.ts @@ -21,6 +21,7 @@ export function useAllTabs() { return { name: i.name as string, path: i.path, + defaultOrder: i.meta.order as number | undefined, ...i.meta, } }), diff --git a/packages/devtools/client/middleware/route.global.ts b/packages/devtools/client/middleware/route.global.ts index 1789871914..a57a187a1e 100644 --- a/packages/devtools/client/middleware/route.global.ts +++ b/packages/devtools/client/middleware/route.global.ts @@ -1,7 +1,16 @@ import { defineNuxtRouteMiddleware, navigateTo } from '#imports' +import { isEmbedded } from '~/composables/embed' import { isFirstVisit } from '~/composables/storage' export default defineNuxtRouteMiddleware((to) => { + // Embedded anchor: skip the first-visit welcome; land on a real tab so the + // frame-nav shim has something to report as the current view. + if (isEmbedded.value) { + if (to.path === '/') + return navigateTo('/modules/overview') + return + } + if (isFirstVisit.value) { if (to.path !== '/') return navigateTo('/') diff --git a/packages/devtools/client/pages/settings.vue b/packages/devtools/client/pages/settings.vue index 9eb1ecfe14..78c0ce1142 100644 --- a/packages/devtools/client/pages/settings.vue +++ b/packages/devtools/client/pages/settings.vue @@ -2,6 +2,7 @@ import { watchEffect } from 'vue' import { definePageMeta } from '#imports' import { useClient } from '~/composables/client' +import { isEmbedded } from '~/composables/embed' import { rpc } from '~/composables/rpc' import { getCategorizedTabs, useAllTabs } from '~/composables/state-tabs' import { telemetryEnabled } from '~/composables/telemetry' @@ -115,7 +116,8 @@ watchEffect(() => { text="DevTools Settings" />
-
+ +

Tabs

@@ -175,38 +177,45 @@ watchEffect(() => { Appearance -
- - -
{{ isDark.value ? 'Dark' : 'Light' }} - - -
-
+ +

UI Scale

-
- - - Expand Sidebar - - - - - Scrollable Sidebar - - + +

Features

- + + Close DevTools when clicking outside