detached 7d1e3b2

fix(rowid): reject malformed explicit history IDs

commit
7d1e3b2bf202d53b62eb5cff3b7b6b525e90a31e
author
brice · · 2026-08-02 21:49 UTC
committer
brice · · 2026-08-03 17:54 UTC
parent
3c2478b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +77 -55
M internal/usecase/copy/mode.go +7 -9
@@ -24,20 +24,18 @@ type Snapshot struct {
2424 Width, Height int
2525 }
2626
27-func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot {
28- var screenRowIDs []vt.RowID
29- if len(rowIDs) > 0 {
30- screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...)
31- }
32- return newSnapshot(historySource, screen, bounds, screenRowIDs)
27+func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot {
28+ return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...))
3329 }
3430
3531 // NewSnapshotFromScreen captures a screen while its owner holds the Screen
36-// lock. Screen.RowIDs already returns owned storage, so the snapshot can take
37-// that copy directly without allocating a duplicate metadata slice.
32+// lock. Screen.RowIDs returns the one owned copy required for immutable
33+// snapshot state; newSnapshot takes that slice directly without duplicating
34+// it. The live screen's rowIDs cannot be transferred or shared because later
35+// scroll, resize, and clear mutations update that storage.
3836 func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot {
3937 if screen == nil {
40- return NewSnapshot(historySource, renderer.Frame{}, nil)
38+ return NewSnapshot(historySource, renderer.Frame{}, nil, nil)
4139 }
4240 return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs())
4341 }
@@ -24,20 +24,18 @@ type Snapshot struct {
24 Width, Height int24 Width, Height int
25 }25 }
26 26
27-func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot {27+func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot {
28- var screenRowIDs []vt.RowID28+ return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...))
29- if len(rowIDs) > 0 {
30- screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...)
31- }
32- return newSnapshot(historySource, screen, bounds, screenRowIDs)
33 }29 }
34 30
35 // NewSnapshotFromScreen captures a screen while its owner holds the Screen31 // NewSnapshotFromScreen captures a screen while its owner holds the Screen
36-// lock. Screen.RowIDs already returns owned storage, so the snapshot can take32+// lock. Screen.RowIDs returns the one owned copy required for immutable
37-// that copy directly without allocating a duplicate metadata slice.33+// snapshot state; newSnapshot takes that slice directly without duplicating
34+// it. The live screen's rowIDs cannot be transferred or shared because later
35+// scroll, resize, and clear mutations update that storage.
38 func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot {36 func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot {
39 if screen == nil {37 if screen == nil {
40- return NewSnapshot(historySource, renderer.Frame{}, nil)38+ return NewSnapshot(historySource, renderer.Frame{}, nil, nil)
41 }39 }
42 return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs())40 return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs())
43 }41 }
M internal/usecase/copy/mode_test.go +6 -6
@@ -44,8 +44,8 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
4444
4545 func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
4646 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
47- require.NoError(t, history.Append(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48- require.NoError(t, history.Append(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
47+ require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48+ require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
4949 screen := renderer.NewFrame(4, 2)
5050 copy(screen.Row(0), row("live"))
5151 copy(screen.Row(1), row("more"))
@@ -81,7 +81,7 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
8181 screen := renderer.NewFrame(4, 2)
8282 copy(screen.Row(0), row("live"))
8383
84- snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}})
84+ snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil)
8585
8686 tests := []struct {
8787 name string
@@ -255,7 +255,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
255255 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
256256 }
257257 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
258- snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil)
258+ snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil, nil)
259259 view := history.SealAndView()
260260 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
261261
@@ -368,7 +368,7 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
368368 copy(screen.Row(0), row("ef", width))
369369 copy(screen.Row(1), row("gh", width))
370370
371- snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}})
371+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)
372372 doc := NewDocument(snapshot, "")
373373
374374 ranges := []CellRange{
@@ -437,7 +437,7 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
437437 screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})
438438 screen.Write([]byte(tc.input))
439439
440- snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds())
440+ snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds(), nil)
441441 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
442442 doc := NewDocument(snapshot, domain.DefaultWordSeparators)
443443
@@ -44,8 +44,8 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
44 44
45 func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {45 func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
46 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})46 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
47- require.NoError(t, history.Append(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))47+ require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48- require.NoError(t, history.Append(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))48+ require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
49 screen := renderer.NewFrame(4, 2)49 screen := renderer.NewFrame(4, 2)
50 copy(screen.Row(0), row("live"))50 copy(screen.Row(0), row("live"))
51 copy(screen.Row(1), row("more"))51 copy(screen.Row(1), row("more"))
@@ -81,7 +81,7 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
81 screen := renderer.NewFrame(4, 2)81 screen := renderer.NewFrame(4, 2)
82 copy(screen.Row(0), row("live"))82 copy(screen.Row(0), row("live"))
83 83
84- snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}})84+ snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil)
85 85
86 tests := []struct {86 tests := []struct {
87 name string87 name string
@@ -255,7 +255,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
255 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))255 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
256 }256 }
257 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))257 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
258- snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil)258+ snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil, nil)
259 view := history.SealAndView()259 view := history.SealAndView()
260 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))260 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
261 261
@@ -368,7 +368,7 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
368 copy(screen.Row(0), row("ef", width))368 copy(screen.Row(0), row("ef", width))
369 copy(screen.Row(1), row("gh", width))369 copy(screen.Row(1), row("gh", width))
370 370
371- snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}})371+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)
372 doc := NewDocument(snapshot, "")372 doc := NewDocument(snapshot, "")
373 373
374 ranges := []CellRange{374 ranges := []CellRange{
@@ -437,7 +437,7 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
437 screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})437 screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})
438 screen.Write([]byte(tc.input))438 screen.Write([]byte(tc.input))
439 439
440- snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds())440+ snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds(), nil)
441 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")441 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
442 doc := NewDocument(snapshot, domain.DefaultWordSeparators)442 doc := NewDocument(snapshot, domain.DefaultWordSeparators)
443 443
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
@@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
101101 sess.client = ac
102102 ac.setSession(sess)
103103 closing.mu.Lock()
104- document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators)
104+ document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
105105 closing.mu.Unlock()
106106 ac.overlays.copyMu.Lock()
107107 ac.overlays.copyPane = closing
@@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
146146 tb := sess.activeTab()
147147 p := tb.focusedPane()
148148 p.mu.Lock()
149- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
149+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
150150 p.mu.Unlock()
151151
152152 // Pause after candidate publication, then make the captured target stale.
@@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
370370 tb := sess.activeTab()
371371 p := tb.focusedPane()
372372 p.mu.Lock()
373- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
373+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
374374 p.mu.Unlock()
375375 tc.invalidate(sess, tb, p)
376376
@@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
101 sess.client = ac101 sess.client = ac
102 ac.setSession(sess)102 ac.setSession(sess)
103 closing.mu.Lock()103 closing.mu.Lock()
104- document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators)104+ document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
105 closing.mu.Unlock()105 closing.mu.Unlock()
106 ac.overlays.copyMu.Lock()106 ac.overlays.copyMu.Lock()
107 ac.overlays.copyPane = closing107 ac.overlays.copyPane = closing
@@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
146 tb := sess.activeTab()146 tb := sess.activeTab()
147 p := tb.focusedPane()147 p := tb.focusedPane()
148 p.mu.Lock()148 p.mu.Lock()
149- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)149+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
150 p.mu.Unlock()150 p.mu.Unlock()
151 151
152 // Pause after candidate publication, then make the captured target stale.152 // Pause after candidate publication, then make the captured target stale.
@@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
370 tb := sess.activeTab()370 tb := sess.activeTab()
371 p := tb.focusedPane()371 p := tb.focusedPane()
372 p.mu.Lock()372 p.mu.Lock()
373- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)373+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
374 p.mu.Unlock()374 p.mu.Unlock()
375 tc.invalidate(sess, tb, p)375 tc.invalidate(sess, tb, p)
376 376
M internal/usecase/daemon/copymode_mouse_test.go +1 -1
@@ -350,7 +350,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
350350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
351351 p := sess.activeTab().focusedPane()
352352 p.mu.Lock()
353- doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
353+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
354354 p.mu.Unlock()
355355 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
356356 }},
@@ -350,7 +350,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
351 p := sess.activeTab().focusedPane()351 p := sess.activeTab().focusedPane()
352 p.mu.Lock()352 p.mu.Lock()
353- doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)353+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
354 p.mu.Unlock()354 p.mu.Unlock()
355 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))355 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
356 }},356 }},
M internal/usecase/daemon/copymode_test.go +5 -5
@@ -130,7 +130,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
130130 tb := sess.activeTab()
131131 pane := tb.focusedPane()
132132 pane.mu.Lock()
133- snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds())
133+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil)
134134 pane.mu.Unlock()
135135 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
136136
@@ -164,7 +164,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
164164 tb := sess.activeTab()
165165 tb.focusedPane().screen = vt.NewScreen(12, 3)
166166 tb.focusedPane().screen.Write([]byte("live"))
167- snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds())
167+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil)
168168 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
169169
170170 bars := barState{status: sess.statusSegments(true)}
@@ -452,7 +452,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
452452 }
453453 p.mu.Lock()
454454 defer p.mu.Unlock()
455- return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12
455+ return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12
456456 }, 2*time.Second, 5*time.Millisecond)
457457
458458 sess := firstSession(d)
@@ -802,7 +802,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
802802 }
803803 fp.screen.Write([]byte("live"))
804804 fp.mu.Lock()
805- total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len()
805+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len()
806806 fp.mu.Unlock()
807807
808808 d.enterCopyMode(sess, ac)
@@ -913,7 +913,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
913913 base := renderer.NewFrame(20, 8)
914914 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
915915 pane.screen.Write([]byte("copy"))
916- mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators))
916+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators))
917917 if tt.selectMode {
918918 mode.ToggleLineSelection()
919919 }
@@ -130,7 +130,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
130 tb := sess.activeTab()130 tb := sess.activeTab()
131 pane := tb.focusedPane()131 pane := tb.focusedPane()
132 pane.mu.Lock()132 pane.mu.Lock()
133- snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds())133+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil)
134 pane.mu.Unlock()134 pane.mu.Unlock()
135 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))135 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
136 136
@@ -164,7 +164,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
164 tb := sess.activeTab()164 tb := sess.activeTab()
165 tb.focusedPane().screen = vt.NewScreen(12, 3)165 tb.focusedPane().screen = vt.NewScreen(12, 3)
166 tb.focusedPane().screen.Write([]byte("live"))166 tb.focusedPane().screen.Write([]byte("live"))
167- snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds())167+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil)
168 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))168 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
169 169
170 bars := barState{status: sess.statusSegments(true)}170 bars := barState{status: sess.statusSegments(true)}
@@ -452,7 +452,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
452 }452 }
453 p.mu.Lock()453 p.mu.Lock()
454 defer p.mu.Unlock()454 defer p.mu.Unlock()
455- return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12455+ return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12
456 }, 2*time.Second, 5*time.Millisecond)456 }, 2*time.Second, 5*time.Millisecond)
457 457
458 sess := firstSession(d)458 sess := firstSession(d)
@@ -802,7 +802,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
802 }802 }
803 fp.screen.Write([]byte("live"))803 fp.screen.Write([]byte("live"))
804 fp.mu.Lock()804 fp.mu.Lock()
805- total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len()805+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len()
806 fp.mu.Unlock()806 fp.mu.Unlock()
807 807
808 d.enterCopyMode(sess, ac)808 d.enterCopyMode(sess, ac)
@@ -913,7 +913,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
913 base := renderer.NewFrame(20, 8)913 base := renderer.NewFrame(20, 8)
914 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})914 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
915 pane.screen.Write([]byte("copy"))915 pane.screen.Write([]byte("copy"))
916- mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators))916+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators))
917 if tt.selectMode {917 if tt.selectMode {
918 mode.ToggleLineSelection()918 mode.ToggleLineSelection()
919 }919 }
M internal/usecase/daemon/daemon_test.go +1 -1
@@ -458,7 +458,7 @@ func testRow(text string) []renderer.Cell {
458458 // wrapped out of. Tests that care about wrapped extents build the bound instead.
459459 func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {
460460 tb.Helper()
461- require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}, vt.RowID(1_000_000+history.Len()+1)))
461+ require.NoError(tb, history.AppendWithID(row, vt.LineBound{End: len(row)}, vt.RowID(1_000_000+history.Len()+1)))
462462 }
463463
464464 func tabCount(sess *session) int {
@@ -458,7 +458,7 @@ func testRow(text string) []renderer.Cell {
458 // wrapped out of. Tests that care about wrapped extents build the bound instead.458 // wrapped out of. Tests that care about wrapped extents build the bound instead.
459 func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {459 func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {
460 tb.Helper()460 tb.Helper()
461- require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}, vt.RowID(1_000_000+history.Len()+1)))461+ require.NoError(tb, history.AppendWithID(row, vt.LineBound{End: len(row)}, vt.RowID(1_000_000+history.Len()+1)))
462 }462 }
463 463
464 func tabCount(sess *session) int {464 func tabCount(sess *session) int {
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
@@ -200,7 +200,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
200200 ac.initOverlays()
201201 history := newTestHistory(1)
202202 appendHistoryRow(t, history, testRow("before"))
203- document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators)
203+ document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil, nil), domain.DefaultWordSeparators)
204204
205205 ac.overlays.copyMu.Lock()
206206 ac.overlays.copyMode = scopy.NewMode(document)
@@ -200,7 +200,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
200 ac.initOverlays()200 ac.initOverlays()
201 history := newTestHistory(1)201 history := newTestHistory(1)
202 appendHistoryRow(t, history, testRow("before"))202 appendHistoryRow(t, history, testRow("before"))
203- document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators)203+ document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil, nil), domain.DefaultWordSeparators)
204 204
205 ac.overlays.copyMu.Lock()205 ac.overlays.copyMu.Lock()
206 ac.overlays.copyMode = scopy.NewMode(document)206 ac.overlays.copyMode = scopy.NewMode(document)
M internal/usecase/daemon/render_test.go +1 -1
@@ -1273,7 +1273,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
12731273 }
12741274 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
12751275 p.screen.Write([]byte("ab\r\ncd"))
1276- document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds())
1276+ document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil)
12771277 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
12781278 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
12791279
@@ -1273,7 +1273,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1273 }1273 }
1274 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})1274 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1275 p.screen.Write([]byte("ab\r\ncd"))1275 p.screen.Write([]byte("ab\r\ncd"))
1276- document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds())1276+ document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil)
1277 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))1277 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
1278 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1278 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1279 1279
M pkg/vt/history.go +18 -19
@@ -92,38 +92,37 @@ func defaultHistoryMaxCells(maxRows int) int {
9292 return maxRows * 160
9393 }
9494
95-// Append records a copy of row along with its logical extent and optional row
96-// identity. Calls without an identity receive a history-owned nonzero ID.
97-// Once a chunk is full it is sealed forever. Rows wider than the total cell
98-// capacity are rejected without mutation.
99-func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) error {
95+// Append records a copy of row along with its logical extent and an
96+// automatically allocated nonzero ID. Once a chunk is full it is sealed
97+// forever. Rows wider than the total cell capacity are rejected without
98+// mutation.
99+func (h *History) Append(row []renderer.Cell, bound LineBound) error {
100100 if h == nil || h.maxRows == 0 {
101101 return nil
102102 }
103103 if len(row) > h.maxCells {
104104 return ErrHistoryRowTooWide
105105 }
106- id := RowID(0)
107- if len(ids) > 0 {
108- id = ids[0]
109- }
110- if id == 0 || h.hasRowID(id) {
111- var err error
112- id, err = h.allocateRowID()
113- if err != nil {
114- return err
115- }
106+ id, err := h.allocateRowID()
107+ if err != nil {
108+ return err
116109 }
117110 return h.appendRow(row, bound, id)
118111 }
119112
120-// appendRestored appends a persisted row without synthesizing an identity.
121-// The caller validates IDs across all persisted blobs before invoking it.
122-func (h *History) appendRestored(row []renderer.Cell, bound LineBound, id RowID) error {
113+// AppendWithID records a copy of row with an explicit persisted identity.
114+// Unlike Append, malformed identities are rejected rather than synthesized.
115+func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) error {
116+ if id == 0 || id >= ^RowID(0)-1 {
117+ return errInvalidHistoryRowID
118+ }
123119 if h == nil || h.maxRows == 0 {
124120 return nil
125121 }
126- if id == 0 || id >= ^RowID(0)-1 || h.hasRowID(id) {
122+ if len(row) > h.maxCells {
123+ return ErrHistoryRowTooWide
124+ }
125+ if h.hasRowID(id) {
127126 return errInvalidHistoryRowID
128127 }
129128 return h.appendRow(row, bound, id)
@@ -92,38 +92,37 @@ func defaultHistoryMaxCells(maxRows int) int {
92 return maxRows * 16092 return maxRows * 160
93 }93 }
94 94
95-// Append records a copy of row along with its logical extent and optional row95+// Append records a copy of row along with its logical extent and an
96-// identity. Calls without an identity receive a history-owned nonzero ID.96+// automatically allocated nonzero ID. Once a chunk is full it is sealed
97-// Once a chunk is full it is sealed forever. Rows wider than the total cell97+// forever. Rows wider than the total cell capacity are rejected without
98-// capacity are rejected without mutation.98+// mutation.
99-func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) error {99+func (h *History) Append(row []renderer.Cell, bound LineBound) error {
100 if h == nil || h.maxRows == 0 {100 if h == nil || h.maxRows == 0 {
101 return nil101 return nil
102 }102 }
103 if len(row) > h.maxCells {103 if len(row) > h.maxCells {
104 return ErrHistoryRowTooWide104 return ErrHistoryRowTooWide
105 }105 }
106- id := RowID(0)106+ id, err := h.allocateRowID()
107- if len(ids) > 0 {107+ if err != nil {
108- id = ids[0]108+ return err
109- }
110- if id == 0 || h.hasRowID(id) {
111- var err error
112- id, err = h.allocateRowID()
113- if err != nil {
114- return err
115- }
116 }109 }
117 return h.appendRow(row, bound, id)110 return h.appendRow(row, bound, id)
118 }111 }
119 112
120-// appendRestored appends a persisted row without synthesizing an identity.113+// AppendWithID records a copy of row with an explicit persisted identity.
121-// The caller validates IDs across all persisted blobs before invoking it.114+// Unlike Append, malformed identities are rejected rather than synthesized.
122-func (h *History) appendRestored(row []renderer.Cell, bound LineBound, id RowID) error {115+func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) error {
116+ if id == 0 || id >= ^RowID(0)-1 {
117+ return errInvalidHistoryRowID
118+ }
123 if h == nil || h.maxRows == 0 {119 if h == nil || h.maxRows == 0 {
124 return nil120 return nil
125 }121 }
126- if id == 0 || id >= ^RowID(0)-1 || h.hasRowID(id) {122+ if len(row) > h.maxCells {
123+ return ErrHistoryRowTooWide
124+ }
125+ if h.hasRowID(id) {
127 return errInvalidHistoryRowID126 return errInvalidHistoryRowID
128 }127 }
129 return h.appendRow(row, bound, id)128 return h.appendRow(row, bound, id)
M pkg/vt/row_id_persistence_test.go +29 -4
@@ -43,6 +43,31 @@ func TestHistoryAppendRejectsExhaustedRowIDCounter(t *testing.T) {
4343 require.Error(t, history.Append(historyRow("row"), LineBound{End: 3}))
4444 }
4545
46+func TestHistoryAppendWithIDRejectsMalformedIDs(t *testing.T) {
47+ const maxPersistedRowID = ^RowID(0) - 2
48+ for _, test := range []struct {
49+ name string
50+ id RowID
51+ existing bool
52+ }{
53+ {name: "zero", id: 0},
54+ {name: "duplicate", id: 7, existing: true},
55+ {name: "first unpersistable", id: maxPersistedRowID + 1},
56+ {name: "maximum", id: ^RowID(0)},
57+ } {
58+ t.Run(test.name, func(t *testing.T) {
59+ history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 2})
60+ if test.existing {
61+ require.NoError(t, history.AppendWithID(historyRow("existing"), LineBound{End: 8}, test.id))
62+ }
63+ before := history.View()
64+ require.Error(t, history.AppendWithID(historyRow("row"), LineBound{End: 3}, test.id))
65+ require.Equal(t, before.Len(), history.Len())
66+ require.Equal(t, before.NextRowID(), history.NextRowID())
67+ })
68+ }
69+}
70+
4671 func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
4772 const maxPersistedRowID = ^RowID(0) - 2
4873 for _, test := range []struct {
@@ -78,7 +103,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
78103 require.Equal(t, test.want+1, view.NextRowID())
79104
80105 history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1})
81- require.NoError(t, history.Append(row, LineBound{End: 1}, screen.RowID(0)))
106+ require.NoError(t, history.AppendWithID(row, LineBound{End: 1}, screen.RowID(0)))
82107 _, err = MarshalHistory(history.SealAndView())
83108 require.NoError(t, err)
84109 })
@@ -95,7 +120,7 @@ func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {
95120
96121 func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
97122 history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1})
98- require.NoError(t, history.Append(historyRow("old"), LineBound{End: 3}, RowID(40)))
123+ require.NoError(t, history.AppendWithID(historyRow("old"), LineBound{End: 3}, RowID(40)))
99124 sealed, tail, err := MarshalSealedHistory(history.SealAndView())
100125 require.NoError(t, err)
101126 transcript, err := MarshalHistory(HistoryView{
@@ -141,8 +166,8 @@ func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *t
141166 func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) {
142167 first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
143168 second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
144- require.NoError(t, first.Append(historyRow("one"), LineBound{End: 3}, RowID(7)))
145- require.NoError(t, second.Append(historyRow("two"), LineBound{End: 3}, RowID(7)))
169+ require.NoError(t, first.AppendWithID(historyRow("one"), LineBound{End: 3}, RowID(7)))
170+ require.NoError(t, second.AppendWithID(historyRow("two"), LineBound{End: 3}, RowID(7)))
146171 firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0))
147172 require.NoError(t, err)
148173 secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0))
@@ -43,6 +43,31 @@ func TestHistoryAppendRejectsExhaustedRowIDCounter(t *testing.T) {
43 require.Error(t, history.Append(historyRow("row"), LineBound{End: 3}))43 require.Error(t, history.Append(historyRow("row"), LineBound{End: 3}))
44 }44 }
45 45
46+func TestHistoryAppendWithIDRejectsMalformedIDs(t *testing.T) {
47+ const maxPersistedRowID = ^RowID(0) - 2
48+ for _, test := range []struct {
49+ name string
50+ id RowID
51+ existing bool
52+ }{
53+ {name: "zero", id: 0},
54+ {name: "duplicate", id: 7, existing: true},
55+ {name: "first unpersistable", id: maxPersistedRowID + 1},
56+ {name: "maximum", id: ^RowID(0)},
57+ } {
58+ t.Run(test.name, func(t *testing.T) {
59+ history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 2})
60+ if test.existing {
61+ require.NoError(t, history.AppendWithID(historyRow("existing"), LineBound{End: 8}, test.id))
62+ }
63+ before := history.View()
64+ require.Error(t, history.AppendWithID(historyRow("row"), LineBound{End: 3}, test.id))
65+ require.Equal(t, before.Len(), history.Len())
66+ require.Equal(t, before.NextRowID(), history.NextRowID())
67+ })
68+ }
69+}
70+
46 func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {71 func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
47 const maxPersistedRowID = ^RowID(0) - 272 const maxPersistedRowID = ^RowID(0) - 2
48 for _, test := range []struct {73 for _, test := range []struct {
@@ -78,7 +103,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
78 require.Equal(t, test.want+1, view.NextRowID())103 require.Equal(t, test.want+1, view.NextRowID())
79 104
80 history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1})105 history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1})
81- require.NoError(t, history.Append(row, LineBound{End: 1}, screen.RowID(0)))106+ require.NoError(t, history.AppendWithID(row, LineBound{End: 1}, screen.RowID(0)))
82 _, err = MarshalHistory(history.SealAndView())107 _, err = MarshalHistory(history.SealAndView())
83 require.NoError(t, err)108 require.NoError(t, err)
84 })109 })
@@ -95,7 +120,7 @@ func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {
95 120
96 func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {121 func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
97 history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1})122 history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1})
98- require.NoError(t, history.Append(historyRow("old"), LineBound{End: 3}, RowID(40)))123+ require.NoError(t, history.AppendWithID(historyRow("old"), LineBound{End: 3}, RowID(40)))
99 sealed, tail, err := MarshalSealedHistory(history.SealAndView())124 sealed, tail, err := MarshalSealedHistory(history.SealAndView())
100 require.NoError(t, err)125 require.NoError(t, err)
101 transcript, err := MarshalHistory(HistoryView{126 transcript, err := MarshalHistory(HistoryView{
@@ -141,8 +166,8 @@ func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *t
141 func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) {166 func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) {
142 first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})167 first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
143 second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})168 second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
144- require.NoError(t, first.Append(historyRow("one"), LineBound{End: 3}, RowID(7)))169+ require.NoError(t, first.AppendWithID(historyRow("one"), LineBound{End: 3}, RowID(7)))
145- require.NoError(t, second.Append(historyRow("two"), LineBound{End: 3}, RowID(7)))170+ require.NoError(t, second.AppendWithID(historyRow("two"), LineBound{End: 3}, RowID(7)))
146 firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0))171 firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0))
147 require.NoError(t, err)172 require.NoError(t, err)
148 secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0))173 secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0))
M pkg/vt/row_id_test.go +3 -3
@@ -95,7 +95,7 @@ func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) {
9595 func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
9696 h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})
9797 for i, text := range []string{"one", "two"} {
98- if err := h.Append(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil {
98+ if err := h.AppendWithID(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil {
9999 t.Fatal(err)
100100 }
101101 }
@@ -104,7 +104,7 @@ func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
104104 t.Fatalf("sealed row ID = %d, want %d", got, want)
105105 }
106106
107- if err := h.Append(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil {
107+ if err := h.AppendWithID(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil {
108108 t.Fatal(err)
109109 }
110110 if got := before.FindRowID(2); got != 1 {
@@ -115,7 +115,7 @@ func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
115115 t.Fatalf("new view FindRowID(3) = %d, want 2", got)
116116 }
117117
118- if err := h.Append(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil {
118+ if err := h.AppendWithID(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil {
119119 t.Fatal(err)
120120 }
121121 if got := before.FindRowID(1); got != 0 {
@@ -95,7 +95,7 @@ func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) {
95 func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {95 func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
96 h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})96 h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})
97 for i, text := range []string{"one", "two"} {97 for i, text := range []string{"one", "two"} {
98- if err := h.Append(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil {98+ if err := h.AppendWithID(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil {
99 t.Fatal(err)99 t.Fatal(err)
100 }100 }
101 }101 }
@@ -104,7 +104,7 @@ func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
104 t.Fatalf("sealed row ID = %d, want %d", got, want)104 t.Fatalf("sealed row ID = %d, want %d", got, want)
105 }105 }
106 106
107- if err := h.Append(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil {107+ if err := h.AppendWithID(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil {
108 t.Fatal(err)108 t.Fatal(err)
109 }109 }
110 if got := before.FindRowID(2); got != 1 {110 if got := before.FindRowID(2); got != 1 {
@@ -115,7 +115,7 @@ func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
115 t.Fatalf("new view FindRowID(3) = %d, want 2", got)115 t.Fatalf("new view FindRowID(3) = %d, want 2", got)
116 }116 }
117 117
118- if err := h.Append(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil {118+ if err := h.AppendWithID(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil {
119 t.Fatal(err)119 t.Fatal(err)
120 }120 }
121 if got := before.FindRowID(1); got != 0 {121 if got := before.FindRowID(1); got != 0 {
M pkg/vt/screen_scroll.go +1 -1
@@ -107,7 +107,7 @@ func (s *Screen) emitLineEvicted(top, n int) {
107107
108108 func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {
109109 if s.history != nil {
110- err := s.history.Append(row, bound, id)
110+ err := s.history.AppendWithID(row, bound, id)
111111 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
112112 panic(err)
113113 }
@@ -107,7 +107,7 @@ func (s *Screen) emitLineEvicted(top, n int) {
107 107
108 func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {108 func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {
109 if s.history != nil {109 if s.history != nil {
110- err := s.history.Append(row, bound, id)110+ err := s.history.AppendWithID(row, bound, id)
111 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {111 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
112 panic(err)112 panic(err)
113 }113 }
M pkg/vt/snapshot.go +1 -1
@@ -179,7 +179,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
179179 if remainingRows == 0 {
180180 bound.Soft = false
181181 }
182- if err := history.appendRestored(row, bound, chunk.rowIDs[i]); err != nil {
182+ if err := history.AppendWithID(row, bound, chunk.rowIDs[i]); err != nil {
183183 return nil, fmt.Errorf("restore recovery transcript: %w", err)
184184 }
185185 }
@@ -179,7 +179,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
179 if remainingRows == 0 {179 if remainingRows == 0 {
180 bound.Soft = false180 bound.Soft = false
181 }181 }
182- if err := history.appendRestored(row, bound, chunk.rowIDs[i]); err != nil {182+ if err := history.AppendWithID(row, bound, chunk.rowIDs[i]); err != nil {
183 return nil, fmt.Errorf("restore recovery transcript: %w", err)183 return nil, fmt.Errorf("restore recovery transcript: %w", err)
184 }184 }
185 }185 }

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