detached 35ddca2

feat(vt): restore transcripts into terminal history

commit
35ddca20efd73d13e141452a20f694911739f0a9
author
brice · · 2026-07-27 11:54 UTC
parent
e95e63a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +137 -0
M pkg/vt/recovery_transcript_test.go +109 -0
@@ -187,6 +187,115 @@ func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) {
187187 require.True(t, view.Row(0)[1].Continuation)
188188 }
189189
190+func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBlank(t *testing.T) {
191+ sealed, tail := recoveryHistoryBlobs(t, []string{"sealed-a", "sealed-b", "tail"})
192+ transcript := recoveryTranscriptBlob(t,
193+ []string{"primary", "alternate"},
194+ []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}},
195+ )
196+
197+ screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript)
198+ require.NoError(t, err)
199+
200+ view := screen.History().View()
201+ require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view))
202+ require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3))
203+ require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam")
204+ for y := range screen.Frame.Height {
205+ for x := range screen.Frame.Width {
206+ require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y)
207+ }
208+ }
209+ require.Equal(t, []LineBound{{}, {}}, screen.LineBounds())
210+}
211+
212+func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.T) {
213+ tests := []struct {
214+ name string
215+ config HistoryConfig
216+ history []string
217+ transcript []string
218+ want []string
219+ }{
220+ {
221+ name: "row budget",
222+ config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2},
223+ history: []string{"old-1", "old-2", "old-3"},
224+ transcript: []string{"new-1", "new-2"},
225+ want: []string{"old-3", "new-1", "new-2"},
226+ },
227+ {
228+ name: "cell budget",
229+ config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4},
230+ history: []string{"aa", "b", "ccc"},
231+ transcript: []string{"d", "ee"},
232+ want: []string{"d", "ee"},
233+ },
234+ }
235+
236+ for _, tt := range tests {
237+ t.Run(tt.name, func(t *testing.T) {
238+ sealed, tail := recoveryHistoryBlobs(t, tt.history)
239+ transcript := recoveryTranscriptBlob(t, tt.transcript, nil)
240+
241+ screen, err := NewScreenWithRecoveryTranscript(4, 2, tt.config, sealed, tail, transcript)
242+ require.NoError(t, err)
243+ require.Equal(t, tt.want, historyViewTexts(screen.History().View()))
244+ })
245+ }
246+}
247+
248+func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing.T) {
249+ sealed, tail := recoveryHistoryBlobs(t, []string{"ok"})
250+ transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil)
251+
252+ screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript)
253+
254+ require.ErrorIs(t, err, ErrHistoryRowTooWide)
255+ require.Nil(t, screen)
256+}
257+
258+func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) {
259+ t.Helper()
260+ history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2})
261+ for _, text := range texts {
262+ require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)}))
263+ }
264+ view := history.SnapshotView()
265+ sealed := make([][]byte, view.ChunkCount())
266+ for i := range sealed {
267+ var err error
268+ sealed[i], err = MarshalHistoryChunk(view.Chunk(i))
269+ require.NoError(t, err)
270+ }
271+ tail, err := MarshalHistoryTail(view)
272+ require.NoError(t, err)
273+ return sealed, tail
274+}
275+
276+func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []byte {
277+ t.Helper()
278+ rows := make([][]renderer.Cell, len(texts))
279+ cells := 0
280+ for i, text := range texts {
281+ rows[i] = historyRow(text)
282+ cells += len(rows[i])
283+ }
284+ if bounds == nil {
285+ bounds = make([]LineBound, len(rows))
286+ for i, row := range rows {
287+ bounds[i].End = len(row)
288+ }
289+ }
290+ view := HistoryView{rows: len(rows), cells: cells}
291+ if len(rows) > 0 {
292+ view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}}
293+ }
294+ blob, err := MarshalHistory(view)
295+ require.NoError(t, err)
296+ return blob
297+}
298+
190299 func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView {
191300 t.Helper()
192301 blob, err := snapshot.Marshal()
@@ -187,6 +187,115 @@ func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) {
187 require.True(t, view.Row(0)[1].Continuation)187 require.True(t, view.Row(0)[1].Continuation)
188 }188 }
189 189
190+func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBlank(t *testing.T) {
191+ sealed, tail := recoveryHistoryBlobs(t, []string{"sealed-a", "sealed-b", "tail"})
192+ transcript := recoveryTranscriptBlob(t,
193+ []string{"primary", "alternate"},
194+ []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}},
195+ )
196+
197+ screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript)
198+ require.NoError(t, err)
199+
200+ view := screen.History().View()
201+ require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view))
202+ require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3))
203+ require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam")
204+ for y := range screen.Frame.Height {
205+ for x := range screen.Frame.Width {
206+ require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y)
207+ }
208+ }
209+ require.Equal(t, []LineBound{{}, {}}, screen.LineBounds())
210+}
211+
212+func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.T) {
213+ tests := []struct {
214+ name string
215+ config HistoryConfig
216+ history []string
217+ transcript []string
218+ want []string
219+ }{
220+ {
221+ name: "row budget",
222+ config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2},
223+ history: []string{"old-1", "old-2", "old-3"},
224+ transcript: []string{"new-1", "new-2"},
225+ want: []string{"old-3", "new-1", "new-2"},
226+ },
227+ {
228+ name: "cell budget",
229+ config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4},
230+ history: []string{"aa", "b", "ccc"},
231+ transcript: []string{"d", "ee"},
232+ want: []string{"d", "ee"},
233+ },
234+ }
235+
236+ for _, tt := range tests {
237+ t.Run(tt.name, func(t *testing.T) {
238+ sealed, tail := recoveryHistoryBlobs(t, tt.history)
239+ transcript := recoveryTranscriptBlob(t, tt.transcript, nil)
240+
241+ screen, err := NewScreenWithRecoveryTranscript(4, 2, tt.config, sealed, tail, transcript)
242+ require.NoError(t, err)
243+ require.Equal(t, tt.want, historyViewTexts(screen.History().View()))
244+ })
245+ }
246+}
247+
248+func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing.T) {
249+ sealed, tail := recoveryHistoryBlobs(t, []string{"ok"})
250+ transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil)
251+
252+ screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript)
253+
254+ require.ErrorIs(t, err, ErrHistoryRowTooWide)
255+ require.Nil(t, screen)
256+}
257+
258+func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) {
259+ t.Helper()
260+ history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2})
261+ for _, text := range texts {
262+ require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)}))
263+ }
264+ view := history.SnapshotView()
265+ sealed := make([][]byte, view.ChunkCount())
266+ for i := range sealed {
267+ var err error
268+ sealed[i], err = MarshalHistoryChunk(view.Chunk(i))
269+ require.NoError(t, err)
270+ }
271+ tail, err := MarshalHistoryTail(view)
272+ require.NoError(t, err)
273+ return sealed, tail
274+}
275+
276+func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []byte {
277+ t.Helper()
278+ rows := make([][]renderer.Cell, len(texts))
279+ cells := 0
280+ for i, text := range texts {
281+ rows[i] = historyRow(text)
282+ cells += len(rows[i])
283+ }
284+ if bounds == nil {
285+ bounds = make([]LineBound, len(rows))
286+ for i, row := range rows {
287+ bounds[i].End = len(row)
288+ }
289+ }
290+ view := HistoryView{rows: len(rows), cells: cells}
291+ if len(rows) > 0 {
292+ view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}}
293+ }
294+ blob, err := MarshalHistory(view)
295+ require.NoError(t, err)
296+ return blob
297+}
298+
190 func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView {299 func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView {
191 t.Helper()300 t.Helper()
192 blob, err := snapshot.Marshal()301 blob, err := snapshot.Marshal()
M pkg/vt/snapshot.go +28 -0
@@ -111,6 +111,34 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
111111 return h, nil
112112 }
113113
114+// NewScreenWithRecoveryTranscript constructs a fresh blank screen whose
115+// history contains the restored bounded history followed by the recovery
116+// transcript. The transcript is decoded in full before history is restored.
117+func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) {
118+ transcriptView, err := UnmarshalHistory(transcript)
119+ if err != nil {
120+ return nil, fmt.Errorf("restore recovery transcript: %w", err)
121+ }
122+
123+ history, err := HistoryFromBlobs(config, sealed, tail)
124+ if err != nil {
125+ return nil, err
126+ }
127+ for i := range transcriptView.Len() {
128+ bound := transcriptView.Bound(i)
129+ if i == transcriptView.Len()-1 {
130+ bound.Soft = false
131+ }
132+ if err := history.Append(transcriptView.BorrowedRow(i), bound); err != nil {
133+ return nil, fmt.Errorf("restore recovery transcript: %w", err)
134+ }
135+ }
136+
137+ screen := NewScreenWithHistory(width, height, config)
138+ screen.history = history
139+ return screen, nil
140+}
141+
114142 // NewScreenWithRestoredHistory constructs a screen that owns history restored
115143 // directly from canonical blobs. Call RestorePrimaryVisible to install a
116144 // persisted primary frame before making the screen live.
@@ -111,6 +111,34 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
111 return h, nil111 return h, nil
112 }112 }
113 113
114+// NewScreenWithRecoveryTranscript constructs a fresh blank screen whose
115+// history contains the restored bounded history followed by the recovery
116+// transcript. The transcript is decoded in full before history is restored.
117+func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) {
118+ transcriptView, err := UnmarshalHistory(transcript)
119+ if err != nil {
120+ return nil, fmt.Errorf("restore recovery transcript: %w", err)
121+ }
122+
123+ history, err := HistoryFromBlobs(config, sealed, tail)
124+ if err != nil {
125+ return nil, err
126+ }
127+ for i := range transcriptView.Len() {
128+ bound := transcriptView.Bound(i)
129+ if i == transcriptView.Len()-1 {
130+ bound.Soft = false
131+ }
132+ if err := history.Append(transcriptView.BorrowedRow(i), bound); err != nil {
133+ return nil, fmt.Errorf("restore recovery transcript: %w", err)
134+ }
135+ }
136+
137+ screen := NewScreenWithHistory(width, height, config)
138+ screen.history = history
139+ return screen, nil
140+}
141+
114 // NewScreenWithRestoredHistory constructs a screen that owns history restored142 // NewScreenWithRestoredHistory constructs a screen that owns history restored
115 // directly from canonical blobs. Call RestorePrimaryVisible to install a143 // directly from canonical blobs. Call RestorePrimaryVisible to install a
116 // persisted primary frame before making the screen live.144 // persisted primary frame before making the screen live.

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