detached a41ec87

fix(html): keep applyAll atomic and the merged cursor current

applyAll now validates the whole geometry chain against the mounted terminal
before any DOM change and applies exactly one merged update, so invalid
batches no longer leave a partial mutation. The merged update always carries
the latest cursor (a trailing cursor-only update was previously dropped).
Style remapping caches per source array instead of stringifying per cell, and
a batch whose merged styles exceed maxStyles falls back to ordered apply.

The Go renderer reuses its run scratch buffers (bounded to 240x80) and sizes
the cell slice for one row: snapshots allocate 33-57% fewer objects, one-row
updates go from 14 to 15. CellUpdate documents that texts share storage, the
README notes the monospace and maxTextBytes requirements of text runs, and new
tests cover multi-row incremental slicing and the run coverage invariants.
commit
a41ec87bac99b05dc95d69657398e7475d8fea3f
author
brice · · 2026-10-02 14:52 UTC
parent
4123601
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +185 -58
M README.md +3 -1
@@ -112,7 +112,9 @@ provides a labeled input proxy, synchronized plain-text accessible output,
112112 typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and
113113 focus events. A synchronous consumer callback decides default prevention.
114114 `applyAll` validates a queue of updates and merges them before touching the
115-DOM, so a consumer that falls behind rebuilds each changed row once.
115+DOM, so a consumer that falls behind rebuilds each changed row once. Text runs
116+assume a monospace primary font whose ASCII advance is `1ch`, and
117+`maxTextBytes` bounds one run, so keep it at least as large as the widest row.
116118 Consumers remain responsible for transport and mapping events to terminal bytes
117119 or application actions. Clipboard text is preserved unchanged, including
118120 control bytes, so consumers forwarding paste events to a PTY must apply their
@@ -112,7 +112,9 @@ provides a labeled input proxy, synchronized plain-text accessible output,
112 typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and112 typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and
113 focus events. A synchronous consumer callback decides default prevention.113 focus events. A synchronous consumer callback decides default prevention.
114 `applyAll` validates a queue of updates and merges them before touching the114 `applyAll` validates a queue of updates and merges them before touching the
115-DOM, so a consumer that falls behind rebuilds each changed row once.115+DOM, so a consumer that falls behind rebuilds each changed row once. Text runs
116+assume a monospace primary font whose ASCII advance is `1ch`, and
117+`maxTextBytes` bounds one run, so keep it at least as large as the widest row.
116 Consumers remain responsible for transport and mapping events to terminal bytes118 Consumers remain responsible for transport and mapping events to terminal bytes
117 or application actions. Clipboard text is preserved unchanged, including119 or application actions. Clipboard text is preserved unchanged, including
118 control bytes, so consumers forwarding paste events to a PTY must apply their120 control bytes, so consumers forwarding paste events to a PTY must apply their
M html/browser/terminal.js +60 -46
@@ -203,49 +203,56 @@
203203 }
204204
205205 /**
206- * Merge validated updates into the fewest equivalent ones. Each output
207- * update keeps the geometry, snapshot flag, and cursor semantics of the
208- * sequence it replaces; style ids are remapped to the styles still used.
206+ * Merge validated updates into one equivalent update, or return null when
207+ * the merged styles would exceed maxStyles (callers then apply in order).
208+ * Every incremental update must match the geometry established by the
209+ * mounted terminal or the latest snapshot, exactly as sequential apply
210+ * requires, so a protocol error is reported before any DOM mutation.
211+ * The merged update keeps the latest snapshot flag, geometry and cursor;
212+ * rows are last-writer-wins and style ids are remapped.
209213 */
210- function coalesce(updates, maxStyles) {
211- const groups = [];
214+ function coalesce(updates, mounted, maxStyles) {
215+ let geometry = mounted;
212216 for (const update of updates) {
213- const last = groups[groups.length - 1];
214- if (update.snapshot) {
215- groups.length = 0;
216- } else if (last && last.width === update.width && last.height === update.height) {
217- for (const row of update.rows) last.rows.set(row.row, { row, styles: update.styles });
218- last.cursor = update.cursor;
219- continue;
220- }
221- const rows = new Map();
222- for (const row of update.rows) rows.set(row.row, { row, styles: update.styles });
223- groups.push({ width: update.width, height: update.height, snapshot: update.snapshot, cursor: update.cursor, rows, single: update });
217+ if (update.snapshot) geometry = { width: update.width, height: update.height };
218+ else if (!geometry || update.width !== geometry.width || update.height !== geometry.height) fail('incremental update dimensions do not match the mounted snapshot');
224219 }
225- return groups.flatMap(group => {
226- if (group.rows.size === group.single.rows.length && [...group.rows.values()].every(entry => entry.styles === group.single.styles)) return [group.single];
227- const styles = [];
228- const ids = new Map();
229- const rows = [...group.rows.keys()].sort((left, right) => left - right).map(key => {
230- const { row, styles: source } = group.rows.get(key);
231- return {
232- row: row.row,
233- bytes: row.bytes,
234- cells: row.cells.map(cell => {
235- const styleKey = JSON.stringify(source[cell.style]);
236- let id = ids.get(styleKey);
237- if (id === undefined) {
238- id = styles.length;
239- ids.set(styleKey, id);
240- styles.push(source[cell.style]);
241- }
242- return { ...cell, style: id };
243- })
244- };
220+ if (updates.length === 1) return updates[0];
221+ let start = 0;
222+ for (let index = 0; index < updates.length; index += 1) if (updates[index].snapshot) start = index;
223+ const tail = updates.slice(start);
224+ const last = tail[tail.length - 1];
225+ const rows = new Map();
226+ for (const update of tail) for (const row of update.rows) rows.set(row.row, { row, styles: update.styles });
227+ const styles = [];
228+ const ids = new Map();
229+ const remaps = new Map();
230+ const merged = [];
231+ for (const key of [...rows.keys()].sort((left, right) => left - right)) {
232+ const { row, styles: source } = rows.get(key);
233+ let remap = remaps.get(source);
234+ if (!remap) {
235+ remap = new Array(source.length);
236+ remaps.set(source, remap);
237+ }
238+ const cells = row.cells.map(cell => {
239+ let id = remap[cell.style];
240+ if (id === undefined) {
241+ const styleKey = JSON.stringify(source[cell.style]);
242+ id = ids.get(styleKey);
243+ if (id === undefined) {
244+ id = styles.length;
245+ ids.set(styleKey, id);
246+ styles.push(source[cell.style]);
247+ }
248+ remap[cell.style] = id;
249+ }
250+ return id === cell.style ? cell : { ...cell, style: id };
245251 });
246- if (styles.length > maxStyles) fail('merged update styles exceed their limit');
247- return [{ schemaVersion: SCHEMA_VERSION, width: group.width, height: group.height, snapshot: group.snapshot, rows, styles, cursor: group.cursor }];
248- });
252+ merged.push({ row: row.row, bytes: row.bytes, cells });
253+ }
254+ if (styles.length > maxStyles) return null;
255+ return { schemaVersion: SCHEMA_VERSION, width: last.width, height: last.height, snapshot: tail[0].snapshot, rows: merged, styles, cursor: last.cursor };
249256 }
250257
251258 function xtermColor(index) {
@@ -470,18 +477,25 @@
470477 }
471478
472479 /**
473- * Validate a queue of updates, then apply the fewest equivalent updates:
474- * a snapshot supersedes everything before it and consecutive updates of
475- * the same geometry merge, last row wins. A consumer that falls behind
476- * rebuilds each changed row once instead of once per queued update.
480+ * Validate a queue of updates and their geometry chain, then apply one
481+ * merged update: a snapshot supersedes everything before it and later
482+ * rows win. A consumer that falls behind rebuilds each changed row once
483+ * instead of once per queued update. Invalid input throws before the DOM
484+ * changes.
477485 * @param {Update[]} values
478486 */
479487 function applyAll(values) {
480488 alive();
481489 if (!Array.isArray(values)) fail('updates must be an array');
482- for (const update of coalesce(values.map(value => validateUpdate(value, configured)), configured.maxStyles)) {
483- applyValidated(update);
484- }
490+ if (values.length === 0) return;
491+ const updates = values.map(value => validateUpdate(value, configured));
492+ const mounted = rowNodes.length === 0 ? null : { width, height };
493+ const merged = coalesce(updates, mounted, configured.maxStyles);
494+ if (merged) applyValidated(merged);
495+ // Rare: the merged rows need more distinct styles than one update may
496+ // carry. Each update is valid on its own and the geometry chain was
497+ // checked above, so apply them in order.
498+ else for (const update of updates) applyValidated(update);
485499 }
486500
487501 function applyValidated(update) {
@@ -203,49 +203,56 @@
203 }203 }
204 204
205 /**205 /**
206- * Merge validated updates into the fewest equivalent ones. Each output206+ * Merge validated updates into one equivalent update, or return null when
207- * update keeps the geometry, snapshot flag, and cursor semantics of the207+ * the merged styles would exceed maxStyles (callers then apply in order).
208- * sequence it replaces; style ids are remapped to the styles still used.208+ * Every incremental update must match the geometry established by the
209+ * mounted terminal or the latest snapshot, exactly as sequential apply
210+ * requires, so a protocol error is reported before any DOM mutation.
211+ * The merged update keeps the latest snapshot flag, geometry and cursor;
212+ * rows are last-writer-wins and style ids are remapped.
209 */213 */
210- function coalesce(updates, maxStyles) {214+ function coalesce(updates, mounted, maxStyles) {
211- const groups = [];215+ let geometry = mounted;
212 for (const update of updates) {216 for (const update of updates) {
213- const last = groups[groups.length - 1];217+ if (update.snapshot) geometry = { width: update.width, height: update.height };
214- if (update.snapshot) {218+ else if (!geometry || update.width !== geometry.width || update.height !== geometry.height) fail('incremental update dimensions do not match the mounted snapshot');
215- groups.length = 0;
216- } else if (last && last.width === update.width && last.height === update.height) {
217- for (const row of update.rows) last.rows.set(row.row, { row, styles: update.styles });
218- last.cursor = update.cursor;
219- continue;
220- }
221- const rows = new Map();
222- for (const row of update.rows) rows.set(row.row, { row, styles: update.styles });
223- groups.push({ width: update.width, height: update.height, snapshot: update.snapshot, cursor: update.cursor, rows, single: update });
224 }219 }
225- return groups.flatMap(group => {220+ if (updates.length === 1) return updates[0];
226- if (group.rows.size === group.single.rows.length && [...group.rows.values()].every(entry => entry.styles === group.single.styles)) return [group.single];221+ let start = 0;
227- const styles = [];222+ for (let index = 0; index < updates.length; index += 1) if (updates[index].snapshot) start = index;
228- const ids = new Map();223+ const tail = updates.slice(start);
229- const rows = [...group.rows.keys()].sort((left, right) => left - right).map(key => {224+ const last = tail[tail.length - 1];
230- const { row, styles: source } = group.rows.get(key);225+ const rows = new Map();
231- return {226+ for (const update of tail) for (const row of update.rows) rows.set(row.row, { row, styles: update.styles });
232- row: row.row,227+ const styles = [];
233- bytes: row.bytes,228+ const ids = new Map();
234- cells: row.cells.map(cell => {229+ const remaps = new Map();
235- const styleKey = JSON.stringify(source[cell.style]);230+ const merged = [];
236- let id = ids.get(styleKey);231+ for (const key of [...rows.keys()].sort((left, right) => left - right)) {
237- if (id === undefined) {232+ const { row, styles: source } = rows.get(key);
238- id = styles.length;233+ let remap = remaps.get(source);
239- ids.set(styleKey, id);234+ if (!remap) {
240- styles.push(source[cell.style]);235+ remap = new Array(source.length);
241- }236+ remaps.set(source, remap);
242- return { ...cell, style: id };237+ }
243- })238+ const cells = row.cells.map(cell => {
244- };239+ let id = remap[cell.style];
240+ if (id === undefined) {
241+ const styleKey = JSON.stringify(source[cell.style]);
242+ id = ids.get(styleKey);
243+ if (id === undefined) {
244+ id = styles.length;
245+ ids.set(styleKey, id);
246+ styles.push(source[cell.style]);
247+ }
248+ remap[cell.style] = id;
249+ }
250+ return id === cell.style ? cell : { ...cell, style: id };
245 });251 });
246- if (styles.length > maxStyles) fail('merged update styles exceed their limit');252+ merged.push({ row: row.row, bytes: row.bytes, cells });
247- return [{ schemaVersion: SCHEMA_VERSION, width: group.width, height: group.height, snapshot: group.snapshot, rows, styles, cursor: group.cursor }];253+ }
248- });254+ if (styles.length > maxStyles) return null;
255+ return { schemaVersion: SCHEMA_VERSION, width: last.width, height: last.height, snapshot: tail[0].snapshot, rows: merged, styles, cursor: last.cursor };
249 }256 }
250 257
251 function xtermColor(index) {258 function xtermColor(index) {
@@ -470,18 +477,25 @@
470 }477 }
471 478
472 /**479 /**
473- * Validate a queue of updates, then apply the fewest equivalent updates:480+ * Validate a queue of updates and their geometry chain, then apply one
474- * a snapshot supersedes everything before it and consecutive updates of481+ * merged update: a snapshot supersedes everything before it and later
475- * the same geometry merge, last row wins. A consumer that falls behind482+ * rows win. A consumer that falls behind rebuilds each changed row once
476- * rebuilds each changed row once instead of once per queued update.483+ * instead of once per queued update. Invalid input throws before the DOM
484+ * changes.
477 * @param {Update[]} values485 * @param {Update[]} values
478 */486 */
479 function applyAll(values) {487 function applyAll(values) {
480 alive();488 alive();
481 if (!Array.isArray(values)) fail('updates must be an array');489 if (!Array.isArray(values)) fail('updates must be an array');
482- for (const update of coalesce(values.map(value => validateUpdate(value, configured)), configured.maxStyles)) {490+ if (values.length === 0) return;
483- applyValidated(update);491+ const updates = values.map(value => validateUpdate(value, configured));
484- }492+ const mounted = rowNodes.length === 0 ? null : { width, height };
493+ const merged = coalesce(updates, mounted, configured.maxStyles);
494+ if (merged) applyValidated(merged);
495+ // Rare: the merged rows need more distinct styles than one update may
496+ // carry. Each update is valid on its own and the geometry chain was
497+ // checked above, so apply them in order.
498+ else for (const update of updates) applyValidated(update);
485 }499 }
486 500
487 function applyValidated(update) {501 function applyValidated(update) {
M html/renderer.go +22 -6
@@ -15,6 +15,10 @@ type Renderer struct {
1515 committed core.Frame
1616 cursor Cursor
1717 initialized bool
18+ // Encoder scratch reused across updates; never referenced by an Update.
19+ ends []int
20+ text []byte
21+ rowStarts []int
1822 }
1923
2024 // CellSource is the read-only semantic grid consumed by HTML rendering.
@@ -269,12 +273,24 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b
269273 // Every row appends its runs to cells and their text to text; ends[i]
270274 // is the text offset after cells[i]. Rows and texts are sliced out once
271275 // at the end, so an update costs one cell slice and one string.
272- var (
273- cells []CellUpdate
274- ends []int
275- text []byte
276- rowStarts []int
277- )
276+ // cells is owned by the update and sized for one full row (an
277+ // incremental update usually changes one or a few rows). The other
278+ // buffers are renderer scratch: the text is copied into one string.
279+ cells := make([]CellUpdate, 0, frame.Width)
280+ ends, text, rowStarts := r.ends[:0], r.text[:0], r.rowStarts[:0]
281+ if cap(ends) < frame.Width {
282+ ends = make([]int, 0, frame.Width)
283+ text = make([]byte, 0, frame.Width)
284+ rowStarts = make([]int, 0, 4)
285+ }
286+ defer func() {
287+ // Keep scratch for frames up to the common 240x80 size only, so one
288+ // huge snapshot does not pin its buffers for the renderer lifetime.
289+ const maxRetainedCells = 240 * 80
290+ if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells {
291+ r.ends, r.text, r.rowStarts = ends, text, rowStarts
292+ }
293+ }()
278294 for y := range frame.Height {
279295 row := readRow(frame, y, scratch)
280296 if !snapshot && rowEqualsFrame(row, r.committed, y) {
@@ -15,6 +15,10 @@ type Renderer struct {
15 committed core.Frame15 committed core.Frame
16 cursor Cursor16 cursor Cursor
17 initialized bool17 initialized bool
18+ // Encoder scratch reused across updates; never referenced by an Update.
19+ ends []int
20+ text []byte
21+ rowStarts []int
18 }22 }
19 23
20 // CellSource is the read-only semantic grid consumed by HTML rendering.24 // CellSource is the read-only semantic grid consumed by HTML rendering.
@@ -269,12 +273,24 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b
269 // Every row appends its runs to cells and their text to text; ends[i]273 // Every row appends its runs to cells and their text to text; ends[i]
270 // is the text offset after cells[i]. Rows and texts are sliced out once274 // is the text offset after cells[i]. Rows and texts are sliced out once
271 // at the end, so an update costs one cell slice and one string.275 // at the end, so an update costs one cell slice and one string.
272- var (276+ // cells is owned by the update and sized for one full row (an
273- cells []CellUpdate277+ // incremental update usually changes one or a few rows). The other
274- ends []int278+ // buffers are renderer scratch: the text is copied into one string.
275- text []byte279+ cells := make([]CellUpdate, 0, frame.Width)
276- rowStarts []int280+ ends, text, rowStarts := r.ends[:0], r.text[:0], r.rowStarts[:0]
277- )281+ if cap(ends) < frame.Width {
282+ ends = make([]int, 0, frame.Width)
283+ text = make([]byte, 0, frame.Width)
284+ rowStarts = make([]int, 0, 4)
285+ }
286+ defer func() {
287+ // Keep scratch for frames up to the common 240x80 size only, so one
288+ // huge snapshot does not pin its buffers for the renderer lifetime.
289+ const maxRetainedCells = 240 * 80
290+ if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells {
291+ r.ends, r.text, r.rowStarts = ends, text, rowStarts
292+ }
293+ }()
278 for y := range frame.Height {294 for y := range frame.Height {
279 row := readRow(frame, y, scratch)295 row := readRow(frame, y, scratch)
280 if !snapshot && rowEqualsFrame(row, r.committed, y) {296 if !snapshot && rowEqualsFrame(row, r.committed, y) {
M html/renderer_test.go +74 -0
@@ -3,6 +3,7 @@ package html
33 import (
44 "errors"
55 "math"
6+ "math/rand"
67 "os"
78 "testing"
89
@@ -158,6 +159,79 @@ func TestRendererMergesASCIICellsIntoStyledTextRuns(t *testing.T) {
158159 require.NoError(t, prepared.Commit())
159160 }
160161
162+func TestRendererIncrementalRunsSliceChangedRowsIndependently(t *testing.T) {
163+ plain := core.DefaultStyle()
164+ frame := core.NewFrame(4, 3)
165+ renderer, err := New(Options{})
166+ require.NoError(t, err)
167+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
168+ require.NoError(t, err)
169+ require.NoError(t, first.Commit())
170+
171+ frame.Set(0, 0, core.Cell{Rune: 'a', Style: plain})
172+ frame.Set(1, 2, core.Cell{Rune: '界', Style: plain})
173+ frame.Set(2, 2, core.Cell{Continuation: true, Style: plain})
174+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
175+ require.NoError(t, err)
176+ rows := prepared.Update().Rows
177+ require.Len(t, rows, 2)
178+ require.Equal(t, RowUpdate{Row: 0, Cells: []CellUpdate{{Column: 0, Width: 4, Text: "a ", Style: 0}}}, rows[0])
179+ require.Equal(t, RowUpdate{Row: 2, Cells: []CellUpdate{
180+ {Column: 0, Width: 1, Text: " ", Style: 0},
181+ {Column: 1, Width: 2, Text: "界", Style: 0},
182+ {Column: 3, Width: 1, Text: " ", Style: 0},
183+ }}, rows[1])
184+ require.NoError(t, prepared.Commit())
185+}
186+
187+// TestRendererRunsCoverEveryRowExactly checks the run invariants on random
188+// frames: entries are contiguous, widths sum to the frame width, and every
189+// entry wider than one cell is printable ASCII with one byte per column.
190+func TestRendererRunsCoverEveryRowExactly(t *testing.T) {
191+ rng := rand.New(rand.NewSource(1))
192+ runes := []rune{0, 'a', 'b', ' ', '~', 'é', '✓', '界'}
193+ styles := []core.Style{core.DefaultStyle(), func() core.Style { s := core.DefaultStyle(); s.Bold = true; return s }()}
194+ for iteration := range 200 {
195+ width, height := 1+rng.Intn(20), 1+rng.Intn(4)
196+ frame := core.NewFrame(width, height)
197+ for y := range height {
198+ for x := 0; x < width; x++ {
199+ r := runes[rng.Intn(len(runes))]
200+ style := styles[rng.Intn(len(styles))]
201+ if core.RuneWidth(r) == 2 {
202+ if x+1 >= width {
203+ r = 'w'
204+ } else {
205+ frame.Set(x, y, core.Cell{Rune: r, Style: style})
206+ frame.Set(x+1, y, core.Cell{Continuation: true, Style: style})
207+ x++
208+ continue
209+ }
210+ }
211+ frame.Set(x, y, core.Cell{Rune: r, Style: style})
212+ }
213+ }
214+ renderer, err := New(Options{})
215+ require.NoError(t, err)
216+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
217+ require.NoError(t, err, "iteration %d", iteration)
218+ for _, row := range prepared.Update().Rows {
219+ column := 0
220+ for _, cell := range row.Cells {
221+ require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row)
222+ if cell.Width > 2 || len(cell.Text) > 1 && cell.Text[0] < 0x80 {
223+ require.Len(t, cell.Text, cell.Width)
224+ for i := range len(cell.Text) {
225+ require.True(t, cell.Text[i] >= 0x20 && cell.Text[i] < 0x7f, "iteration %d text %q", iteration, cell.Text)
226+ }
227+ }
228+ column += cell.Width
229+ }
230+ require.Equal(t, width, column, "iteration %d row %d", iteration, row.Row)
231+ }
232+ }
233+}
234+
161235 func TestRendererAcceptsMaxIntGeneratedBytesLimit(t *testing.T) {
162236 renderer, err := New(Options{Limits: Limits{MaxGeneratedBytes: math.MaxInt}})
163237 require.NoError(t, err)
@@ -3,6 +3,7 @@ package html
3 import (3 import (
4 "errors"4 "errors"
5 "math"5 "math"
6+ "math/rand"
6 "os"7 "os"
7 "testing"8 "testing"
8 9
@@ -158,6 +159,79 @@ func TestRendererMergesASCIICellsIntoStyledTextRuns(t *testing.T) {
158 require.NoError(t, prepared.Commit())159 require.NoError(t, prepared.Commit())
159 }160 }
160 161
162+func TestRendererIncrementalRunsSliceChangedRowsIndependently(t *testing.T) {
163+ plain := core.DefaultStyle()
164+ frame := core.NewFrame(4, 3)
165+ renderer, err := New(Options{})
166+ require.NoError(t, err)
167+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
168+ require.NoError(t, err)
169+ require.NoError(t, first.Commit())
170+
171+ frame.Set(0, 0, core.Cell{Rune: 'a', Style: plain})
172+ frame.Set(1, 2, core.Cell{Rune: '界', Style: plain})
173+ frame.Set(2, 2, core.Cell{Continuation: true, Style: plain})
174+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
175+ require.NoError(t, err)
176+ rows := prepared.Update().Rows
177+ require.Len(t, rows, 2)
178+ require.Equal(t, RowUpdate{Row: 0, Cells: []CellUpdate{{Column: 0, Width: 4, Text: "a ", Style: 0}}}, rows[0])
179+ require.Equal(t, RowUpdate{Row: 2, Cells: []CellUpdate{
180+ {Column: 0, Width: 1, Text: " ", Style: 0},
181+ {Column: 1, Width: 2, Text: "界", Style: 0},
182+ {Column: 3, Width: 1, Text: " ", Style: 0},
183+ }}, rows[1])
184+ require.NoError(t, prepared.Commit())
185+}
186+
187+// TestRendererRunsCoverEveryRowExactly checks the run invariants on random
188+// frames: entries are contiguous, widths sum to the frame width, and every
189+// entry wider than one cell is printable ASCII with one byte per column.
190+func TestRendererRunsCoverEveryRowExactly(t *testing.T) {
191+ rng := rand.New(rand.NewSource(1))
192+ runes := []rune{0, 'a', 'b', ' ', '~', 'é', '✓', '界'}
193+ styles := []core.Style{core.DefaultStyle(), func() core.Style { s := core.DefaultStyle(); s.Bold = true; return s }()}
194+ for iteration := range 200 {
195+ width, height := 1+rng.Intn(20), 1+rng.Intn(4)
196+ frame := core.NewFrame(width, height)
197+ for y := range height {
198+ for x := 0; x < width; x++ {
199+ r := runes[rng.Intn(len(runes))]
200+ style := styles[rng.Intn(len(styles))]
201+ if core.RuneWidth(r) == 2 {
202+ if x+1 >= width {
203+ r = 'w'
204+ } else {
205+ frame.Set(x, y, core.Cell{Rune: r, Style: style})
206+ frame.Set(x+1, y, core.Cell{Continuation: true, Style: style})
207+ x++
208+ continue
209+ }
210+ }
211+ frame.Set(x, y, core.Cell{Rune: r, Style: style})
212+ }
213+ }
214+ renderer, err := New(Options{})
215+ require.NoError(t, err)
216+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
217+ require.NoError(t, err, "iteration %d", iteration)
218+ for _, row := range prepared.Update().Rows {
219+ column := 0
220+ for _, cell := range row.Cells {
221+ require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row)
222+ if cell.Width > 2 || len(cell.Text) > 1 && cell.Text[0] < 0x80 {
223+ require.Len(t, cell.Text, cell.Width)
224+ for i := range len(cell.Text) {
225+ require.True(t, cell.Text[i] >= 0x20 && cell.Text[i] < 0x7f, "iteration %d text %q", iteration, cell.Text)
226+ }
227+ }
228+ column += cell.Width
229+ }
230+ require.Equal(t, width, column, "iteration %d row %d", iteration, row.Row)
231+ }
232+ }
233+}
234+
161 func TestRendererAcceptsMaxIntGeneratedBytesLimit(t *testing.T) {235 func TestRendererAcceptsMaxIntGeneratedBytesLimit(t *testing.T) {
162 renderer, err := New(Options{Limits: Limits{MaxGeneratedBytes: math.MaxInt}})236 renderer, err := New(Options{Limits: Limits{MaxGeneratedBytes: math.MaxInt}})
163 require.NoError(t, err)237 require.NoError(t, err)
M html/update.go +2 -1
@@ -84,7 +84,8 @@ type Style struct {
8484 // either a text run of Width printable ASCII characters (blank cells are
8585 // spaces) sharing one style, or one non-ASCII character of width 1 or 2.
8686 // Wide-cell continuation markers are represented by the preceding cell's
87-// width.
87+// width. Text values of one update may share backing storage, so retaining
88+// one keeps that update's text alive.
8889 type CellUpdate struct {
8990 Column int `json:"column"`
9091 Width int `json:"width"`
@@ -84,7 +84,8 @@ type Style struct {
84 // either a text run of Width printable ASCII characters (blank cells are84 // either a text run of Width printable ASCII characters (blank cells are
85 // spaces) sharing one style, or one non-ASCII character of width 1 or 2.85 // spaces) sharing one style, or one non-ASCII character of width 1 or 2.
86 // Wide-cell continuation markers are represented by the preceding cell's86 // Wide-cell continuation markers are represented by the preceding cell's
87-// width.87+// width. Text values of one update may share backing storage, so retaining
88+// one keeps that update's text alive.
88 type CellUpdate struct {89 type CellUpdate struct {
89 Column int `json:"column"`90 Column int `json:"column"`
90 Width int `json:"width"`91 Width int `json:"width"`
M internal/htmlharness/terminal.spec.js +24 -4
@@ -308,14 +308,34 @@ test('applyAll coalesces queued updates into the same final DOM as sequential ap
308308 const rebuilt = [...document.querySelectorAll('.vev-terminal__row')].map((node, index) => node !== nodes[index]);
309309 terminal.applyAll([queue[0], snapshot, queue[2]]);
310310 const afterSnapshot = capture().text;
311- let invalid = '';
312- try { terminal.applyAll([queue[0], { ...queue[1], width: 5 }]); } catch (error) { invalid = error.message; }
313- return { sequential, merged, rebuilt, afterSnapshot, invalid, unchanged: capture().text === afterSnapshot };
311+ const cursorAt = () => {
312+ const node = document.querySelector('.vev-terminal__cursor');
313+ return [node.style.top, node.style.left];
314+ };
315+ const cursorOnly = column => ({ schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [], rows: [], cursor: { ...cursor(column), row: 2 } });
316+ terminal.apply(snapshot);
317+ for (const update of [queue[0], cursorOnly(3)]) terminal.apply(update);
318+ const cursorSequential = cursorAt();
319+ terminal.apply(snapshot);
320+ terminal.applyAll([queue[0], cursorOnly(3)]);
321+ const cursorMerged = cursorAt();
322+ terminal.applyAll([snapshot, cursorOnly(2), cursorOnly(1)]);
323+ const cursorAfterSnapshot = cursorAt();
324+ const before = capture();
325+ const failures = [
326+ [{ ...snapshot, width: 5, rows: snapshot.rows.map(r => ({ row: r.row, cells: [{ column: 0, width: 5, text: 'zzzzz', style: 0 }] })) }, queue[0]],
327+ [queue[0], { ...cursorOnly(0), width: 5 }, snapshot]
328+ ].map(batch => {
329+ try { terminal.applyAll(batch); return ''; } catch (error) { return error.message; }
330+ });
331+ return { sequential, merged, rebuilt, afterSnapshot, cursorSequential, cursorMerged, cursorAfterSnapshot, failures, unchanged: JSON.stringify(capture()) === JSON.stringify(before) };
314332 });
315333 expect(result.merged).toEqual(result.sequential);
316334 expect(result.rebuilt).toEqual([true, true, true]);
317335 expect(result.afterSnapshot).toBe('A3 \nbbbb\ncccc');
318- expect(result.invalid).not.toBe('');
336+ expect(result.cursorMerged).toEqual(result.cursorSequential);
337+ expect(result.cursorAfterSnapshot[1]).toContain('1 *');
338+ expect(result.failures.every(message => message.includes('dimensions do not match'))).toBe(true);
319339 expect(result.unchanged).toBe(true);
320340 });
321341
@@ -308,14 +308,34 @@ test('applyAll coalesces queued updates into the same final DOM as sequential ap
308 const rebuilt = [...document.querySelectorAll('.vev-terminal__row')].map((node, index) => node !== nodes[index]);308 const rebuilt = [...document.querySelectorAll('.vev-terminal__row')].map((node, index) => node !== nodes[index]);
309 terminal.applyAll([queue[0], snapshot, queue[2]]);309 terminal.applyAll([queue[0], snapshot, queue[2]]);
310 const afterSnapshot = capture().text;310 const afterSnapshot = capture().text;
311- let invalid = '';311+ const cursorAt = () => {
312- try { terminal.applyAll([queue[0], { ...queue[1], width: 5 }]); } catch (error) { invalid = error.message; }312+ const node = document.querySelector('.vev-terminal__cursor');
313- return { sequential, merged, rebuilt, afterSnapshot, invalid, unchanged: capture().text === afterSnapshot };313+ return [node.style.top, node.style.left];
314+ };
315+ const cursorOnly = column => ({ schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [], rows: [], cursor: { ...cursor(column), row: 2 } });
316+ terminal.apply(snapshot);
317+ for (const update of [queue[0], cursorOnly(3)]) terminal.apply(update);
318+ const cursorSequential = cursorAt();
319+ terminal.apply(snapshot);
320+ terminal.applyAll([queue[0], cursorOnly(3)]);
321+ const cursorMerged = cursorAt();
322+ terminal.applyAll([snapshot, cursorOnly(2), cursorOnly(1)]);
323+ const cursorAfterSnapshot = cursorAt();
324+ const before = capture();
325+ const failures = [
326+ [{ ...snapshot, width: 5, rows: snapshot.rows.map(r => ({ row: r.row, cells: [{ column: 0, width: 5, text: 'zzzzz', style: 0 }] })) }, queue[0]],
327+ [queue[0], { ...cursorOnly(0), width: 5 }, snapshot]
328+ ].map(batch => {
329+ try { terminal.applyAll(batch); return ''; } catch (error) { return error.message; }
330+ });
331+ return { sequential, merged, rebuilt, afterSnapshot, cursorSequential, cursorMerged, cursorAfterSnapshot, failures, unchanged: JSON.stringify(capture()) === JSON.stringify(before) };
314 });332 });
315 expect(result.merged).toEqual(result.sequential);333 expect(result.merged).toEqual(result.sequential);
316 expect(result.rebuilt).toEqual([true, true, true]);334 expect(result.rebuilt).toEqual([true, true, true]);
317 expect(result.afterSnapshot).toBe('A3 \nbbbb\ncccc');335 expect(result.afterSnapshot).toBe('A3 \nbbbb\ncccc');
318- expect(result.invalid).not.toBe('');336+ expect(result.cursorMerged).toEqual(result.cursorSequential);
337+ expect(result.cursorAfterSnapshot[1]).toContain('1 *');
338+ expect(result.failures.every(message => message.includes('dimensions do not match'))).toBe(true);
319 expect(result.unchanged).toBe(true);339 expect(result.unchanged).toBe(true);
320 });340 });
321 341

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close