detached 9548cb5

feat: add scrollback ring foundation

commit
9548cb54a62cabc621cb6c84cc867abe0514b797
author
brice · · 2026-07-02 15:50 UTC
parent
8f5354b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +369 -23
A internal/usecase/copy/scrollback.go +51 -0
@@ -0,0 +1,51 @@
1+package copy
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// Scrollback stores evicted terminal rows in a fixed-size ring buffer.
6+type Scrollback struct {
7+ rows [][]renderer.Cell
8+ head int
9+ len int
10+}
11+
12+// NewScrollback constructs a scrollback ring capped at capacity rows.
13+func NewScrollback(capacity int) *Scrollback {
14+ if capacity < 0 {
15+ capacity = 0
16+ }
17+ return &Scrollback{rows: make([][]renderer.Cell, capacity)}
18+}
19+
20+// Cap returns the maximum number of rows retained.
21+func (s *Scrollback) Cap() int { return len(s.rows) }
22+
23+// Len returns the number of retained rows.
24+func (s *Scrollback) Len() int { return s.len }
25+
26+// Head returns the physical ring slot holding the oldest retained row.
27+func (s *Scrollback) Head() int { return s.head }
28+
29+// Append stores a copy of row, evicting the oldest row when the ring is full.
30+func (s *Scrollback) Append(row []renderer.Cell) {
31+ if len(s.rows) == 0 {
32+ return
33+ }
34+ cp := append([]renderer.Cell(nil), row...)
35+ if s.len < len(s.rows) {
36+ s.rows[(s.head+s.len)%len(s.rows)] = cp
37+ s.len++
38+ return
39+ }
40+ s.rows[s.head] = cp
41+ s.head = (s.head + 1) % len(s.rows)
42+}
43+
44+// Row returns a copy of the retained row at logical index i, oldest first.
45+func (s *Scrollback) Row(i int) []renderer.Cell {
46+ if i < 0 || i >= s.len || len(s.rows) == 0 {
47+ return nil
48+ }
49+ row := s.rows[(s.head+i)%len(s.rows)]
50+ return append([]renderer.Cell(nil), row...)
51+}
@@ -0,0 +1,51 @@
1+package copy
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// Scrollback stores evicted terminal rows in a fixed-size ring buffer.
6+type Scrollback struct {
7+ rows [][]renderer.Cell
8+ head int
9+ len int
10+}
11+
12+// NewScrollback constructs a scrollback ring capped at capacity rows.
13+func NewScrollback(capacity int) *Scrollback {
14+ if capacity < 0 {
15+ capacity = 0
16+ }
17+ return &Scrollback{rows: make([][]renderer.Cell, capacity)}
18+}
19+
20+// Cap returns the maximum number of rows retained.
21+func (s *Scrollback) Cap() int { return len(s.rows) }
22+
23+// Len returns the number of retained rows.
24+func (s *Scrollback) Len() int { return s.len }
25+
26+// Head returns the physical ring slot holding the oldest retained row.
27+func (s *Scrollback) Head() int { return s.head }
28+
29+// Append stores a copy of row, evicting the oldest row when the ring is full.
30+func (s *Scrollback) Append(row []renderer.Cell) {
31+ if len(s.rows) == 0 {
32+ return
33+ }
34+ cp := append([]renderer.Cell(nil), row...)
35+ if s.len < len(s.rows) {
36+ s.rows[(s.head+s.len)%len(s.rows)] = cp
37+ s.len++
38+ return
39+ }
40+ s.rows[s.head] = cp
41+ s.head = (s.head + 1) % len(s.rows)
42+}
43+
44+// Row returns a copy of the retained row at logical index i, oldest first.
45+func (s *Scrollback) Row(i int) []renderer.Cell {
46+ if i < 0 || i >= s.len || len(s.rows) == 0 {
47+ return nil
48+ }
49+ row := s.rows[(s.head+i)%len(s.rows)]
50+ return append([]renderer.Cell(nil), row...)
51+}
A internal/usecase/copy/scrollback_test.go +112 -0
@@ -0,0 +1,112 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func row(text string) []renderer.Cell {
10+ cells := make([]renderer.Cell, len(text))
11+ for i, r := range text {
12+ cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
13+ }
14+ return cells
15+}
16+
17+func rowText(cells []renderer.Cell) string {
18+ runes := make([]rune, len(cells))
19+ for i, c := range cells {
20+ runes[i] = c.Rune
21+ }
22+ return string(runes)
23+}
24+
25+func TestScrollbackRing(t *testing.T) {
26+ tests := []struct {
27+ name string
28+ cap int
29+ append []string
30+ wantHead int
31+ wantLen int
32+ wantRows []string
33+ }{
34+ {
35+ name: "empty ring drops rows",
36+ cap: 0,
37+ append: []string{"aa", "bb"},
38+ wantHead: 0,
39+ wantLen: 0,
40+ },
41+ {
42+ name: "below capacity keeps head at zero",
43+ cap: 3,
44+ append: []string{"aa", "bb"},
45+ wantHead: 0,
46+ wantLen: 2,
47+ wantRows: []string{"aa", "bb"},
48+ },
49+ {
50+ name: "at capacity fills without rotating head",
51+ cap: 3,
52+ append: []string{"aa", "bb", "cc"},
53+ wantHead: 0,
54+ wantLen: 3,
55+ wantRows: []string{"aa", "bb", "cc"},
56+ },
57+ {
58+ name: "over capacity evicts oldest and rotates head",
59+ cap: 3,
60+ append: []string{"aa", "bb", "cc", "dd", "ee"},
61+ wantHead: 2,
62+ wantLen: 3,
63+ wantRows: []string{"cc", "dd", "ee"},
64+ },
65+ }
66+
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ sb := NewScrollback(tt.cap)
70+ for _, r := range tt.append {
71+ sb.Append(row(r))
72+ }
73+
74+ if got := sb.Cap(); got != tt.cap {
75+ t.Fatalf("Cap() = %d, want %d", got, tt.cap)
76+ }
77+ if got := sb.Head(); got != tt.wantHead {
78+ t.Fatalf("Head() = %d, want %d", got, tt.wantHead)
79+ }
80+ if got := sb.Len(); got != tt.wantLen {
81+ t.Fatalf("Len() = %d, want %d", got, tt.wantLen)
82+ }
83+ for i, want := range tt.wantRows {
84+ if got := rowText(sb.Row(i)); got != want {
85+ t.Fatalf("Row(%d) = %q, want %q", i, got, want)
86+ }
87+ }
88+ })
89+ }
90+}
91+
92+func TestScrollbackCopiesRows(t *testing.T) {
93+ tests := []struct {
94+ name string
95+ }{
96+ {name: "append copies caller row"},
97+ }
98+ for _, tt := range tests {
99+ t.Run(tt.name, func(t *testing.T) {
100+ sb := NewScrollback(1)
101+ r := row("ab")
102+ sb.Append(r)
103+ r[0].Rune = 'z'
104+
105+ got := sb.Row(0)
106+ got[1].Rune = 'y'
107+ if text := rowText(sb.Row(0)); text != "ab" {
108+ t.Fatalf("stored row = %q, want copy unaffected by caller/read mutation", text)
109+ }
110+ })
111+ }
112+}
@@ -0,0 +1,112 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func row(text string) []renderer.Cell {
10+ cells := make([]renderer.Cell, len(text))
11+ for i, r := range text {
12+ cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
13+ }
14+ return cells
15+}
16+
17+func rowText(cells []renderer.Cell) string {
18+ runes := make([]rune, len(cells))
19+ for i, c := range cells {
20+ runes[i] = c.Rune
21+ }
22+ return string(runes)
23+}
24+
25+func TestScrollbackRing(t *testing.T) {
26+ tests := []struct {
27+ name string
28+ cap int
29+ append []string
30+ wantHead int
31+ wantLen int
32+ wantRows []string
33+ }{
34+ {
35+ name: "empty ring drops rows",
36+ cap: 0,
37+ append: []string{"aa", "bb"},
38+ wantHead: 0,
39+ wantLen: 0,
40+ },
41+ {
42+ name: "below capacity keeps head at zero",
43+ cap: 3,
44+ append: []string{"aa", "bb"},
45+ wantHead: 0,
46+ wantLen: 2,
47+ wantRows: []string{"aa", "bb"},
48+ },
49+ {
50+ name: "at capacity fills without rotating head",
51+ cap: 3,
52+ append: []string{"aa", "bb", "cc"},
53+ wantHead: 0,
54+ wantLen: 3,
55+ wantRows: []string{"aa", "bb", "cc"},
56+ },
57+ {
58+ name: "over capacity evicts oldest and rotates head",
59+ cap: 3,
60+ append: []string{"aa", "bb", "cc", "dd", "ee"},
61+ wantHead: 2,
62+ wantLen: 3,
63+ wantRows: []string{"cc", "dd", "ee"},
64+ },
65+ }
66+
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ sb := NewScrollback(tt.cap)
70+ for _, r := range tt.append {
71+ sb.Append(row(r))
72+ }
73+
74+ if got := sb.Cap(); got != tt.cap {
75+ t.Fatalf("Cap() = %d, want %d", got, tt.cap)
76+ }
77+ if got := sb.Head(); got != tt.wantHead {
78+ t.Fatalf("Head() = %d, want %d", got, tt.wantHead)
79+ }
80+ if got := sb.Len(); got != tt.wantLen {
81+ t.Fatalf("Len() = %d, want %d", got, tt.wantLen)
82+ }
83+ for i, want := range tt.wantRows {
84+ if got := rowText(sb.Row(i)); got != want {
85+ t.Fatalf("Row(%d) = %q, want %q", i, got, want)
86+ }
87+ }
88+ })
89+ }
90+}
91+
92+func TestScrollbackCopiesRows(t *testing.T) {
93+ tests := []struct {
94+ name string
95+ }{
96+ {name: "append copies caller row"},
97+ }
98+ for _, tt := range tests {
99+ t.Run(tt.name, func(t *testing.T) {
100+ sb := NewScrollback(1)
101+ r := row("ab")
102+ sb.Append(r)
103+ r[0].Rune = 'z'
104+
105+ got := sb.Row(0)
106+ got[1].Rune = 'y'
107+ if text := rowText(sb.Row(0)); text != "ab" {
108+ t.Fatalf("stored row = %q, want copy unaffected by caller/read mutation", text)
109+ }
110+ })
111+ }
112+}
M internal/usecase/daemon/daemon.go +27 -19
@@ -39,6 +39,7 @@ import (
3939
4040 "github.com/bnema/vev/internal/domain"
4141 "github.com/bnema/vev/internal/ports"
42+ scopy "github.com/bnema/vev/internal/usecase/copy"
4243 "github.com/bnema/vev/internal/usecase/keys"
4344 "github.com/bnema/vev/pkg/renderer"
4445 "github.com/bnema/vev/pkg/vt"
@@ -52,6 +53,9 @@ const debounceInterval = 10 * time.Millisecond
5253 // ptyReadBufSize is the PTY reader's read buffer.
5354 const ptyReadBufSize = 32 * 1024
5455
56+// defaultScrollbackRows is the per-window retained history size.
57+const defaultScrollbackRows = 10_000
58+
5559 // detachNotifyTimeout bounds the best-effort Detached notification on the
5660 // detach/kill/shutdown paths: if a wedged client (full kernel send buffer)
5761 // blocks the write for this long, its transport is force-closed, which fails
@@ -123,13 +127,14 @@ type session struct {
123127 // applies back-pressure by collapsing (a full buffer means "a render is
124128 // already pending"), never by blocking the reader.
125129 type window struct {
126- pty ports.PTY
127- mu sync.Mutex // guards screen and every attached renderer's shadow
128- screen *vt.Screen
129- dirty chan struct{}
130- size domain.Size
131- ctx context.Context
132- cancel context.CancelFunc
130+ pty ports.PTY
131+ mu sync.Mutex // guards screen and every attached renderer's shadow
132+ screen *vt.Screen
133+ scrollback *scopy.Scrollback
134+ dirty chan struct{}
135+ size domain.Size
136+ ctx context.Context
137+ cancel context.CancelFunc
133138 }
134139
135140 // attachedClient is a client currently attached to a session's window. rend is
@@ -501,12 +506,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
501506 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
502507 d.nextID++
503508
504- win := &window{
505- pty: pty,
506- screen: vt.NewScreen(winSize.Cols, winSize.Rows),
507- dirty: make(chan struct{}, 1),
508- size: winSize,
509- }
509+ win := newWindow(pty, winSize)
510510 sctx, cancel := context.WithCancel(d.serveCtx)
511511 win.ctx, win.cancel = context.WithCancel(sctx)
512512 sess := &session{
@@ -528,12 +528,7 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
528528 if err != nil {
529529 return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err)
530530 }
531- win := &window{
532- pty: pty,
533- screen: vt.NewScreen(winSize.Cols, winSize.Rows),
534- dirty: make(chan struct{}, 1),
535- size: winSize,
536- }
531+ win := newWindow(pty, winSize)
537532 win.ctx, win.cancel = context.WithCancel(sess.ctx)
538533 sess.mu.Lock()
539534 sess.windows = append(sess.windows, win)
@@ -543,6 +538,19 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
543538 return nil
544539 }
545540
541+func newWindow(pty ports.PTY, sz domain.Size) *window {
542+ sb := scopy.NewScrollback(defaultScrollbackRows)
543+ screen := vt.NewScreen(sz.Cols, sz.Rows)
544+ screen.OnLineEvicted = sb.Append
545+ return &window{
546+ pty: pty,
547+ screen: screen,
548+ scrollback: sb,
549+ dirty: make(chan struct{}, 1),
550+ size: sz,
551+ }
552+}
553+
546554 func windowSize(clientSize domain.Size) domain.Size {
547555 if !clientSize.Valid() {
548556 clientSize = defaultSize
@@ -39,6 +39,7 @@ import (
39 39
40 "github.com/bnema/vev/internal/domain"40 "github.com/bnema/vev/internal/domain"
41 "github.com/bnema/vev/internal/ports"41 "github.com/bnema/vev/internal/ports"
42+ scopy "github.com/bnema/vev/internal/usecase/copy"
42 "github.com/bnema/vev/internal/usecase/keys"43 "github.com/bnema/vev/internal/usecase/keys"
43 "github.com/bnema/vev/pkg/renderer"44 "github.com/bnema/vev/pkg/renderer"
44 "github.com/bnema/vev/pkg/vt"45 "github.com/bnema/vev/pkg/vt"
@@ -52,6 +53,9 @@ const debounceInterval = 10 * time.Millisecond
52 // ptyReadBufSize is the PTY reader's read buffer.53 // ptyReadBufSize is the PTY reader's read buffer.
53 const ptyReadBufSize = 32 * 102454 const ptyReadBufSize = 32 * 1024
54 55
56+// defaultScrollbackRows is the per-window retained history size.
57+const defaultScrollbackRows = 10_000
58+
55 // detachNotifyTimeout bounds the best-effort Detached notification on the59 // detachNotifyTimeout bounds the best-effort Detached notification on the
56 // detach/kill/shutdown paths: if a wedged client (full kernel send buffer)60 // detach/kill/shutdown paths: if a wedged client (full kernel send buffer)
57 // blocks the write for this long, its transport is force-closed, which fails61 // blocks the write for this long, its transport is force-closed, which fails
@@ -123,13 +127,14 @@ type session struct {
123 // applies back-pressure by collapsing (a full buffer means "a render is127 // applies back-pressure by collapsing (a full buffer means "a render is
124 // already pending"), never by blocking the reader.128 // already pending"), never by blocking the reader.
125 type window struct {129 type window struct {
126- pty ports.PTY130+ pty ports.PTY
127- mu sync.Mutex // guards screen and every attached renderer's shadow131+ mu sync.Mutex // guards screen and every attached renderer's shadow
128- screen *vt.Screen132+ screen *vt.Screen
129- dirty chan struct{}133+ scrollback *scopy.Scrollback
130- size domain.Size134+ dirty chan struct{}
131- ctx context.Context135+ size domain.Size
132- cancel context.CancelFunc136+ ctx context.Context
137+ cancel context.CancelFunc
133 }138 }
134 139
135 // attachedClient is a client currently attached to a session's window. rend is140 // attachedClient is a client currently attached to a session's window. rend is
@@ -501,12 +506,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
501 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))506 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
502 d.nextID++507 d.nextID++
503 508
504- win := &window{509+ win := newWindow(pty, winSize)
505- pty: pty,
506- screen: vt.NewScreen(winSize.Cols, winSize.Rows),
507- dirty: make(chan struct{}, 1),
508- size: winSize,
509- }
510 sctx, cancel := context.WithCancel(d.serveCtx)510 sctx, cancel := context.WithCancel(d.serveCtx)
511 win.ctx, win.cancel = context.WithCancel(sctx)511 win.ctx, win.cancel = context.WithCancel(sctx)
512 sess := &session{512 sess := &session{
@@ -528,12 +528,7 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
528 if err != nil {528 if err != nil {
529 return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err)529 return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err)
530 }530 }
531- win := &window{531+ win := newWindow(pty, winSize)
532- pty: pty,
533- screen: vt.NewScreen(winSize.Cols, winSize.Rows),
534- dirty: make(chan struct{}, 1),
535- size: winSize,
536- }
537 win.ctx, win.cancel = context.WithCancel(sess.ctx)532 win.ctx, win.cancel = context.WithCancel(sess.ctx)
538 sess.mu.Lock()533 sess.mu.Lock()
539 sess.windows = append(sess.windows, win)534 sess.windows = append(sess.windows, win)
@@ -543,6 +538,19 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
543 return nil538 return nil
544 }539 }
545 540
541+func newWindow(pty ports.PTY, sz domain.Size) *window {
542+ sb := scopy.NewScrollback(defaultScrollbackRows)
543+ screen := vt.NewScreen(sz.Cols, sz.Rows)
544+ screen.OnLineEvicted = sb.Append
545+ return &window{
546+ pty: pty,
547+ screen: screen,
548+ scrollback: sb,
549+ dirty: make(chan struct{}, 1),
550+ size: sz,
551+ }
552+}
553+
546 func windowSize(clientSize domain.Size) domain.Size {554 func windowSize(clientSize domain.Size) domain.Size {
547 if !clientSize.Valid() {555 if !clientSize.Valid() {
548 clientSize = defaultSize556 clientSize = defaultSize
A internal/usecase/daemon/scrollback_test.go +49 -0
@@ -0,0 +1,49 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func TestNewWindowInitializesScrollback(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ write []byte
13+ wantLen int
14+ wantRow string
15+ }{
16+ {
17+ name: "window owns scrollback wired to vt eviction callback",
18+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
19+ wantLen: 1,
20+ wantRow: "AAAA",
21+ },
22+ }
23+
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ win := newWindow(nil, domain.Size{Cols: 4, Rows: 3})
27+ if win.scrollback == nil {
28+ t.Fatal("scrollback is nil")
29+ }
30+ if win.scrollback.Cap() != defaultScrollbackRows {
31+ t.Fatalf("scrollback cap = %d, want %d", win.scrollback.Cap(), defaultScrollbackRows)
32+ }
33+
34+ win.screen.Write(tt.write)
35+
36+ if got := win.scrollback.Len(); got != tt.wantLen {
37+ t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
38+ }
39+ row := win.scrollback.Row(0)
40+ runes := make([]rune, len(row))
41+ for i, c := range row {
42+ runes[i] = c.Rune
43+ }
44+ if got := string(runes); got != tt.wantRow {
45+ t.Fatalf("scrollback row = %q, want %q", got, tt.wantRow)
46+ }
47+ })
48+ }
49+}
@@ -0,0 +1,49 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func TestNewWindowInitializesScrollback(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ write []byte
13+ wantLen int
14+ wantRow string
15+ }{
16+ {
17+ name: "window owns scrollback wired to vt eviction callback",
18+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
19+ wantLen: 1,
20+ wantRow: "AAAA",
21+ },
22+ }
23+
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ win := newWindow(nil, domain.Size{Cols: 4, Rows: 3})
27+ if win.scrollback == nil {
28+ t.Fatal("scrollback is nil")
29+ }
30+ if win.scrollback.Cap() != defaultScrollbackRows {
31+ t.Fatalf("scrollback cap = %d, want %d", win.scrollback.Cap(), defaultScrollbackRows)
32+ }
33+
34+ win.screen.Write(tt.write)
35+
36+ if got := win.scrollback.Len(); got != tt.wantLen {
37+ t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
38+ }
39+ row := win.scrollback.Row(0)
40+ runes := make([]rune, len(row))
41+ for i, c := range row {
42+ runes[i] = c.Rune
43+ }
44+ if got := string(runes); got != tt.wantRow {
45+ t.Fatalf("scrollback row = %q, want %q", got, tt.wantRow)
46+ }
47+ })
48+ }
49+}
M pkg/vt/screen.go +18 -4
@@ -29,10 +29,14 @@ type screenState struct {
2929 const maxEscapeBufferLen = 4096
3030
3131 type Screen struct {
32- Frame renderer.Frame
33- Row int
34- Col int
35- Style renderer.Style
32+ Frame renderer.Frame
33+ Row int
34+ Col int
35+ Style renderer.Style
36+ // OnLineEvicted is called just before a full-width upward scroll recycles
37+ // and blanks rows. The callback receives a stable copy of each evicted row.
38+ OnLineEvicted func([]renderer.Cell)
39+
3640 damage []renderer.Damage
3741 escapeBuf []byte
3842
@@ -463,11 +467,21 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) {
463467 // VT scroll regions always span the full frame width, so we rotate the
464468 // frame's line offsets (recycling and blanking the evicted rows in place)
465469 // instead of copying cells. See renderer.Frame.ScrollUp.
470+ s.emitLineEvicted(top, n)
466471 s.Frame.ScrollUp(top, bottom, n)
467472 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
468473 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
469474 }
470475
476+func (s *Screen) emitLineEvicted(top, n int) {
477+ if s.OnLineEvicted == nil || s.alternate != nil {
478+ return
479+ }
480+ for y := top; y < top+n; y++ {
481+ s.OnLineEvicted(append([]renderer.Cell(nil), s.Frame.Row(y)...))
482+ }
483+}
484+
471485 func (s *Screen) scrollDownRegion(top, bottom, n int) {
472486 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
473487 return
@@ -29,10 +29,14 @@ type screenState struct {
29 const maxEscapeBufferLen = 409629 const maxEscapeBufferLen = 4096
30 30
31 type Screen struct {31 type Screen struct {
32- Frame renderer.Frame32+ Frame renderer.Frame
33- Row int33+ Row int
34- Col int34+ Col int
35- Style renderer.Style35+ Style renderer.Style
36+ // OnLineEvicted is called just before a full-width upward scroll recycles
37+ // and blanks rows. The callback receives a stable copy of each evicted row.
38+ OnLineEvicted func([]renderer.Cell)
39+
36 damage []renderer.Damage40 damage []renderer.Damage
37 escapeBuf []byte41 escapeBuf []byte
38 42
@@ -463,11 +467,21 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) {
463 // VT scroll regions always span the full frame width, so we rotate the467 // VT scroll regions always span the full frame width, so we rotate the
464 // frame's line offsets (recycling and blanking the evicted rows in place)468 // frame's line offsets (recycling and blanking the evicted rows in place)
465 // instead of copying cells. See renderer.Frame.ScrollUp.469 // instead of copying cells. See renderer.Frame.ScrollUp.
470+ s.emitLineEvicted(top, n)
466 s.Frame.ScrollUp(top, bottom, n)471 s.Frame.ScrollUp(top, bottom, n)
467 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})472 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
468 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})473 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
469 }474 }
470 475
476+func (s *Screen) emitLineEvicted(top, n int) {
477+ if s.OnLineEvicted == nil || s.alternate != nil {
478+ return
479+ }
480+ for y := top; y < top+n; y++ {
481+ s.OnLineEvicted(append([]renderer.Cell(nil), s.Frame.Row(y)...))
482+ }
483+}
484+
471 func (s *Screen) scrollDownRegion(top, bottom, n int) {485 func (s *Screen) scrollDownRegion(top, bottom, n int) {
472 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {486 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
473 return487 return
A pkg/vt/scrollback_test.go +112 -0
@@ -0,0 +1,112 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func evictedTexts(rows [][]renderer.Cell) []string {
10+ out := make([]string, len(rows))
11+ for i, row := range rows {
12+ runes := make([]rune, len(row))
13+ for x, c := range row {
14+ runes[x] = c.Rune
15+ }
16+ out[i] = string(runes)
17+ }
18+ return out
19+}
20+
21+func TestOnLineEvicted(t *testing.T) {
22+ tests := []struct {
23+ name string
24+ write []byte
25+ wantRows []string
26+ wantFrame []string
27+ }{
28+ {
29+ name: "newline scroll evicts top row",
30+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
31+ wantRows: []string{"AAAA"},
32+ wantFrame: []string{"BBBB", "CCCC", " "},
33+ },
34+ {
35+ name: "CSI S count evicts each top row in order",
36+ write: []byte("AAAA\r\nBBBB\r\nCCCC\x1b[2S"),
37+ wantRows: []string{"AAAA", "BBBB"},
38+ wantFrame: []string{"CCCC", " ", " "},
39+ },
40+ }
41+
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ s := NewScreen(4, 3)
45+ var evicted [][]renderer.Cell
46+ s.OnLineEvicted = func(row []renderer.Cell) {
47+ evicted = append(evicted, append([]renderer.Cell(nil), row...))
48+ }
49+
50+ s.Write(tt.write)
51+
52+ if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) {
53+ t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows)
54+ }
55+ for y, want := range tt.wantFrame {
56+ if got := lineText(s, y); got != want {
57+ t.Fatalf("line %d = %q, want %q", y, got, want)
58+ }
59+ }
60+ })
61+ }
62+}
63+
64+func TestOnLineEvictedAltScreenAndRotation(t *testing.T) {
65+ tests := []struct {
66+ name string
67+ write []byte
68+ wantRows []string
69+ }{
70+ {
71+ name: "alternate screen scroll does not evict",
72+ write: []byte("main\x1b[?1049hAAAA\r\nBBBB\r\nCCCC\r\nDDDD\x1b[?1049lEEEE\r\n"),
73+ wantRows: nil,
74+ },
75+ {
76+ name: "evicted row follows logical top after prior lineOffset rotation",
77+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\n"),
78+ wantRows: []string{"AAAA", "BBBB"},
79+ },
80+ }
81+
82+ for _, tt := range tests {
83+ t.Run(tt.name, func(t *testing.T) {
84+ s := NewScreen(4, 3)
85+ var evicted [][]renderer.Cell
86+ s.OnLineEvicted = func(row []renderer.Cell) {
87+ evicted = append(evicted, append([]renderer.Cell(nil), row...))
88+ }
89+
90+ s.Write(tt.write)
91+
92+ if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) {
93+ t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows)
94+ }
95+ if err := s.Frame.CheckInvariants(); err != nil {
96+ t.Fatalf("frame invariants after scrollback callback: %v", err)
97+ }
98+ })
99+ }
100+}
101+
102+func equalStrings(a, b []string) bool {
103+ if len(a) != len(b) {
104+ return false
105+ }
106+ for i := range a {
107+ if a[i] != b[i] {
108+ return false
109+ }
110+ }
111+ return true
112+}
@@ -0,0 +1,112 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func evictedTexts(rows [][]renderer.Cell) []string {
10+ out := make([]string, len(rows))
11+ for i, row := range rows {
12+ runes := make([]rune, len(row))
13+ for x, c := range row {
14+ runes[x] = c.Rune
15+ }
16+ out[i] = string(runes)
17+ }
18+ return out
19+}
20+
21+func TestOnLineEvicted(t *testing.T) {
22+ tests := []struct {
23+ name string
24+ write []byte
25+ wantRows []string
26+ wantFrame []string
27+ }{
28+ {
29+ name: "newline scroll evicts top row",
30+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
31+ wantRows: []string{"AAAA"},
32+ wantFrame: []string{"BBBB", "CCCC", " "},
33+ },
34+ {
35+ name: "CSI S count evicts each top row in order",
36+ write: []byte("AAAA\r\nBBBB\r\nCCCC\x1b[2S"),
37+ wantRows: []string{"AAAA", "BBBB"},
38+ wantFrame: []string{"CCCC", " ", " "},
39+ },
40+ }
41+
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ s := NewScreen(4, 3)
45+ var evicted [][]renderer.Cell
46+ s.OnLineEvicted = func(row []renderer.Cell) {
47+ evicted = append(evicted, append([]renderer.Cell(nil), row...))
48+ }
49+
50+ s.Write(tt.write)
51+
52+ if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) {
53+ t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows)
54+ }
55+ for y, want := range tt.wantFrame {
56+ if got := lineText(s, y); got != want {
57+ t.Fatalf("line %d = %q, want %q", y, got, want)
58+ }
59+ }
60+ })
61+ }
62+}
63+
64+func TestOnLineEvictedAltScreenAndRotation(t *testing.T) {
65+ tests := []struct {
66+ name string
67+ write []byte
68+ wantRows []string
69+ }{
70+ {
71+ name: "alternate screen scroll does not evict",
72+ write: []byte("main\x1b[?1049hAAAA\r\nBBBB\r\nCCCC\r\nDDDD\x1b[?1049lEEEE\r\n"),
73+ wantRows: nil,
74+ },
75+ {
76+ name: "evicted row follows logical top after prior lineOffset rotation",
77+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\n"),
78+ wantRows: []string{"AAAA", "BBBB"},
79+ },
80+ }
81+
82+ for _, tt := range tests {
83+ t.Run(tt.name, func(t *testing.T) {
84+ s := NewScreen(4, 3)
85+ var evicted [][]renderer.Cell
86+ s.OnLineEvicted = func(row []renderer.Cell) {
87+ evicted = append(evicted, append([]renderer.Cell(nil), row...))
88+ }
89+
90+ s.Write(tt.write)
91+
92+ if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) {
93+ t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows)
94+ }
95+ if err := s.Frame.CheckInvariants(); err != nil {
96+ t.Fatalf("frame invariants after scrollback callback: %v", err)
97+ }
98+ })
99+ }
100+}
101+
102+func equalStrings(a, b []string) bool {
103+ if len(a) != len(b) {
104+ return false
105+ }
106+ for i := range a {
107+ if a[i] != b[i] {
108+ return false
109+ }
110+ }
111+ return true
112+}

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