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
2 changes: 1 addition & 1 deletion packages/core/src/client/webcomponents/.generated/css.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { DevToolsViewLauncher, DevToolsViewLauncherStatus } from '@vitejs/devtools-kit'
import type { DocksContext } from '@vitejs/devtools-kit/client'
import { DEVTOOLS_TERMINALS_DOCK_ID } from '@vitejs/devtools-kit/constants'
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import Button from '../display/Button.vue'
import DockIcon from '../dock/DockIcon.vue'

Expand All @@ -11,8 +11,29 @@ const props = defineProps<{
entry: DevToolsViewLauncher
}>()

// Selectable launch roots the owner attached to the entry. When present a
// picker is shown and the chosen root travels with the launch as `{ root }`.
const roots = computed(() => props.entry.launcher.roots)
const selectedRoot = ref<string | undefined>(roots.value?.[0]?.value)

// Keep the selection valid as roots change: default to the first root, and
// reset if the current pick disappears from the list.
watch(roots, (next) => {
const first = next?.[0]
if (!first) {
selectedRoot.value = undefined
return
}
if (!next!.some(root => root.value === selectedRoot.value))
selectedRoot.value = first.value
}, { immediate: true })

function onLaunch() {
props.context.rpc.call('devtoolskit:internal:docks:on-launch', props.entry.id)
props.context.rpc.call(
'devtoolskit:internal:docks:on-launch',
props.entry.id,
roots.value?.length ? { root: selectedRoot.value } : undefined,
)
}

const status = computed(() => props.entry.launcher.status || 'idle')
Expand Down Expand Up @@ -65,6 +86,21 @@ const canLaunch = computed(() => status.value === 'idle' || status.value === 'er
{{ entry.launcher.title }}
</h1>
<p>{{ entry.launcher.description }}</p>

<label v-if="roots?.length" class="flex flex-col gap-1 max-w-full w-64 items-start">
<span class="text-xs op60">Launch root</span>
<select
v-model="selectedRoot"
:disabled="status === 'loading'"
:title="roots.find(root => root.value === selectedRoot)?.description"
class="w-full px3 py2 text-sm rounded-lg bg-base color-base border border-base outline-none transition-all focus-visible:ring-3 focus-visible:ring-primary-500/30 disabled:op50 disabled:pointer-events-none"
>
<option v-for="root in roots" :key="root.value" :value="root.value">
{{ root.label }}
</option>
</select>
</label>

<Button
class="min-w-40"
:variant="status === 'error' ? 'danger' : 'primary'"
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/node/rpc/internal/docks-on-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const docksOnLaunch = defineRpcFunction({
setup: (context) => {
const launchMap = new Map<string, Promise<unknown>>()
return {
handler: async (entryId: string) => {
handler: async (entryId: string, payload?: unknown) => {
// De-dupe concurrent launches of the same entry, but only while one is
// in flight — the entry is removed once it settles so a later click
// (e.g. Retry after a failure) starts a fresh launch.
Expand Down Expand Up @@ -35,12 +35,13 @@ export const docksOnLaunch = defineRpcFunction({
})
// devframe ≥0.7.4 made `onLaunch` optional in favour of a bound
// `command` (the serializable launch path). Prefer the in-process
// handler; fall back to executing the command.
// handler; fall back to executing the command. The optional payload
// (e.g. the user's selected launch root) is forwarded to both.
const { onLaunch, command } = entry.launcher
const result = onLaunch
? await onLaunch()
? await (onLaunch as (payload?: unknown) => Promise<void>)(payload)
: command
? await context.commands.execute(command)
? await context.commands.execute(command, payload)
: undefined
// The launch may have replaced the entry (e.g. swapped to an
// iframe); only stamp success while it is still a launcher.
Expand Down
18 changes: 14 additions & 4 deletions packages/kit/src/node/create-process-launcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DevframeDockEntryIcon } from '@devframes/hub/types'
import type { DevToolsCommandKeybinding } from '../types/commands'
import type { DevToolsViewIframe, DevToolsViewLauncher } from '../types/docks'
import type { DevToolsLaunchPayload, DevToolsLaunchRoot, DevToolsViewIframe, DevToolsViewLauncher } from '../types/docks'
import type { DevToolsChildProcessExecuteOptions, DevToolsChildProcessTerminalSession } from '../types/terminals'
import type { PluginWithDevTools } from '../types/vite-augment'
import type { ViteDevToolsNodeContext } from '../types/vite-plugin'
Expand All @@ -27,8 +27,16 @@ export interface ProcessLauncherOptions {
/**
* The child process spawned when the launcher is invoked. Pass a function to
* resolve it lazily on each launch — e.g. to pick a free port and build args.
* The function receives the launch payload, including the `root` the user
* picked from {@link ProcessLauncherOptions.roots} (use it as the spawn `cwd`).
*/
process: DevToolsChildProcessExecuteOptions | (() => Awaitable<DevToolsChildProcessExecuteOptions>)
process: DevToolsChildProcessExecuteOptions | ((payload: DevToolsLaunchPayload) => Awaitable<DevToolsChildProcessExecuteOptions>)
/**
* Selectable launch roots. When provided, the launcher card renders a picker
* above the launch button, and the chosen root's `value` is forwarded to
* {@link ProcessLauncherOptions.process} as `payload.root`.
*/
roots?: DevToolsLaunchRoot[]
/**
* Runs once per launch, before the process is spawned. Use it for on-demand
* setup such as installing an optional dependency. Throwing here surfaces on
Expand Down Expand Up @@ -120,6 +128,7 @@ export function createProcessLauncher(options: ProcessLauncherOptions): PluginWi
process: executeOptions,
prepare,
serve,
roots,
name,
} = options

Expand Down Expand Up @@ -160,6 +169,7 @@ export function createProcessLauncher(options: ProcessLauncherOptions): PluginWi
// keybinding resolve to it); the on-launch bridge falls back to
// it, so no in-process `onLaunch` is needed.
command: commandId,
...(roots ? { roots } : {}),
...(extras.tracking ? { terminalSessionId: sessionId } : {}),
// Hub's author-set `digest` — a short line of progress/status.
...(extras.progress ? { digest: extras.progress } : {}),
Expand All @@ -182,7 +192,7 @@ export function createProcessLauncher(options: ProcessLauncherOptions): PluginWi

ctx.docks.register<DevToolsViewLauncher>(entry('idle'))

async function launch(): Promise<void> {
async function launch(payload: DevToolsLaunchPayload = {}): Promise<void> {
// A live session is left running: re-show the embed for a server
// launcher, otherwise no-op (don't disturb an in-flight launch).
if (ctx.terminals.sessions.get(sessionId)?.status === 'running') {
Expand All @@ -197,7 +207,7 @@ export function createProcessLauncher(options: ProcessLauncherOptions): PluginWi
if (session)
await session.terminate().catch(() => {})

const execute = typeof executeOptions === 'function' ? await executeOptions() : executeOptions
const execute = typeof executeOptions === 'function' ? await executeOptions(payload) : executeOptions
session = await ctx.terminals.startChildProcess(execute, {
id: sessionId,
title: options.session?.title ?? label,
Expand Down
47 changes: 45 additions & 2 deletions packages/kit/src/types/docks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DevframeDockEntryCategory } from '@devframes/hub/types'
import type { DevframeDockEntryCategory, DevframeViewLauncher } from '@devframes/hub/types'

export type {
ClientScriptEntry,
Expand All @@ -16,12 +16,55 @@ export type {
DevframeViewGroup as DevToolsViewGroup,
DevframeViewIframe as DevToolsViewIframe,
DevframeViewJsonRender as DevToolsViewJsonRender,
DevframeViewLauncher as DevToolsViewLauncher,
DevframeViewLauncherStatus as DevToolsViewLauncherStatus,
RemoteConnectionInfo,
RemoteDockOptions,
} from '@devframes/hub/types'

/**
* A selectable launch root offered by a launcher dock entry.
*
* When a launcher supplies {@link DevToolsViewLauncher.launcher.roots}, the
* viewer renders a picker above the launch button. The selected root's
* {@link DevToolsLaunchRoot.value} is forwarded to the launch as `{ root }`,
* where a `createProcessLauncher` uses it as the spawned process's `cwd`.
*/
export interface DevToolsLaunchRoot {
/** Absolute path forwarded as the spawn `cwd` when this root is selected. */
value: string
/** Human-friendly label shown in the picker (e.g. `Workspace root`). */
label: string
/** Optional secondary line, e.g. the path itself. */
description?: string
}

/**
* Payload carried from the client launch action to the bound launch command.
*/
export interface DevToolsLaunchPayload {
/** The {@link DevToolsLaunchRoot.value} of the root the user selected. */
root?: string
}

/**
* Kit augmentation of hub's launcher entry: adds optional selectable launch
* {@link DevToolsViewLauncher.launcher.roots | roots}.
*
* Docks belong to `@devframes/hub`; this extends the upstream launcher shape
* locally until the field lands there. Since `roots` is optional, a plain hub
* `DevframeViewLauncher` remains assignable to this type.
*/
export interface DevToolsViewLauncher extends DevframeViewLauncher {
launcher: DevframeViewLauncher['launcher'] & {
/**
* Selectable launch roots, owner-populated via `docks.update()`. When
* present the viewer renders a picker; the chosen root's `value` is
* forwarded to the launch command as {@link DevToolsLaunchPayload}.
*/
roots?: DevToolsLaunchRoot[]
}
}

/**
* The kit's dock-entry category union. Vite Plus integrations are collected
* under a dedicated dock group (see `DEVTOOLS_VITEPLUS_GROUP_ID`) rather than
Expand Down
3 changes: 2 additions & 1 deletion packages/vitest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"get-port-please": "catalog:deps",
"local-pkg": "catalog:deps",
"nostics": "catalog:deps",
"nypm": "catalog:deps"
"nypm": "catalog:deps",
"tinyglobby": "catalog:deps"
},
"devDependencies": {
"tsdown": "catalog:build"
Expand Down
69 changes: 62 additions & 7 deletions packages/vitest/src/node/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { PluginWithDevTools } from '@vitejs/devtools-kit'
import type { DevToolsLaunchRoot, PluginWithDevTools } from '@vitejs/devtools-kit'
import { dirname, relative, resolve } from 'node:path'
import process from 'node:process'
import { DEVTOOLS_VITEPLUS_GROUP_ID } from '@vitejs/devtools-kit/constants'
import { createProcessLauncher } from '@vitejs/devtools-kit/node'
import { getPort } from 'get-port-please'
import { isPackageExists } from 'local-pkg'
import { addDependency } from 'nypm'
import { glob } from 'tinyglobby'
import { clientPublicDir } from '../dirs'
import { diagnostics } from './diagnostics'

Expand Down Expand Up @@ -44,6 +46,8 @@ export function DevToolsVitestUI(): PluginWithDevTools {
const icon = `${VITEST_DEVTOOLS_BASE}favicon.svg`
const hasUi = isPackageExists('@vitest/ui', { paths: [cwd] })

const roots = await discoverRoots(cwd, ctx.workspaceRoot ?? cwd)

// The chosen URL, shared between the spawn spec and the readiness probe.
let url: string

Expand All @@ -58,6 +62,7 @@ export function DevToolsVitestUI(): PluginWithDevTools {
: 'Install `@vitest/ui` (as a devDependency) and view it inside DevTools.',
buttonStart: hasUi ? 'Start Vitest UI' : 'Install @vitest/ui & start',
buttonLoading: 'Starting Vitest UI…',
roots,
command: { id: 'vite:devtools:vitest:start-ui', title: 'Start Vitest UI', icon },
session: {
id: SESSION_ID,
Expand All @@ -79,16 +84,21 @@ export function DevToolsVitestUI(): PluginWithDevTools {
}
}
},
process: async () => {
process: async ({ root }) => {
const port = await getPort({ port: PREFERRED_PORT, portRange: [PREFERRED_PORT, PREFERRED_PORT + 500] })
url = `http://localhost:${port}/${VITEST_UI_PATH}`
return {
command: 'vitest',
// `--ui` runs in watch mode (needed for a persistent server);
// `--no-open` keeps Vitest from opening a separate browser tab
// since we embed it in the DevTools iframe instead.
args: ['--ui', '--no-open', '--api.port', String(port)],
cwd,
// `--watch` keeps the server (and its WebSocket API the UI needs)
// alive after the first run — a spawned child process has no TTY,
// so Vitest would otherwise default to a single run and exit,
// leaving the embedded UI unable to connect. `--no-open` keeps
// Vitest from opening a separate browser tab since we embed it in
// the DevTools iframe instead.
args: ['--ui', '--no-open', '--watch', '--api.port', String(port)],
// Run in the user-selected launch root (falls back to the project
// root when no picker choice is present).
cwd: root ?? cwd,
}
},
serve: {
Expand All @@ -106,6 +116,51 @@ export function DevToolsVitestUI(): PluginWithDevTools {
}
}

/**
* Build the list of launch roots the user can run Vitest from: every directory
* holding a Vitest/Vite config in the workspace, with the project and workspace
* roots relabelled for clarity. The project root is only offered when it
* actually holds a config — running Vitest from a config-less directory isn't
* meaningful. Deduped by absolute path, first label wins.
*/
async function discoverRoots(cwd: string, workspaceRoot: string): Promise<DevToolsLaunchRoot[]> {
const roots = new Map<string, DevToolsLaunchRoot>()
const add = (path: string, label: string): void => {
const abs = resolve(path)
if (!roots.has(abs))
roots.set(abs, { value: abs, label, description: abs })
}

let configDirs: string[] = []
try {
const matches = await glob(
['**/vitest.config.*', '**/vitest.workspace.*', '**/vite.config.*'],
{
cwd: workspaceRoot,
absolute: true,
ignore: ['**/node_modules/**', '**/dist/**'],
},
)
configDirs = matches.map(file => resolve(dirname(file)))
}
catch {
// A failed scan just means fewer roots to pick from.
}

// Only offer the project root when it holds a config of its own.
if (configDirs.includes(resolve(cwd)))
add(cwd, 'Project root')
if (resolve(workspaceRoot) !== resolve(cwd))
add(workspaceRoot, 'Workspace root')

for (const dir of [...configDirs].sort((a, b) => a.localeCompare(b))) {
const rel = relative(workspaceRoot, dir)
add(dir, rel === '' ? 'Workspace root' : rel)
}

return [...roots.values()]
}

async function waitForServer(url: string, timeout: number): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeout) {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,26 @@ export interface CreateKitContextOptions extends CreateHubContextOptions {
viteConfig?: ResolvedConfig;
viteServer?: ViteDevServer;
}
export interface DevToolsLaunchPayload {
root?: string;
}
export interface DevToolsLaunchRoot {
value: string;
label: string;
description?: string;
}
export interface DevToolsPluginOptions {
capabilities?: {
dev?: DevframeCapabilities | boolean;
build?: DevframeCapabilities | boolean;
};
setup: (_: ViteDevToolsNodeContext) => void | Promise<void>;
}
export interface DevToolsViewLauncher extends DevframeViewLauncher {
launcher: DevframeViewLauncher['launcher'] & {
roots?: DevToolsLaunchRoot[];
};
}
export interface KitNodeContext extends DevframeHubContext {
readonly viteConfig?: ResolvedConfig;
readonly viteServer?: ViteDevServer;
Expand Down Expand Up @@ -91,7 +104,6 @@ export { DevToolsViewGroup }
export { DevToolsViewHost }
export { DevToolsViewIframe }
export { DevToolsViewJsonRender }
export { DevToolsViewLauncher }
export { DevToolsViewLauncherStatus }
export { EntriesToObject }
export { EventEmitter }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export interface ProcessLauncherOptions {
description?: string;
buttonStart?: string;
buttonLoading?: string;
process: DevToolsChildProcessExecuteOptions | (() => Awaitable<DevToolsChildProcessExecuteOptions>);
process: DevToolsChildProcessExecuteOptions | ((_: DevToolsLaunchPayload) => Awaitable<DevToolsChildProcessExecuteOptions>);
roots?: DevToolsLaunchRoot[];
prepare?: () => Awaitable<void>;
serve?: {
onReady: (_: DevToolsChildProcessTerminalSession) => Awaitable<string>;
Expand Down
Loading