detached e95e63a

feat(vt): add recovery transcript capture

commit
e95e63afa91fcc770422d7efb135da412d60d909
author
brice · · 2026-07-27 11:50 UTC
parent
c26f90f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +309 -0
A pkg/vt/recovery_transcript.go +112 -0
@@ -0,0 +1,112 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport
6+// rows that should be replayed after retained terminal history during recovery.
7+type RecoveryTranscriptSnapshot struct {
8+ segments []recoveryTranscriptSegment
9+}
10+
11+type recoveryTranscriptSegment struct {
12+ rows [][]renderer.Cell
13+ bounds []LineBound
14+}
15+
16+// RecoveryTranscriptSnapshot captures the active primary viewport, or the
17+// saved primary viewport followed by the active alternate viewport.
18+func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {
19+ if s == nil {
20+ return RecoveryTranscriptSnapshot{}
21+ }
22+
23+ buffers := []*buffer{s.buffer}
24+ if s.alternate != nil {
25+ buffers = []*buffer{s.alternate.buffer, s.buffer}
26+ }
27+
28+ snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers))}
29+ for _, b := range buffers {
30+ segment := captureRecoveryTranscriptSegment(b)
31+ if len(segment.rows) > 0 {
32+ snapshot.segments = append(snapshot.segments, segment)
33+ }
34+ }
35+ return snapshot
36+}
37+
38+func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment {
39+ if b == nil {
40+ return recoveryTranscriptSegment{}
41+ }
42+
43+ rowCount := b.frame.Height
44+ for rowCount > 0 && recoveryTranscriptRowUntouched(b.frame.Row(rowCount-1), b.bound(rowCount-1)) {
45+ rowCount--
46+ }
47+ if rowCount == 0 {
48+ return recoveryTranscriptSegment{}
49+ }
50+
51+ segment := recoveryTranscriptSegment{
52+ rows: make([][]renderer.Cell, rowCount),
53+ bounds: append([]LineBound(nil), b.boundaries[:rowCount]...),
54+ }
55+ for y := range rowCount {
56+ segment.rows[y] = append([]renderer.Cell(nil), b.frame.Row(y)...)
57+ }
58+ segment.bounds[rowCount-1].Soft = false
59+ return segment
60+}
61+
62+func recoveryTranscriptRowUntouched(row []renderer.Cell, bound LineBound) bool {
63+ if bound.End != 0 || bound.Soft {
64+ return false
65+ }
66+ blank := renderer.BlankCell()
67+ for _, cell := range row {
68+ if !cell.Equal(blank) {
69+ return false
70+ }
71+ }
72+ return true
73+}
74+
75+// Marshal encodes the captured rows as canonical terminal history without
76+// consulting or mutating live Screen history.
77+func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
78+ rows := 0
79+ cells := 0
80+ for _, segment := range snapshot.segments {
81+ rows += len(segment.rows)
82+ for _, row := range segment.rows {
83+ cells += len(row)
84+ }
85+ }
86+
87+ view := HistoryView{rows: rows, cells: cells}
88+ for _, segment := range snapshot.segments {
89+ for start := 0; start < len(segment.rows); {
90+ space := maxHistoryChunkRows
91+ if len(view.chunks) > 0 {
92+ last := view.chunks[len(view.chunks)-1]
93+ if len(last.rows) < maxHistoryChunkRows {
94+ space = maxHistoryChunkRows - len(last.rows)
95+ end := min(start+space, len(segment.rows))
96+ last.rows = append(last.rows, segment.rows[start:end]...)
97+ last.bounds = append(last.bounds, segment.bounds[start:end]...)
98+ start = end
99+ continue
100+ }
101+ }
102+
103+ end := min(start+space, len(segment.rows))
104+ view.chunks = append(view.chunks, &HistoryChunk{
105+ rows: append([][]renderer.Cell(nil), segment.rows[start:end]...),
106+ bounds: append([]LineBound(nil), segment.bounds[start:end]...),
107+ })
108+ start = end
109+ }
110+ }
111+ return MarshalHistory(view)
112+}
@@ -0,0 +1,112 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport
6+// rows that should be replayed after retained terminal history during recovery.
7+type RecoveryTranscriptSnapshot struct {
8+ segments []recoveryTranscriptSegment
9+}
10+
11+type recoveryTranscriptSegment struct {
12+ rows [][]renderer.Cell
13+ bounds []LineBound
14+}
15+
16+// RecoveryTranscriptSnapshot captures the active primary viewport, or the
17+// saved primary viewport followed by the active alternate viewport.
18+func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {
19+ if s == nil {
20+ return RecoveryTranscriptSnapshot{}
21+ }
22+
23+ buffers := []*buffer{s.buffer}
24+ if s.alternate != nil {
25+ buffers = []*buffer{s.alternate.buffer, s.buffer}
26+ }
27+
28+ snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers))}
29+ for _, b := range buffers {
30+ segment := captureRecoveryTranscriptSegment(b)
31+ if len(segment.rows) > 0 {
32+ snapshot.segments = append(snapshot.segments, segment)
33+ }
34+ }
35+ return snapshot
36+}
37+
38+func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment {
39+ if b == nil {
40+ return recoveryTranscriptSegment{}
41+ }
42+
43+ rowCount := b.frame.Height
44+ for rowCount > 0 && recoveryTranscriptRowUntouched(b.frame.Row(rowCount-1), b.bound(rowCount-1)) {
45+ rowCount--
46+ }
47+ if rowCount == 0 {
48+ return recoveryTranscriptSegment{}
49+ }
50+
51+ segment := recoveryTranscriptSegment{
52+ rows: make([][]renderer.Cell, rowCount),
53+ bounds: append([]LineBound(nil), b.boundaries[:rowCount]...),
54+ }
55+ for y := range rowCount {
56+ segment.rows[y] = append([]renderer.Cell(nil), b.frame.Row(y)...)
57+ }
58+ segment.bounds[rowCount-1].Soft = false
59+ return segment
60+}
61+
62+func recoveryTranscriptRowUntouched(row []renderer.Cell, bound LineBound) bool {
63+ if bound.End != 0 || bound.Soft {
64+ return false
65+ }
66+ blank := renderer.BlankCell()
67+ for _, cell := range row {
68+ if !cell.Equal(blank) {
69+ return false
70+ }
71+ }
72+ return true
73+}
74+
75+// Marshal encodes the captured rows as canonical terminal history without
76+// consulting or mutating live Screen history.
77+func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
78+ rows := 0
79+ cells := 0
80+ for _, segment := range snapshot.segments {
81+ rows += len(segment.rows)
82+ for _, row := range segment.rows {
83+ cells += len(row)
84+ }
85+ }
86+
87+ view := HistoryView{rows: rows, cells: cells}
88+ for _, segment := range snapshot.segments {
89+ for start := 0; start < len(segment.rows); {
90+ space := maxHistoryChunkRows
91+ if len(view.chunks) > 0 {
92+ last := view.chunks[len(view.chunks)-1]
93+ if len(last.rows) < maxHistoryChunkRows {
94+ space = maxHistoryChunkRows - len(last.rows)
95+ end := min(start+space, len(segment.rows))
96+ last.rows = append(last.rows, segment.rows[start:end]...)
97+ last.bounds = append(last.bounds, segment.bounds[start:end]...)
98+ start = end
99+ continue
100+ }
101+ }
102+
103+ end := min(start+space, len(segment.rows))
104+ view.chunks = append(view.chunks, &HistoryChunk{
105+ rows: append([][]renderer.Cell(nil), segment.rows[start:end]...),
106+ bounds: append([]LineBound(nil), segment.bounds[start:end]...),
107+ })
108+ start = end
109+ }
110+ }
111+ return MarshalHistory(view)
112+}
A pkg/vt/recovery_transcript_test.go +197 -0
@@ -0,0 +1,197 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) {
11+ s := NewScreen(4, 2)
12+ s.Write([]byte("keep"))
13+
14+ snapshot := s.RecoveryTranscriptSnapshot()
15+ s.Frame.Set(0, 0, renderer.BlankCell())
16+ s.buffer.boundaries[0] = LineBound{}
17+
18+ view := decodeRecoveryTranscript(t, snapshot)
19+ require.Equal(t, []string{"keep"}, historyViewTexts(view))
20+ require.Equal(t, LineBound{End: 4}, view.Bound(0))
21+}
22+
23+func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) {
24+ s := NewScreen(4, 3)
25+ s.Write([]byte("prim"))
26+ s.buffer.boundaries[0].Soft = true
27+ s.Write([]byte("\x1b[?1049h"))
28+ s.Write([]byte("alt"))
29+ s.buffer.boundaries[0].Soft = true
30+
31+ snapshot := s.RecoveryTranscriptSnapshot()
32+ s.Frame.Set(0, 0, renderer.BlankCell())
33+ s.buffer.boundaries[0] = LineBound{}
34+ s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell())
35+ s.alternate.buffer.boundaries[0] = LineBound{}
36+
37+ view := decodeRecoveryTranscript(t, snapshot)
38+
39+ require.Equal(t, []string{"prim", "alt "}, historyViewTexts(view))
40+ require.Equal(t, LineBound{End: 4}, view.Bound(0), "saved-primary seam must be hard")
41+ require.Equal(t, LineBound{End: 3}, view.Bound(1), "final alternate seam must be hard")
42+}
43+
44+func TestRecoveryTranscriptSnapshotTrimsEachViewportIndependently(t *testing.T) {
45+ s := NewScreen(4, 3)
46+ s.Write([]byte("main"))
47+ s.Write([]byte("\x1b[?1049h"))
48+ s.Write([]byte("alt"))
49+
50+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
51+
52+ require.Equal(t, []string{"main", "alt "}, historyViewTexts(view))
53+}
54+
55+func TestRecoveryTranscriptSnapshotExcludesExistingBoundedHistory(t *testing.T) {
56+ s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2})
57+ require.NoError(t, s.History().Append(historyRow("old"), LineBound{End: 3}))
58+ s.Write([]byte("live"))
59+
60+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
61+
62+ require.Equal(t, []string{"live"}, historyViewTexts(view))
63+}
64+
65+func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) {
66+ styledBlank := renderer.BlankCell()
67+ styledBlank.Style.Bold = true
68+
69+ tests := []struct {
70+ name string
71+ candidateCell renderer.Cell
72+ candidateBound LineBound
73+ wantRows int
74+ wantCell renderer.Cell
75+ }{
76+ {
77+ name: "untouched default blank with zero hard bound",
78+ candidateCell: renderer.BlankCell(),
79+ wantRows: 1,
80+ },
81+ {
82+ name: "written default space",
83+ candidateCell: renderer.BlankCell(),
84+ candidateBound: LineBound{End: 1},
85+ wantRows: 2,
86+ wantCell: renderer.BlankCell(),
87+ },
88+ {
89+ name: "styled blank",
90+ candidateCell: styledBlank,
91+ wantRows: 2,
92+ wantCell: styledBlank,
93+ },
94+ {
95+ name: "soft blank",
96+ candidateCell: renderer.BlankCell(),
97+ candidateBound: LineBound{Soft: true},
98+ wantRows: 2,
99+ wantCell: renderer.BlankCell(),
100+ },
101+ }
102+
103+ for _, tt := range tests {
104+ t.Run(tt.name, func(t *testing.T) {
105+ s := NewScreen(3, 3)
106+ s.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
107+ s.buffer.boundaries[0] = LineBound{End: 1}
108+ s.Frame.Set(0, 1, tt.candidateCell)
109+ s.buffer.boundaries[1] = tt.candidateBound
110+
111+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
112+
113+ require.Equal(t, tt.wantRows, view.Len())
114+ if tt.wantRows == 2 {
115+ require.True(t, view.Row(1)[0].Equal(tt.wantCell))
116+ require.False(t, view.Bound(1).Soft, "the retained segment's final row must be hardened")
117+ }
118+ })
119+ }
120+}
121+
122+func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) {
123+ s := NewScreen(3, 4)
124+ s.Frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
125+ s.buffer.boundaries[2] = LineBound{End: 1}
126+
127+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
128+
129+ require.Equal(t, []string{" ", " ", "x "}, historyViewTexts(view))
130+ require.Equal(t, []LineBound{{}, {}, {End: 1}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)})
131+}
132+
133+func TestRecoveryTranscriptSnapshotPreservesBoundsAndHardensOnlyTheFinalRow(t *testing.T) {
134+ s := NewScreen(4, 3)
135+ s.buffer.boundaries = []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3, Soft: true}}
136+
137+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
138+
139+ require.Equal(t, []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)})
140+}
141+
142+func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) {
143+ s := NewScreen(4, 2)
144+
145+ got, err := s.RecoveryTranscriptSnapshot().Marshal()
146+ require.NoError(t, err)
147+ want, err := MarshalHistory(HistoryView{})
148+ require.NoError(t, err)
149+
150+ require.Equal(t, want, got)
151+ view, err := UnmarshalHistory(got)
152+ require.NoError(t, err)
153+ require.Zero(t, view.Len())
154+ require.Zero(t, view.ChunkCount())
155+}
156+
157+func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) {
158+ s := NewScreen(1, 257)
159+ for y := range s.Frame.Height {
160+ s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
161+ s.buffer.boundaries[y] = LineBound{End: 1, Soft: true}
162+ }
163+
164+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
165+
166+ require.Equal(t, 257, view.Len())
167+ require.Equal(t, 2, view.ChunkCount())
168+ require.Len(t, view.Chunk(0).rows, 256)
169+ require.Len(t, view.Chunk(1).rows, 1)
170+ require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam")
171+ require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard")
172+}
173+
174+func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) {
175+ s := NewScreen(4, 2)
176+ s.Write([]byte("界"))
177+ want := append([]renderer.Cell(nil), s.Frame.Row(0)...)
178+
179+ snapshot := s.RecoveryTranscriptSnapshot()
180+ for x := range s.Frame.Width {
181+ s.Frame.Set(x, 0, renderer.BlankCell())
182+ }
183+
184+ view := decodeRecoveryTranscript(t, snapshot)
185+ require.Equal(t, want, view.Row(0))
186+ require.Equal(t, LineBound{End: 2}, view.Bound(0))
187+ require.True(t, view.Row(0)[1].Continuation)
188+}
189+
190+func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView {
191+ t.Helper()
192+ blob, err := snapshot.Marshal()
193+ require.NoError(t, err)
194+ view, err := UnmarshalHistory(blob)
195+ require.NoError(t, err)
196+ return view
197+}
@@ -0,0 +1,197 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) {
11+ s := NewScreen(4, 2)
12+ s.Write([]byte("keep"))
13+
14+ snapshot := s.RecoveryTranscriptSnapshot()
15+ s.Frame.Set(0, 0, renderer.BlankCell())
16+ s.buffer.boundaries[0] = LineBound{}
17+
18+ view := decodeRecoveryTranscript(t, snapshot)
19+ require.Equal(t, []string{"keep"}, historyViewTexts(view))
20+ require.Equal(t, LineBound{End: 4}, view.Bound(0))
21+}
22+
23+func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) {
24+ s := NewScreen(4, 3)
25+ s.Write([]byte("prim"))
26+ s.buffer.boundaries[0].Soft = true
27+ s.Write([]byte("\x1b[?1049h"))
28+ s.Write([]byte("alt"))
29+ s.buffer.boundaries[0].Soft = true
30+
31+ snapshot := s.RecoveryTranscriptSnapshot()
32+ s.Frame.Set(0, 0, renderer.BlankCell())
33+ s.buffer.boundaries[0] = LineBound{}
34+ s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell())
35+ s.alternate.buffer.boundaries[0] = LineBound{}
36+
37+ view := decodeRecoveryTranscript(t, snapshot)
38+
39+ require.Equal(t, []string{"prim", "alt "}, historyViewTexts(view))
40+ require.Equal(t, LineBound{End: 4}, view.Bound(0), "saved-primary seam must be hard")
41+ require.Equal(t, LineBound{End: 3}, view.Bound(1), "final alternate seam must be hard")
42+}
43+
44+func TestRecoveryTranscriptSnapshotTrimsEachViewportIndependently(t *testing.T) {
45+ s := NewScreen(4, 3)
46+ s.Write([]byte("main"))
47+ s.Write([]byte("\x1b[?1049h"))
48+ s.Write([]byte("alt"))
49+
50+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
51+
52+ require.Equal(t, []string{"main", "alt "}, historyViewTexts(view))
53+}
54+
55+func TestRecoveryTranscriptSnapshotExcludesExistingBoundedHistory(t *testing.T) {
56+ s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2})
57+ require.NoError(t, s.History().Append(historyRow("old"), LineBound{End: 3}))
58+ s.Write([]byte("live"))
59+
60+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
61+
62+ require.Equal(t, []string{"live"}, historyViewTexts(view))
63+}
64+
65+func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) {
66+ styledBlank := renderer.BlankCell()
67+ styledBlank.Style.Bold = true
68+
69+ tests := []struct {
70+ name string
71+ candidateCell renderer.Cell
72+ candidateBound LineBound
73+ wantRows int
74+ wantCell renderer.Cell
75+ }{
76+ {
77+ name: "untouched default blank with zero hard bound",
78+ candidateCell: renderer.BlankCell(),
79+ wantRows: 1,
80+ },
81+ {
82+ name: "written default space",
83+ candidateCell: renderer.BlankCell(),
84+ candidateBound: LineBound{End: 1},
85+ wantRows: 2,
86+ wantCell: renderer.BlankCell(),
87+ },
88+ {
89+ name: "styled blank",
90+ candidateCell: styledBlank,
91+ wantRows: 2,
92+ wantCell: styledBlank,
93+ },
94+ {
95+ name: "soft blank",
96+ candidateCell: renderer.BlankCell(),
97+ candidateBound: LineBound{Soft: true},
98+ wantRows: 2,
99+ wantCell: renderer.BlankCell(),
100+ },
101+ }
102+
103+ for _, tt := range tests {
104+ t.Run(tt.name, func(t *testing.T) {
105+ s := NewScreen(3, 3)
106+ s.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
107+ s.buffer.boundaries[0] = LineBound{End: 1}
108+ s.Frame.Set(0, 1, tt.candidateCell)
109+ s.buffer.boundaries[1] = tt.candidateBound
110+
111+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
112+
113+ require.Equal(t, tt.wantRows, view.Len())
114+ if tt.wantRows == 2 {
115+ require.True(t, view.Row(1)[0].Equal(tt.wantCell))
116+ require.False(t, view.Bound(1).Soft, "the retained segment's final row must be hardened")
117+ }
118+ })
119+ }
120+}
121+
122+func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) {
123+ s := NewScreen(3, 4)
124+ s.Frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
125+ s.buffer.boundaries[2] = LineBound{End: 1}
126+
127+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
128+
129+ require.Equal(t, []string{" ", " ", "x "}, historyViewTexts(view))
130+ require.Equal(t, []LineBound{{}, {}, {End: 1}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)})
131+}
132+
133+func TestRecoveryTranscriptSnapshotPreservesBoundsAndHardensOnlyTheFinalRow(t *testing.T) {
134+ s := NewScreen(4, 3)
135+ s.buffer.boundaries = []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3, Soft: true}}
136+
137+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
138+
139+ require.Equal(t, []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)})
140+}
141+
142+func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) {
143+ s := NewScreen(4, 2)
144+
145+ got, err := s.RecoveryTranscriptSnapshot().Marshal()
146+ require.NoError(t, err)
147+ want, err := MarshalHistory(HistoryView{})
148+ require.NoError(t, err)
149+
150+ require.Equal(t, want, got)
151+ view, err := UnmarshalHistory(got)
152+ require.NoError(t, err)
153+ require.Zero(t, view.Len())
154+ require.Zero(t, view.ChunkCount())
155+}
156+
157+func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) {
158+ s := NewScreen(1, 257)
159+ for y := range s.Frame.Height {
160+ s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
161+ s.buffer.boundaries[y] = LineBound{End: 1, Soft: true}
162+ }
163+
164+ view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
165+
166+ require.Equal(t, 257, view.Len())
167+ require.Equal(t, 2, view.ChunkCount())
168+ require.Len(t, view.Chunk(0).rows, 256)
169+ require.Len(t, view.Chunk(1).rows, 1)
170+ require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam")
171+ require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard")
172+}
173+
174+func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) {
175+ s := NewScreen(4, 2)
176+ s.Write([]byte("界"))
177+ want := append([]renderer.Cell(nil), s.Frame.Row(0)...)
178+
179+ snapshot := s.RecoveryTranscriptSnapshot()
180+ for x := range s.Frame.Width {
181+ s.Frame.Set(x, 0, renderer.BlankCell())
182+ }
183+
184+ view := decodeRecoveryTranscript(t, snapshot)
185+ require.Equal(t, want, view.Row(0))
186+ require.Equal(t, LineBound{End: 2}, view.Bound(0))
187+ require.True(t, view.Row(0)[1].Continuation)
188+}
189+
190+func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView {
191+ t.Helper()
192+ blob, err := snapshot.Marshal()
193+ require.NoError(t, err)
194+ view, err := UnmarshalHistory(blob)
195+ require.NoError(t, err)
196+ return view
197+}

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