detached 22dd029

fix(vt): bound pending screen damage

commit
22dd02927a2d406c9d9f978cffac6f96183ac2ee
author
brice · · 2026-07-12 09:12 UTC
parent
ef75ff9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +18 -2
M pkg/vt/screen.go +17 -1
@@ -35,6 +35,10 @@ type screenState struct {
3535 // so keep this value in sync if that payload cap changes.
3636 const maxEscapeBufferLen = 128 * 1024
3737
38+// maxPendingDamage bounds metadata retained while no render transaction can
39+// acknowledge a screen. Saturation falls back to one exact full redraw.
40+const maxPendingDamage = 1024
41+
3842 const (
3943 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
4044 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -88,6 +92,7 @@ type Screen struct {
8892
8993 damage []renderer.Damage
9094 damageGeneration uint64
95+ damageSaturated bool
9196 escapeBuf []byte
9297 csiScratch []int
9398 sgrScratch []int
@@ -173,7 +178,10 @@ type DamageCapture struct {
173178 // Damage returns the current damage list. The caller must not modify the
174179 // returned slice; ClearDamage must be called after the damage is consumed.
175180 func (s *Screen) Damage() []renderer.Damage { return s.damage }
176-func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }
181+func (s *Screen) ClearDamage() {
182+ s.damage = s.damage[:0]
183+ s.damageSaturated = false
184+}
177185
178186 // CaptureDamage snapshots pending damage without consuming it.
179187 func (s *Screen) CaptureDamage() DamageCapture {
@@ -625,6 +633,14 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
625633
626634 func (s *Screen) record(d renderer.Damage) {
627635 s.damageGeneration++
636+ if s.damageSaturated {
637+ return
638+ }
639+ if len(s.damage) >= maxPendingDamage {
640+ s.damage = []renderer.Damage{renderer.FullRedraw()}
641+ s.damageSaturated = true
642+ return
643+ }
628644 // Replace FullRedraw with the first concrete damage item.
629645 if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
630646 s.damage[0] = d
@@ -35,6 +35,10 @@ type screenState struct {
35 // so keep this value in sync if that payload cap changes.35 // so keep this value in sync if that payload cap changes.
36 const maxEscapeBufferLen = 128 * 102436 const maxEscapeBufferLen = 128 * 1024
37 37
38+// maxPendingDamage bounds metadata retained while no render transaction can
39+// acknowledge a screen. Saturation falls back to one exact full redraw.
40+const maxPendingDamage = 1024
41+
38 const (42 const (
39 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.43 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
40 ColorSchemeReportDark = "\x1b[?997;1n"44 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -88,6 +92,7 @@ type Screen struct {
88 92
89 damage []renderer.Damage93 damage []renderer.Damage
90 damageGeneration uint6494 damageGeneration uint64
95+ damageSaturated bool
91 escapeBuf []byte96 escapeBuf []byte
92 csiScratch []int97 csiScratch []int
93 sgrScratch []int98 sgrScratch []int
@@ -173,7 +178,10 @@ type DamageCapture struct {
173 // Damage returns the current damage list. The caller must not modify the178 // Damage returns the current damage list. The caller must not modify the
174 // returned slice; ClearDamage must be called after the damage is consumed.179 // returned slice; ClearDamage must be called after the damage is consumed.
175 func (s *Screen) Damage() []renderer.Damage { return s.damage }180 func (s *Screen) Damage() []renderer.Damage { return s.damage }
176-func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }181+func (s *Screen) ClearDamage() {
182+ s.damage = s.damage[:0]
183+ s.damageSaturated = false
184+}
177 185
178 // CaptureDamage snapshots pending damage without consuming it.186 // CaptureDamage snapshots pending damage without consuming it.
179 func (s *Screen) CaptureDamage() DamageCapture {187 func (s *Screen) CaptureDamage() DamageCapture {
@@ -625,6 +633,14 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
625 633
626 func (s *Screen) record(d renderer.Damage) {634 func (s *Screen) record(d renderer.Damage) {
627 s.damageGeneration++635 s.damageGeneration++
636+ if s.damageSaturated {
637+ return
638+ }
639+ if len(s.damage) >= maxPendingDamage {
640+ s.damage = []renderer.Damage{renderer.FullRedraw()}
641+ s.damageSaturated = true
642+ return
643+ }
628 // Replace FullRedraw with the first concrete damage item.644 // Replace FullRedraw with the first concrete damage item.
629 if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {645 if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
630 s.damage[0] = d646 s.damage[0] = d
M pkg/vt/screen_test.go +1 -1
@@ -2060,7 +2060,7 @@ func TestDamageSaturationBoundsPendingRecords(t *testing.T) {
20602060 s.ClearDamage()
20612061
20622062 for i := range 1_100 {
2063- s.record(renderer.Damage{Kind: renderer.DamageText, X: i % 2, Y: (i / 2) % 2, Width: 1, Height: 1, Count: 1})
2063+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1})
20642064 }
20652065 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
20662066
@@ -2060,7 +2060,7 @@ func TestDamageSaturationBoundsPendingRecords(t *testing.T) {
2060 s.ClearDamage()2060 s.ClearDamage()
2061 2061
2062 for i := range 1_100 {2062 for i := range 1_100 {
2063- s.record(renderer.Damage{Kind: renderer.DamageText, X: i % 2, Y: (i / 2) % 2, Width: 1, Height: 1, Count: 1})2063+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1})
2064 }2064 }
2065 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())2065 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
2066 2066

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