detached 9db9bf9

docs(vt): document snapshot ownership

commit
9db9bf9ba087fc760f90e1bdc2402e6ed9c7c045
author
brice · · 2026-07-28 14:11 UTC
committer
brice · · 2026-07-29 05:00 UTC
parent
ff327ba
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +104 -0
M pkg/vt/doc.go +5 -0
@@ -5,4 +5,9 @@
55 // sequences. Wide runes that fit are stored as a head cell plus a continuation
66 // cell, and resize or edit operations repair row-boundary splits instead of
77 // leaving orphaned halves.
8+//
9+// Screen is single-owner. Snapshot returns an owned capture of the active
10+// visible viewport and renderer-relevant metadata; callers must serialize it
11+// with Write, Resize, and host mutations. Terminal history remains available
12+// through HistoryView and HistorySnapshotView rather than ScreenSnapshot.
813 package vt
@@ -5,4 +5,9 @@
5 // sequences. Wide runes that fit are stored as a head cell plus a continuation5 // sequences. Wide runes that fit are stored as a head cell plus a continuation
6 // cell, and resize or edit operations repair row-boundary splits instead of6 // cell, and resize or edit operations repair row-boundary splits instead of
7 // leaving orphaned halves.7 // leaving orphaned halves.
8+//
9+// Screen is single-owner. Snapshot returns an owned capture of the active
10+// visible viewport and renderer-relevant metadata; callers must serialize it
11+// with Write, Resize, and host mutations. Terminal history remains available
12+// through HistoryView and HistorySnapshotView rather than ScreenSnapshot.
8 package vt13 package vt
M pkg/vt/screen_snapshot_test.go +99 -0
@@ -300,3 +300,102 @@ func TestScreenSnapshotBoundsAreIndependent(t *testing.T) {
300300 })
301301 }
302302 }
303+
304+func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
305+ tests := []struct {
306+ name string
307+ make func() *Screen
308+ }{
309+ {
310+ name: "screen without history",
311+ make: func() *Screen {
312+ screen := NewScreen(4, 2)
313+ screen.ClearDamage()
314+ screen.Write([]byte("abcdef"))
315+ return screen
316+ },
317+ },
318+ {
319+ name: "screen with sealed chunks and mutable tail",
320+ make: func() *Screen {
321+ screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 64, ChunkRows: 2})
322+ screen.Write([]byte("111122223333444455"))
323+ return screen
324+ },
325+ },
326+ }
327+
328+ for _, test := range tests {
329+ t.Run(test.name, func(t *testing.T) {
330+ screen := test.make()
331+ beforeDamage := screen.CaptureDamage()
332+ var beforeHistory HistorySnapshotView
333+ beforeCap, beforeCellCap := 0, 0
334+ if screen.History() != nil {
335+ beforeHistory = screen.History().SnapshotView()
336+ beforeCap = screen.History().Cap()
337+ beforeCellCap = screen.History().CellCap()
338+ }
339+
340+ _ = screen.Snapshot()
341+
342+ afterDamage := screen.CaptureDamage()
343+ require.Equal(t, beforeDamage, afterDamage)
344+ if screen.History() != nil {
345+ require.Equal(t, beforeHistory, screen.History().SnapshotView())
346+ require.Equal(t, beforeCap, screen.History().Cap())
347+ require.Equal(t, beforeCellCap, screen.History().CellCap())
348+ }
349+ })
350+ }
351+}
352+
353+func TestScreenSnapshotPreservesStaleDamageAcknowledge(t *testing.T) {
354+ tests := []struct {
355+ name string
356+ mutate func(*Screen)
357+ }{
358+ {name: "write after capture", mutate: func(screen *Screen) { screen.Write([]byte("b")) }},
359+ {name: "resize after capture", mutate: func(screen *Screen) { screen.Resize(6, 2) }},
360+ }
361+
362+ for _, test := range tests {
363+ t.Run(test.name, func(t *testing.T) {
364+ screen := NewScreen(4, 2)
365+ screen.ClearDamage()
366+ screen.Write([]byte("a"))
367+ captured := screen.CaptureDamage()
368+
369+ _ = screen.Snapshot()
370+ test.mutate(screen)
371+
372+ require.False(t, screen.AcknowledgeDamage(captured.Generation))
373+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage())
374+
375+ screen.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
376+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage(), "full redraw stays sticky until exact acknowledgement")
377+ })
378+ }
379+}
380+
381+func TestScreenSnapshotPreservesExactDamageAcknowledge(t *testing.T) {
382+ tests := []struct {
383+ name string
384+ }{
385+ {name: "snapshot does not stale an exact damage capture"},
386+ }
387+
388+ for _, test := range tests {
389+ t.Run(test.name, func(t *testing.T) {
390+ screen := NewScreen(4, 2)
391+ screen.ClearDamage()
392+ screen.Write([]byte("a"))
393+ captured := screen.CaptureDamage()
394+
395+ _ = screen.Snapshot()
396+
397+ require.True(t, screen.AcknowledgeDamage(captured.Generation))
398+ require.Empty(t, screen.Damage())
399+ })
400+ }
401+}
@@ -300,3 +300,102 @@ func TestScreenSnapshotBoundsAreIndependent(t *testing.T) {
300 })300 })
301 }301 }
302 }302 }
303+
304+func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
305+ tests := []struct {
306+ name string
307+ make func() *Screen
308+ }{
309+ {
310+ name: "screen without history",
311+ make: func() *Screen {
312+ screen := NewScreen(4, 2)
313+ screen.ClearDamage()
314+ screen.Write([]byte("abcdef"))
315+ return screen
316+ },
317+ },
318+ {
319+ name: "screen with sealed chunks and mutable tail",
320+ make: func() *Screen {
321+ screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 64, ChunkRows: 2})
322+ screen.Write([]byte("111122223333444455"))
323+ return screen
324+ },
325+ },
326+ }
327+
328+ for _, test := range tests {
329+ t.Run(test.name, func(t *testing.T) {
330+ screen := test.make()
331+ beforeDamage := screen.CaptureDamage()
332+ var beforeHistory HistorySnapshotView
333+ beforeCap, beforeCellCap := 0, 0
334+ if screen.History() != nil {
335+ beforeHistory = screen.History().SnapshotView()
336+ beforeCap = screen.History().Cap()
337+ beforeCellCap = screen.History().CellCap()
338+ }
339+
340+ _ = screen.Snapshot()
341+
342+ afterDamage := screen.CaptureDamage()
343+ require.Equal(t, beforeDamage, afterDamage)
344+ if screen.History() != nil {
345+ require.Equal(t, beforeHistory, screen.History().SnapshotView())
346+ require.Equal(t, beforeCap, screen.History().Cap())
347+ require.Equal(t, beforeCellCap, screen.History().CellCap())
348+ }
349+ })
350+ }
351+}
352+
353+func TestScreenSnapshotPreservesStaleDamageAcknowledge(t *testing.T) {
354+ tests := []struct {
355+ name string
356+ mutate func(*Screen)
357+ }{
358+ {name: "write after capture", mutate: func(screen *Screen) { screen.Write([]byte("b")) }},
359+ {name: "resize after capture", mutate: func(screen *Screen) { screen.Resize(6, 2) }},
360+ }
361+
362+ for _, test := range tests {
363+ t.Run(test.name, func(t *testing.T) {
364+ screen := NewScreen(4, 2)
365+ screen.ClearDamage()
366+ screen.Write([]byte("a"))
367+ captured := screen.CaptureDamage()
368+
369+ _ = screen.Snapshot()
370+ test.mutate(screen)
371+
372+ require.False(t, screen.AcknowledgeDamage(captured.Generation))
373+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage())
374+
375+ screen.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
376+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage(), "full redraw stays sticky until exact acknowledgement")
377+ })
378+ }
379+}
380+
381+func TestScreenSnapshotPreservesExactDamageAcknowledge(t *testing.T) {
382+ tests := []struct {
383+ name string
384+ }{
385+ {name: "snapshot does not stale an exact damage capture"},
386+ }
387+
388+ for _, test := range tests {
389+ t.Run(test.name, func(t *testing.T) {
390+ screen := NewScreen(4, 2)
391+ screen.ClearDamage()
392+ screen.Write([]byte("a"))
393+ captured := screen.CaptureDamage()
394+
395+ _ = screen.Snapshot()
396+
397+ require.True(t, screen.AcknowledgeDamage(captured.Generation))
398+ require.Empty(t, screen.Damage())
399+ })
400+ }
401+}

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