detached 178c6c5

perf(screen): skip unused eviction copies and reuse Kitty escape buffers

emitLineEvicted returns early without history or OnLineEvicted and otherwise
builds the row in a Screen-owned scratch buffer; OnLineEvicted still receives
a fresh copy. Fragmented Kitty APCs reuse a bounded escapeBuf/combine buffer
and dispatch borrowed APC bytes, since the session copies what it retains.
commit
178c6c50d86975d8b656036997dcd3c4dcf2b377
author
brice · · 2026-10-02 09:33 UTC
parent
2234949
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +130 -19
M screen.go +65 -18
@@ -18,6 +18,11 @@ const maxEscapeBufferLen = 128 * 1024
1818 // through the text path or truncated at the OSC clipboard bound.
1919 const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5
2020
21+// maxRetainedEscapeBufferCap bounds the capacity of the escape scratch buffers
22+// kept between writes so that a single huge APC does not pin its memory, while
23+// ordinary fragmented uploads still reuse their buffer.
24+const maxRetainedEscapeBufferCap = 1 << 20
25+
2126 const (
2227 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
2328 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -72,6 +77,8 @@ type Screen struct {
7277 damageSaturated bool
7378 damageFullRedrawSticky bool
7479 escapeBuf []byte
80+ combineBuf []byte
81+ evictScratch []renderer.Cell
7582 kittyDiscard bool
7683 kittyDiscardEscaped bool
7784 kittyPendingDisplay *kittygraphics.Controls
@@ -168,7 +175,9 @@ func (s *Screen) LineBounds() []LineBound {
168175 }
169176
170177 func (s *Screen) Write(data []byte) {
171- if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) {
178+ if len(s.escapeBuf) == 0 {
179+ s.trimEscapeBuffers()
180+ } else if isKittyEscapeContinuation(s.escapeBuf, data) {
172181 prefix := s.escapeBuf
173182 s.escapeBuf = nil
174183 data = s.continueKittyEscape(prefix, data)
@@ -177,11 +186,20 @@ func (s *Screen) Write(data []byte) {
177186 }
178187 }
179188 if len(s.escapeBuf) > 0 {
180- combined := make([]byte, 0, len(s.escapeBuf)+len(data))
189+ // Reuse a bounded scratch buffer: data is consumed before Write returns
190+ // and nothing retains slices of it (the partial-escape path copies).
191+ need := len(s.escapeBuf) + len(data)
192+ combined := s.combineBuf[:0]
193+ if cap(combined) < need {
194+ combined = make([]byte, 0, need)
195+ }
181196 combined = append(combined, s.escapeBuf...)
182197 combined = append(combined, data...)
198+ if cap(combined) <= maxRetainedEscapeBufferCap {
199+ s.combineBuf = combined
200+ }
183201 data = combined
184- s.escapeBuf = nil
202+ s.escapeBuf = s.escapeBuf[:0]
185203 }
186204 if s.kittyDiscard {
187205 consumed := s.consumeKittyDiscard(data)
@@ -249,42 +267,55 @@ func isKittyEscapeContinuation(prefix, data []byte) bool {
249267 return true
250268 }
251269
270+// trimEscapeBuffers drops oversized idle escape scratch buffers.
271+func (s *Screen) trimEscapeBuffers() {
272+ if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
273+ s.escapeBuf = nil
274+ }
275+ if cap(s.combineBuf) > maxRetainedEscapeBufferCap {
276+ s.combineBuf = nil
277+ }
278+}
279+
280+// recycleEscapeBuf keeps buf's capacity for the next fragmented escape when no
281+// other buffer has been installed meanwhile. Callers must be done with buf.
282+func (s *Screen) recycleEscapeBuf(buf []byte) {
283+ if s.escapeBuf == nil && cap(buf) <= maxRetainedEscapeBufferCap {
284+ s.escapeBuf = buf[:0]
285+ }
286+}
287+
288+// continueKittyEscape completes or extends the pending Kitty APC in prefix,
289+// which is the Screen's former escapeBuf (s.escapeBuf is nil on entry). The
290+// protocol adapter copies whatever it retains from a dispatched APC, so prefix
291+// is recycled afterwards instead of being reallocated for every APC.
252292 func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
253293 if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
254294 if len(prefix)+1 <= maxKittyEscapeBufferLen {
255295 apc := append(prefix, data[:1]...)
256296 s.dispatchKittyGraphics(apc)
297+ s.recycleEscapeBuf(apc)
257298 } else {
258299 s.abortKittyPendingDisplay()
300+ s.recycleEscapeBuf(prefix)
259301 }
260302 return data[1:]
261303 }
262304 for i := 0; i < len(data); i++ {
263305 if data[i] == 0x9c {
264306 end := i + 1
265- if len(prefix)+end <= maxKittyEscapeBufferLen {
266- apc := append(prefix, data[:end]...)
267- s.dispatchKittyGraphics(apc)
268- } else {
269- s.abortKittyPendingDisplay()
270- }
307+ s.finishKittyEscape(prefix, data[:end])
271308 return data[end:]
272309 }
273310 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
274311 end := i + 2
275- if len(prefix)+end <= maxKittyEscapeBufferLen {
276- apc := append(prefix, data[:end]...)
277- s.dispatchKittyGraphics(apc)
278- } else {
279- s.abortKittyPendingDisplay()
280- }
312+ s.finishKittyEscape(prefix, data[:end])
281313 return data[end:]
282314 }
283315 }
284316 remaining := maxKittyEscapeBufferLen - len(prefix)
285317 if len(data) <= remaining {
286- returnData := append(prefix, data...)
287- s.escapeBuf = returnData
318+ s.escapeBuf = append(prefix, data...)
288319 return nil
289320 }
290321 s.abortKittyPendingDisplay()
@@ -294,10 +325,24 @@ func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
294325 escaped = data[remaining-1] == 0x1b
295326 }
296327 s.kittyDiscardEscaped = escaped
328+ s.recycleEscapeBuf(prefix)
297329 tail := data[remaining:]
298330 return tail[s.consumeKittyDiscard(tail):]
299331 }
300332
333+// finishKittyEscape dispatches prefix+terminated, or aborts when the framed
334+// APC would exceed the Kitty escape bound, then recycles prefix.
335+func (s *Screen) finishKittyEscape(prefix, terminated []byte) {
336+ if len(prefix)+len(terminated) > maxKittyEscapeBufferLen {
337+ s.abortKittyPendingDisplay()
338+ s.recycleEscapeBuf(prefix)
339+ return
340+ }
341+ apc := append(prefix, terminated...)
342+ s.dispatchKittyGraphics(apc)
343+ s.recycleEscapeBuf(apc)
344+}
345+
301346 func (s *Screen) consumeKittyDiscard(data []byte) int {
302347 for i, b := range data {
303348 if s.kittyDiscardEscaped {
@@ -418,7 +463,9 @@ func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool)
418463 }
419464
420465 func (s *Screen) dispatchKittyGraphics(apc []byte) {
421- command, err := kittygraphics.ParseAPC(apc)
466+ // The session copies any payload it retains, so the APC bytes may be
467+ // borrowed for the duration of this call only.
468+ command, err := kittygraphics.ParseAPCBorrowed(apc)
422469 if err != nil {
423470 s.abortKittyPendingDisplay()
424471 return
@@ -18,6 +18,11 @@ const maxEscapeBufferLen = 128 * 1024
18 // through the text path or truncated at the OSC clipboard bound.18 // through the text path or truncated at the OSC clipboard bound.
19 const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 519 const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5
20 20
21+// maxRetainedEscapeBufferCap bounds the capacity of the escape scratch buffers
22+// kept between writes so that a single huge APC does not pin its memory, while
23+// ordinary fragmented uploads still reuse their buffer.
24+const maxRetainedEscapeBufferCap = 1 << 20
25+
21 const (26 const (
22 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.27 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
23 ColorSchemeReportDark = "\x1b[?997;1n"28 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -72,6 +77,8 @@ type Screen struct {
72 damageSaturated bool77 damageSaturated bool
73 damageFullRedrawSticky bool78 damageFullRedrawSticky bool
74 escapeBuf []byte79 escapeBuf []byte
80+ combineBuf []byte
81+ evictScratch []renderer.Cell
75 kittyDiscard bool82 kittyDiscard bool
76 kittyDiscardEscaped bool83 kittyDiscardEscaped bool
77 kittyPendingDisplay *kittygraphics.Controls84 kittyPendingDisplay *kittygraphics.Controls
@@ -168,7 +175,9 @@ func (s *Screen) LineBounds() []LineBound {
168 }175 }
169 176
170 func (s *Screen) Write(data []byte) {177 func (s *Screen) Write(data []byte) {
171- if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) {178+ if len(s.escapeBuf) == 0 {
179+ s.trimEscapeBuffers()
180+ } else if isKittyEscapeContinuation(s.escapeBuf, data) {
172 prefix := s.escapeBuf181 prefix := s.escapeBuf
173 s.escapeBuf = nil182 s.escapeBuf = nil
174 data = s.continueKittyEscape(prefix, data)183 data = s.continueKittyEscape(prefix, data)
@@ -177,11 +186,20 @@ func (s *Screen) Write(data []byte) {
177 }186 }
178 }187 }
179 if len(s.escapeBuf) > 0 {188 if len(s.escapeBuf) > 0 {
180- combined := make([]byte, 0, len(s.escapeBuf)+len(data))189+ // Reuse a bounded scratch buffer: data is consumed before Write returns
190+ // and nothing retains slices of it (the partial-escape path copies).
191+ need := len(s.escapeBuf) + len(data)
192+ combined := s.combineBuf[:0]
193+ if cap(combined) < need {
194+ combined = make([]byte, 0, need)
195+ }
181 combined = append(combined, s.escapeBuf...)196 combined = append(combined, s.escapeBuf...)
182 combined = append(combined, data...)197 combined = append(combined, data...)
198+ if cap(combined) <= maxRetainedEscapeBufferCap {
199+ s.combineBuf = combined
200+ }
183 data = combined201 data = combined
184- s.escapeBuf = nil202+ s.escapeBuf = s.escapeBuf[:0]
185 }203 }
186 if s.kittyDiscard {204 if s.kittyDiscard {
187 consumed := s.consumeKittyDiscard(data)205 consumed := s.consumeKittyDiscard(data)
@@ -249,42 +267,55 @@ func isKittyEscapeContinuation(prefix, data []byte) bool {
249 return true267 return true
250 }268 }
251 269
270+// trimEscapeBuffers drops oversized idle escape scratch buffers.
271+func (s *Screen) trimEscapeBuffers() {
272+ if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
273+ s.escapeBuf = nil
274+ }
275+ if cap(s.combineBuf) > maxRetainedEscapeBufferCap {
276+ s.combineBuf = nil
277+ }
278+}
279+
280+// recycleEscapeBuf keeps buf's capacity for the next fragmented escape when no
281+// other buffer has been installed meanwhile. Callers must be done with buf.
282+func (s *Screen) recycleEscapeBuf(buf []byte) {
283+ if s.escapeBuf == nil && cap(buf) <= maxRetainedEscapeBufferCap {
284+ s.escapeBuf = buf[:0]
285+ }
286+}
287+
288+// continueKittyEscape completes or extends the pending Kitty APC in prefix,
289+// which is the Screen's former escapeBuf (s.escapeBuf is nil on entry). The
290+// protocol adapter copies whatever it retains from a dispatched APC, so prefix
291+// is recycled afterwards instead of being reallocated for every APC.
252 func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {292 func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
253 if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {293 if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
254 if len(prefix)+1 <= maxKittyEscapeBufferLen {294 if len(prefix)+1 <= maxKittyEscapeBufferLen {
255 apc := append(prefix, data[:1]...)295 apc := append(prefix, data[:1]...)
256 s.dispatchKittyGraphics(apc)296 s.dispatchKittyGraphics(apc)
297+ s.recycleEscapeBuf(apc)
257 } else {298 } else {
258 s.abortKittyPendingDisplay()299 s.abortKittyPendingDisplay()
300+ s.recycleEscapeBuf(prefix)
259 }301 }
260 return data[1:]302 return data[1:]
261 }303 }
262 for i := 0; i < len(data); i++ {304 for i := 0; i < len(data); i++ {
263 if data[i] == 0x9c {305 if data[i] == 0x9c {
264 end := i + 1306 end := i + 1
265- if len(prefix)+end <= maxKittyEscapeBufferLen {307+ s.finishKittyEscape(prefix, data[:end])
266- apc := append(prefix, data[:end]...)
267- s.dispatchKittyGraphics(apc)
268- } else {
269- s.abortKittyPendingDisplay()
270- }
271 return data[end:]308 return data[end:]
272 }309 }
273 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {310 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
274 end := i + 2311 end := i + 2
275- if len(prefix)+end <= maxKittyEscapeBufferLen {312+ s.finishKittyEscape(prefix, data[:end])
276- apc := append(prefix, data[:end]...)
277- s.dispatchKittyGraphics(apc)
278- } else {
279- s.abortKittyPendingDisplay()
280- }
281 return data[end:]313 return data[end:]
282 }314 }
283 }315 }
284 remaining := maxKittyEscapeBufferLen - len(prefix)316 remaining := maxKittyEscapeBufferLen - len(prefix)
285 if len(data) <= remaining {317 if len(data) <= remaining {
286- returnData := append(prefix, data...)318+ s.escapeBuf = append(prefix, data...)
287- s.escapeBuf = returnData
288 return nil319 return nil
289 }320 }
290 s.abortKittyPendingDisplay()321 s.abortKittyPendingDisplay()
@@ -294,10 +325,24 @@ func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
294 escaped = data[remaining-1] == 0x1b325 escaped = data[remaining-1] == 0x1b
295 }326 }
296 s.kittyDiscardEscaped = escaped327 s.kittyDiscardEscaped = escaped
328+ s.recycleEscapeBuf(prefix)
297 tail := data[remaining:]329 tail := data[remaining:]
298 return tail[s.consumeKittyDiscard(tail):]330 return tail[s.consumeKittyDiscard(tail):]
299 }331 }
300 332
333+// finishKittyEscape dispatches prefix+terminated, or aborts when the framed
334+// APC would exceed the Kitty escape bound, then recycles prefix.
335+func (s *Screen) finishKittyEscape(prefix, terminated []byte) {
336+ if len(prefix)+len(terminated) > maxKittyEscapeBufferLen {
337+ s.abortKittyPendingDisplay()
338+ s.recycleEscapeBuf(prefix)
339+ return
340+ }
341+ apc := append(prefix, terminated...)
342+ s.dispatchKittyGraphics(apc)
343+ s.recycleEscapeBuf(apc)
344+}
345+
301 func (s *Screen) consumeKittyDiscard(data []byte) int {346 func (s *Screen) consumeKittyDiscard(data []byte) int {
302 for i, b := range data {347 for i, b := range data {
303 if s.kittyDiscardEscaped {348 if s.kittyDiscardEscaped {
@@ -418,7 +463,9 @@ func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool)
418 }463 }
419 464
420 func (s *Screen) dispatchKittyGraphics(apc []byte) {465 func (s *Screen) dispatchKittyGraphics(apc []byte) {
421- command, err := kittygraphics.ParseAPC(apc)466+ // The session copies any payload it retains, so the APC bytes may be
467+ // borrowed for the duration of this call only.
468+ command, err := kittygraphics.ParseAPCBorrowed(apc)
422 if err != nil {469 if err != nil {
423 s.abortKittyPendingDisplay()470 s.abortKittyPendingDisplay()
424 return471 return
A screen_evict_scratch_test.go +26 -0
@@ -0,0 +1,26 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt/core"
7+)
8+
9+// OnLineEvicted callers may retain the slice: successive evictions must not
10+// share the Screen's internal scratch row.
11+func TestOnLineEvictedRowsAreIndependentCopies(t *testing.T) {
12+ s := NewScreen(4, 2)
13+ var rows [][]renderer.Cell
14+ s.OnLineEvicted = func(row []renderer.Cell) { rows = append(rows, row) }
15+ s.Write([]byte("aaaa\r\nbbbb\r\ncccc\r\ndddd"))
16+ if len(rows) != 2 {
17+ t.Fatalf("evicted %d rows, want 2", len(rows))
18+ }
19+ if rows[0][0].Rune != 'a' || rows[1][0].Rune != 'b' {
20+ t.Fatalf("evicted rows = %q, %q", rows[0][0].Rune, rows[1][0].Rune)
21+ }
22+ rows[0][0].Rune = 'z'
23+ if rows[1][0].Rune != 'b' {
24+ t.Fatal("evicted rows alias each other")
25+ }
26+}
@@ -0,0 +1,26 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt/core"
7+)
8+
9+// OnLineEvicted callers may retain the slice: successive evictions must not
10+// share the Screen's internal scratch row.
11+func TestOnLineEvictedRowsAreIndependentCopies(t *testing.T) {
12+ s := NewScreen(4, 2)
13+ var rows [][]renderer.Cell
14+ s.OnLineEvicted = func(row []renderer.Cell) { rows = append(rows, row) }
15+ s.Write([]byte("aaaa\r\nbbbb\r\ncccc\r\ndddd"))
16+ if len(rows) != 2 {
17+ t.Fatalf("evicted %d rows, want 2", len(rows))
18+ }
19+ if rows[0][0].Rune != 'a' || rows[1][0].Rune != 'b' {
20+ t.Fatalf("evicted rows = %q, %q", rows[0][0].Rune, rows[1][0].Rune)
21+ }
22+ rows[0][0].Rune = 'z'
23+ if rows[1][0].Rune != 'b' {
24+ t.Fatal("evicted rows alias each other")
25+ }
26+}
M screen_graphics_test.go +25 -0
@@ -331,3 +331,28 @@ func TestScreenKittyPlacementScrollSkipsBottomEdgeOverflow(t *testing.T) {
331331 placement = screen.GraphicsSnapshot().Placements()[0]
332332 require.Equal(t, nearLimit, placement.Destination())
333333 }
334+
335+// Fragmented APCs reuse the Screen's escape buffer, so retained graphics state
336+// (assets and pending chunked uploads) must not alias it.
337+func TestScreenKittyFragmentedAPCsDoNotAliasEscapeBuffer(t *testing.T) {
338+ screen := NewScreen(16, 3)
339+ writeByteWise := func(data []byte) {
340+ for i := range data {
341+ screen.Write(data[i : i+1])
342+ }
343+ }
344+ enc := func(b ...byte) string { return base64.StdEncoding.EncodeToString(b) }
345+ writeByteWise([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;" + enc(1, 2, 3, 4) + "\x1b\\"))
346+ // Chunked upload interleaved with other escape sequences reusing the buffer.
347+ writeByteWise([]byte("\x1b_Ga=t,i=2,f=32,s=1,v=1,m=1;" + enc(9, 9, 9, 9)[:4] + "\x1b\\"))
348+ writeByteWise([]byte("\x1b[1mtext\x1b[0m"))
349+ writeByteWise([]byte("\x1b_Gm=0;" + enc(9, 9, 9, 9)[4:] + "\x1b\\"))
350+
351+ snapshot := screen.GraphicsSnapshot()
352+ require.NotNil(t, snapshot)
353+ var got [][]byte
354+ for _, asset := range snapshot.Assets() {
355+ got = append(got, asset.Bytes())
356+ }
357+ require.ElementsMatch(t, [][]byte{{1, 2, 3, 4}, {9, 9, 9, 9}}, got)
358+}
@@ -331,3 +331,28 @@ func TestScreenKittyPlacementScrollSkipsBottomEdgeOverflow(t *testing.T) {
331 placement = screen.GraphicsSnapshot().Placements()[0]331 placement = screen.GraphicsSnapshot().Placements()[0]
332 require.Equal(t, nearLimit, placement.Destination())332 require.Equal(t, nearLimit, placement.Destination())
333 }333 }
334+
335+// Fragmented APCs reuse the Screen's escape buffer, so retained graphics state
336+// (assets and pending chunked uploads) must not alias it.
337+func TestScreenKittyFragmentedAPCsDoNotAliasEscapeBuffer(t *testing.T) {
338+ screen := NewScreen(16, 3)
339+ writeByteWise := func(data []byte) {
340+ for i := range data {
341+ screen.Write(data[i : i+1])
342+ }
343+ }
344+ enc := func(b ...byte) string { return base64.StdEncoding.EncodeToString(b) }
345+ writeByteWise([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;" + enc(1, 2, 3, 4) + "\x1b\\"))
346+ // Chunked upload interleaved with other escape sequences reusing the buffer.
347+ writeByteWise([]byte("\x1b_Ga=t,i=2,f=32,s=1,v=1,m=1;" + enc(9, 9, 9, 9)[:4] + "\x1b\\"))
348+ writeByteWise([]byte("\x1b[1mtext\x1b[0m"))
349+ writeByteWise([]byte("\x1b_Gm=0;" + enc(9, 9, 9, 9)[4:] + "\x1b\\"))
350+
351+ snapshot := screen.GraphicsSnapshot()
352+ require.NotNil(t, snapshot)
353+ var got [][]byte
354+ for _, asset := range snapshot.Assets() {
355+ got = append(got, asset.Bytes())
356+ }
357+ require.ElementsMatch(t, [][]byte{{1, 2, 3, 4}, {9, 9, 9, 9}}, got)
358+}
M screen_scroll.go +14 -1
@@ -103,10 +103,23 @@ func (s *Screen) emitLineEvicted(top, n int) {
103103 if s.alternate != nil || top != 0 {
104104 return
105105 }
106+ // Without a history or an observer nothing consumes the evicted rows, so
107+ // skip materialising them.
108+ if s.history == nil && s.OnLineEvicted == nil {
109+ return
110+ }
106111 // Read boundaries and IDs before the caller rotates the frame: a soft link
107112 // belongs to the row it follows, and rotation reassigns row indices.
108113 for y := top; y < top+n; y++ {
109- s.recordEvicted(s.frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y])
114+ // The scratch row is only valid for this call: History copies appended
115+ // rows and OnLineEvicted receives its own copy (see recordEvicted).
116+ row := s.evictScratch[:0]
117+ for x := range s.frame.Width {
118+ row = append(row, s.frame.Cell(x, y))
119+ }
120+ s.evictScratch = row
121+ s.recordEvicted(row, s.buffer.bound(y), s.buffer.rowIDs[y])
122+ clear(s.evictScratch)
110123 }
111124 }
112125
@@ -103,10 +103,23 @@ func (s *Screen) emitLineEvicted(top, n int) {
103 if s.alternate != nil || top != 0 {103 if s.alternate != nil || top != 0 {
104 return104 return
105 }105 }
106+ // Without a history or an observer nothing consumes the evicted rows, so
107+ // skip materialising them.
108+ if s.history == nil && s.OnLineEvicted == nil {
109+ return
110+ }
106 // Read boundaries and IDs before the caller rotates the frame: a soft link111 // Read boundaries and IDs before the caller rotates the frame: a soft link
107 // belongs to the row it follows, and rotation reassigns row indices.112 // belongs to the row it follows, and rotation reassigns row indices.
108 for y := top; y < top+n; y++ {113 for y := top; y < top+n; y++ {
109- s.recordEvicted(s.frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y])114+ // The scratch row is only valid for this call: History copies appended
115+ // rows and OnLineEvicted receives its own copy (see recordEvicted).
116+ row := s.evictScratch[:0]
117+ for x := range s.frame.Width {
118+ row = append(row, s.frame.Cell(x, y))
119+ }
120+ s.evictScratch = row
121+ s.recordEvicted(row, s.buffer.bound(y), s.buffer.rowIDs[y])
122+ clear(s.evictScratch)
110 }123 }
111 }124 }
112 125

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 2

Tags 13

↑↓ moveenter openesc close