detached c6dd596

fix(html): retain 240x80 encoder scratch and clear stale payload slots

The row-start scratch is sized for up to 80 rows up front, so frames taller
than 64 rows no longer drop the encoder scratch on every update. CopyFrom
clears the reused payload tail so graphemes and hyperlinks from a larger
earlier frame are not kept alive. The size probe is excluded from selection,
and copyFrame drops a branch that could not run.

A property test drives one renderer through edits with payloads and
equivalent styles, aborts, resets, stale commits and failed prepares, and
checks each update against a cell-by-cell diff of the committed shadow.
commit
c6dd596575459bd57c9074a0ca96d6391b5eed5d
author
brice · · 2026-10-02 16:37 UTC
parent
f49c70a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +132 -7
M core/frame_copy.go +6 -0
@@ -45,7 +45,13 @@ func (f *Frame) CopyFrom(src Frame) {
4545 clear(dst.styleIndex)
4646 }
4747 maps.Copy(dst.styleIndex, from.styleIndex)
48+ // Payloads hold strings: clear the reused tail so stale graphemes and
49+ // hyperlinks are not kept alive by the backing array.
50+ oldPayloads := len(dst.payloads)
4851 dst.payloads = append(dst.payloads[:0], from.payloads...)
52+ if oldPayloads > len(dst.payloads) {
53+ clear(dst.payloads[len(dst.payloads):oldPayloads])
54+ }
4955 dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)
5056 dst.payloadBytes = from.payloadBytes
5157 if dst.payloadIndex == nil && len(from.payloadIndex) > 0 {
@@ -45,7 +45,13 @@ func (f *Frame) CopyFrom(src Frame) {
45 clear(dst.styleIndex)45 clear(dst.styleIndex)
46 }46 }
47 maps.Copy(dst.styleIndex, from.styleIndex)47 maps.Copy(dst.styleIndex, from.styleIndex)
48+ // Payloads hold strings: clear the reused tail so stale graphemes and
49+ // hyperlinks are not kept alive by the backing array.
50+ oldPayloads := len(dst.payloads)
48 dst.payloads = append(dst.payloads[:0], from.payloads...)51 dst.payloads = append(dst.payloads[:0], from.payloads...)
52+ if oldPayloads > len(dst.payloads) {
53+ clear(dst.payloads[len(dst.payloads):oldPayloads])
54+ }
49 dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)55 dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)
50 dst.payloadBytes = from.payloadBytes56 dst.payloadBytes = from.payloadBytes
51 if dst.payloadIndex == nil && len(from.payloadIndex) > 0 {57 if dst.payloadIndex == nil && len(from.payloadIndex) > 0 {
M core/frame_copy_test.go +18 -0
@@ -136,6 +136,24 @@ func TestCopyFromReleasesStorageWhenSourceShrinks(t *testing.T) {
136136 }
137137 }
138138
139+func TestCopyFromClearsStalePayloadSlots(t *testing.T) {
140+ src := copyTestFrame(t, 8, 2, 'a')
141+ dst := NewFrame(8, 2)
142+ for x := range 8 {
143+ dst.Set(x, 0, Cell{Rune: 'x', Payload: copyTestPayload(t, "", "https://example.test/"+string(rune('a'+x)))})
144+ }
145+ old := len(dst.page.payloads)
146+ dst.CopyFrom(src)
147+ if len(dst.page.payloads) >= old {
148+ t.Fatalf("test needs a source with fewer payload slots: %d >= %d", len(dst.page.payloads), old)
149+ }
150+ for i, slot := range dst.page.payloads[len(dst.page.payloads):old] {
151+ if slot != (payloadSlot{}) {
152+ t.Fatalf("stale payload slot %d still holds %+v", len(dst.page.payloads)+i, slot.value)
153+ }
154+ }
155+}
156+
139157 func TestRowsEqualAt(t *testing.T) {
140158 a := copyTestFrame(t, 10, 4, 'a')
141159 b := a.Clone()
@@ -136,6 +136,24 @@ func TestCopyFromReleasesStorageWhenSourceShrinks(t *testing.T) {
136 }136 }
137 }137 }
138 138
139+func TestCopyFromClearsStalePayloadSlots(t *testing.T) {
140+ src := copyTestFrame(t, 8, 2, 'a')
141+ dst := NewFrame(8, 2)
142+ for x := range 8 {
143+ dst.Set(x, 0, Cell{Rune: 'x', Payload: copyTestPayload(t, "", "https://example.test/"+string(rune('a'+x)))})
144+ }
145+ old := len(dst.page.payloads)
146+ dst.CopyFrom(src)
147+ if len(dst.page.payloads) >= old {
148+ t.Fatalf("test needs a source with fewer payload slots: %d >= %d", len(dst.page.payloads), old)
149+ }
150+ for i, slot := range dst.page.payloads[len(dst.page.payloads):old] {
151+ if slot != (payloadSlot{}) {
152+ t.Fatalf("stale payload slot %d still holds %+v", len(dst.page.payloads)+i, slot.value)
153+ }
154+ }
155+}
156+
139 func TestRowsEqualAt(t *testing.T) {157 func TestRowsEqualAt(t *testing.T) {
140 a := copyTestFrame(t, 10, 4, 'a')158 a := copyTestFrame(t, 10, 4, 'a')
141 b := a.Clone()159 b := a.Clone()
M html/renderer.go +10 -7
@@ -209,17 +209,18 @@ func (r *Renderer) materializeCellSource(source CellSource) (core.Frame, error)
209209 }
210210
211211 // copyFrame copies a validated frame into the spare page, which the returned
212-// frame then owns.
212+// frame then owns. CopyFrom always succeeds here: src passed Validate.
213213 func (r *Renderer) copyFrame(src core.Frame) core.Frame {
214214 frame := r.spare
215215 r.spare = core.Frame{}
216216 frame.CopyFrom(src)
217- if frame.Width != src.Width || frame.Height != src.Height {
218- return src.Clone()
219- }
220217 return frame
221218 }
222219
220+// maxRetainedRows bounds the row count whose encoder scratch the renderer
221+// keeps between updates.
222+const maxRetainedRows = 80
223+
223224 // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column
224225 // produced after writing the last column with autowrap enabled onto the
225226 // last visible column. It leaves every in-bounds cursor untouched.
@@ -291,13 +292,15 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b
291292 if cap(ends) < frame.Width {
292293 ends = make([]int, 0, frame.Width)
293294 text = make([]byte, 0, frame.Width)
294- rowStarts = make([]int, 0, 4)
295+ }
296+ if cap(rowStarts) < min(frame.Height, maxRetainedRows) {
297+ rowStarts = make([]int, 0, min(frame.Height, maxRetainedRows))
295298 }
296299 defer func() {
297300 // Keep scratch for frames up to the common 240x80 size only, so one
298301 // huge snapshot does not pin its buffers for the renderer lifetime.
299- const maxRetainedCells = 240 * 80
300- if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= 80 {
302+ const maxRetainedCells = 240 * maxRetainedRows
303+ if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= maxRetainedRows {
301304 r.ends, r.text, r.rowStarts = ends, text, rowStarts
302305 }
303306 }()
@@ -209,17 +209,18 @@ func (r *Renderer) materializeCellSource(source CellSource) (core.Frame, error)
209 }209 }
210 210
211 // copyFrame copies a validated frame into the spare page, which the returned211 // copyFrame copies a validated frame into the spare page, which the returned
212-// frame then owns.212+// frame then owns. CopyFrom always succeeds here: src passed Validate.
213 func (r *Renderer) copyFrame(src core.Frame) core.Frame {213 func (r *Renderer) copyFrame(src core.Frame) core.Frame {
214 frame := r.spare214 frame := r.spare
215 r.spare = core.Frame{}215 r.spare = core.Frame{}
216 frame.CopyFrom(src)216 frame.CopyFrom(src)
217- if frame.Width != src.Width || frame.Height != src.Height {
218- return src.Clone()
219- }
220 return frame217 return frame
221 }218 }
222 219
220+// maxRetainedRows bounds the row count whose encoder scratch the renderer
221+// keeps between updates.
222+const maxRetainedRows = 80
223+
223 // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column224 // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column
224 // produced after writing the last column with autowrap enabled onto the225 // produced after writing the last column with autowrap enabled onto the
225 // last visible column. It leaves every in-bounds cursor untouched.226 // last visible column. It leaves every in-bounds cursor untouched.
@@ -291,13 +292,15 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b
291 if cap(ends) < frame.Width {292 if cap(ends) < frame.Width {
292 ends = make([]int, 0, frame.Width)293 ends = make([]int, 0, frame.Width)
293 text = make([]byte, 0, frame.Width)294 text = make([]byte, 0, frame.Width)
294- rowStarts = make([]int, 0, 4)295+ }
296+ if cap(rowStarts) < min(frame.Height, maxRetainedRows) {
297+ rowStarts = make([]int, 0, min(frame.Height, maxRetainedRows))
295 }298 }
296 defer func() {299 defer func() {
297 // Keep scratch for frames up to the common 240x80 size only, so one300 // Keep scratch for frames up to the common 240x80 size only, so one
298 // huge snapshot does not pin its buffers for the renderer lifetime.301 // huge snapshot does not pin its buffers for the renderer lifetime.
299- const maxRetainedCells = 240 * 80302+ const maxRetainedCells = 240 * maxRetainedRows
300- if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= 80 {303+ if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= maxRetainedRows {
301 r.ends, r.text, r.rowStarts = ends, text, rowStarts304 r.ends, r.text, r.rowStarts = ends, text, rowStarts
302 }305 }
303 }()306 }()
M html/renderer_test.go +97 -0
@@ -435,6 +435,103 @@ func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) {
435435 require.Empty(t, steady.Update().Rows)
436436 }
437437
438+func TestRendererKeepsEncoderScratchAt240x80(t *testing.T) {
439+ frames := [2]core.Frame{core.NewFrame(240, 80), core.NewFrame(240, 80)}
440+ for y := range 80 {
441+ for x := range 240 {
442+ frames[0].Set(x, y, core.Cell{Rune: 'a'})
443+ frames[1].Set(x, y, core.Cell{Rune: 'b'})
444+ }
445+ }
446+ renderer, err := New(Options{})
447+ require.NoError(t, err)
448+ for step := range 2 {
449+ prepared, err := renderer.Prepare(frames[step], nil, false, Cursor{})
450+ require.NoError(t, err)
451+ require.NoError(t, prepared.Commit())
452+ }
453+ require.GreaterOrEqual(t, cap(renderer.rowStarts), 80, "row-start scratch was not retained")
454+ require.GreaterOrEqual(t, cap(renderer.ends), 240, "run-end scratch was not retained")
455+}
456+
457+// TestRendererMatchesFreshRendererAcrossTransactions drives one renderer
458+// (reused spare pages and scratch) through random edits with payloads and
459+// equivalent styles, aborts, resets, stale commits and failed prepares. Most
460+// steps commit and keep editing, so the shadow and spare pages alternate.
461+// Every update must list exactly the rows that differ from a cloned committed
462+// shadow (compared cell by cell with Cell.Equal) and encode the source frame.
463+func TestRendererMatchesFreshRendererAcrossTransactions(t *testing.T) {
464+ rng := rand.New(rand.NewSource(7))
465+ link := func(i int) core.CellPayload {
466+ payload, err := core.NewCellPayload("", "https://example.test/"+string(rune('a'+i)))
467+ require.NoError(t, err)
468+ return payload
469+ }
470+ bold := core.DefaultStyle()
471+ bold.Bold = true
472+ equivalent := bold
473+ equivalent.ForegroundRGB = core.RGB{R: 1} // inactive RGB: same style
474+ for seed := range 30 {
475+ width, height := 2+rng.Intn(10), 1+rng.Intn(5)
476+ frame := core.NewFrame(width, height)
477+ renderer, err := New(Options{})
478+ require.NoError(t, err)
479+ var committed core.Frame
480+ var stale *PreparedDraw
481+ for step := range 60 {
482+ for range rng.Intn(4) {
483+ cell := core.Cell{Rune: rune('a' + rng.Intn(4))}
484+ switch rng.Intn(4) {
485+ case 0:
486+ cell.Style = bold
487+ case 1:
488+ cell.Style = equivalent
489+ case 2:
490+ cell.Payload = link(rng.Intn(3))
491+ }
492+ frame.Set(rng.Intn(width), rng.Intn(height), cell)
493+ }
494+ if stale != nil && rng.Intn(3) == 0 {
495+ require.ErrorIs(t, stale.Commit(), ErrStaleDraw)
496+ stale = nil
497+ }
498+ if rng.Intn(10) == 0 {
499+ // A failed prepare must not lose or corrupt the spare page.
500+ _, err := renderer.Prepare(frame, nil, false, Cursor{Row: height})
501+ require.Error(t, err)
502+ }
503+
504+ want := []int{}
505+ for y := range height {
506+ for x := range width {
507+ if committed.Width == 0 || !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
508+ want = append(want, y)
509+ break
510+ }
511+ }
512+ }
513+ got, err := renderer.Prepare(frame, nil, false, Cursor{})
514+ require.NoError(t, err, "seed %d step %d", seed, step)
515+ update := got.Update()
516+ require.Equal(t, committed.Width == 0, update.Snapshot, "seed %d step %d", seed, step)
517+ require.Equal(t, want, rowIndexes(update), "seed %d step %d", seed, step)
518+ requireRunsMatchFrame(t, update, frame, step)
519+
520+ switch k := rng.Intn(20); {
521+ case k == 0:
522+ renderer.Reset()
523+ committed = core.Frame{}
524+ stale = got
525+ case k < 4:
526+ require.NoError(t, got.Abort())
527+ default:
528+ require.NoError(t, got.Commit())
529+ committed = frame.Clone()
530+ }
531+ }
532+ }
533+}
534+
438535 func rowIndexes(update Update) []int {
439536 rows := make([]int, 0, len(update.Rows))
440537 for _, row := range update.Rows {
@@ -435,6 +435,103 @@ func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) {
435 require.Empty(t, steady.Update().Rows)435 require.Empty(t, steady.Update().Rows)
436 }436 }
437 437
438+func TestRendererKeepsEncoderScratchAt240x80(t *testing.T) {
439+ frames := [2]core.Frame{core.NewFrame(240, 80), core.NewFrame(240, 80)}
440+ for y := range 80 {
441+ for x := range 240 {
442+ frames[0].Set(x, y, core.Cell{Rune: 'a'})
443+ frames[1].Set(x, y, core.Cell{Rune: 'b'})
444+ }
445+ }
446+ renderer, err := New(Options{})
447+ require.NoError(t, err)
448+ for step := range 2 {
449+ prepared, err := renderer.Prepare(frames[step], nil, false, Cursor{})
450+ require.NoError(t, err)
451+ require.NoError(t, prepared.Commit())
452+ }
453+ require.GreaterOrEqual(t, cap(renderer.rowStarts), 80, "row-start scratch was not retained")
454+ require.GreaterOrEqual(t, cap(renderer.ends), 240, "run-end scratch was not retained")
455+}
456+
457+// TestRendererMatchesFreshRendererAcrossTransactions drives one renderer
458+// (reused spare pages and scratch) through random edits with payloads and
459+// equivalent styles, aborts, resets, stale commits and failed prepares. Most
460+// steps commit and keep editing, so the shadow and spare pages alternate.
461+// Every update must list exactly the rows that differ from a cloned committed
462+// shadow (compared cell by cell with Cell.Equal) and encode the source frame.
463+func TestRendererMatchesFreshRendererAcrossTransactions(t *testing.T) {
464+ rng := rand.New(rand.NewSource(7))
465+ link := func(i int) core.CellPayload {
466+ payload, err := core.NewCellPayload("", "https://example.test/"+string(rune('a'+i)))
467+ require.NoError(t, err)
468+ return payload
469+ }
470+ bold := core.DefaultStyle()
471+ bold.Bold = true
472+ equivalent := bold
473+ equivalent.ForegroundRGB = core.RGB{R: 1} // inactive RGB: same style
474+ for seed := range 30 {
475+ width, height := 2+rng.Intn(10), 1+rng.Intn(5)
476+ frame := core.NewFrame(width, height)
477+ renderer, err := New(Options{})
478+ require.NoError(t, err)
479+ var committed core.Frame
480+ var stale *PreparedDraw
481+ for step := range 60 {
482+ for range rng.Intn(4) {
483+ cell := core.Cell{Rune: rune('a' + rng.Intn(4))}
484+ switch rng.Intn(4) {
485+ case 0:
486+ cell.Style = bold
487+ case 1:
488+ cell.Style = equivalent
489+ case 2:
490+ cell.Payload = link(rng.Intn(3))
491+ }
492+ frame.Set(rng.Intn(width), rng.Intn(height), cell)
493+ }
494+ if stale != nil && rng.Intn(3) == 0 {
495+ require.ErrorIs(t, stale.Commit(), ErrStaleDraw)
496+ stale = nil
497+ }
498+ if rng.Intn(10) == 0 {
499+ // A failed prepare must not lose or corrupt the spare page.
500+ _, err := renderer.Prepare(frame, nil, false, Cursor{Row: height})
501+ require.Error(t, err)
502+ }
503+
504+ want := []int{}
505+ for y := range height {
506+ for x := range width {
507+ if committed.Width == 0 || !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
508+ want = append(want, y)
509+ break
510+ }
511+ }
512+ }
513+ got, err := renderer.Prepare(frame, nil, false, Cursor{})
514+ require.NoError(t, err, "seed %d step %d", seed, step)
515+ update := got.Update()
516+ require.Equal(t, committed.Width == 0, update.Snapshot, "seed %d step %d", seed, step)
517+ require.Equal(t, want, rowIndexes(update), "seed %d step %d", seed, step)
518+ requireRunsMatchFrame(t, update, frame, step)
519+
520+ switch k := rng.Intn(20); {
521+ case k == 0:
522+ renderer.Reset()
523+ committed = core.Frame{}
524+ stale = got
525+ case k < 4:
526+ require.NoError(t, got.Abort())
527+ default:
528+ require.NoError(t, got.Commit())
529+ committed = frame.Clone()
530+ }
531+ }
532+ }
533+}
534+
438 func rowIndexes(update Update) []int {535 func rowIndexes(update Update) []int {
439 rows := make([]int, 0, len(update.Rows))536 rows := make([]int, 0, len(update.Rows))
440 for _, row := range update.Rows {537 for _, row := range update.Rows {
M html/terminal.css +1 -0
@@ -76,6 +76,7 @@
7676 white-space: pre;
7777 visibility: hidden;
7878 pointer-events: none;
79+ user-select: none;
7980 }
8081
8182 .vev-terminal__cell--bold { font-weight: 700; }
@@ -76,6 +76,7 @@
76 white-space: pre;76 white-space: pre;
77 visibility: hidden;77 visibility: hidden;
78 pointer-events: none;78 pointer-events: none;
79+ user-select: none;
79 }80 }
80 81
81 .vev-terminal__cell--bold { font-weight: 700; }82 .vev-terminal__cell--bold { font-weight: 700; }

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