diff --git a/docs/dev.md b/docs/dev.md index eb9dd09bc..2511b5851 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -88,6 +88,8 @@ In an interactive terminal, `nuxt dev` renders a pinned panel: the server URLs, | `?` | Show all shortcuts | | `q` | Quit | +Inside a view, `y` copies the selected row and `shift-y` copies every row the filters and search leave, keeping the newest when there is too much to paste. In the info view, `shift-y` copies the [`nuxt info`](/docs/api/commands/info) table instead. + Pass `--no-tui` to stream logs instead, which is also what `NUXT_TUI=plain` does for good. `NUXT_TUI=1` forces the UI on where the environment checks would otherwise turn it off, but never where the output is piped or redirected. ![nuxt dev with plain output](/capture/output/nuxt-dev-plain-static.svg) diff --git a/packages/nuxi/src/run.ts b/packages/nuxi/src/run.ts index e1c6a8ad6..7e8a4ea69 100644 --- a/packages/nuxi/src/run.ts +++ b/packages/nuxi/src/run.ts @@ -4,7 +4,7 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { // Programmatic usage fallback startTime: Date.now(), entry: fileURLToPath( - new URL('../../bin/nuxi.mjs', import.meta.url), + new URL('../bin/nuxi.mjs', import.meta.url), ), } diff --git a/packages/nuxt-cli/src/commands/info.ts b/packages/nuxt-cli/src/commands/info.ts index 7df7817cd..91672b30f 100644 --- a/packages/nuxt-cli/src/commands/info.ts +++ b/packages/nuxt-cli/src/commands/info.ts @@ -195,6 +195,18 @@ async function resolveDependencyVersion( ?? devDependencies[name] } +/** Render `nuxt info --json` output as the Markdown table `nuxt info` copies. */ +export function formatJsonAsMarkdownTable(json: Record): string { + const labels = Object.fromEntries(Object.entries(JSON_KEYS).map(([label, key]) => [key, label])) + const info: Record = {} + for (const [key, value] of Object.entries(json)) { + if (labels[key]) { + info[labels[key]] = Array.isArray(value) ? value.map(item => `\`${item}\``).join(', ') : (value as string | null) ?? undefined + } + } + return formatMarkdownTable(info) +} + export function formatMarkdownTable(info: Record): string { const entries = Object.entries(info).map(([label, value]) => [label, value || '-'] as const) const labelWidth = Math.max(...entries.map(([label]) => label.length + 4)) diff --git a/packages/nuxt-cli/src/dev/tui/help-overlay.ts b/packages/nuxt-cli/src/dev/tui/help-overlay.ts index c56e02b55..480446ee7 100644 --- a/packages/nuxt-cli/src/dev/tui/help-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/help-overlay.ts @@ -13,6 +13,12 @@ export interface HelpEntry { description: string } +const VIEW_ENTRIES: HelpEntry[] = [ + { keys: ['y', 'enter'], description: 'copy the selected line' }, + { keys: ['Y'], description: 'copy the whole view' }, + { keys: ['/'], description: 'search' }, +] + /** The keyboard shortcuts, as a view rather than a wall of log output. */ export class HelpOverlay extends ScreenOverlay { #entries: () => HelpEntry[] @@ -32,10 +38,15 @@ export class HelpOverlay extends ScreenOverlay { protected renderEntries(): OverlayEntry[] { const entries = this.#entries() - const width = Math.max(...entries.map(entry => formatKeys(entry).length)) - return entries.map(entry => ({ + const width = Math.max(...[...entries, ...VIEW_ENTRIES].map(entry => formatKeys(entry).length)) + const row = (entry: HelpEntry): OverlayEntry => ({ lines: [`${styleText('bold', formatKeys(entry).padEnd(width))} ${styleText(MUTED, entry.description)}`], - })) + }) + return [ + ...entries.map(row), + { lines: ['', styleText('bold', 'in a view')] }, + ...VIEW_ENTRIES.map(row), + ] } protected renderHints(columns: number): string { diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index feb028fc8..6ac1acf06 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -31,6 +31,7 @@ import { attachKeys } from './keys' import { LOGO_FRAME_MS } from './logo' import { LogOverlay } from './overlay' import { describeListenURLs, URL_LABELS, URL_STYLES } from './panel' +import { readProjectReport } from './project-report' import { RequestOverlay } from './request-overlay' import { RequestLog } from './requests' import { RouteOverlay } from './route-overlay' @@ -140,6 +141,7 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) write, release, () => qrCode, + () => readProjectReport(cwd), ) const views = [overlay, trafficOverlay, routeOverlay, helpOverlay, infoOverlay] const openOverlay = () => views.find(view => view.isOpen) diff --git a/packages/nuxt-cli/src/dev/tui/info-overlay.ts b/packages/nuxt-cli/src/dev/tui/info-overlay.ts index ab2f03338..ae306b8ee 100644 --- a/packages/nuxt-cli/src/dev/tui/info-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/info-overlay.ts @@ -25,12 +25,15 @@ const VALUE_STYLES: Array<{ pattern: RegExp, style: Parameters export class InfoOverlay extends ScreenOverlay { #sections: () => InfoSection[] #panel: () => string | undefined + #report?: () => Promise constructor( sections: () => InfoSection[], write: (chunk: string) => void, onClose: () => void, panel: () => string | undefined = () => undefined, + /** The text `Y` copies in place of the rows. */ + report?: () => Promise, ) { super({ write, @@ -44,6 +47,15 @@ export class InfoOverlay extends ScreenOverlay { }) this.#sections = sections this.#panel = panel + this.#report = report + } + + protected async copyAllText(): Promise { + if (!this.#report) { + return undefined + } + this.notify('collecting project info…') + return this.#report() } protected get closeKeys(): readonly string[] { @@ -68,13 +80,17 @@ export class InfoOverlay extends ScreenOverlay { return withSidePanel(rows, this.#panel(), columns).map(line => ({ lines: [line], - // Copying a whole info screen is rarely useful; a single value is. copy: stripAnsi(line).trim().split(/\s{2,}/).at(-1), })) } protected renderHints(columns: number): string { - return formatHints([['q', 'close']], columns) + return formatHints([ + ['↑/↓', 'select'], + ['y', 'copy'], + ...this.#report ? [['Y', 'copy for an issue'] as [string, string]] : [], + ['q', 'close'], + ], columns) } } diff --git a/packages/nuxt-cli/src/dev/tui/overlay.ts b/packages/nuxt-cli/src/dev/tui/overlay.ts index d05c3dbdc..415324227 100644 --- a/packages/nuxt-cli/src/dev/tui/overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/overlay.ts @@ -107,7 +107,8 @@ export class LogOverlay extends ScreenOverlay { ['c/b/r', 'cli/build/runtime'], ['/', 'search'], ['x', 'clear'], - ['enter', 'copy'], + ['y', 'copy'], + ['Y', 'copy all'], ['q', 'close'], ], columns) } diff --git a/packages/nuxt-cli/src/dev/tui/project-report.ts b/packages/nuxt-cli/src/dev/tui/project-report.ts new file mode 100644 index 000000000..f63d92847 --- /dev/null +++ b/packages/nuxt-cli/src/dev/tui/project-report.ts @@ -0,0 +1,14 @@ +import { execFile } from 'node:child_process' +import process from 'node:process' +import { promisify } from 'node:util' + +/** The `nuxt info` table for the project in `cwd`, gathered in a separate process. */ +export async function readProjectReport(cwd: string): Promise { + const { stdout } = await promisify(execFile)( + process.execPath, + [globalThis.__nuxt_cli__!.entry, 'info', '--json', cwd], + { cwd, timeout: 30_000 }, + ) + const { formatJsonAsMarkdownTable } = await import('../../commands/info') + return formatJsonAsMarkdownTable(JSON.parse(stdout)) +} diff --git a/packages/nuxt-cli/src/dev/tui/request-overlay.ts b/packages/nuxt-cli/src/dev/tui/request-overlay.ts index 3cbf8a55b..1516e5685 100644 --- a/packages/nuxt-cli/src/dev/tui/request-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/request-overlay.ts @@ -132,7 +132,8 @@ export class RequestOverlay extends ScreenOverlay { if (this.#detail) { return formatHints([ ['↑/↓', 'select'], - ['enter', 'copy'], + ['y', 'copy'], + ['Y', 'copy all'], ['esc', 'back'], ], columns) } @@ -146,6 +147,7 @@ export class RequestOverlay extends ScreenOverlay { ['b', 'bundler'], ['/', 'search'], ['y', 'copy'], + ['Y', 'copy all'], ['q', 'close'], ], columns) } diff --git a/packages/nuxt-cli/src/dev/tui/route-overlay.ts b/packages/nuxt-cli/src/dev/tui/route-overlay.ts index 81e53d20c..b6f5bec65 100644 --- a/packages/nuxt-cli/src/dev/tui/route-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/route-overlay.ts @@ -108,7 +108,8 @@ export class RouteOverlay extends ScreenOverlay { ['s', 'server'], ['a', 'all'], ['/', 'search'], - ['enter', 'copy'], + ['y', 'copy'], + ['Y', 'copy all'], ['q', 'close'], ], columns) } diff --git a/packages/nuxt-cli/src/dev/tui/screen.ts b/packages/nuxt-cli/src/dev/tui/screen.ts index cde3825d6..25d3d4956 100644 --- a/packages/nuxt-cli/src/dev/tui/screen.ts +++ b/packages/nuxt-cli/src/dev/tui/screen.ts @@ -12,6 +12,9 @@ const RENDER_DELAY_MS = 50 /** How long a copy confirmation stays in the hint line. */ const NOTICE_MS = 2000 +/** Most characters copying a whole view puts on the clipboard, keeping the newest entries. */ +const COPY_ALL_MAX_CHARS = 60_000 + /** Marks the selected entry; the same width is reserved on every row. */ const SELECTED_GUTTER = '▎ ' const GUTTER = ' ' @@ -77,6 +80,11 @@ export abstract class ScreenOverlay { return false } + /** Text `Y` copies instead of every entry's own. */ + protected copyAllText(): Promise | string | undefined { + return undefined + } + get isOpen(): boolean { return this.#open } @@ -160,7 +168,7 @@ export abstract class ScreenOverlay { void this.#copySelected() return case 'y': - void this.#copySelected() + void (key.sequence === 'Y' ? this.#copyAll() : this.#copySelected()) return default: if ((key.name && this.closeKeys.includes(key.name)) || (key.sequence && this.closeKeys.includes(key.sequence))) { @@ -329,29 +337,57 @@ export abstract class ScreenOverlay { const entries = this.#entries() const text = this.#selected === undefined ? undefined : entries[this.#selected]?.copy if (!text) { - this.#notify('nothing selected to copy') + this.notify('nothing selected to copy') return } + await this.#copy(text, 'copied') + } + + async #copyAll(): Promise { + let custom: string | undefined + try { + custom = await this.copyAllText() + } + catch { + this.notify('could not gather what to copy') + return + } + if (custom) { + return this.#copy(custom.slice(0, COPY_ALL_MAX_CHARS), 'copied') + } + const texts = this.#entries().map(entry => entry.copy).filter(text => !!text) as string[] + if (!texts.length) { + this.notify('nothing to copy') + return + } + let length = 0 + let start = texts.length + while (start > 0 && length + texts[start - 1]!.length + 1 <= COPY_ALL_MAX_CHARS) { + length += texts[--start]!.length + 1 + } + const kept = start === texts.length ? [texts.at(-1)!.slice(0, COPY_ALL_MAX_CHARS)] : texts.slice(start) + const count = kept.length === texts.length ? `${kept.length}` : `the last ${kept.length} of ${texts.length}` + await this.#copy(kept.join('\n'), `copied ${count} ${texts.length === 1 ? 'entry' : 'entries'}`) + } + + async #copy(text: string, done: string): Promise { try { const { writeText } = await import('tinyclip') // What lands on the clipboard is going into an issue or a search box, // so it should carry no colour or hyperlink escapes. await writeText(stripAnsi(text)) - this.#notify('copied to clipboard') + this.notify(`${done} to clipboard`) } catch { - this.#notify('no clipboard available') + this.notify('no clipboard available') } } - #notify(text: string): void { + /** Replace the hint line with `text` for a moment. */ + protected notify(text: string): void { this.#notice = { text: ` ${text}`, until: Date.now() + NOTICE_MS } - this.render() - setTimeout(() => { - if (this.#open) { - this.render() - } - }, NOTICE_MS + 50).unref?.() + this.repaint() + setTimeout(() => this.repaint(), NOTICE_MS + 50).unref?.() } #scheduleRender(): void { diff --git a/packages/nuxt-cli/src/run.ts b/packages/nuxt-cli/src/run.ts index aad0501c4..9d0b2c7c0 100644 --- a/packages/nuxt-cli/src/run.ts +++ b/packages/nuxt-cli/src/run.ts @@ -12,7 +12,7 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { // Programmatic usage fallback startTime: Date.now(), entry: fileURLToPath( - new URL('../../bin/nuxi.mjs', import.meta.url), + new URL('../bin/nuxi.mjs', import.meta.url), ), devEntry: fileURLToPath( new URL('../dev/index.mjs', import.meta.url), diff --git a/packages/nuxt-cli/test/unit/commands/info-run.spec.ts b/packages/nuxt-cli/test/unit/commands/info-run.spec.ts index 6a4729f1b..d2ef904b8 100644 --- a/packages/nuxt-cli/test/unit/commands/info-run.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/info-run.spec.ts @@ -6,7 +6,7 @@ import { runCommand } from 'citty' import { join } from 'pathe' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import info from '../../../src/commands/info' +import info, { formatJsonAsMarkdownTable } from '../../../src/commands/info' import { render, screen } from '../../utils/terminal' vi.mock('tinyclip', () => ({ writeText: () => Promise.reject(new Error('no clipboard')) })) @@ -83,6 +83,19 @@ describe('info command', () => { expect(payload.modules).toEqual(['./modules/a, b.ts']) }) + it('should render `--json` output as the table it prints', async () => { + await writeFile(join(cwd, 'package.json'), JSON.stringify({ name: 'app', private: true })) + await writeFile(join(cwd, 'nuxt.config.mjs'), `export default { modules: ['@nuxt/image'], app: {} }`) + + const table = formatJsonAsMarkdownTable(await runInfoJSON()) + vi.restoreAllMocks() + const output = await runInfo() + + for (const row of table.trim().split('\n')) { + expect(output).toContain(row) + } + }) + it('should still report on a project with no config', async () => { await writeFile(join(cwd, 'package.json'), JSON.stringify({ name: 'app', private: true })) diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index cd2f08fbb..e0179c7b8 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -1480,6 +1480,49 @@ describe('log overlay', () => { expect(copied[0]).not.toContain('\u001B') }) + it('copies every entry the filters leave', async () => { + const events = new DevEventLog() + events.push(event({ message: 'all good', source: 'cli' })) + events.push(event({ message: 'boom\n at handler (server/api/x.ts:3:9)', level: 0, type: 'error', request: 'GET /x', requestId: '1', source: 'runtime' })) + events.push(event({ message: 'careful', level: 1, type: 'warn', tag: 'vite', source: 'build' })) + const { overlay, lastFrame } = create(events) + overlay.open() + overlay.handleKey({ name: 'w' }) + overlay.handleKey({ name: 'y', sequence: 'Y' }) + + await vi.waitFor(() => expect(copied).toHaveLength(1)) + const lines = copied[0]!.split('\n') + expect(lines[0]).toMatch(/GET \/x boom$/) + expect(lines[1]).toContain('at handler (server/api/x.ts:3:9)') + expect(lines[2]).toMatch(/careful$/) + expect(copied[0]).not.toContain('all good') + await vi.waitFor(() => expect(strip(lastFrame())).toContain('copied 2 entries to clipboard')) + }) + + it('keeps the newest entries when there are too many to paste', async () => { + const events = new DevEventLog() + for (let index = 0; index < 1500; index++) { + events.push(event({ message: `entry ${index} ${'x'.repeat(80)}`, source: 'runtime' })) + } + const { overlay, lastFrame } = create(events) + overlay.open() + overlay.handleKey({ name: 'y', sequence: 'Y' }) + + await vi.waitFor(() => expect(copied).toHaveLength(1)) + expect(copied[0]!.length).toBeLessThanOrEqual(60_000) + expect(copied[0]).toContain('entry 1499 ') + expect(copied[0]).not.toContain('entry 0 ') + await vi.waitFor(() => expect(strip(lastFrame())).toMatch(/copied the last \d+ of 1500 entries/)) + }) + + it('says so when there is nothing to copy at all', async () => { + const { overlay, lastFrame } = create(new DevEventLog()) + overlay.open() + overlay.handleKey({ name: 'y', sequence: 'Y' }) + await vi.waitFor(() => expect(strip(lastFrame())).toContain('nothing to copy')) + expect(copied).toHaveLength(0) + }) + it('says so when there is nothing selected to copy', async () => { const events = new DevEventLog() events.push(event({ message: 'anything' })) @@ -1712,6 +1755,13 @@ describe('help overlay', () => { expect(lastFrame()).toContain('restart the dev server') }) + it('lists the keys available inside a view', () => { + const { overlay, lastFrame } = create() + overlay.open() + expect(lastFrame()).toContain('in a view') + expect(lastFrame()).toMatch(/shift-y\s+copy the whole view/) + }) + it('closes on h as well as q', () => { const { overlay, closed } = create() overlay.open() @@ -1756,6 +1806,60 @@ describe('info overlay', () => { expect(frame.indexOf('versions')).toBeLessThan(frame.indexOf('urls')) }) + it('copies the issue report rather than the rows it is showing', async () => { + copied.length = 0 + const overlay = new InfoOverlay( + () => [{ heading: 'urls', entries: [['local', 'http://localhost:3000/']] }], + () => {}, + () => {}, + () => 'QR-A\nQR-B', + async () => '| **Nuxt version** | `4.5.1` |', + ) + overlay.open() + overlay.handleKey({ name: 'y', sequence: 'Y' }) + + await vi.waitFor(() => expect(copied).toHaveLength(1)) + expect(copied[0]).toBe('| **Nuxt version** | `4.5.1` |') + }) + + it('does not draw once closed while the report is gathered', async () => { + copied.length = 0 + let output = '' + let finish!: (text: string) => void + const overlay = new InfoOverlay(() => [], (chunk) => { + output += chunk + }, () => {}, undefined, () => new Promise((resolve) => { + finish = resolve + })) + overlay.open() + overlay.handleKey({ name: 'y', sequence: 'Y' }) + overlay.close() + const closedAt = output.length + finish('| report |') + + await vi.waitFor(() => expect(copied).toEqual(['| report |'])) + expect(output.length).toBe(closedAt) + }) + + it('says so rather than copying its rows when the report cannot be gathered', async () => { + copied.length = 0 + let output = '' + const overlay = new InfoOverlay( + () => [{ heading: 'urls', entries: [['local', 'http://localhost:3000/']] }], + (chunk) => { + output += chunk + }, + () => {}, + () => 'QR-A\nQR-B', + () => Promise.reject(new Error('broken config')), + ) + overlay.open() + overlay.handleKey({ name: 'y', sequence: 'Y' }) + + await vi.waitFor(() => expect(strip(output)).toContain('could not gather what to copy')) + expect(copied).toHaveLength(0) + }) + it('puts a side panel to the right when there is room', () => { let output = '' const overlay = new InfoOverlay(