Skip to content
Merged
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
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand 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",
Expand Down
1 change: 1 addition & 0 deletions packages/devtools-kit/src/_types/custom-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export interface ModuleBuiltinTab {
title?: string
path?: string
category?: TabCategory
defaultOrder?: number
show?: () => MaybeRefOrGetter<any>
badge?: () => MaybeRefOrGetter<number | string | undefined>
onClick?: () => void
Expand Down
19 changes: 15 additions & 4 deletions packages/devtools/client/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -148,12 +158,14 @@ registerCommands(() => [
</NLoading>
<div
v-else
:class="isUtilityView ? 'flex' : sidebarExpanded ? 'grid grid-cols-[250px_1fr]' : 'grid grid-cols-[50px_1fr]'"
:class="isEmbedded ? 'grid grid-cols-[1fr]' : isUtilityView ? 'flex' : sidebarExpanded ? 'grid grid-cols-[250px_1fr]' : 'grid grid-cols-[50px_1fr]'"
h-full h-screen of-hidden rounded-xl bg-base font-sans
>
<SideNav v-show="!isUtilityView" of-x-hidden of-y-auto />
<NuxtLayout>
<NSplitPane storage-key="devtools:split-screen-mode" :min-size="20">
<!-- Embedded single-tab view: mount the tab content directly, no split pane -->
<NuxtPage v-if="isEmbedded" />
<NSplitPane v-else storage-key="devtools:split-screen-mode" :min-size="20">
<template #left>
<NuxtPage />
</template>
Expand All @@ -163,7 +175,6 @@ registerCommands(() => [
</NSplitPane>
</NuxtLayout>
<CommandPalette />
<AuthConfirmDialog />
</div>
<DisconnectIndicator v-if="showDisconnectIndicator" />
<RestartDialogs />
Expand Down
3 changes: 0 additions & 3 deletions packages/devtools/client/components/AuthConfirmDialog.vue

This file was deleted.

32 changes: 32 additions & 0 deletions packages/devtools/client/composables/embed.ts
Original file line number Diff line number Diff line change
@@ -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())
128 changes: 128 additions & 0 deletions packages/devtools/client/composables/frame-nav.ts
Original file line number Diff line number Diff line change
@@ -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<FrameNavTab[]>(() => [
...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<string, unknown>) {
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 })
})
}
1 change: 1 addition & 0 deletions packages/devtools/client/composables/state-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}),
Expand Down
9 changes: 9 additions & 0 deletions packages/devtools/client/middleware/route.global.ts
Original file line number Diff line number Diff line change
@@ -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('/')
Expand Down
51 changes: 30 additions & 21 deletions packages/devtools/client/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -115,7 +116,8 @@ watchEffect(() => {
text="DevTools Settings"
/>
<div grid="~ lg:cols-2 gap-x-10 gap-y-3" max-w-300>
<div flex="~ col gap-2">
<!-- Tab visibility/order is controlled by the Vite DevTools dock when embedded. -->
<div v-if="!isEmbedded" flex="~ col gap-2">
<h3 text-lg>
Tabs
</h3>
Expand Down Expand Up @@ -175,38 +177,45 @@ watchEffect(() => {
Appearance
</h3>
<NCard p4 flex="~ col gap-2">
<div>
<NDarkToggle v-slot="{ toggle, isDark }">
<NButton n="primary" @click="toggle">
<div i-carbon-sun dark:i-carbon-moon translate-y--1px /> {{ isDark.value ? 'Dark' : 'Light' }}
</NButton>
</NDarkToggle>
</div>
<div mx--2 my1 h-1px border="b base" op75 />
<!-- Color mode is driven by Vite DevTools when embedded. -->
<template v-if="!isEmbedded">
<div>
<NDarkToggle v-slot="{ toggle, isDark }">
<NButton n="primary" @click="toggle">
<div i-carbon-sun dark:i-carbon-moon translate-y--1px /> {{ isDark.value ? 'Dark' : 'Light' }}
</NButton>
</NDarkToggle>
</div>
<div mx--2 my1 h-1px border="b base" op75 />
</template>
<p>UI Scale</p>
<NSelect v-model="scale" n="primary">
<option v-for="i of scaleOptions" :key="i[0]" :value="i[1]">
{{ i[0] }}
</option>
</NSelect>
<div mx--2 my1 h-1px border="b base" op75 />
<NCheckbox v-model="sidebarExpanded" n-primary>
<span>
Expand Sidebar
</span>
</NCheckbox>
<NCheckbox v-model="sidebarScrollable" :disabled="sidebarExpanded" n-primary>
<span>
Scrollable Sidebar
</span>
</NCheckbox>
<!-- The SideNav is hidden when embedded, so its options are moot. -->
<template v-if="!isEmbedded">
<div mx--2 my1 h-1px border="b base" op75 />
<NCheckbox v-model="sidebarExpanded" n-primary>
<span>
Expand Sidebar
</span>
</NCheckbox>
<NCheckbox v-model="sidebarScrollable" :disabled="sidebarExpanded" n-primary>
<span>
Scrollable Sidebar
</span>
</NCheckbox>
</template>
</NCard>

<h3 mt2 text-lg>
Features
</h3>
<NCard p4 flex="~ col gap-2">
<NCheckbox v-model="interactionCloseOnOutsideClick" n-primary>
<!-- Panel open/close behaviour is owned by the Vite DevTools dock when embedded. -->
<NCheckbox v-if="!isEmbedded" v-model="interactionCloseOnOutsideClick" n-primary>
<span>Close DevTools when clicking outside</span>
</NCheckbox>
<!-- <NCheckbox v-model="showExperimentalFeatures" n-primary>
Expand Down
3 changes: 1 addition & 2 deletions packages/devtools/src/integrations/code-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,7 @@ export function setup(ctx: NuxtDevtoolsServerContext): void {
return mountDevframe(kit as any, mountedDefinition, {
dock: {
groupId: NUXT_DEVTOOLS_GROUP_ID,
category: 'framework',
defaultOrder: -200,
category: 'modules',
},
})
}, nuxt)
Expand Down
3 changes: 1 addition & 2 deletions packages/devtools/src/integrations/data-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,7 @@ export function setup(ctx: NuxtDevtoolsServerContext): void {
return mountDevframe(kit as any, definition, {
dock: {
groupId: NUXT_DEVTOOLS_GROUP_ID,
category: 'framework',
defaultOrder: -100,
category: 'advanced',
},
})
}, nuxt)
Expand Down
Loading
Loading