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
72 changes: 71 additions & 1 deletion packages/nuxt-cli/src/dev/tui/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ interface PatchableStream extends NodeJS.WriteStream {
}

const REPAINT_DELAY_MS = 16

/** How long after the last resize event a drag is taken to be over. */
const RESIZE_SETTLE_MS = 120

const PENDING_CHUNK_LIMIT = 2000

/**
Expand Down Expand Up @@ -58,10 +62,20 @@ export class PanelSurface {
#pending: PendingOutput[] = []
#rowsWritten = 0
#rows = process.stdout.rows || 24
#columns = process.stdout.columns || 80
#onResize = () => {
const rows = process.stdout.rows || 24
const columns = process.stdout.columns || 80
const grew = rows > this.#rows
const rewrapped = columns !== this.#columns
this.#rows = rows
this.#columns = columns
if (this.#screen === 'alternate-screen') {
this.#resizedWhileHidden = true
this.#rewrappedWhileHidden ||= rewrapped
this.#resized?.()
return
}
// The owner re-renders; the cached lines were laid out for the old width.
this.#erase()
this.#resized?.()
Expand All @@ -71,8 +85,16 @@ export class PanelSurface {
if (grew) {
this.padToBottom()
}
if (rewrapped) {
this.#scheduleRecovery()
}
}

#resizedWhileHidden = false
#rewrappedWhileHidden = false
#recoverTimer?: NodeJS.Timeout
/** Paint against the last row rather than wherever the cursor is. */
#reseat = false
#resized?: () => void

constructor(options: { onResize?: () => void } = {}) {
Expand Down Expand Up @@ -102,7 +124,19 @@ export class PanelSurface {
return
}
this.#flush()
this.#paint()
const resized = this.#resizedWhileHidden
const rewrapped = this.#rewrappedWhileHidden
this.#resizedWhileHidden = false
this.#rewrappedWhileHidden = false
if (rewrapped) {
this.#recover()
}
else if (resized) {
this.padToBottom()
}
else {
this.#paint()
}
}

/**
Expand Down Expand Up @@ -183,6 +217,33 @@ export class PanelSurface {
this.#paint()
}

#scheduleRecovery(): void {
clearTimeout(this.#recoverTimer)
this.#recoverTimer = setTimeout(() => {
this.#recoverTimer = undefined
this.#recover()
}, RESIZE_SETTLE_MS)
this.#recoverTimer.unref?.()
}

/**
* Scroll the screen into the scrollback and paint the panel on what is left.
*
* A terminal re-wraps the screen when its width changes, so afterwards
* neither the rows the panel occupies nor the row the cursor is on follow
* from what was painted, and there is nothing to erase against.
*/
#recover(): void {
if (this.#closed || this.#suspended || this.#screen !== 'split-footer' || !this.#lines.length) {
return
}
this.#painted = 0
this.#raw('\n'.repeat(process.stdout.rows || 24))
this.#atLineStart = true
this.#reseat = true
this.#paint()
}

/** Forget the rows counted so far, after the screen has been cleared. */
resetRows(): void {
this.#rowsWritten = 0
Expand Down Expand Up @@ -220,6 +281,8 @@ export class PanelSurface {
}
this.#externalOutput = 'passthrough'
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,
Expand Down Expand Up @@ -343,6 +406,13 @@ export class PanelSurface {
if (!this.#lines.length || this.#closed || this.#suspended || this.#screen === 'alternate-screen') {
return
}
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`)
this.#atLineStart = true
}
const leading = this.#atLineStart ? '' : '\n'
this.#raw(`${leading}${this.#lines.join('\n')}`)
this.#painted = this.#lines.length
Expand Down
156 changes: 143 additions & 13 deletions packages/nuxt-cli/test/unit/dev-tui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1913,20 +1913,12 @@ describe('panel surface', () => {
expect(frame.indexOf('a log line')).toBeLessThan(frame.indexOf('--- footer ---'))
})

function withStubbedTerminal(rows: number, run: (written: () => string) => void): void {
const chunks: string[] = []
const descriptors = (['rows', 'isTTY'] as const).map(key => [key, Object.getOwnPropertyDescriptor(process.stdout, key)] as const)
Object.defineProperty(process.stdout, 'rows', { value: rows, configurable: true })
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
chunks.push(String(chunk))
return true
})
try {
run(() => chunks.join(''))
function stub(values: Array<[key: 'rows' | 'columns' | 'isTTY', value: number | boolean]>): () => void {
const descriptors = values.map(([key]) => [key, Object.getOwnPropertyDescriptor(process.stdout, key)] as const)
for (const [key, value] of values) {
Object.defineProperty(process.stdout, key, { value, configurable: true })
}
finally {
write.mockRestore()
return () => {
for (const [key, descriptor] of descriptors) {
if (descriptor) {
Object.defineProperty(process.stdout, key, descriptor)
Expand All @@ -1938,6 +1930,62 @@ describe('panel surface', () => {
}
}

function stubTerminal(rows: number): { written: () => string, restore: () => void } {
const chunks: string[] = []
const restore = stub([['rows', rows], ['isTTY', true]])
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
chunks.push(String(chunk))
return true
})
return {
written: () => chunks.join(''),
restore: () => {
write.mockRestore()
restore()
},
}
}

function withStubbedTerminal(rows: number, run: (written: () => string) => void): void {
const { written, restore } = stubTerminal(rows)
try {
run(written)
}
finally {
restore()
}
}

async function withStubbedTerminalAsync(rows: number, run: (written: () => string) => Promise<void>): Promise<void> {
const { written, restore } = stubTerminal(rows)
try {
await run(written)
}
finally {
restore()
}
}

function withStubbedColumns(columns: number, run: () => void): void {
const restore = stub([['columns', columns]])
try {
run()
}
finally {
restore()
}
}

async function withStubbedColumnsAsync(columns: number, run: () => Promise<void>): Promise<void> {
const restore = stub([['columns', columns]])
try {
await run()
}
finally {
restore()
}
}

it('pads the screen so the footer starts at the bottom', () => {
withStubbedTerminal(10, (written) => {
const surface = new PanelSurface()
Expand Down Expand Up @@ -2006,6 +2054,88 @@ describe('panel surface', () => {
})
})

it('starts a clean screen once a change of width has settled', async () => {
let written = ''
await withStubbedColumnsAsync(40, () => withStubbedTerminalAsync(24, async (read) => {
const surface = new PanelSurface()
surface.render(['--- footer ---'])
surface.padToBottom()
const before = read().length
await withStubbedColumnsAsync(30, async () => {
process.stdout.emit('resize')
await new Promise(resolve => setTimeout(resolve, 200))
})
written = read().slice(before)
surface.close()
}))

expect(written).toContain('\n'.repeat(24))
expect(written).toContain('\u001B[24;1H\u001B[J--- footer ---')
})

it('does not scroll the screen away when only the height changes', async () => {
let written = ''
await withStubbedColumnsAsync(40, () => withStubbedTerminalAsync(24, async (read) => {
const surface = new PanelSurface()
surface.render(['--- footer ---'])
surface.padToBottom()
const before = read().length
Object.defineProperty(process.stdout, 'rows', { value: 20, configurable: true })
process.stdout.emit('resize')
await new Promise(resolve => setTimeout(resolve, 200))
written = read().slice(before)
surface.close()
}))

expect(written).not.toContain('\n'.repeat(20))
expect(written).not.toContain('\u001B[J--- footer ---')
})

it('erases no more rows than it painted when the width changes', () => {
withStubbedColumns(40, () => {
withStubbedTerminal(24, (written) => {
const surface = new PanelSurface()
surface.render(['x'.repeat(30)])
const before = written().length
withStubbedColumns(10, () => process.stdout.emit('resize'))
expect(written().slice(before)).toContain('\r\u001B[J')
expect(written().slice(before)).not.toContain('A\u001B[J')
surface.close()
})
})
})

it('re-seats the panel at the bottom after a resize while a view owned the screen', () => {
withStubbedTerminal(10, (written) => {
const surface = new PanelSurface()
surface.render(['--- footer ---'])
surface.screenMode = 'alternate-screen'
Object.defineProperty(process.stdout, 'rows', { value: 20, configurable: true })
process.stdout.emit('resize')
const before = written().length
surface.screenMode = 'split-footer'
surface.close()
expect(written().slice(before)).toContain('\n'.repeat(18))
})
})

it('starts a clean screen when the width changed while a view owned the screen', () => {
withStubbedColumns(40, () => {
withStubbedTerminal(24, (written) => {
const surface = new PanelSurface()
surface.render(['--- footer ---'])
surface.padToBottom()
surface.screenMode = 'alternate-screen'
withStubbedColumns(30, () => process.stdout.emit('resize'))
const before = written().length
surface.screenMode = 'split-footer'
expect(written().slice(before)).toContain('\n'.repeat(24))
expect(written().slice(before)).toContain('\u001B[24;1H\u001B[J--- footer ---')
surface.close()
})
})
})

it('writes nothing on resize while a view owns the screen', () => {
withStubbedTerminal(24, (written) => {
const surface = new PanelSurface()
Expand Down
Loading