diff --git a/packages/nuxi/src/launcher.ts b/packages/nuxi/src/launcher.ts index dc6994602..85c1ca600 100644 --- a/packages/nuxi/src/launcher.ts +++ b/packages/nuxi/src/launcher.ts @@ -6,8 +6,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { resolveModulePath } from 'exsolve' import { isNuxiCommand } from '../../nuxt-cli/src/commands/_utils' -import { tryResolveNuxt } from '../../nuxt-cli/src/utils/kit' -import { withNodePath } from '../../nuxt-cli/src/utils/paths' +import { tryResolveNuxt, withNodePath } from '../../nuxt-cli/src/utils/resolve-nuxt' const FLAG_RE = /^-/ diff --git a/packages/nuxt-cli/bin/nuxi.mjs b/packages/nuxt-cli/bin/nuxi.mjs index 21c7b6c2e..ce33c31a9 100755 --- a/packages/nuxt-cli/bin/nuxi.mjs +++ b/packages/nuxt-cli/bin/nuxi.mjs @@ -67,6 +67,13 @@ if ( } } +// Only `dev` has anything to show before the command graph loads. +if (process.argv[2] === 'dev') { + // Without the panel the command still runs, so a failure here is not fatal. + // eslint-disable-next-line antfu/no-top-level-await + await import('../dist/boot.mjs').then(({ bootDevUI }) => bootDevUI()).catch(() => {}) +} + // eslint-disable-next-line antfu/no-top-level-await const { runMain } = await import('../dist/index.mjs') diff --git a/packages/nuxt-cli/src/boot.ts b/packages/nuxt-cli/src/boot.ts new file mode 100644 index 000000000..97ed61d30 --- /dev/null +++ b/packages/nuxt-cli/src/boot.ts @@ -0,0 +1,55 @@ +import process from 'node:process' + +/** Flags that mean this is not going to be a panel session. */ +const OPT_OUT = /^(?:--help|-h|--version|-v|--no-tui|--tui|--inspect|--inspect-brk|--profile)(?:=|$)/ + +/** + * Paint the dev panel and bind its shortcuts, before the command graph loads. + * + * The `dev` command joins the same session and the same shortcut context, and + * gives the terminal back if the resolved arguments refuse the panel. + */ +export async function bootDevUI(): Promise { + const argv = process.argv.slice(2) + if (argv[0] !== 'dev' || argv.some(arg => OPT_OUT.test(arg))) { + return + } + // Cheap first pass at what `resolveDevUISupport` decides properly. + if (!process.stdout.isTTY || !process.stdin.isTTY) { + return + } + const options = { cwd: resolveCwd(argv), startTime: globalThis.__nuxt_cli__?.startTime } + const { paintFirstFrame } = await import('./dev/tui/first-frame') + const start = paintFirstFrame(options) + if (!start) { + return + } + try { + const { devShortcutContext } = await import('./dev/shortcut-context') + const { setupDevUI } = await import('./dev/tui/controller') + await setupDevUI(devShortcutContext().context, { ...options, start }) + } + catch (error) { + // The frame is on screen with nothing behind it, so give the terminal back + // before the failure travels on. + start.surface.close() + throw error + } + + // Loading the command graph blocks the loop, so the key reader goes first. + await new Promise(resolve => setImmediate(resolve)) +} + +/** Where to read the project version from: `--cwd`, or `dev`'s `ROOTDIR`. */ +function resolveCwd(argv: string[]): string | undefined { + for (let index = 1; index < argv.length; index++) { + const arg = argv[index]! + if (arg.startsWith('--cwd=')) { + return arg.slice('--cwd='.length) + } + if (arg === '--cwd') { + return argv[index + 1] + } + } + return argv[1] && !argv[1].startsWith('-') ? argv[1] : undefined +} diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index 76dafe0af..4fff5400f 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -12,17 +12,16 @@ import { defineCommand } from 'citty' import { isBun, isTest } from 'std-env' import { satisfies } from 'verkit' -import { initialize } from '../dev' import { closeInspector, openInspector, resolveInspectOptions } from '../dev/inspect' import { isReusePortSupported, parsePort } from '../dev/listen' import { ForkPool } from '../dev/pool' import { preflight } from '../dev/preflight' import { formatRestartReason } from '../dev/reason' -import { deferShortcutContext } from '../dev/shortcut-context' +import { devShortcutContext } from '../dev/shortcut-context' import { SUPERVISOR_SHUTDOWN_TIMEOUT_MS } from '../dev/shutdown' import { formatTakeoverRefusal, takeOverDevServer } from '../dev/takeover' -import { beginDevUI, setupDevUI } from '../dev/tui/controller' +import { beginDevUI, setupDevUI, teardownDevUI } from '../dev/tui/controller' import { replaceCwdArg } from '../utils/args' import { resolveLockDir } from '../utils/dev-server' import { summariseActiveResources } from '../utils/hang' @@ -177,20 +176,21 @@ const command = defineCommand({ }, async run(ctx) { const requestedCwd = resolveRootDir(ctx.args) - const cwd = await preflight({ cwd: requestedCwd }) + const cwd = await beforeServing(() => preflight({ cwd: requestedCwd })) if (cwd !== requestedCwd) { replaceCwdArg(ctx.rawArgs, cwd, requestedCwd) } const listenOverrides = resolveListenOverrides(ctx.args) - const buildDir = await resolveLockDir(cwd) + const buildDir = await beforeServing(() => resolveLockDir(cwd)) - const takeover = await takeOverDevServer(buildDir, { + const takeover = await beforeServing(() => takeOverDevServer(buildDir, { requestedPort: parsePort(listenOverrides.port), takeover: ctx.args.takeover, - }) + })) if (takeover.action === 'refused') { + await teardownDevUI() logger.error(formatTakeoverRefusal(takeover.existing, takeover.reason)) process.exit(1) } @@ -232,10 +232,16 @@ const command = defineCommand({ listenOverrides.showURL = false } - const { context: shortcutContext, attach: attachServer } = deferShortcutContext({ clearCaches }) + const { context: shortcutContext, attach: attachServer, provide } = devShortcutContext() + provide({ clearCaches }) const startingUI = ui ? await setupDevUI(shortcutContext, { ...uiOptions, enabled: true }) : undefined setupSignalHandlers(() => shortcutContext.close()) + // Evaluating the dev server's graph blocks the loop; let the panel answer + // anything already typed first. + await new Promise(resolve => setImmediate(resolve)) + const { initialize } = await import('../dev') + const started = await initialize({ cwd, args: ctx.args, handoverFrom: takeover.action === 'taken' ? takeover.pid : undefined }, { data: ctx.data, listenOverrides, @@ -457,6 +463,17 @@ type ArgsT = Exclude< undefined | ((...args: unknown[]) => unknown) > +/** Run work that must succeed before there is a server, freeing the terminal if it does not. */ +async function beforeServing(work: () => Promise): Promise { + try { + return await work() + } + catch (error) { + await teardownDevUI() + throw error + } +} + /** * Shut the dev server down on `SIGINT`/`SIGTERM`. * diff --git a/packages/nuxt-cli/src/commands/info.ts b/packages/nuxt-cli/src/commands/info.ts index 09d4b47d7..7df7817cd 100644 --- a/packages/nuxt-cli/src/commands/info.ts +++ b/packages/nuxt-cli/src/commands/info.ts @@ -19,13 +19,13 @@ import { getBuilder } from '../utils/banner' import { resolveCatalogEntry } from '../utils/catalog' import { withDirectStdout } from '../utils/console' import { formatInfoBox } from '../utils/formatting' -import { tryResolveNuxt } from '../utils/kit' import { logger } from '../utils/logger' import { resolveNitroVersion } from '../utils/nitro' import { getNuxtConfig } from '../utils/nuxt-config' import { readDependencyPackageJson } from '../utils/package-json' import { getPackageManagerVersion } from '../utils/packageManagers' import { resolveRootDir } from '../utils/paths' +import { tryResolveNuxt } from '../utils/resolve-nuxt' import { rootDirArgs } from './_shared' const LEADING_SLASH_RE = /^\// diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index 067d9ae65..15c8645ff 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -16,7 +16,8 @@ import { x } from 'tinyexec' import { resolveDotenvFileNames } from '../utils/args' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' -import { resolveRootDir, withNodePath } from '../utils/paths' +import { resolveRootDir } from '../utils/paths' +import { withNodePath } from '../utils/resolve-nuxt' import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared' type TypeChecker = 'vue-tsc' | 'golar' diff --git a/packages/nuxt-cli/src/dev/binaries.ts b/packages/nuxt-cli/src/dev/binaries.ts index f06e042d9..9b9bc63db 100644 --- a/packages/nuxt-cli/src/dev/binaries.ts +++ b/packages/nuxt-cli/src/dev/binaries.ts @@ -14,7 +14,7 @@ import { restoreRawMode, withDirectStdout } from '../utils/console' import { debug, logger } from '../utils/logger' import { logNetworkError } from '../utils/network' import { findInPath } from '../utils/path-env' -import { withStartupClockPaused } from '../utils/startup-clock' +import { withUserAttention } from '../utils/startup-clock' interface ConsentOptions { /** Key under `tools` in the user `.nuxtrc` used to persist acceptance. */ @@ -61,7 +61,7 @@ async function locateTool(name: string, options: { url?: string, archive?: boole } // A first-run consent prompt and download can dwarf the server's own // startup, so neither counts towards the reported time to ready. - return withStartupClockPaused(async () => { + return withUserAttention(async () => { if (!await confirmToolInstall(options.consent)) { return undefined } diff --git a/packages/nuxt-cli/src/dev/loading-template.ts b/packages/nuxt-cli/src/dev/loading-template.ts index 8262fbfb0..b7c620c23 100644 --- a/packages/nuxt-cli/src/dev/loading-template.ts +++ b/packages/nuxt-cli/src/dev/loading-template.ts @@ -1,7 +1,7 @@ import { pathToFileURL } from 'node:url' import { resolveModulePath } from 'exsolve' import { debug } from '../utils/logger' -import { withNodePath } from '../utils/paths' +import { withNodePath } from '../utils/resolve-nuxt' export type LoadingTemplate = (data: { loading?: string }) => string diff --git a/packages/nuxt-cli/src/dev/preflight.ts b/packages/nuxt-cli/src/dev/preflight.ts index 616ccb1bf..8ab242295 100644 --- a/packages/nuxt-cli/src/dev/preflight.ts +++ b/packages/nuxt-cli/src/dev/preflight.ts @@ -8,11 +8,11 @@ import { dirname, join } from 'pathe' import { restoreRawMode, withDirectStdout } from '../utils/console' import { ActionableError } from '../utils/errors' -import { tryResolveNuxt } from '../utils/kit' import { debug, logger } from '../utils/logger' import { CONFIG_EXTENSIONS } from '../utils/nuxt-config' import { relativeTo } from '../utils/paths' -import { withStartupClockPaused } from '../utils/startup-clock' +import { tryResolveNuxt } from '../utils/resolve-nuxt' +import { withUserAttention } from '../utils/startup-clock' import { isInteractive } from '../utils/stdout' const NUXT_PACKAGES = ['nuxt', 'nuxt-nightly'] @@ -143,7 +143,7 @@ async function resolveProjectDirectory(cwd: string, interactive: boolean): Promi if (interactive) { logger.warn(`${styleText('cyan', cwd)} is not a Nuxt project, but ${styleText('cyan', location.ancestor)} is.`) - const answer = await withStartupClockPaused(() => confirm({ message: `Run there instead?`, initialValue: true })) + const answer = await withUserAttention(() => confirm({ message: `Run there instead?`, initialValue: true })) restoreRawMode() if (!isCancel(answer) && answer) { @@ -218,7 +218,7 @@ async function checkDependencies(cwd: string, interactive: boolean): Promise offerInstall(cwd, interactive)) + await withUserAttention(() => offerInstall(cwd, interactive)) } /** diff --git a/packages/nuxt-cli/src/dev/shortcut-context.ts b/packages/nuxt-cli/src/dev/shortcut-context.ts index a6b5931b4..79dd98d2a 100644 --- a/packages/nuxt-cli/src/dev/shortcut-context.ts +++ b/packages/nuxt-cli/src/dev/shortcut-context.ts @@ -21,6 +21,8 @@ interface ShortcutServer { export interface DeferredShortcutContext { context: ShortcutContext attach: (server: ShortcutServer) => void + /** What the dev server cannot supply, such as the caches a restart clears. */ + provide: (options: Pick) => void } /** @@ -32,11 +34,14 @@ export interface DeferredShortcutContext { export function deferShortcutContext(options: Pick = {}): DeferredShortcutContext { let server: ShortcutServer | undefined let closing: Promise | undefined + let clearCaches = options.clearCaches const pendingReady: Array<(address: string) => void> = [] return { context: { - clearCaches: options.clearCaches, + get clearCaches() { + return clearCaches + }, get listener() { return server?.listener }, @@ -64,5 +69,15 @@ export function deferShortcutContext(options: Pick { + clearCaches = next.clearCaches ?? clearCaches + }, } } + +let shared: DeferredShortcutContext | undefined + +/** The context this process's dev shortcuts act through, shared by the entry and the command. */ +export function devShortcutContext(): DeferredShortcutContext { + return (shared ??= deferShortcutContext()) +} diff --git a/packages/nuxt-cli/src/dev/takeover.ts b/packages/nuxt-cli/src/dev/takeover.ts index c23b7f456..99ffc444e 100644 --- a/packages/nuxt-cli/src/dev/takeover.ts +++ b/packages/nuxt-cli/src/dev/takeover.ts @@ -10,7 +10,7 @@ import { isCI } from 'std-env' import { restoreRawMode, withDirectStdout } from '../utils/console' import { clearStaleLock, clearTakeover, isLockEnabled, isProcessAlive, markTakenOver, readLock } from '../utils/lockfile' import { logger } from '../utils/logger' -import { withStartupClockPaused } from '../utils/startup-clock' +import { withUserAttention } from '../utils/startup-clock' import { isInteractiveSession } from '../utils/stdout' import { DEV_SHUTDOWN_TIMEOUT_MS } from './shutdown' @@ -127,7 +127,7 @@ async function resolveTakeover(buildDir: string, options: TakeoverOptions): Prom const prompt = options.prompt ?? promptForTakeover // Nothing ends an interactive session on a bare enter, so the default // depends on whether a person is watching the other server. - const choice = await withStartupClockPaused(() => prompt(existing, existing.interactive ? 'abort' : 'takeover')) + const choice = await withUserAttention(() => prompt(existing, existing.interactive ? 'abort' : 'takeover')) if (choice === 'abort') { return { action: 'refused', existing, reason: 'declined' } } @@ -138,7 +138,7 @@ async function resolveTakeover(buildDir: string, options: TakeoverOptions): Prom } } - return withStartupClockPaused(() => performTakeover(buildDir, existing, options.timeouts)) + return withUserAttention(() => performTakeover(buildDir, existing, options.timeouts)) } async function performTakeover(buildDir: string, existing: LockInfo, timeouts: TakeoverOptions['timeouts'] = {}): Promise { diff --git a/packages/nuxt-cli/src/dev/tui/controller.ts b/packages/nuxt-cli/src/dev/tui/controller.ts index ecbea4aec..75ac2eac7 100644 --- a/packages/nuxt-cli/src/dev/tui/controller.ts +++ b/packages/nuxt-cli/src/dev/tui/controller.ts @@ -67,6 +67,12 @@ export async function beginDevUI(options: DevUIOptions = {}): Promise { + const { teardownDevUI } = await import('./session') + teardownDevUI() +} + /** The interactive controller, or the line-based shortcuts and a no-op. */ export async function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}): Promise { if (options.enabled === false) { diff --git a/packages/nuxt-cli/src/dev/tui/first-frame.ts b/packages/nuxt-cli/src/dev/tui/first-frame.ts new file mode 100644 index 000000000..2a2260fbd --- /dev/null +++ b/packages/nuxt-cli/src/dev/tui/first-frame.ts @@ -0,0 +1,65 @@ +import type { PanelState } from './panel' +import type { DevUISupportOptions } from './support' + +import process from 'node:process' + +import { getPkgVersion } from '../../utils/pkg' +import { resolveBackground } from '../../utils/terminal-theme' +import { DEFAULT_HINTS, renderPanel } from './panel' +import { resolveDevUISupport, supportsUnicode } from './support' +import { PanelSurface } from './surface' + +export interface PanelStart { + surface: PanelSurface + state: PanelState +} + +export interface PanelStartOptions extends DevUISupportOptions { + version?: string + cwd?: string + startTime?: number +} + +/** The panel as it looks before anything has been loaded. */ +export function createPanelState(options: PanelStartOptions = {}): PanelState { + const cwd = options.cwd || process.cwd() + return { + status: 'starting', + version: options.version || getPkgVersion(cwd, 'nuxt') || getPkgVersion(cwd, 'nuxt-nightly') || undefined, + warnings: 0, + errors: 0, + ascii: !supportsUnicode(), + background: resolveBackground(), + loadStartedAt: options.startTime ?? Date.now(), + elapsedMs: 0, + progress: 0, + hints: DEFAULT_HINTS, + hintsDimmed: true, + } +} + +export function renderPanelState(surface: PanelSurface, state: PanelState): void { + surface.render(panelLines(state)) +} + +function panelLines(state: PanelState): string[] { + return renderPanel(state, process.stdout.columns || 80, process.stdout.rows || 24) +} + +/** + * Put the panel on screen before the session that drives it exists. + * + * Log capture, the event log and the progress feed all cost more to load than + * the frame, and none of them has anything to show yet. + */ +export function paintFirstFrame(options: PanelStartOptions = {}): PanelStart | undefined { + if (!resolveDevUISupport(options).enabled) { + return undefined + } + const state = createPanelState(options) + const surface = new PanelSurface() + // Until the session takes over, the frame repaints itself. + surface.onResize(() => renderPanelState(surface, state)) + surface.renderAtBottom(panelLines(state)) + return { surface, state } +} diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index 6f57403b4..feb028fc8 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -1,6 +1,7 @@ import type { TerminalNotification } from '../../utils/terminal-host' import type { ShortcutContext } from '../shortcuts' import type { DevUIController } from './controller' +import type { PanelStart } from './first-frame' import type { InfoSection } from './info-overlay' import type { Key } from './keys' import type { DevStatus, PanelState, PanelURL } from './panel' @@ -38,6 +39,9 @@ import { beginDevUI } from './session' export { beginDevUI } from './session' export type { DevUIController } +/** The controller driving each session, so a second caller joins it. */ +const attached = new WeakMap() + /** How often the traffic ticker may repaint, so bursts cannot strobe the panel. */ const TICKER_REPAINT_MS = 250 @@ -79,6 +83,8 @@ export interface DevUIOptions extends DevUISupportOptions { cwd?: string /** When the command started, so the panel can report a time to ready. */ startTime?: number + /** A frame already on screen, for the session to adopt. */ + start?: PanelStart } /** @@ -92,9 +98,13 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) setupShortcuts(context) return NOOP_CONTROLLER } + if (attached.has(session)) { + return attached.get(session)! + } const sessionStart = Date.now() session.stopStartupTicker() + session.onTeardown(() => attached.delete(session)) const { surface, events, state, surfaceText, render } = session const requests = new RequestLog() const version = options.version ?? state.version @@ -600,7 +610,7 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) refresh() - return { + const controller: DevUIController = { interactive: true, settleRestart, setStatus: (status, note) => { @@ -689,6 +699,9 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) }) }, } + + attached.set(session, controller) + return controller } /** diff --git a/packages/nuxt-cli/src/dev/tui/session.ts b/packages/nuxt-cli/src/dev/tui/session.ts index 589b68520..bd176449f 100644 --- a/packages/nuxt-cli/src/dev/tui/session.ts +++ b/packages/nuxt-cli/src/dev/tui/session.ts @@ -1,8 +1,8 @@ import type { ProgressSnapshot } from '../../utils/progress-snapshot' import type { ListenURL } from '../listen' import type { DevLogEvent } from './events' +import type { PanelStart, PanelStartOptions } from './first-frame' import type { PanelState } from './panel' -import type { DevUISupportOptions } from './support' import process from 'node:process' import { formatWithOptions, styleText } from 'node:util' @@ -14,13 +14,14 @@ import { debug, isEmittingCliLog, setLoggerImpl } from '../../utils/logger' import { getPkgVersion } from '../../utils/pkg' import { READY_MESSAGE } from '../../utils/progress-snapshot' import { startupElapsedMs } from '../../utils/startup-clock' -import { resolveBackground } from '../../utils/terminal-theme' +import { registerTerminalHost } from '../../utils/terminal-host' import { currentRequest, isServingRequest } from '../serving-state' import { queryBackground } from './background' import { DevEventLog, isBoxedNotice, normaliseMessage, noteRoute } from './events' +import { createPanelState, renderPanelState } from './first-frame' import { LOGO_FRAME_MS } from './logo' -import { DEFAULT_HINTS, describeListenURLs, renderPanel } from './panel' -import { resolveDevUISupport, supportsUnicode } from './support' +import { describeListenURLs } from './panel' +import { resolveDevUISupport } from './support' import { PanelSurface } from './surface' import { stripAnsi } from './width' @@ -106,6 +107,14 @@ export interface DevUISession { let current: DevUISession | undefined +/** Point the running session at the project the resolved arguments name. */ +let retargetCurrent: ((options: { cwd?: string, version?: string }) => void) | undefined + +/** Give the terminal back, if this process has taken it. */ +export function teardownDevUI(): void { + current?.teardown() +} + /** * Take over the terminal before anything is loaded. * @@ -113,32 +122,30 @@ let current: DevUISession | undefined * place and capturing before the dev server is initialised or the calm default * view would begin with a screen of build output. */ -export function beginDevUI(options: DevUISupportOptions & { version?: string, cwd?: string, startTime?: number } = {}): DevUISession | undefined { +export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = {}): DevUISession | undefined { const support = resolveDevUISupport(options) - if (current || !support.enabled) { - if (!current) { + if (current) { + // Only the arguments can refuse a panel that is already up; the terminal + // it was started in has not changed. + if (support.reason === 'flag' || support.reason === 'inspector') { debug(`Interactive dev UI disabled: ${support.reason}`) + current.teardown() + return undefined } + retargetCurrent?.(options) return current } + if (!support.enabled) { + debug(`Interactive dev UI disabled: ${support.reason}`) + return undefined + } const cwd = options.cwd || process.cwd() - const state: PanelState = { - status: 'starting', - version: options.version || getPkgVersion(cwd, 'nuxt') || getPkgVersion(cwd, 'nuxt-nightly') || undefined, - warnings: 0, - errors: 0, - ascii: !supportsUnicode(), - background: resolveBackground(), - loadStartedAt: options.startTime ?? Date.now(), - elapsedMs: 0, - progress: 0, - hints: DEFAULT_HINTS, - hintsDimmed: true, - } + const state = options.start?.state ?? createPanelState(options) const events = new DevEventLog() - const surface = new PanelSurface({ onResize: () => render() }) + const surface = options.start?.surface ?? new PanelSurface() + surface.onResize(() => render()) // Nothing waits on the answer: the mark is painted in colours that are safe // on either background and repainted in the exact ones if a reply arrives. @@ -180,9 +187,49 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw } function render(): void { - surface.render(renderPanel(state, process.stdout.columns || 80, process.stdout.rows || 24)) + renderPanelState(surface, state) } + // Startup questions are asked before the controller exists, so the terminal + // is lent from here too. + const tasks: Array<{ label: string, startedAt: number }> = [] + const releaseHost = registerTerminalHost({ + version: 1, + withTerminal: async (work) => { + const resume = surface.suspend() + try { + return await work() + } + finally { + if (!torn) { + resume() + } + } + }, + startTask: (label) => { + const task = { label, startedAt: Date.now() } + tasks.push(task) + state.task = tasks.at(-1) + repaint() + const forget = () => { + const index = tasks.indexOf(task) + if (index !== -1) { + tasks.splice(index, 1) + } + state.task = tasks.at(-1) + repaint() + } + return { + update: (next) => { + task.label = next + repaint() + }, + stop: forget, + } + }, + }) + teardownTasks.push(releaseHost) + let progressListener: (() => void) | undefined /** Repaint through the controller where one is attached, so it sees the change. */ @@ -406,6 +453,7 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw } torn = true current = undefined + retargetCurrent = undefined stopStartupTicker() clearImmediate(flushTimer) // A fatal startup error tears down and exits before the surface delay can @@ -518,7 +566,18 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw } current = session - render() - surface.padToBottom() + retargetCurrent = (next) => { + const nextCwd = next.cwd || cwd + const version = next.version || getPkgVersion(nextCwd, 'nuxt') || getPkgVersion(nextCwd, 'nuxt-nightly') || undefined + if (version === state.version) { + return + } + state.version = version + render() + } + if (!options.start) { + render() + surface.padToBottom() + } return session } diff --git a/packages/nuxt-cli/src/dev/tui/surface.ts b/packages/nuxt-cli/src/dev/tui/surface.ts index 141f47473..82daaa105 100644 --- a/packages/nuxt-cli/src/dev/tui/surface.ts +++ b/packages/nuxt-cli/src/dev/tui/surface.ts @@ -7,8 +7,6 @@ interface PatchableStream extends NodeJS.WriteStream { __write?: WriteFn } -const REPAINT_DELAY_MS = 16 - /** How long after the last resize event a drag is taken to be over. */ const RESIZE_SETTLE_MS = 120 @@ -51,7 +49,6 @@ export class PanelSurface { * mid-line does not. */ #atLineStart = true - #repaintTimer?: NodeJS.Timeout #restore: Array<() => void> = [] #raw: WriteFn #closed = false @@ -109,6 +106,11 @@ export class PanelSurface { this.#restore.push(() => process.stdout.off('resize', this.#onResize)) } + /** Repaint through this callback whenever the terminal is resized. */ + onResize(listener: () => void): void { + this.#resized = listener + } + /** Hand the terminal to a full-screen view, or take it back. */ get screenMode(): ScreenMode { return this.#screen @@ -171,8 +173,6 @@ export class PanelSurface { return () => {} } this.#suspended = true - clearTimeout(this.#repaintTimer) - this.#repaintTimer = undefined this.#erase() let resumed = false return () => { @@ -192,8 +192,13 @@ export class PanelSurface { if (this.#closed || this.#screen === 'alternate-screen' || unchanged) { return } - this.#erase() - this.#paint() + this.#paint(this.#eraseSequence()) + } + + /** Paint `lines` already resting on the last row, in one write. */ + renderAtBottom(lines: string[]): void { + this.#lines = lines + this.padToBottom() } /** @@ -207,14 +212,16 @@ export class PanelSurface { if (this.#closed || this.#suspended || this.#screen === 'alternate-screen') { return } - this.#erase() + const erase = this.#eraseSequence() const rows = process.stdout.rows || 24 const padding = rows - this.#lines.length - this.#rowsWritten - 1 - if (padding > 0) { - this.#raw('\n'.repeat(padding)) - this.#rowsWritten += padding + if (padding <= 0) { + this.#paint(erase) + return } - this.#paint() + this.#rowsWritten += padding + this.#atLineStart = true + this.#paint(`${erase}${'\n'.repeat(padding)}`) } #scheduleRecovery(): void { @@ -283,7 +290,6 @@ export class PanelSurface { this.#sink = undefined this.#reseat = false clearTimeout(this.#recoverTimer) - clearTimeout(this.#repaintTimer) this.#erase() // Whatever a view was holding is the session's last word on what happened, // and there is no longer anywhere to fold it away to. @@ -327,10 +333,11 @@ export class PanelSurface { } return true } + // The panel comes back in the same tick: a frame without it reads as a blink. this.#erase() this.#track(asText(chunk)) const result = original.call(stream, chunk, encoding, callback) - this.#scheduleRepaint() + this.#paint() return result } stream[target] = guarded @@ -375,10 +382,9 @@ export class PanelSurface { } #toScrollback(text: string): void { - this.#erase() + const erase = this.#eraseSequence() this.#track(text) - this.#raw(text) - this.#scheduleRepaint() + this.#paint(`${erase}${text}`) } #track(text: string | undefined): void { @@ -389,43 +395,49 @@ export class PanelSurface { this.#atLineStart = text.endsWith('\n') } - #scheduleRepaint(): void { - if (this.#repaintTimer) { - return - } - this.#repaintTimer = setTimeout(() => { - this.#repaintTimer = undefined - if (!this.#closed && !this.#painted) { - this.#paint() - } - }, REPAINT_DELAY_MS) - this.#repaintTimer.unref?.() - } - - #paint(): void { + /** `before` is written in the same call, for callers making room first. */ + #paint(before = ''): void { if (!this.#lines.length || this.#closed || this.#suspended || this.#screen === 'alternate-screen') { + if (before) { + this.#raw(before) + } return } + let prefix = before if (this.#reseat) { this.#reseat = false const rows = process.stdout.rows || 24 this.#rowsWritten = Math.max(0, rows - this.#lines.length) - this.#raw(`\u001B[${Math.max(1, rows - this.#lines.length + 1)};1H\u001B[J`) + // Seating at an absolute row supersedes whatever room the caller made. + prefix = `\u001B[${Math.max(1, rows - this.#lines.length + 1)};1H\u001B[J` this.#atLineStart = true } const leading = this.#atLineStart ? '' : '\n' - this.#raw(`${leading}${this.#lines.join('\n')}`) + this.#raw(`${prefix}${leading}${this.#lines.join('\n')}`) this.#painted = this.#lines.length } #erase(): void { + const sequence = this.#eraseSequence() + if (sequence) { + this.#raw(sequence) + } + } + + /** + * The sequence that takes the painted rows off the screen, marking them gone. + * + * Returned rather than written so a repaint sends it with the rows that + * replace it: two writes are two frames on a terminal that presents between. + */ + #eraseSequence(): string { if (!this.#painted) { - return + return '' } const up = this.#painted - 1 - this.#raw(`\r${up > 0 ? `\u001B[${up}A` : ''}\u001B[J`) this.#painted = 0 this.#atLineStart = true + return `\r${up > 0 ? `\u001B[${up}A` : ''}\u001B[J` } } diff --git a/packages/nuxt-cli/src/main.ts b/packages/nuxt-cli/src/main.ts index 65e5d90e4..22d0387c1 100644 --- a/packages/nuxt-cli/src/main.ts +++ b/packages/nuxt-cli/src/main.ts @@ -145,6 +145,7 @@ async function warnUnknownFlags(command: string, rawArgs: string[]): Promise confirm({ message: `Use ${styleText('cyan', replacement)} instead?`, initialValue: true })) + const answer = await withUserAttention(() => withDirectStdout(() => confirm({ message: `Use ${styleText('cyan', replacement)} instead?`, initialValue: true }))) restoreRawMode() // Ctrl-C at the prompt must abort, not fall through to running the command // with the flag the user was told is unknown. @@ -191,7 +192,8 @@ async function reportUnknownCommand(command: string, rawArgs: string[]): Promise logger.warn(`Unknown command ${styleText('cyan', command)}.`) const { confirm, isCancel } = await import('@clack/prompts') const { restoreRawMode, withDirectStdout } = await import('./utils/console') - const answer = await withDirectStdout(() => confirm({ message: `Run ${styleText('cyan', `nuxt ${suggestion}`)} instead?`, initialValue: true })) + const { withUserAttention } = await import('./utils/startup-clock') + const answer = await withUserAttention(() => withDirectStdout(() => confirm({ message: `Run ${styleText('cyan', `nuxt ${suggestion}`)} instead?`, initialValue: true }))) restoreRawMode() if (isCancel(answer)) { diff --git a/packages/nuxt-cli/src/utils/console.ts b/packages/nuxt-cli/src/utils/console.ts index 186230139..1f3390e67 100644 --- a/packages/nuxt-cli/src/utils/console.ts +++ b/packages/nuxt-cli/src/utils/console.ts @@ -7,8 +7,8 @@ import { consola } from 'consola' import { resolveModulePath } from 'exsolve' import { isRemotePeerError } from './errors' -import { tryResolveNuxt } from './kit' import { debug } from './logger' +import { tryResolveNuxt } from './resolve-nuxt' import { withStartupClockPaused } from './startup-clock' import { isInteractiveSession, trackOutputSpacing } from './stdout' import { useTerminalHost } from './terminal-host' diff --git a/packages/nuxt-cli/src/utils/kit.ts b/packages/nuxt-cli/src/utils/kit.ts index ca46174d8..7cc801fd7 100644 --- a/packages/nuxt-cli/src/utils/kit.ts +++ b/packages/nuxt-cli/src/utils/kit.ts @@ -1,7 +1,7 @@ import { pathToFileURL } from 'node:url' import { resolveModulePath } from 'exsolve' import { ActionableError } from './errors' -import { withNodePath } from './paths' +import { tryResolveNuxt } from './resolve-nuxt' // `exsolve` and Node.js word their resolution failures differently const KIT_NOT_FOUND_RE = /Cannot (?:find|resolve) module ['"]@nuxt\/kit['"]/ @@ -21,13 +21,3 @@ export async function loadKit(rootDir: string): Promise relative(dir, absolute) || absolute }) } - -export function withNodePath(path: string) { - return [path, ...(process.env.NODE_PATH?.split(delimiter) || [])] -} diff --git a/packages/nuxt-cli/src/utils/pkg.ts b/packages/nuxt-cli/src/utils/pkg.ts index f62a72a7c..532f6254c 100644 --- a/packages/nuxt-cli/src/utils/pkg.ts +++ b/packages/nuxt-cli/src/utils/pkg.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { resolveModulePath } from 'exsolve' -import { tryResolveNuxt } from './kit' +import { tryResolveNuxt } from './resolve-nuxt' export function getPkgVersion(cwd: string, pkg: string, options?: PkgJSONOptions) { const pkgJSON = getPkgJSON(cwd, pkg, options) diff --git a/packages/nuxt-cli/src/utils/resolve-nuxt.ts b/packages/nuxt-cli/src/utils/resolve-nuxt.ts new file mode 100644 index 000000000..1f9b94830 --- /dev/null +++ b/packages/nuxt-cli/src/utils/resolve-nuxt.ts @@ -0,0 +1,19 @@ +import { delimiter } from 'node:path' +import process from 'node:process' +import { resolveModulePath } from 'exsolve' + +/** `path` first, then whatever `NODE_PATH` adds, as resolution roots. */ +export function withNodePath(path: string): string[] { + return [path, ...(process.env.NODE_PATH?.split(delimiter) || [])] +} + +/** Where the project's `nuxt` lives, nightly first, or `null` if it has none. */ +export function tryResolveNuxt(rootDir: string): string | null { + for (const pkg of ['nuxt-nightly', 'nuxt']) { + const path = resolveModulePath(pkg, { from: withNodePath(rootDir), try: true }) + if (path) { + return path + } + } + return null +} diff --git a/packages/nuxt-cli/src/utils/startup-clock.ts b/packages/nuxt-cli/src/utils/startup-clock.ts index afae807ac..a1eaf6ff3 100644 --- a/packages/nuxt-cli/src/utils/startup-clock.ts +++ b/packages/nuxt-cli/src/utils/startup-clock.ts @@ -5,6 +5,8 @@ * than how long a question sat on screen. */ +import { useTerminalHost } from './terminal-host' + /** Closed pauses, as `[start, end]`. Only a handful happen in a session. */ const pauses: Array<[number, number]> = [] let pausedAt: number | undefined @@ -34,6 +36,17 @@ export async function withStartupClockPaused(work: () => Promise): Promise } } +/** + * {@link withStartupClockPaused}, with the terminal to itself. + * + * For work that talks to the user without going through consola's prompt, + * which borrows the terminal itself. + */ +export function withUserAttention(work: () => Promise): Promise { + const host = useTerminalHost() + return withStartupClockPaused(host ? () => host.withTerminal(work) : work) +} + /** * Milliseconds since `since`, not counting paused stretches. * diff --git a/packages/nuxt-cli/test/unit/dev-boot.spec.ts b/packages/nuxt-cli/test/unit/dev-boot.spec.ts new file mode 100644 index 000000000..c40d7d1c6 --- /dev/null +++ b/packages/nuxt-cli/test/unit/dev-boot.spec.ts @@ -0,0 +1,94 @@ +import process from 'node:process' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +const close = vi.fn() +const paintFirstFrame = vi.fn(() => ({ surface: { close }, state: {} })) +const setupDevUI = vi.fn(() => Promise.resolve({})) + +vi.mock('../../src/dev/tui/first-frame', () => ({ paintFirstFrame })) +vi.mock('../../src/dev/tui/controller', () => ({ setupDevUI })) + +/** Run the entry as if `argv` had been passed, on a terminal unless told otherwise. */ +async function boot(argv: string[], terminal = true) { + const descriptors = { + argv: Object.getOwnPropertyDescriptor(process, 'argv')!, + stdout: Object.getOwnPropertyDescriptor(process, 'stdout')!, + stdin: Object.getOwnPropertyDescriptor(process, 'stdin')!, + } + Object.defineProperty(process, 'argv', { value: ['node', 'nuxi', ...argv], configurable: true }) + Object.defineProperty(process, 'stdout', { value: { ...process.stdout, isTTY: terminal }, configurable: true }) + Object.defineProperty(process, 'stdin', { value: { ...process.stdin, isTTY: terminal }, configurable: true }) + try { + const { bootDevUI } = await import('../../src/boot') + await bootDevUI() + } + finally { + for (const [key, descriptor] of Object.entries(descriptors)) { + Object.defineProperty(process, key, descriptor) + } + } +} + +describe('dev panel from the cli entry', () => { + afterEach(() => { + paintFirstFrame.mockClear() + setupDevUI.mockClear() + close.mockClear() + setupDevUI.mockImplementation(() => Promise.resolve({})) + }) + + it('should take the terminal for `nuxt dev`', async () => { + await boot(['dev']) + + expect(paintFirstFrame).toHaveBeenCalledWith({ cwd: undefined, startTime: undefined }) + expect(setupDevUI).toHaveBeenCalled() + }) + + it('should give the terminal back when the panel cannot be finished', async () => { + setupDevUI.mockImplementationOnce(() => Promise.reject(new Error('no panel'))) + + await expect(boot(['dev'])).rejects.toThrow('no panel') + expect(close).toHaveBeenCalled() + }) + + it('should read the project directory from `--cwd` or the positional', async () => { + await boot(['dev', '--cwd', 'apps/site']) + expect(paintFirstFrame).toHaveBeenLastCalledWith(expect.objectContaining({ cwd: 'apps/site' })) + + await boot(['dev', '--cwd=apps/site']) + expect(paintFirstFrame).toHaveBeenLastCalledWith(expect.objectContaining({ cwd: 'apps/site' })) + + await boot(['dev', 'apps/site', '--port', '3001']) + expect(paintFirstFrame).toHaveBeenLastCalledWith(expect.objectContaining({ cwd: 'apps/site' })) + }) + + it.each([ + ['another command', ['build']], + ['help', ['dev', '--help']], + ['a version check', ['dev', '--version']], + ['the panel turned off', ['dev', '--no-tui']], + ['an explicit --tui value', ['dev', '--tui', 'false']], + ['the inspector', ['dev', '--inspect']], + ['the profiler', ['dev', '--profile=verbose']], + ])('should leave %s to the command', async (_case, argv) => { + await boot(argv) + + expect(paintFirstFrame).not.toHaveBeenCalled() + expect(setupDevUI).not.toHaveBeenCalled() + }) + + it('should not load the panel when the output is not a terminal', async () => { + await boot(['dev'], false) + + expect(paintFirstFrame).not.toHaveBeenCalled() + }) + + it('should leave the command to start the session when the terminal refuses the panel', async () => { + paintFirstFrame.mockReturnValueOnce(undefined as never) + + await boot(['dev']) + + expect(setupDevUI).not.toHaveBeenCalled() + }) +}) diff --git a/packages/nuxt-cli/test/unit/dev-first-frame.spec.ts b/packages/nuxt-cli/test/unit/dev-first-frame.spec.ts new file mode 100644 index 000000000..97a787cf2 --- /dev/null +++ b/packages/nuxt-cli/test/unit/dev-first-frame.spec.ts @@ -0,0 +1,86 @@ +import process from 'node:process' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { paintFirstFrame } from '../../src/dev/tui/first-frame' +import { beginDevUI } from '../../src/dev/tui/session' + +/** Pretend stdout and stdin are a terminal of a usable size. */ +function withTerminal(run: (chunks: string[]) => T, terminal = true): T { + const chunks: string[] = [] + const saved = [ + ['stdout', 'isTTY', Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')], + ['stdout', 'columns', Object.getOwnPropertyDescriptor(process.stdout, 'columns')], + ['stdout', 'rows', Object.getOwnPropertyDescriptor(process.stdout, 'rows')], + ['stdin', 'isTTY', Object.getOwnPropertyDescriptor(process.stdin, 'isTTY')], + ['stdin', 'setRawMode', Object.getOwnPropertyDescriptor(process.stdin, 'setRawMode')], + ] as const + Object.defineProperty(process.stdout, 'isTTY', { value: terminal, configurable: true }) + Object.defineProperty(process.stdout, 'columns', { value: 100, configurable: true }) + Object.defineProperty(process.stdout, 'rows', { value: 30, configurable: true }) + Object.defineProperty(process.stdin, 'isTTY', { value: terminal, configurable: true }) + Object.defineProperty(process.stdin, 'setRawMode', { value: () => process.stdin, configurable: true }) + const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + chunks.push(String(chunk)) + return true + }) + try { + return run(chunks) + } + finally { + write.mockRestore() + for (const [stream, key, descriptor] of saved) { + if (descriptor) { + Object.defineProperty(process[stream], key, descriptor) + } + } + } +} + +describe('first panel frame', () => { + afterEach(() => { + vi.resetModules() + }) + + it('should paint a starting panel', () => { + withTerminal((chunks) => { + const start = paintFirstFrame({ version: '4.5.2', ci: false, test: false }) + + expect(start).toBeDefined() + expect(chunks.join('')).toContain('4.5.2') + start!.surface.close({ keep: false }) + }) + }) + + it('should paint nothing where the panel is not supported', () => { + withTerminal((chunks) => { + expect(paintFirstFrame({ version: '4.5.2', ci: false, test: false })).toBeUndefined() + expect(chunks.join('')).toBe('') + }, false) + }) + + it('should repaint itself when the window is resized before the session exists', () => { + withTerminal((chunks) => { + const start = paintFirstFrame({ version: '4.5.2', ci: false, test: false })! + chunks.length = 0 + + Object.defineProperty(process.stdout, 'columns', { value: 60, configurable: true }) + process.stdout.emit('resize') + + // An erase with nothing after it would leave the panel off the screen. + expect(chunks.join('')).toContain('4.5.2') + start.surface.close({ keep: false }) + }) + }) + + it('should let the session adopt the frame already on screen', () => { + withTerminal(() => { + const start = paintFirstFrame({ version: '4.5.2', ci: false, test: false })! + const session = beginDevUI({ version: '4.5.2', ci: false, test: false, start }) + + expect(session).toBeDefined() + expect(session!.surface).toBe(start.surface) + session!.teardown() + }) + }) +}) diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index 0d33d7758..b8b433aae 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -1906,6 +1906,98 @@ describe('route overlay', () => { }) describe('panel surface', () => { + /** Every write the surface makes, with the panel's own writes marked. */ + function recordWrites(): { writes: string[], restore: () => void } { + const writes: string[] = [] + const isTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }) + const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + writes.push(String(chunk)) + return true + }) + return { + writes, + restore: () => { + write.mockRestore() + if (isTTY) { + Object.defineProperty(process.stdout, 'isTTY', isTTY) + } + else { + Reflect.deleteProperty(process.stdout, 'isTTY') + } + }, + } + } + + /** + * Each of these pins one write to one frame. Splitting any of them in two + * puts a screen with no panel on it in front of the user. + */ + it('makes the room the panel needs and paints it in one write', () => { + const { writes, restore } = recordWrites() + const surface = new PanelSurface() + try { + surface.renderAtBottom(['--- footer ---']) + + expect(writes).toHaveLength(1) + expect(writes[0]).toContain('--- footer ---') + } + finally { + surface.close() + restore() + } + }) + + it('erases and repaints in one write', () => { + const { writes, restore } = recordWrites() + try { + const surface = new PanelSurface() + surface.renderAtBottom(['--- footer ---']) + writes.length = 0 + surface.render(['--- footer ---', 'second row']) + surface.close() + } + finally { + restore() + } + + // eslint-disable-next-line no-control-regex + expect(writes[0]).toMatch(/\u001B\[J[\s\S]*second row/) + }) + + it('sends a line going above the panel together with the panel', () => { + const { writes, restore } = recordWrites() + try { + const surface = new PanelSurface() + surface.renderAtBottom(['--- footer ---']) + writes.length = 0 + surface.writeAbove('a line above') + surface.close() + } + finally { + restore() + } + + expect(writes[0]).toContain('a line above') + expect(writes[0]).toContain('--- footer ---') + }) + + it('brings the panel back in the same tick as output it cannot merge with', async () => { + const { writes, restore } = recordWrites() + try { + const surface = new PanelSurface() + surface.renderAtBottom(['--- footer ---']) + writes.length = 0 + process.stdout.write('output the panel cannot fold in\n') + // No waiting: a repaint on a timer would be a frame with no panel in it. + expect(writes.join('')).toContain('--- footer ---') + surface.close() + } + finally { + restore() + } + }) + it('keeps the panel pinned below log output', async () => { const renderer = await render(async () => { const surface = new PanelSurface() diff --git a/packages/nuxt-cli/test/unit/preflight.spec.ts b/packages/nuxt-cli/test/unit/preflight.spec.ts index d4c528230..20cecc9ab 100644 --- a/packages/nuxt-cli/test/unit/preflight.spec.ts +++ b/packages/nuxt-cli/test/unit/preflight.spec.ts @@ -16,7 +16,8 @@ vi.mock('@clack/prompts', async (importOriginal) => { const resolvedNuxt = vi.hoisted(() => ({ path: null as string | null, error: null as Error | null })) -vi.mock('../../src/utils/kit', () => ({ +vi.mock('../../src/utils/resolve-nuxt', async importOriginal => ({ + ...await importOriginal(), tryResolveNuxt: () => { if (resolvedNuxt.error) { throw resolvedNuxt.error diff --git a/packages/nuxt-cli/tsdown.config.ts b/packages/nuxt-cli/tsdown.config.ts index 013cdb8e0..883f9bb50 100644 --- a/packages/nuxt-cli/tsdown.config.ts +++ b/packages/nuxt-cli/tsdown.config.ts @@ -10,7 +10,7 @@ export const packaging: PackagingContract = { } export default defineCliConfig({ - entry: ['src/index.ts', 'src/dev/index.ts'], + entry: ['src/index.ts', 'src/boot.ts', 'src/dev/index.ts'], deps: { onlyBundle: ['@bomb.sh/tab', 'citty', 'h3', 'nypm', '@speed-highlight/core'], neverBundle: PARSER_PACKAGES }, ...packaging, })