detached 74eecd7

feat(vt): carry per-row logical extents into history

commit
74eecd7cbe12d7c6cd6fd1ac1160a8e3b014ad70
author
brice · · 2026-07-25 05:59 UTC
parent
acd4dbe
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
23 files · +300 -86
M internal/usecase/copy/mode.go +1 -1
@@ -34,7 +34,7 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
3434 ChunkRows: 256,
3535 })
3636 for _, row := range rows {
37- if err := history.Append(row); err != nil {
37+ if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil {
3838 panic("copy snapshot: configured history rejected supplied row")
3939 }
4040 }
@@ -34,7 +34,7 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
34 ChunkRows: 256,34 ChunkRows: 256,
35 })35 })
36 for _, row := range rows {36 for _, row := range rows {
37- if err := history.Append(row); err != nil {37+ if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil {
38 panic("copy snapshot: configured history rejected supplied row")38 panic("copy snapshot: configured history rejected supplied row")
39 }39 }
40 }40 }
M internal/usecase/copy/mode_test.go +3 -2
@@ -189,10 +189,11 @@ func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) {
189189 func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
190190 const rows = 10_000
191191 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
192+ unmatched, target := row("unmatched"), row("target")
192193 for range rows {
193- require.NoError(t, history.Append(row("unmatched")))
194+ require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
194195 }
195- require.NoError(t, history.Append(row("target")))
196+ require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
196197 snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))
197198 view := history.SealAndView()
198199 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
@@ -189,10 +189,11 @@ func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) {
189 func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {189 func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
190 const rows = 10_000190 const rows = 10_000
191 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})191 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
192+ unmatched, target := row("unmatched"), row("target")
192 for range rows {193 for range rows {
193- require.NoError(t, history.Append(row("unmatched")))194+ require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
194 }195 }
195- require.NoError(t, history.Append(row("target")))196+ require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
196 snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))197 snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))
197 view := history.SealAndView()198 view := history.SealAndView()
198 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))199 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
M internal/usecase/daemon/copymode_test.go +9 -9
@@ -301,7 +301,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
301301 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
302302 pane := sess.tabs[0].focusedPane()
303303 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
304- require.NoError(t, pane.history.Append(testRow("old alpha")))
304+ appendHistoryRow(t, pane.history, testRow("old alpha"))
305305 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
306306
307307 d.enterCopyMode(sess, ac)
@@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
330330 p, _ := newBlockingPTYWithWrites(t, writes)
331331 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
332332 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
333- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))
334- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))
333+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 "))
334+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 "))
335335 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
336336
337337 d.enterCopyMode(sess, ac)
@@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
505505 p, _ := newBlockingPTY(t)
506506 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
507507 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
508- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))
509- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))
508+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 "))
509+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 "))
510510 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
511511
512512 d.enterCopyMode(sess, ac)
@@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
739739 var want *pane
740740 if tc.useFloating {
741741 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
742- require.NoError(t, want.history.Append(testRow("flt-old")))
742+ appendHistoryRow(t, want.history, testRow("flt-old"))
743743 want.screen.Write([]byte("flt-live"))
744744 } else {
745745 want = main
746- require.NoError(t, want.history.Append(testRow("one-old")))
746+ appendHistoryRow(t, want.history, testRow("one-old"))
747747 want.screen.Write([]byte("one-live"))
748748 }
749749
@@ -780,7 +780,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
780780 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
781781 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
782782 for i := range 30 {
783- require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%02d", i))))
783+ appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%02d", i)))
784784 }
785785 fp.screen.Write([]byte("live"))
786786 fp.mu.Lock()
@@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
806806 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
807807 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
808808 for i := range 5 {
809- require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%d", i))))
809+ appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%d", i)))
810810 }
811811 fp.screen.Write([]byte("live"))
812812
@@ -301,7 +301,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
301 d, sess, ac, sends := newManualSessionWithPTYs(t, p)301 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
302 pane := sess.tabs[0].focusedPane()302 pane := sess.tabs[0].focusedPane()
303 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})303 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
304- require.NoError(t, pane.history.Append(testRow("old alpha")))304+ appendHistoryRow(t, pane.history, testRow("old alpha"))
305 copy(pane.screen.Frame.Row(0), testRow("live alpha"))305 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
306 306
307 d.enterCopyMode(sess, ac)307 d.enterCopyMode(sess, ac)
@@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
330 p, _ := newBlockingPTYWithWrites(t, writes)330 p, _ := newBlockingPTYWithWrites(t, writes)
331 d, sess, ac, sends := newManualSessionWithPTYs(t, p)331 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
332 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})332 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
333- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))333+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 "))
334- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))334+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 "))
335 sess.tabs[0].focusedPane().screen.Write([]byte("live"))335 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
336 336
337 d.enterCopyMode(sess, ac)337 d.enterCopyMode(sess, ac)
@@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
505 p, _ := newBlockingPTY(t)505 p, _ := newBlockingPTY(t)
506 d, sess, ac, sends := newManualSessionWithPTYs(t, p)506 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
507 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})507 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
508- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))508+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 "))
509- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))509+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 "))
510 sess.tabs[0].focusedPane().screen.Write([]byte("live"))510 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
511 511
512 d.enterCopyMode(sess, ac)512 d.enterCopyMode(sess, ac)
@@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
739 var want *pane739 var want *pane
740 if tc.useFloating {740 if tc.useFloating {
741 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})741 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
742- require.NoError(t, want.history.Append(testRow("flt-old")))742+ appendHistoryRow(t, want.history, testRow("flt-old"))
743 want.screen.Write([]byte("flt-live"))743 want.screen.Write([]byte("flt-live"))
744 } else {744 } else {
745 want = main745 want = main
746- require.NoError(t, want.history.Append(testRow("one-old")))746+ appendHistoryRow(t, want.history, testRow("one-old"))
747 want.screen.Write([]byte("one-live"))747 want.screen.Write([]byte("one-live"))
748 }748 }
749 749
@@ -780,7 +780,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
780 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)780 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
781 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})781 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
782 for i := range 30 {782 for i := range 30 {
783- require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%02d", i))))783+ appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%02d", i)))
784 }784 }
785 fp.screen.Write([]byte("live"))785 fp.screen.Write([]byte("live"))
786 fp.mu.Lock()786 fp.mu.Lock()
@@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
806 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)806 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
807 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})807 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
808 for i := range 5 {808 for i := range 5 {
809- require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%d", i))))809+ appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%d", i)))
810 }810 }
811 fp.screen.Write([]byte("live"))811 fp.screen.Write([]byte("live"))
812 812
M internal/usecase/daemon/daemon_test.go +8 -0
@@ -432,6 +432,14 @@ func testRow(text string) []renderer.Cell {
432432 return cells
433433 }
434434
435+// appendHistoryRow records a row whose logical extent covers it entirely and
436+// that ends its logical line — the shape of a line scrolled off a grid it never
437+// wrapped out of. Tests that care about wrapped extents build the bound instead.
438+func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {
439+ tb.Helper()
440+ require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}))
441+}
442+
435443 func tabCount(sess *session) int {
436444 sess.mu.Lock()
437445 defer sess.mu.Unlock()
@@ -432,6 +432,14 @@ func testRow(text string) []renderer.Cell {
432 return cells432 return cells
433 }433 }
434 434
435+// appendHistoryRow records a row whose logical extent covers it entirely and
436+// that ends its logical line — the shape of a line scrolled off a grid it never
437+// wrapped out of. Tests that care about wrapped extents build the bound instead.
438+func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {
439+ tb.Helper()
440+ require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}))
441+}
442+
435 func tabCount(sess *session) int {443 func tabCount(sess *session) int {
436 sess.mu.Lock()444 sess.mu.Lock()
437 defer sess.mu.Unlock()445 defer sess.mu.Unlock()
M internal/usecase/daemon/input_test.go +2 -2
@@ -1237,7 +1237,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi
12371237
12381238 // Output evicts five lines after Press; the Document remains immutable.
12391239 for range 5 {
1240- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("evicted")))
1240+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("evicted"))
12411241 }
12421242
12431243 // Motion maps the next endpoint through the press-owned Document.
@@ -1772,7 +1772,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T)
17721772 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
17731773 pane := sess.activeTab().focusedPane()
17741774 for range 8 {
1775- require.NoError(t, pane.history.Append(testRow("history")))
1775+ appendHistoryRow(t, pane.history, testRow("history"))
17761776 }
17771777 d.enterCopyMode(sess, ac)
17781778 mustOutputData(t, sends)
@@ -1237,7 +1237,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi
1237 1237
1238 // Output evicts five lines after Press; the Document remains immutable.1238 // Output evicts five lines after Press; the Document remains immutable.
1239 for range 5 {1239 for range 5 {
1240- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("evicted")))1240+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("evicted"))
1241 }1241 }
1242 1242
1243 // Motion maps the next endpoint through the press-owned Document.1243 // Motion maps the next endpoint through the press-owned Document.
@@ -1772,7 +1772,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T)
1772 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1772 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1773 pane := sess.activeTab().focusedPane()1773 pane := sess.activeTab().focusedPane()
1774 for range 8 {1774 for range 8 {
1775- require.NoError(t, pane.history.Append(testRow("history")))1775+ appendHistoryRow(t, pane.history, testRow("history"))
1776 }1776 }
1777 d.enterCopyMode(sess, ac)1777 d.enterCopyMode(sess, ac)
1778 mustOutputData(t, sends)1778 mustOutputData(t, sends)
M internal/usecase/daemon/overlay_runtime_test.go +2 -2
@@ -199,7 +199,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
199199 ac := &attachedClient{}
200200 ac.initOverlays()
201201 history := newTestHistory(1)
202- require.NoError(t, history.Append(testRow("before")))
202+ appendHistoryRow(t, history, testRow("before"))
203203 document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)
204204
205205 ac.overlays.copyMu.Lock()
@@ -207,7 +207,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
207207 ac.overlays.copyDocument = document
208208 ac.overlays.copyMu.Unlock()
209209
210- require.NoError(t, history.Append(testRow("after")))
210+ appendHistoryRow(t, history, testRow("after"))
211211 renderSnap := ac.overlays.SnapshotForRender()
212212 defer renderSnap.Unlock()
213213 require.NotNil(t, renderSnap.copyMode)
@@ -199,7 +199,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
199 ac := &attachedClient{}199 ac := &attachedClient{}
200 ac.initOverlays()200 ac.initOverlays()
201 history := newTestHistory(1)201 history := newTestHistory(1)
202- require.NoError(t, history.Append(testRow("before")))202+ appendHistoryRow(t, history, testRow("before"))
203 document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)203 document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)
204 204
205 ac.overlays.copyMu.Lock()205 ac.overlays.copyMu.Lock()
@@ -207,7 +207,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
207 ac.overlays.copyDocument = document207 ac.overlays.copyDocument = document
208 ac.overlays.copyMu.Unlock()208 ac.overlays.copyMu.Unlock()
209 209
210- require.NoError(t, history.Append(testRow("after")))210+ appendHistoryRow(t, history, testRow("after"))
211 renderSnap := ac.overlays.SnapshotForRender()211 renderSnap := ac.overlays.SnapshotForRender()
212 defer renderSnap.Unlock()212 defer renderSnap.Unlock()
213 require.NotNil(t, renderSnap.copyMode)213 require.NotNil(t, renderSnap.copyMode)
M internal/usecase/daemon/performance_benchmark_test.go +1 -3
@@ -643,9 +643,7 @@ func mutateBenchmarkTail(fixture *performanceFixture, operation int) {
643643 for column := range row {
644644 row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}
645645 }
646- if err := fixture.activePane.history.Append(row); err != nil {
647- fixture.t.Fatal(err)
648- }
646+ appendHistoryRow(fixture.t, fixture.activePane.history, row)
649647 }
650648
651649 func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) {
@@ -643,9 +643,7 @@ func mutateBenchmarkTail(fixture *performanceFixture, operation int) {
643 for column := range row {643 for column := range row {
644 row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}644 row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}
645 }645 }
646- if err := fixture.activePane.history.Append(row); err != nil {646+ appendHistoryRow(fixture.t, fixture.activePane.history, row)
647- fixture.t.Fatal(err)
648- }
649 }647 }
650 648
651 func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) {649 func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) {
M internal/usecase/daemon/picker_test.go +1 -1
@@ -835,7 +835,7 @@ func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab {
835835 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
836836 p := tb.focusedPane()
837837 for range historyRows {
838- require.NoError(t, p.screen.History().Append(testRow("history-only-marker")))
838+ appendHistoryRow(t, p.screen.History(), testRow("history-only-marker"))
839839 }
840840 p.screen.Write([]byte("NOW"))
841841 return tb
@@ -835,7 +835,7 @@ func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab {
835 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})835 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
836 p := tb.focusedPane()836 p := tb.focusedPane()
837 for range historyRows {837 for range historyRows {
838- require.NoError(t, p.screen.History().Append(testRow("history-only-marker")))838+ appendHistoryRow(t, p.screen.History(), testRow("history-only-marker"))
839 }839 }
840 p.screen.Write([]byte("NOW"))840 p.screen.Write([]byte("NOW"))
841 return tb841 return tb
M internal/usecase/daemon/render_test.go +1 -1
@@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
12891289 defer releaseFloating()
12901290 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
12911291 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1292- require.NoError(t, fp.history.Append(testRow("flt-old")))
1292+ appendHistoryRow(t, fp.history, testRow("flt-old"))
12931293 fp.screen.Write([]byte("flt-live"))
12941294 installTestFloating(sess.activeTab(), fp, true)
12951295
@@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1289 defer releaseFloating()1289 defer releaseFloating()
1290 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)1290 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
1291 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})1291 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1292- require.NoError(t, fp.history.Append(testRow("flt-old")))1292+ appendHistoryRow(t, fp.history, testRow("flt-old"))
1293 fp.screen.Write([]byte("flt-live"))1293 fp.screen.Write([]byte("flt-live"))
1294 installTestFloating(sess.activeTab(), fp, true)1294 installTestFloating(sess.activeTab(), fp, true)
1295 1295
M internal/usecase/daemon/snapshot_acceptance_test.go +1 -1
@@ -56,7 +56,7 @@ func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapsh
5656 for col := range cells {
5757 cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)}
5858 }
59- require.NoError(t, history.Append(cells))
59+ appendHistoryRow(t, history, cells)
6060 }
6161 return history.SnapshotView()
6262 }
@@ -56,7 +56,7 @@ func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapsh
56 for col := range cells {56 for col := range cells {
57 cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)}57 cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)}
58 }58 }
59- require.NoError(t, history.Append(cells))59+ appendHistoryRow(t, history, cells)
60 }60 }
61 return history.SnapshotView()61 return history.SnapshotView()
62 }62 }
M internal/usecase/daemon/snapshot_incremental_test.go +3 -3
@@ -22,7 +22,7 @@ func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {
2222 t.Run(tt.name, func(t *testing.T) {
2323 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2})
2424 if tt.fill {
25- require.NoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))
25+ appendHistoryRow(t, history, []renderer.Cell{{Rune: 'x'}})
2626 }
2727 tail := history.SnapshotView().Tail()
2828
@@ -71,7 +71,7 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) {
7171 func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
7272 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})
7373 for i := range 6 {
74- require.NoError(t, history.Append([]renderer.Cell{{Rune: rune('a' + i)}}))
74+ appendHistoryRow(t, history, []renderer.Cell{{Rune: rune('a' + i)}})
7575 }
7676 view := history.SnapshotView()
7777 require.Equal(t, 6, view.ChunkCount())
@@ -119,7 +119,7 @@ func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
119119
120120 func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
121121 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
122- require.NoError(t, history.Append([]renderer.Cell{renderer.BlankCell()}))
122+ appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()})
123123 view := history.SnapshotView()
124124 visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()
125125 d := New(nil, nil, nil)
@@ -22,7 +22,7 @@ func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {
22 t.Run(tt.name, func(t *testing.T) {22 t.Run(tt.name, func(t *testing.T) {
23 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2})23 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2})
24 if tt.fill {24 if tt.fill {
25- require.NoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))25+ appendHistoryRow(t, history, []renderer.Cell{{Rune: 'x'}})
26 }26 }
27 tail := history.SnapshotView().Tail()27 tail := history.SnapshotView().Tail()
28 28
@@ -71,7 +71,7 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) {
71 func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {71 func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
72 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})72 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})
73 for i := range 6 {73 for i := range 6 {
74- require.NoError(t, history.Append([]renderer.Cell{{Rune: rune('a' + i)}}))74+ appendHistoryRow(t, history, []renderer.Cell{{Rune: rune('a' + i)}})
75 }75 }
76 view := history.SnapshotView()76 view := history.SnapshotView()
77 require.Equal(t, 6, view.ChunkCount())77 require.Equal(t, 6, view.ChunkCount())
@@ -119,7 +119,7 @@ func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
119 119
120 func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {120 func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
121 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})121 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
122- require.NoError(t, history.Append([]renderer.Cell{renderer.BlankCell()}))122+ appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()})
123 view := history.SnapshotView()123 view := history.SnapshotView()
124 visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()124 visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()
125 d := New(nil, nil, nil)125 d := New(nil, nil, nil)
M internal/usecase/daemon/snapshot_restore_test.go +1 -1
@@ -132,7 +132,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
132132 for i := range row {
133133 row[i] = renderer.Cell{Rune: ch}
134134 }
135- require.NoError(t, history.Append(row))
135+ appendHistoryRow(t, history, row)
136136 }
137137 view := history.SnapshotView()
138138 sealed := make([][]byte, view.ChunkCount())
@@ -132,7 +132,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
132 for i := range row {132 for i := range row {
133 row[i] = renderer.Cell{Rune: ch}133 row[i] = renderer.Cell{Rune: ch}
134 }134 }
135- require.NoError(t, history.Append(row))135+ appendHistoryRow(t, history, row)
136 }136 }
137 view := history.SnapshotView()137 view := history.SnapshotView()
138 sealed := make([][]byte, view.ChunkCount())138 sealed := make([][]byte, view.ChunkCount())
M internal/usecase/daemon/snapshot_test.go +1 -1
@@ -44,7 +44,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
4444 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
4545 p := tb.panes["pane-1"]
4646 p.screen.Write([]byte("hello"))
47- require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}))
47+ appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
4848 sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}
4949 sess.snapEligible.Store(!ephemeral && name != "")
5050 return sess
@@ -44,7 +44,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
44 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})44 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
45 p := tb.panes["pane-1"]45 p := tb.panes["pane-1"]
46 p.screen.Write([]byte("hello"))46 p.screen.Write([]byte("hello"))
47- require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}))47+ appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
48 sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}48 sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}
49 sess.snapEligible.Store(!ephemeral && name != "")49 sess.snapEligible.Store(!ephemeral && name != "")
50 return sess50 return sess
M internal/usecase/snapshot/codec_test.go +3 -3
@@ -210,7 +210,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
210210 // must remain a valid v3 import payload.
211211 mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
212212 for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} {
213- requireNoError(t, mutableHistory.Append(row))
213+ requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)}))
214214 }
215215 mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView())
216216 requireNoError(t, err)
@@ -343,7 +343,7 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
343343 func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
344344 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})
345345 for range 256 {
346- requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))
346+ requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}, vt.LineBound{End: 1}))
347347 }
348348 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
349349 requireNoError(t, err)
@@ -406,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
406406 t.Helper()
407407 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
408408 for _, row := range rows {
409- requireNoError(t, h.Append(row))
409+ requireNoError(t, h.Append(row, vt.LineBound{End: len(row)}))
410410 }
411411 sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
412412 if err != nil {
@@ -210,7 +210,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
210 // must remain a valid v3 import payload.210 // must remain a valid v3 import payload.
211 mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})211 mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
212 for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} {212 for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} {
213- requireNoError(t, mutableHistory.Append(row))213+ requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)}))
214 }214 }
215 mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView())215 mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView())
216 requireNoError(t, err)216 requireNoError(t, err)
@@ -343,7 +343,7 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
343 func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {343 func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
344 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})344 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})
345 for range 256 {345 for range 256 {
346- requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))346+ requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}, vt.LineBound{End: 1}))
347 }347 }
348 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())348 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
349 requireNoError(t, err)349 requireNoError(t, err)
@@ -406,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
406 t.Helper()406 t.Helper()
407 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})407 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
408 for _, row := range rows {408 for _, row := range rows {
409- requireNoError(t, h.Append(row))409+ requireNoError(t, h.Append(row, vt.LineBound{End: len(row)}))
410 }410 }
411 sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())411 sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
412 if err != nil {412 if err != nil {
M pkg/vt/benchmark_test.go +6 -1
@@ -28,9 +28,14 @@ func TestHistoryViewPartialTailAllocationBounded(t *testing.T) {
2828 partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows)
2929 partialBefore := partial.View()
3030
31+ // Copying a partial tail costs one allocation per row plus a fixed set of
32+ // headers: the chunk slice, its growth for the tail chunk, the chunk itself,
33+ // the row slice, and the bounds slice parallel to it.
34+ const tailHeaders = 4
35+
3136 sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })
3237 partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })
33- if partialAllocs > sealedAllocs+tailRows+3 {
38+ if partialAllocs > sealedAllocs+tailRows+tailHeaders {
3439 t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows)
3540 }
3641 if partial.View().Chunk(0) != partialBefore.Chunk(0) {
@@ -28,9 +28,14 @@ func TestHistoryViewPartialTailAllocationBounded(t *testing.T) {
28 partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows)28 partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows)
29 partialBefore := partial.View()29 partialBefore := partial.View()
30 30
31+ // Copying a partial tail costs one allocation per row plus a fixed set of
32+ // headers: the chunk slice, its growth for the tail chunk, the chunk itself,
33+ // the row slice, and the bounds slice parallel to it.
34+ const tailHeaders = 4
35+
31 sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })36 sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })
32 partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })37 partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })
33- if partialAllocs > sealedAllocs+tailRows+3 {38+ if partialAllocs > sealedAllocs+tailRows+tailHeaders {
34 t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows)39 t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows)
35 }40 }
36 if partial.View().Chunk(0) != partialBefore.Chunk(0) {41 if partial.View().Chunk(0) != partialBefore.Chunk(0) {
M pkg/vt/buffer.go +32 -9
@@ -39,6 +39,16 @@ func (b *buffer) content(y, end int) {
3939 b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
4040 }
4141
42+// bound reports the logical extent of row y. Callers on eviction paths may run
43+// against a nil or shorter buffer than the frame they are walking, so an
44+// unknown row reads as a hard row of no extent rather than panicking.
45+func (b *buffer) bound(y int) LineBound {
46+ if b == nil || y < 0 || y >= len(b.boundaries) {
47+ return LineBound{}
48+ }
49+ return b.boundaries[y]
50+}
51+
4252 func (b *buffer) truncate(y, end int) {
4353 if y < 0 || y >= len(b.boundaries) {
4454 return
@@ -140,15 +150,18 @@ func (b *buffer) hasSoft() bool {
140150
141151 // resizeFixed keeps hard physical lines independent. It is the common shell
142152 // path and avoids constructing logical-line scratch state when nothing wraps.
143-func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell {
153+func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
144154 anchor := 0
145155 if active != nil {
146156 anchor = active.row
147157 }
148158 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
149159 evicted := make([][]renderer.Cell, 0, shift)
160+ evictedBounds := make([]LineBound, 0, shift)
150161 for y := range shift {
151162 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
163+ // The evicted row keeps its source width, so its extent needs no clamping.
164+ evictedBounds = append(evictedBounds, b.boundaries[y])
152165 }
153166 // Fixed-line resizes can retain the boundary backing store. In particular,
154167 // keep its capacity through a short viewport so the common shrink/grow
@@ -180,7 +193,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][
180193 }
181194 }
182195 *b = next
183- return evicted
196+ return evicted, evictedBounds
184197 }
185198
186199 type reflowPoint struct {
@@ -221,7 +234,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint
221234 // the first maps both cursors and counts rows; the second writes only the
222235 // retained viewport (and the rows genuinely evicted to history). It never
223236 // materializes logical lines, per-cell position maps, or temporary output rows.
224-func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell {
237+func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
225238 if !b.hasSoft() {
226239 return b.resizeFixed(width, height, active, saved)
227240 }
@@ -235,7 +248,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
235248 }
236249
237250 points := b.cursorReflowPoints(active, saved)
238- rows := b.layoutReflow(width, &points, nil, 0, nil)
251+ rows := b.layoutReflow(width, &points, nil, 0, nil, nil)
239252 anchor := 0
240253 if active != nil {
241254 anchor = points[0].row
@@ -245,16 +258,18 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
245258 next := newBuffer(width, height)
246259 var evicted [][]renderer.Cell
247260 var evictedCells []renderer.Cell
261+ var evictedBounds []LineBound
248262 if shift > 0 {
249263 // History needs owned rows. One flat backing store replaces a temporary
250264 // allocation per evicted output row.
251265 evicted = make([][]renderer.Cell, shift)
252266 evictedCells = make([]renderer.Cell, shift*width)
267+ evictedBounds = make([]LineBound, shift)
253268 for y := range evicted {
254269 evicted[y] = evictedCells[y*width : (y+1)*width]
255270 }
256271 }
257- b.layoutReflow(width, &points, next, shift, evictedCells)
272+ b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds)
258273 for i, cur := range [2]*bufferCursor{active, saved} {
259274 if cur != nil {
260275 cur.row = clamp(points[i].row-shift, 0, height-1)
@@ -262,13 +277,13 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
262277 }
263278 }
264279 *b = *next
265- return evicted
280+ return evicted, evictedBounds
266281 }
267282
268283 // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
269284 // [shift, shift+dst.height). Rows before shift are written straight into the
270285 // contiguous eviction backing store. The return is the logical output height.
271-func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int {
286+func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int {
272287 row, col := 0, 0
273288 blankEvicted := func(outputRow int) []renderer.Cell {
274289 if outputRow < shift {
@@ -288,8 +303,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
288303 output = blankEvicted(0)
289304 }
290305 finishRow := func(soft bool) {
291- if dst != nil && row >= shift && row < shift+dst.frame.Height {
292- dst.boundaries[row-shift] = LineBound{End: col, Soft: soft}
306+ // An output row belongs either to history or to the retained viewport, and
307+ // only this pass knows where it ended, so both destinations are written here.
308+ bound := LineBound{End: col, Soft: soft}
309+ switch {
310+ case row < shift:
311+ if evictedBounds != nil {
312+ evictedBounds[row] = bound
313+ }
314+ case dst != nil && row < shift+dst.frame.Height:
315+ dst.boundaries[row-shift] = bound
293316 }
294317 row++
295318 col = 0
@@ -39,6 +39,16 @@ func (b *buffer) content(y, end int) {
39 b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width))39 b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
40 }40 }
41 41
42+// bound reports the logical extent of row y. Callers on eviction paths may run
43+// against a nil or shorter buffer than the frame they are walking, so an
44+// unknown row reads as a hard row of no extent rather than panicking.
45+func (b *buffer) bound(y int) LineBound {
46+ if b == nil || y < 0 || y >= len(b.boundaries) {
47+ return LineBound{}
48+ }
49+ return b.boundaries[y]
50+}
51+
42 func (b *buffer) truncate(y, end int) {52 func (b *buffer) truncate(y, end int) {
43 if y < 0 || y >= len(b.boundaries) {53 if y < 0 || y >= len(b.boundaries) {
44 return54 return
@@ -140,15 +150,18 @@ func (b *buffer) hasSoft() bool {
140 150
141 // resizeFixed keeps hard physical lines independent. It is the common shell151 // resizeFixed keeps hard physical lines independent. It is the common shell
142 // path and avoids constructing logical-line scratch state when nothing wraps.152 // path and avoids constructing logical-line scratch state when nothing wraps.
143-func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell {153+func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
144 anchor := 0154 anchor := 0
145 if active != nil {155 if active != nil {
146 anchor = active.row156 anchor = active.row
147 }157 }
148 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))158 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
149 evicted := make([][]renderer.Cell, 0, shift)159 evicted := make([][]renderer.Cell, 0, shift)
160+ evictedBounds := make([]LineBound, 0, shift)
150 for y := range shift {161 for y := range shift {
151 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))162 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
163+ // The evicted row keeps its source width, so its extent needs no clamping.
164+ evictedBounds = append(evictedBounds, b.boundaries[y])
152 }165 }
153 // Fixed-line resizes can retain the boundary backing store. In particular,166 // Fixed-line resizes can retain the boundary backing store. In particular,
154 // keep its capacity through a short viewport so the common shrink/grow167 // keep its capacity through a short viewport so the common shrink/grow
@@ -180,7 +193,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][
180 }193 }
181 }194 }
182 *b = next195 *b = next
183- return evicted196+ return evicted, evictedBounds
184 }197 }
185 198
186 type reflowPoint struct {199 type reflowPoint struct {
@@ -221,7 +234,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint
221 // the first maps both cursors and counts rows; the second writes only the234 // the first maps both cursors and counts rows; the second writes only the
222 // retained viewport (and the rows genuinely evicted to history). It never235 // retained viewport (and the rows genuinely evicted to history). It never
223 // materializes logical lines, per-cell position maps, or temporary output rows.236 // materializes logical lines, per-cell position maps, or temporary output rows.
224-func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell {237+func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
225 if !b.hasSoft() {238 if !b.hasSoft() {
226 return b.resizeFixed(width, height, active, saved)239 return b.resizeFixed(width, height, active, saved)
227 }240 }
@@ -235,7 +248,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
235 }248 }
236 249
237 points := b.cursorReflowPoints(active, saved)250 points := b.cursorReflowPoints(active, saved)
238- rows := b.layoutReflow(width, &points, nil, 0, nil)251+ rows := b.layoutReflow(width, &points, nil, 0, nil, nil)
239 anchor := 0252 anchor := 0
240 if active != nil {253 if active != nil {
241 anchor = points[0].row254 anchor = points[0].row
@@ -245,16 +258,18 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
245 next := newBuffer(width, height)258 next := newBuffer(width, height)
246 var evicted [][]renderer.Cell259 var evicted [][]renderer.Cell
247 var evictedCells []renderer.Cell260 var evictedCells []renderer.Cell
261+ var evictedBounds []LineBound
248 if shift > 0 {262 if shift > 0 {
249 // History needs owned rows. One flat backing store replaces a temporary263 // History needs owned rows. One flat backing store replaces a temporary
250 // allocation per evicted output row.264 // allocation per evicted output row.
251 evicted = make([][]renderer.Cell, shift)265 evicted = make([][]renderer.Cell, shift)
252 evictedCells = make([]renderer.Cell, shift*width)266 evictedCells = make([]renderer.Cell, shift*width)
267+ evictedBounds = make([]LineBound, shift)
253 for y := range evicted {268 for y := range evicted {
254 evicted[y] = evictedCells[y*width : (y+1)*width]269 evicted[y] = evictedCells[y*width : (y+1)*width]
255 }270 }
256 }271 }
257- b.layoutReflow(width, &points, next, shift, evictedCells)272+ b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds)
258 for i, cur := range [2]*bufferCursor{active, saved} {273 for i, cur := range [2]*bufferCursor{active, saved} {
259 if cur != nil {274 if cur != nil {
260 cur.row = clamp(points[i].row-shift, 0, height-1)275 cur.row = clamp(points[i].row-shift, 0, height-1)
@@ -262,13 +277,13 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
262 }277 }
263 }278 }
264 *b = *next279 *b = *next
265- return evicted280+ return evicted, evictedBounds
266 }281 }
267 282
268 // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in283 // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
269 // [shift, shift+dst.height). Rows before shift are written straight into the284 // [shift, shift+dst.height). Rows before shift are written straight into the
270 // contiguous eviction backing store. The return is the logical output height.285 // contiguous eviction backing store. The return is the logical output height.
271-func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int {286+func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int {
272 row, col := 0, 0287 row, col := 0, 0
273 blankEvicted := func(outputRow int) []renderer.Cell {288 blankEvicted := func(outputRow int) []renderer.Cell {
274 if outputRow < shift {289 if outputRow < shift {
@@ -288,8 +303,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
288 output = blankEvicted(0)303 output = blankEvicted(0)
289 }304 }
290 finishRow := func(soft bool) {305 finishRow := func(soft bool) {
291- if dst != nil && row >= shift && row < shift+dst.frame.Height {306+ // An output row belongs either to history or to the retained viewport, and
292- dst.boundaries[row-shift] = LineBound{End: col, Soft: soft}307+ // only this pass knows where it ended, so both destinations are written here.
308+ bound := LineBound{End: col, Soft: soft}
309+ switch {
310+ case row < shift:
311+ if evictedBounds != nil {
312+ evictedBounds[row] = bound
313+ }
314+ case dst != nil && row < shift+dst.frame.Height:
315+ dst.boundaries[row-shift] = bound
293 }316 }
294 row++317 row++
295 col = 0318 col = 0
M pkg/vt/history.go +80 -24
@@ -22,19 +22,22 @@ type HistoryConfig struct {
2222 // mutated by the owner of a Screen; views are safe to retain after later
2323 // appends.
2424 type History struct {
25- maxRows int
26- maxCells int
27- chunkRows int
28- chunks []*HistoryChunk
29- tail [][]renderer.Cell
30- rows int
31- cells int
25+ maxRows int
26+ maxCells int
27+ chunkRows int
28+ chunks []*HistoryChunk
29+ tail [][]renderer.Cell
30+ tailBounds []LineBound
31+ rows int
32+ cells int
3233 }
3334
3435 // HistoryChunk is an immutable group of history rows. Its identity is stable
3536 // and can be used by consumers to avoid copying unchanged sealed chunks.
37+// bounds is parallel to rows and always has the same length.
3638 type HistoryChunk struct {
37- rows [][]renderer.Cell
39+ rows [][]renderer.Cell
40+ bounds []LineBound
3841 }
3942
4043 // HistoryView is an immutable snapshot of history. Row returns a copy so the
@@ -49,10 +52,11 @@ type HistoryView struct {
4952 // independently. Sealed chunks are shared by identity; Tail is owned by the
5053 // view and can be serialized without rotating the live tail into a chunk.
5154 type HistorySnapshotView struct {
52- chunks []*HistoryChunk
53- tail [][]renderer.Cell
54- rows int
55- cells int
55+ chunks []*HistoryChunk
56+ tail [][]renderer.Cell
57+ tailBounds []LineBound
58+ rows int
59+ cells int
5660 }
5761
5862 func NewHistory(config HistoryConfig) *History {
@@ -79,9 +83,10 @@ func defaultHistoryMaxCells(maxRows int) int {
7983 return maxRows * 160
8084 }
8185
82-// Append records a copy of row. Once a chunk is full it is sealed forever.
83-// Rows wider than the total cell capacity are rejected without mutation.
84-func (h *History) Append(row []renderer.Cell) error {
86+// Append records a copy of row along with its logical extent. Once a chunk is
87+// full it is sealed forever. Rows wider than the total cell capacity are
88+// rejected without mutation.
89+func (h *History) Append(row []renderer.Cell, bound LineBound) error {
8590 if h == nil || h.maxRows == 0 {
8691 return nil
8792 }
@@ -92,6 +97,7 @@ func (h *History) Append(row []renderer.Cell) error {
9297 // capacity is MaxInt.
9398 h.evictFor(len(row))
9499 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
100+ h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
95101 h.rows++
96102 h.cells += len(row)
97103 if len(h.tail) == h.chunkRows {
@@ -100,12 +106,31 @@ func (h *History) Append(row []renderer.Cell) error {
100106 return nil
101107 }
102108
109+// clampBound keeps End inside the row it describes so a persisted bound can be
110+// validated without consulting its row again.
111+func clampBound(b LineBound, cells int) LineBound {
112+ b.End = min(max(b.End, 0), cells)
113+ return b
114+}
115+
116+// growBounds returns bounds sized to rows, padding with zero values. It keeps
117+// the parallel-slice invariant when a chunk arrives with fewer bounds than rows.
118+func growBounds(bounds []LineBound, rows int) []LineBound {
119+ if len(bounds) == rows {
120+ return bounds
121+ }
122+ out := make([]LineBound, rows)
123+ copy(out, bounds)
124+ return out
125+}
126+
103127 func (h *History) sealTail() {
104128 if len(h.tail) == 0 {
105129 return
106130 }
107- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail})
131+ h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))})
108132 h.tail = nil
133+ h.tailBounds = nil
109134 }
110135
111136 // normalizeTail seals complete chunks so the mutable tail remains shorter than
@@ -113,8 +138,12 @@ func (h *History) sealTail() {
113138 // size than the current history configuration.
114139 func (h *History) normalizeTail() {
115140 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
116- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail[:h.chunkRows]})
141+ h.chunks = append(h.chunks, &HistoryChunk{
142+ rows: h.tail[:h.chunkRows],
143+ bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows],
144+ })
117145 h.tail = h.tail[h.chunkRows:]
146+ h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:]
118147 }
119148 }
120149
@@ -143,7 +172,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
143172 } else {
144173 // Preserve cell storage while replacing only the chunk wrapper: a
145174 // retained view may still refer to the original immutable chunk.
146- h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]}
175+ h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:], bounds: growBounds(chunk.bounds, len(chunk.rows))[1:]}
147176 }
148177 continue
149178 }
@@ -155,6 +184,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
155184 h.cells -= len(row)
156185 h.tail[0] = nil
157186 h.tail = h.tail[1:]
187+ h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
158188 }
159189 }
160190
@@ -175,7 +205,10 @@ func (h *History) View() HistoryView {
175205 }
176206 chunks := append([]*HistoryChunk(nil), h.chunks...)
177207 if len(h.tail) > 0 {
178- chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)})
208+ chunks = append(chunks, &HistoryChunk{
209+ rows: cloneHistoryRows(h.tail),
210+ bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
211+ })
179212 }
180213 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
181214 }
@@ -187,10 +220,11 @@ func (h *History) SnapshotView() HistorySnapshotView {
187220 return HistorySnapshotView{}
188221 }
189222 return HistorySnapshotView{
190- chunks: append([]*HistoryChunk(nil), h.chunks...),
191- tail: cloneHistoryRows(h.tail),
192- rows: h.rows,
193- cells: h.cells,
223+ chunks: append([]*HistoryChunk(nil), h.chunks...),
224+ tail: cloneHistoryRows(h.tail),
225+ tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
226+ rows: h.rows,
227+ cells: h.cells,
194228 }
195229 }
196230
@@ -282,6 +316,24 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
282316 return nil
283317 }
284318
319+// Bound returns the logical extent of the row at i, or the zero value when i is
320+// out of range. A zero value describes a hard row whose End is not meaningful.
321+func (v HistoryView) Bound(i int) LineBound {
322+ if i < 0 {
323+ return LineBound{}
324+ }
325+ for _, chunk := range v.chunks {
326+ if i < len(chunk.rows) {
327+ if i < len(chunk.bounds) {
328+ return chunk.bounds[i]
329+ }
330+ return LineBound{}
331+ }
332+ i -= len(chunk.rows)
333+ }
334+ return LineBound{}
335+}
336+
285337 func (v HistorySnapshotView) Len() int { return v.rows }
286338 func (v HistorySnapshotView) Cells() int { return v.cells }
287339 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
@@ -299,7 +351,11 @@ func (v HistorySnapshotView) Tail() HistoryView {
299351 if len(v.tail) == 0 {
300352 return HistoryView{}
301353 }
302- return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)}
354+ return HistoryView{
355+ chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}},
356+ rows: len(v.tail),
357+ cells: historyRowsCells(v.tail),
358+ }
303359 }
304360
305361 func historyRowsCells(rows [][]renderer.Cell) int {
@@ -22,19 +22,22 @@ type HistoryConfig struct {
22 // mutated by the owner of a Screen; views are safe to retain after later22 // mutated by the owner of a Screen; views are safe to retain after later
23 // appends.23 // appends.
24 type History struct {24 type History struct {
25- maxRows int25+ maxRows int
26- maxCells int26+ maxCells int
27- chunkRows int27+ chunkRows int
28- chunks []*HistoryChunk28+ chunks []*HistoryChunk
29- tail [][]renderer.Cell29+ tail [][]renderer.Cell
30- rows int30+ tailBounds []LineBound
31- cells int31+ rows int
32+ cells int
32 }33 }
33 34
34 // HistoryChunk is an immutable group of history rows. Its identity is stable35 // HistoryChunk is an immutable group of history rows. Its identity is stable
35 // and can be used by consumers to avoid copying unchanged sealed chunks.36 // and can be used by consumers to avoid copying unchanged sealed chunks.
37+// bounds is parallel to rows and always has the same length.
36 type HistoryChunk struct {38 type HistoryChunk struct {
37- rows [][]renderer.Cell39+ rows [][]renderer.Cell
40+ bounds []LineBound
38 }41 }
39 42
40 // HistoryView is an immutable snapshot of history. Row returns a copy so the43 // HistoryView is an immutable snapshot of history. Row returns a copy so the
@@ -49,10 +52,11 @@ type HistoryView struct {
49 // independently. Sealed chunks are shared by identity; Tail is owned by the52 // independently. Sealed chunks are shared by identity; Tail is owned by the
50 // view and can be serialized without rotating the live tail into a chunk.53 // view and can be serialized without rotating the live tail into a chunk.
51 type HistorySnapshotView struct {54 type HistorySnapshotView struct {
52- chunks []*HistoryChunk55+ chunks []*HistoryChunk
53- tail [][]renderer.Cell56+ tail [][]renderer.Cell
54- rows int57+ tailBounds []LineBound
55- cells int58+ rows int
59+ cells int
56 }60 }
57 61
58 func NewHistory(config HistoryConfig) *History {62 func NewHistory(config HistoryConfig) *History {
@@ -79,9 +83,10 @@ func defaultHistoryMaxCells(maxRows int) int {
79 return maxRows * 16083 return maxRows * 160
80 }84 }
81 85
82-// Append records a copy of row. Once a chunk is full it is sealed forever.86+// Append records a copy of row along with its logical extent. Once a chunk is
83-// Rows wider than the total cell capacity are rejected without mutation.87+// full it is sealed forever. Rows wider than the total cell capacity are
84-func (h *History) Append(row []renderer.Cell) error {88+// rejected without mutation.
89+func (h *History) Append(row []renderer.Cell, bound LineBound) error {
85 if h == nil || h.maxRows == 0 {90 if h == nil || h.maxRows == 0 {
86 return nil91 return nil
87 }92 }
@@ -92,6 +97,7 @@ func (h *History) Append(row []renderer.Cell) error {
92 // capacity is MaxInt.97 // capacity is MaxInt.
93 h.evictFor(len(row))98 h.evictFor(len(row))
94 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))99 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
100+ h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
95 h.rows++101 h.rows++
96 h.cells += len(row)102 h.cells += len(row)
97 if len(h.tail) == h.chunkRows {103 if len(h.tail) == h.chunkRows {
@@ -100,12 +106,31 @@ func (h *History) Append(row []renderer.Cell) error {
100 return nil106 return nil
101 }107 }
102 108
109+// clampBound keeps End inside the row it describes so a persisted bound can be
110+// validated without consulting its row again.
111+func clampBound(b LineBound, cells int) LineBound {
112+ b.End = min(max(b.End, 0), cells)
113+ return b
114+}
115+
116+// growBounds returns bounds sized to rows, padding with zero values. It keeps
117+// the parallel-slice invariant when a chunk arrives with fewer bounds than rows.
118+func growBounds(bounds []LineBound, rows int) []LineBound {
119+ if len(bounds) == rows {
120+ return bounds
121+ }
122+ out := make([]LineBound, rows)
123+ copy(out, bounds)
124+ return out
125+}
126+
103 func (h *History) sealTail() {127 func (h *History) sealTail() {
104 if len(h.tail) == 0 {128 if len(h.tail) == 0 {
105 return129 return
106 }130 }
107- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail})131+ h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))})
108 h.tail = nil132 h.tail = nil
133+ h.tailBounds = nil
109 }134 }
110 135
111 // normalizeTail seals complete chunks so the mutable tail remains shorter than136 // normalizeTail seals complete chunks so the mutable tail remains shorter than
@@ -113,8 +138,12 @@ func (h *History) sealTail() {
113 // size than the current history configuration.138 // size than the current history configuration.
114 func (h *History) normalizeTail() {139 func (h *History) normalizeTail() {
115 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {140 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
116- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail[:h.chunkRows]})141+ h.chunks = append(h.chunks, &HistoryChunk{
142+ rows: h.tail[:h.chunkRows],
143+ bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows],
144+ })
117 h.tail = h.tail[h.chunkRows:]145 h.tail = h.tail[h.chunkRows:]
146+ h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:]
118 }147 }
119 }148 }
120 149
@@ -143,7 +172,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
143 } else {172 } else {
144 // Preserve cell storage while replacing only the chunk wrapper: a173 // Preserve cell storage while replacing only the chunk wrapper: a
145 // retained view may still refer to the original immutable chunk.174 // retained view may still refer to the original immutable chunk.
146- h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]}175+ h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:], bounds: growBounds(chunk.bounds, len(chunk.rows))[1:]}
147 }176 }
148 continue177 continue
149 }178 }
@@ -155,6 +184,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
155 h.cells -= len(row)184 h.cells -= len(row)
156 h.tail[0] = nil185 h.tail[0] = nil
157 h.tail = h.tail[1:]186 h.tail = h.tail[1:]
187+ h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
158 }188 }
159 }189 }
160 190
@@ -175,7 +205,10 @@ func (h *History) View() HistoryView {
175 }205 }
176 chunks := append([]*HistoryChunk(nil), h.chunks...)206 chunks := append([]*HistoryChunk(nil), h.chunks...)
177 if len(h.tail) > 0 {207 if len(h.tail) > 0 {
178- chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)})208+ chunks = append(chunks, &HistoryChunk{
209+ rows: cloneHistoryRows(h.tail),
210+ bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
211+ })
179 }212 }
180 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}213 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
181 }214 }
@@ -187,10 +220,11 @@ func (h *History) SnapshotView() HistorySnapshotView {
187 return HistorySnapshotView{}220 return HistorySnapshotView{}
188 }221 }
189 return HistorySnapshotView{222 return HistorySnapshotView{
190- chunks: append([]*HistoryChunk(nil), h.chunks...),223+ chunks: append([]*HistoryChunk(nil), h.chunks...),
191- tail: cloneHistoryRows(h.tail),224+ tail: cloneHistoryRows(h.tail),
192- rows: h.rows,225+ tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
193- cells: h.cells,226+ rows: h.rows,
227+ cells: h.cells,
194 }228 }
195 }229 }
196 230
@@ -282,6 +316,24 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
282 return nil316 return nil
283 }317 }
284 318
319+// Bound returns the logical extent of the row at i, or the zero value when i is
320+// out of range. A zero value describes a hard row whose End is not meaningful.
321+func (v HistoryView) Bound(i int) LineBound {
322+ if i < 0 {
323+ return LineBound{}
324+ }
325+ for _, chunk := range v.chunks {
326+ if i < len(chunk.rows) {
327+ if i < len(chunk.bounds) {
328+ return chunk.bounds[i]
329+ }
330+ return LineBound{}
331+ }
332+ i -= len(chunk.rows)
333+ }
334+ return LineBound{}
335+}
336+
285 func (v HistorySnapshotView) Len() int { return v.rows }337 func (v HistorySnapshotView) Len() int { return v.rows }
286 func (v HistorySnapshotView) Cells() int { return v.cells }338 func (v HistorySnapshotView) Cells() int { return v.cells }
287 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }339 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
@@ -299,7 +351,11 @@ func (v HistorySnapshotView) Tail() HistoryView {
299 if len(v.tail) == 0 {351 if len(v.tail) == 0 {
300 return HistoryView{}352 return HistoryView{}
301 }353 }
302- return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)}354+ return HistoryView{
355+ chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}},
356+ rows: len(v.tail),
357+ cells: historyRowsCells(v.tail),
358+ }
303 }359 }
304 360
305 func historyRowsCells(rows [][]renderer.Cell) int {361 func historyRowsCells(rows [][]renderer.Cell) int {
M pkg/vt/history_test.go +72 -5
@@ -10,7 +10,7 @@ import (
1010
1111 func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) {
1212 t.Helper()
13- if err := history.Append(row); err != nil {
13+ if err := history.Append(row, LineBound{End: len(row)}); err != nil {
1414 t.Fatalf("append history row: %v", err)
1515 }
1616 }
@@ -204,7 +204,7 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
204204 func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {
205205 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})
206206 for _, text := range []string{"aa", "bbb", "c", "dd"} {
207- if err := history.Append(historyRow(text)); err != nil {
207+ if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
208208 t.Fatalf("append %q: %v", text, err)
209209 }
210210 }
@@ -235,7 +235,8 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
235235
236236 for _, tt := range tests {
237237 t.Run(tt.name, func(t *testing.T) {
238- if err := tt.history.Append(historyRow("row")); err != nil {
238+ row := historyRow("row")
239+ if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil {
239240 t.Fatalf("append error = %v, want nil", err)
240241 }
241242 if got := tt.history.Len(); got != 0 {
@@ -247,12 +248,14 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
247248
248249 func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
249250 history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})
250- if err := history.Append(historyRow("ok")); err != nil {
251+ kept := historyRow("ok")
252+ if err := history.Append(kept, LineBound{End: len(kept)}); err != nil {
251253 t.Fatalf("append retained row: %v", err)
252254 }
253255 before := history.View()
254256
255- err := history.Append(historyRow("wide"))
257+ wide := historyRow("wide")
258+ err := history.Append(wide, LineBound{End: len(wide)})
256259 if !errors.Is(err, ErrHistoryRowTooWide) {
257260 t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)
258261 }
@@ -283,6 +286,70 @@ func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T)
283286 }
284287 }
285288
289+func TestHistoryAppendRetainsBounds(t *testing.T) {
290+ row := func(s string) []renderer.Cell {
291+ cells := make([]renderer.Cell, 0, len(s))
292+ for _, r := range s {
293+ cells = append(cells, renderer.Cell{Rune: r})
294+ }
295+ return cells
296+ }
297+
298+ tests := []struct {
299+ name string
300+ chunkRows int
301+ bounds []LineBound
302+ }{
303+ {
304+ name: "within the mutable tail",
305+ chunkRows: 8,
306+ bounds: []LineBound{{End: 3, Soft: true}, {End: 2}},
307+ },
308+ {
309+ name: "across a sealed chunk boundary",
310+ chunkRows: 2,
311+ bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}},
312+ },
313+ }
314+
315+ for _, tc := range tests {
316+ t.Run(tc.name, func(t *testing.T) {
317+ h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows})
318+ for i, b := range tc.bounds {
319+ if err := h.Append(row("abc"[:b.End]), b); err != nil {
320+ t.Fatalf("append %d: %v", i, err)
321+ }
322+ }
323+ view := h.SealAndView()
324+ for i, want := range tc.bounds {
325+ if got := view.Bound(i); got != want {
326+ t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
327+ }
328+ }
329+ })
330+ }
331+}
332+
333+func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {
334+ h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2})
335+ for i := range 5 {
336+ cells := []renderer.Cell{{Rune: rune('a' + i)}}
337+ if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil {
338+ t.Fatalf("append %d: %v", i, err)
339+ }
340+ }
341+ view := h.SealAndView()
342+ if view.Len() != 3 {
343+ t.Fatalf("Len() = %d, want 3", view.Len())
344+ }
345+ // Rows 2, 3, 4 survived; their Soft flags were true, false, true.
346+ for i, want := range []bool{true, false, true} {
347+ if got := view.Bound(i).Soft; got != want {
348+ t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune)
349+ }
350+ }
351+}
352+
286353 func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {
287354 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
288355 if got, want := history.CellCap(), math.MaxInt; got != want {
@@ -10,7 +10,7 @@ import (
10 10
11 func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) {11 func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) {
12 t.Helper()12 t.Helper()
13- if err := history.Append(row); err != nil {13+ if err := history.Append(row, LineBound{End: len(row)}); err != nil {
14 t.Fatalf("append history row: %v", err)14 t.Fatalf("append history row: %v", err)
15 }15 }
16 }16 }
@@ -204,7 +204,7 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
204 func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {204 func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {
205 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})205 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})
206 for _, text := range []string{"aa", "bbb", "c", "dd"} {206 for _, text := range []string{"aa", "bbb", "c", "dd"} {
207- if err := history.Append(historyRow(text)); err != nil {207+ if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
208 t.Fatalf("append %q: %v", text, err)208 t.Fatalf("append %q: %v", text, err)
209 }209 }
210 }210 }
@@ -235,7 +235,8 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
235 235
236 for _, tt := range tests {236 for _, tt := range tests {
237 t.Run(tt.name, func(t *testing.T) {237 t.Run(tt.name, func(t *testing.T) {
238- if err := tt.history.Append(historyRow("row")); err != nil {238+ row := historyRow("row")
239+ if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil {
239 t.Fatalf("append error = %v, want nil", err)240 t.Fatalf("append error = %v, want nil", err)
240 }241 }
241 if got := tt.history.Len(); got != 0 {242 if got := tt.history.Len(); got != 0 {
@@ -247,12 +248,14 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
247 248
248 func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {249 func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
249 history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})250 history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})
250- if err := history.Append(historyRow("ok")); err != nil {251+ kept := historyRow("ok")
252+ if err := history.Append(kept, LineBound{End: len(kept)}); err != nil {
251 t.Fatalf("append retained row: %v", err)253 t.Fatalf("append retained row: %v", err)
252 }254 }
253 before := history.View()255 before := history.View()
254 256
255- err := history.Append(historyRow("wide"))257+ wide := historyRow("wide")
258+ err := history.Append(wide, LineBound{End: len(wide)})
256 if !errors.Is(err, ErrHistoryRowTooWide) {259 if !errors.Is(err, ErrHistoryRowTooWide) {
257 t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)260 t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)
258 }261 }
@@ -283,6 +286,70 @@ func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T)
283 }286 }
284 }287 }
285 288
289+func TestHistoryAppendRetainsBounds(t *testing.T) {
290+ row := func(s string) []renderer.Cell {
291+ cells := make([]renderer.Cell, 0, len(s))
292+ for _, r := range s {
293+ cells = append(cells, renderer.Cell{Rune: r})
294+ }
295+ return cells
296+ }
297+
298+ tests := []struct {
299+ name string
300+ chunkRows int
301+ bounds []LineBound
302+ }{
303+ {
304+ name: "within the mutable tail",
305+ chunkRows: 8,
306+ bounds: []LineBound{{End: 3, Soft: true}, {End: 2}},
307+ },
308+ {
309+ name: "across a sealed chunk boundary",
310+ chunkRows: 2,
311+ bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}},
312+ },
313+ }
314+
315+ for _, tc := range tests {
316+ t.Run(tc.name, func(t *testing.T) {
317+ h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows})
318+ for i, b := range tc.bounds {
319+ if err := h.Append(row("abc"[:b.End]), b); err != nil {
320+ t.Fatalf("append %d: %v", i, err)
321+ }
322+ }
323+ view := h.SealAndView()
324+ for i, want := range tc.bounds {
325+ if got := view.Bound(i); got != want {
326+ t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
327+ }
328+ }
329+ })
330+ }
331+}
332+
333+func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {
334+ h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2})
335+ for i := range 5 {
336+ cells := []renderer.Cell{{Rune: rune('a' + i)}}
337+ if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil {
338+ t.Fatalf("append %d: %v", i, err)
339+ }
340+ }
341+ view := h.SealAndView()
342+ if view.Len() != 3 {
343+ t.Fatalf("Len() = %d, want 3", view.Len())
344+ }
345+ // Rows 2, 3, 4 survived; their Soft flags were true, false, true.
346+ for i, want := range []bool{true, false, true} {
347+ if got := view.Bound(i).Soft; got != want {
348+ t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune)
349+ }
350+ }
351+}
352+
286 func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {353 func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {
287 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})354 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
288 if got, want := history.CellCap(), math.MaxInt; got != want {355 if got, want := history.CellCap(), math.MaxInt; got != want {
M pkg/vt/screen_resize.go +3 -3
@@ -25,10 +25,10 @@ func (s *Screen) Resize(width, height int) {
2525 if saved != nil && saved.saved {
2626 savedPoint = &bufferCursor{row: saved.row, col: saved.col}
2727 }
28- evicted := b.resize(width, height, active, savedPoint)
28+ evicted, evictedBounds := b.resize(width, height, active, savedPoint)
2929 if evict {
30- for _, line := range evicted {
31- s.recordEvicted(line)
30+ for i, line := range evicted {
31+ s.recordEvicted(line, evictedBounds[i])
3232 }
3333 }
3434 if savedPoint != nil {
@@ -25,10 +25,10 @@ func (s *Screen) Resize(width, height int) {
25 if saved != nil && saved.saved {25 if saved != nil && saved.saved {
26 savedPoint = &bufferCursor{row: saved.row, col: saved.col}26 savedPoint = &bufferCursor{row: saved.row, col: saved.col}
27 }27 }
28- evicted := b.resize(width, height, active, savedPoint)28+ evicted, evictedBounds := b.resize(width, height, active, savedPoint)
29 if evict {29 if evict {
30- for _, line := range evicted {30+ for i, line := range evicted {
31- s.recordEvicted(line)31+ s.recordEvicted(line, evictedBounds[i])
32 }32 }
33 }33 }
34 if savedPoint != nil {34 if savedPoint != nil {
M pkg/vt/screen_scroll.go +5 -3
@@ -85,14 +85,16 @@ func (s *Screen) emitLineEvicted(top, n int) {
8585 if s.alternate != nil || top != 0 {
8686 return
8787 }
88+ // Read boundaries before the caller rotates the frame: a soft link belongs
89+ // to the row it follows, and rotation reassigns row indices.
8890 for y := top; y < top+n; y++ {
89- s.recordEvicted(s.Frame.Row(y))
91+ s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y))
9092 }
9193 }
9294
93-func (s *Screen) recordEvicted(row []renderer.Cell) {
95+func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound) {
9496 if s.history != nil {
95- err := s.history.Append(row)
97+ err := s.history.Append(row, bound)
9698 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
9799 panic(err)
98100 }
@@ -85,14 +85,16 @@ func (s *Screen) emitLineEvicted(top, n int) {
85 if s.alternate != nil || top != 0 {85 if s.alternate != nil || top != 0 {
86 return86 return
87 }87 }
88+ // Read boundaries before the caller rotates the frame: a soft link belongs
89+ // to the row it follows, and rotation reassigns row indices.
88 for y := top; y < top+n; y++ {90 for y := top; y < top+n; y++ {
89- s.recordEvicted(s.Frame.Row(y))91+ s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y))
90 }92 }
91 }93 }
92 94
93-func (s *Screen) recordEvicted(row []renderer.Cell) {95+func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound) {
94 if s.history != nil {96 if s.history != nil {
95- err := s.history.Append(row)97+ err := s.history.Append(row, bound)
96 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {98 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
97 panic(err)99 panic(err)
98 }100 }
M pkg/vt/scrollback_test.go +59 -0
@@ -99,6 +99,65 @@ func TestOnLineEvictedAltScreenAndRotation(t *testing.T) {
9999 }
100100 }
101101
102+func TestEvictedLinesCarryTheirBound(t *testing.T) {
103+ tests := []struct {
104+ name string
105+ evict func(s *Screen)
106+ wantSoft bool
107+ }{
108+ {
109+ name: "scroll evicts a soft-wrapped row",
110+ evict: func(s *Screen) {
111+ // Filling all four columns only leaves deferred wrap pending. The
112+ // fifth printable character triggers the soft wrap before scrolling.
113+ s.Write([]byte("abcde"))
114+ s.Write([]byte("\r\n\r\n\r\n\r\n"))
115+ },
116+ wantSoft: true,
117+ },
118+ {
119+ name: "scroll evicts a hard row",
120+ evict: func(s *Screen) {
121+ s.Write([]byte("ab\r\n\r\n\r\n\r\n"))
122+ },
123+ wantSoft: false,
124+ },
125+ }
126+
127+ for _, tc := range tests {
128+ t.Run(tc.name, func(t *testing.T) {
129+ s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 1024})
130+ tc.evict(s)
131+ view := s.History().SealAndView()
132+ if view.Len() == 0 {
133+ t.Fatal("nothing was evicted to history")
134+ }
135+ if got := view.Bound(0).Soft; got != tc.wantSoft {
136+ t.Errorf("Bound(0).Soft = %v, want %v", got, tc.wantSoft)
137+ }
138+ })
139+ }
140+}
141+
142+func TestReflowEvictedLinesCarryTheirBound(t *testing.T) {
143+ s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxCells: 1024})
144+ // Fill the grid with wrapped content, then shrink so rows are evicted by
145+ // the reflow path in buffer.resize rather than by scrolling.
146+ s.Write([]byte("aaaaaaaabbbbbbbbcccccccc"))
147+ s.Resize(4, 2)
148+
149+ view := s.History().SealAndView()
150+ if view.Len() == 0 {
151+ t.Fatal("reflow evicted nothing to history")
152+ }
153+ for i := range view.Len() {
154+ bound := view.Bound(i)
155+ if bound.End == 0 {
156+ t.Errorf("Bound(%d).End = 0, want the reflowed row extent", i)
157+ }
158+ }
159+}
160+
102161 func equalStrings(a, b []string) bool {
103162 if len(a) != len(b) {
104163 return false
@@ -99,6 +99,65 @@ func TestOnLineEvictedAltScreenAndRotation(t *testing.T) {
99 }99 }
100 }100 }
101 101
102+func TestEvictedLinesCarryTheirBound(t *testing.T) {
103+ tests := []struct {
104+ name string
105+ evict func(s *Screen)
106+ wantSoft bool
107+ }{
108+ {
109+ name: "scroll evicts a soft-wrapped row",
110+ evict: func(s *Screen) {
111+ // Filling all four columns only leaves deferred wrap pending. The
112+ // fifth printable character triggers the soft wrap before scrolling.
113+ s.Write([]byte("abcde"))
114+ s.Write([]byte("\r\n\r\n\r\n\r\n"))
115+ },
116+ wantSoft: true,
117+ },
118+ {
119+ name: "scroll evicts a hard row",
120+ evict: func(s *Screen) {
121+ s.Write([]byte("ab\r\n\r\n\r\n\r\n"))
122+ },
123+ wantSoft: false,
124+ },
125+ }
126+
127+ for _, tc := range tests {
128+ t.Run(tc.name, func(t *testing.T) {
129+ s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 1024})
130+ tc.evict(s)
131+ view := s.History().SealAndView()
132+ if view.Len() == 0 {
133+ t.Fatal("nothing was evicted to history")
134+ }
135+ if got := view.Bound(0).Soft; got != tc.wantSoft {
136+ t.Errorf("Bound(0).Soft = %v, want %v", got, tc.wantSoft)
137+ }
138+ })
139+ }
140+}
141+
142+func TestReflowEvictedLinesCarryTheirBound(t *testing.T) {
143+ s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxCells: 1024})
144+ // Fill the grid with wrapped content, then shrink so rows are evicted by
145+ // the reflow path in buffer.resize rather than by scrolling.
146+ s.Write([]byte("aaaaaaaabbbbbbbbcccccccc"))
147+ s.Resize(4, 2)
148+
149+ view := s.History().SealAndView()
150+ if view.Len() == 0 {
151+ t.Fatal("reflow evicted nothing to history")
152+ }
153+ for i := range view.Len() {
154+ bound := view.Bound(i)
155+ if bound.End == 0 {
156+ t.Errorf("Bound(%d).End = 0, want the reflowed row extent", i)
157+ }
158+ }
159+}
160+
102 func equalStrings(a, b []string) bool {161 func equalStrings(a, b []string) bool {
103 if len(a) != len(b) {162 if len(a) != len(b) {
104 return false163 return false
M pkg/vt/snapshot.go +1 -0
@@ -102,6 +102,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
102102 if len(view.chunks) == 1 {
103103 h.evictUntil(len(view.chunks[0].rows), view.Cells())
104104 h.tail = view.chunks[0].rows
105+ h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail))
105106 h.rows += len(h.tail)
106107 h.cells += view.Cells()
107108 }
@@ -102,6 +102,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
102 if len(view.chunks) == 1 {102 if len(view.chunks) == 1 {
103 h.evictUntil(len(view.chunks[0].rows), view.Cells())103 h.evictUntil(len(view.chunks[0].rows), view.Cells())
104 h.tail = view.chunks[0].rows104 h.tail = view.chunks[0].rows
105+ h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail))
105 h.rows += len(h.tail)106 h.rows += len(h.tail)
106 h.cells += view.Cells()107 h.cells += view.Cells()
107 }108 }
M pkg/vt/snapshot_test.go +5 -11
@@ -10,15 +10,9 @@ import (
1010
1111 func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
1212 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
13- if err := history.Append(historyRow("AAAA")); err != nil {
14- t.Fatal(err)
15- }
16- if err := history.Append(historyRow("BBBB")); err != nil {
17- t.Fatal(err)
18- }
19- if err := history.Append(historyRow("CCCC")); err != nil {
20- t.Fatal(err)
21- }
13+ requireHistoryAppend(t, history, historyRow("AAAA"))
14+ requireHistoryAppend(t, history, historyRow("BBBB"))
15+ requireHistoryAppend(t, history, historyRow("CCCC"))
2216 sealed := history.View().Chunk(0)
2317
2418 view := history.SnapshotView()
@@ -58,7 +52,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
5852
5953 restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail)
6054 require.NoError(t, err)
61- require.NoError(t, restored.Append(historyRow("CCCC")))
55+ requireHistoryAppend(t, restored, historyRow("CCCC"))
6256
6357 require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View()))
6458 require.Len(t, restored.chunks, 1, "a full restored tail must become sealed")
@@ -68,7 +62,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
6862 func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) {
6963 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2})
7064 for _, text := range []string{"a", "b", "cc"} {
71- require.NoError(t, history.Append(historyRow(text)))
65+ requireHistoryAppend(t, history, historyRow(text))
7266 }
7367 view := history.SnapshotView()
7468 sealed := make([][]byte, view.ChunkCount())
@@ -10,15 +10,9 @@ import (
10 10
11 func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {11 func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
12 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})12 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
13- if err := history.Append(historyRow("AAAA")); err != nil {13+ requireHistoryAppend(t, history, historyRow("AAAA"))
14- t.Fatal(err)14+ requireHistoryAppend(t, history, historyRow("BBBB"))
15- }15+ requireHistoryAppend(t, history, historyRow("CCCC"))
16- if err := history.Append(historyRow("BBBB")); err != nil {
17- t.Fatal(err)
18- }
19- if err := history.Append(historyRow("CCCC")); err != nil {
20- t.Fatal(err)
21- }
22 sealed := history.View().Chunk(0)16 sealed := history.View().Chunk(0)
23 17
24 view := history.SnapshotView()18 view := history.SnapshotView()
@@ -58,7 +52,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
58 52
59 restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail)53 restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail)
60 require.NoError(t, err)54 require.NoError(t, err)
61- require.NoError(t, restored.Append(historyRow("CCCC")))55+ requireHistoryAppend(t, restored, historyRow("CCCC"))
62 56
63 require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View()))57 require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View()))
64 require.Len(t, restored.chunks, 1, "a full restored tail must become sealed")58 require.Len(t, restored.chunks, 1, "a full restored tail must become sealed")
@@ -68,7 +62,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
68 func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) {62 func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) {
69 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2})63 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2})
70 for _, text := range []string{"a", "b", "cc"} {64 for _, text := range []string{"a", "b", "cc"} {
71- require.NoError(t, history.Append(historyRow(text)))65+ requireHistoryAppend(t, history, historyRow(text))
72 }66 }
73 view := history.SnapshotView()67 view := history.SnapshotView()
74 sealed := make([][]byte, view.ChunkCount())68 sealed := make([][]byte, view.ChunkCount())

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 12

Tags 11

↑↓ moveenter openesc close