fix(web): model erase-in-line, tab stops, and cross-line SGR
Six findings. Each terminal-semantics case was checked in a real terminal (tmux, reading back the painted screen) before changing anything: `100%\r\x1b[KOK` shows `OK`. Modelling the `\r` without its erase left the previous frame's tail standing — a regression against the old truncate, since `\r\x1b[K` is the single idiom every spinner and progress bar writes. Erase is now part of the same replay, in all three parameter forms. `a\tb\rXY` shows `XY b`. Counting a tab as one column produced `XYb` and destroyed the alignment this card exists to hold, so the cursor now advances by terminal columns: tabs reach the next 8-column stop and a wide character takes two cells. `\x1b[31mabc\rX\nnext` paints BOTH lines red. A newline does not reset the graphic state, so state threads from one replayed line to the next instead of closing at each line end. Only `m` accumulates into a cell's style now. Folding cursor and erase sequences in grew the state string per redraw and emitted boundaries anser had to discard. The empty check reads the parsed lines the card renders rather than the raw text: output that is only escapes or control bytes survives `trim()` yet parses to nothing, and drew blank rows plus a copy control for invisible bytes instead of the placeholder. The gutter is the card's own left padding rather than a margin. Every render site rewrites `margin` wholesale for its own indent, which silently cancelled the reservation and let a container clip the dot. The fixture sample no longer carries an `[exit code: 1]` line: the real bash presenter consumes that marker precisely because the card shows the exit as its own pill, so the built-bundle snapshot had pinned a frame showing it twice — one the product path cannot produce.
This commit is contained in:
@@ -249,6 +249,72 @@ describe('parseAnsiLines: backspaces', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: erase and column arithmetic', () => {
|
||||
it('erases the rest of the line, the fixed companion of a redraw', () => {
|
||||
// Verified in a real terminal: `100%\r\x1b[KOK` shows `OK`. Every spinner and
|
||||
// progress bar writes `\r\x1b[K`; without the erase the previous frame's tail
|
||||
// stands and the card shows text the terminal never displayed.
|
||||
expect(onlySpan(`100%\r${ESC}[KOK`)).toEqual({ text: 'OK', style: undefined })
|
||||
// The parameterless form and `0` are the same erase.
|
||||
expect(onlySpan(`100%\r${ESC}[0KOK`)).toEqual({ text: 'OK', style: undefined })
|
||||
})
|
||||
|
||||
it('erases the whole line for the 2K form and to the cursor for 1K', () => {
|
||||
expect(onlySpan(`ab\r${ESC}[2Kxy`)).toEqual({ text: 'xy', style: undefined })
|
||||
// 1K clears left of the cursor without moving it, so those columns read as
|
||||
// blanks — verified in a real terminal, which shows ` |` for this input.
|
||||
expect(onlySpan(`abcd${ESC}[1K|`)).toEqual({ text: ' |', style: undefined })
|
||||
})
|
||||
|
||||
it('paints columns a 2K dropped as blanks when a later write lands past them', () => {
|
||||
// 2K clears the line but leaves the cursor where it was, so writing there
|
||||
// leaves the columns before it unwritten — blanks, as a terminal shows.
|
||||
expect(onlySpan(`abcd${ESC}[2Kx`)).toEqual({ text: ' x', style: undefined })
|
||||
})
|
||||
|
||||
it('advances a redraw cursor by tab stops, leaving a tabbed column standing', () => {
|
||||
// Verified in a real terminal: `a\tb\rXY` shows `XY b` — the `b` sits at
|
||||
// column 8, which a two-character redraw cannot reach. Counting the tab as
|
||||
// one column would have produced `XYb` and destroyed the alignment.
|
||||
expect(onlySpan('a\tb\rXY')).toEqual({ text: 'XY b', style: undefined })
|
||||
})
|
||||
|
||||
it('counts a wide character as the two columns a terminal advances', () => {
|
||||
// `中` occupies two cells, so a two-character redraw covers exactly it.
|
||||
expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
|
||||
})
|
||||
|
||||
it('does not accumulate a cursor or erase sequence into a cell style', () => {
|
||||
// Only SGR carries graphic state. An erase folded into the style string
|
||||
// would grow it per redraw and emit boundaries anser has to discard.
|
||||
expect(parseAnsiLines(`${ESC}[31ma\r${ESC}[Kb`)).toEqual([[
|
||||
{ text: 'b', style: { color: 'var(--dsw-alias-state-error-primary)' } },
|
||||
]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: SGR across lines', () => {
|
||||
it('carries active state past a newline, as a terminal does', () => {
|
||||
// Verified in a real terminal: `\x1b[31mabc\rX\nnext` paints BOTH lines red.
|
||||
// A newline does not reset the graphic state, so a replayed line must hand
|
||||
// its state to the next one instead of closing it off.
|
||||
expect(parseAnsiLines(`${ESC}[31mabc\rX\nnext`)).toEqual([
|
||||
[{ text: 'Xbc', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
|
||||
[{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
|
||||
])
|
||||
})
|
||||
|
||||
it('tracks state through a line that needs no replay', () => {
|
||||
// The middle line has no movement, so it is not replayed — but its own SGR
|
||||
// still has to reach the line after it.
|
||||
expect(parseAnsiLines(`a\r${ESC}[32mb\nplain\nc`)).toEqual([
|
||||
[{ text: 'b', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
|
||||
[{ text: 'plain', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
|
||||
[{ text: 'c', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: runs spanning lines', () => {
|
||||
it('carries one run\'s style onto every line it covers', () => {
|
||||
expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([
|
||||
|
||||
@@ -124,6 +124,17 @@ describe('TerminalBlock states', () => {
|
||||
expect(screen.getByText('无输出')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('treats output that renders nothing visible as empty', () => {
|
||||
// A lone reset, an OSC title, an erase: all survive `text.trim()` yet parse
|
||||
// to nothing. Judging emptiness on the raw text drew a box of blank rows
|
||||
// plus a copy control for invisible bytes, and hid the placeholder.
|
||||
const view = render(<TerminalBlock command="true" output={`${ESC}[0m`} exitCode={0} />)
|
||||
expect(view.getByText('无输出')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
view.rerender(<TerminalBlock command="true" output={`${ESC}]0;title${ESC}\\`} exitCode={0} />)
|
||||
expect(view.getByText('无输出')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('merges className onto the wrapper', () => {
|
||||
const view = render(<TerminalBlock command="ls" className="x" output="a" />)
|
||||
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
|
||||
|
||||
Reference in New Issue
Block a user