detached 89d8fa0

refactor(snapshot): replace v2 snapshots with v3 chunks

commit
89d8fa0303d60ba9b6cc5733b9aaf6de13260ae3
author
brice · · 2026-07-12 15:58 UTC
parent
d2b05f9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
25 files · +732 -1336
M internal/usecase/copy/mode.go +13 -12
@@ -7,6 +7,7 @@ import (
77 "unicode"
88
99 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
1011 )
1112
1213 // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits
@@ -17,20 +18,20 @@ import (
1718 const OSC52MaxPayloadBytes = 75_000
1819
1920 // Snapshot is the immutable scrollback-mode document: history followed by the
20-// visible screen. History rows are immutable by Scrollback's append contract;
21-// the visible frame is cloned once when the snapshot is constructed.
21+// visible screen. VT HistoryView retains immutable chunks; the visible frame is
22+// cloned once when the snapshot is constructed.
2223 type Snapshot struct {
23- history HistoryView
24+ history vt.HistoryView
2425 screen renderer.Frame
2526 Width int
2627 Height int
2728 }
2829
29-// NewSnapshot freezes the current scrollback view and clones the visible screen.
30-func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
31- var history HistoryView
32- if sb != nil {
33- history = sb.View()
30+// NewSnapshot freezes the current VT history view and clones the visible screen.
31+func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
32+ var history vt.HistoryView
33+ if historySource != nil {
34+ history = historySource.View()
3435 }
3536 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
3637 }
@@ -39,11 +40,11 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
3940 // It is intended for callers that already have a complete document rather than
4041 // a scrollback and visible frame.
4142 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
42- owned := make([][]renderer.Cell, len(rows))
43- for i, row := range rows {
44- owned[i] = append([]renderer.Cell(nil), row...)
43+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256})
44+ for _, row := range rows {
45+ history.Append(row)
4546 }
46- return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height}
47+ return Snapshot{history: history.SealAndView(), Width: width, Height: height}
4748 }
4849
4950 // Len returns the number of document rows.
@@ -7,6 +7,7 @@ import (
7 "unicode"7 "unicode"
8 8
9 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
10 )11 )
11 12
12 // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits13 // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits
@@ -17,20 +18,20 @@ import (
17 const OSC52MaxPayloadBytes = 75_00018 const OSC52MaxPayloadBytes = 75_000
18 19
19 // Snapshot is the immutable scrollback-mode document: history followed by the20 // Snapshot is the immutable scrollback-mode document: history followed by the
20-// visible screen. History rows are immutable by Scrollback's append contract;21+// visible screen. VT HistoryView retains immutable chunks; the visible frame is
21-// the visible frame is cloned once when the snapshot is constructed.22+// cloned once when the snapshot is constructed.
22 type Snapshot struct {23 type Snapshot struct {
23- history HistoryView24+ history vt.HistoryView
24 screen renderer.Frame25 screen renderer.Frame
25 Width int26 Width int
26 Height int27 Height int
27 }28 }
28 29
29-// NewSnapshot freezes the current scrollback view and clones the visible screen.30+// NewSnapshot freezes the current VT history view and clones the visible screen.
30-func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {31+func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
31- var history HistoryView32+ var history vt.HistoryView
32- if sb != nil {33+ if historySource != nil {
33- history = sb.View()34+ history = historySource.View()
34 }35 }
35 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}36 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
36 }37 }
@@ -39,11 +40,11 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
39 // It is intended for callers that already have a complete document rather than40 // It is intended for callers that already have a complete document rather than
40 // a scrollback and visible frame.41 // a scrollback and visible frame.
41 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {42 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
42- owned := make([][]renderer.Cell, len(rows))43+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256})
43- for i, row := range rows {44+ for _, row := range rows {
44- owned[i] = append([]renderer.Cell(nil), row...)45+ history.Append(row)
45 }46 }
46- return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height}47+ return Snapshot{history: history.SealAndView(), Width: width, Height: height}
47 }48 }
48 49
49 // Len returns the number of document rows.50 // Len returns the number of document rows.
M internal/usecase/copy/mode_test.go +12 -1
@@ -7,11 +7,22 @@ import (
77 "testing"
88
99 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
1011 "github.com/stretchr/testify/require"
1112 )
1213
1314 var benchmarkSnapshotSink Snapshot
1415
16+func row(text string) []renderer.Cell {
17+ result := make([]renderer.Cell, 0, len([]rune(text)))
18+ for _, r := range text {
19+ result = append(result, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
20+ }
21+ return result
22+}
23+
24+func newHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
25+
1526 func snapshot(lines []string, height int) Snapshot {
1627 rows := make([][]renderer.Cell, len(lines))
1728 for i, line := range lines {
@@ -485,7 +496,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) {
485496 historyRows = 10_000
486497 )
487498
488- scrollback := NewScrollback(historyRows)
499+ scrollback := newHistory(historyRows)
489500 for range historyRows {
490501 scrollback.Append(row(strings.Repeat("x", width)))
491502 }
@@ -7,11 +7,22 @@ import (
7 "testing"7 "testing"
8 8
9 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 )12 )
12 13
13 var benchmarkSnapshotSink Snapshot14 var benchmarkSnapshotSink Snapshot
14 15
16+func row(text string) []renderer.Cell {
17+ result := make([]renderer.Cell, 0, len([]rune(text)))
18+ for _, r := range text {
19+ result = append(result, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
20+ }
21+ return result
22+}
23+
24+func newHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
25+
15 func snapshot(lines []string, height int) Snapshot {26 func snapshot(lines []string, height int) Snapshot {
16 rows := make([][]renderer.Cell, len(lines))27 rows := make([][]renderer.Cell, len(lines))
17 for i, line := range lines {28 for i, line := range lines {
@@ -485,7 +496,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) {
485 historyRows = 10_000496 historyRows = 10_000
486 )497 )
487 498
488- scrollback := NewScrollback(historyRows)499+ scrollback := newHistory(historyRows)
489 for range historyRows {500 for range historyRows {
490 scrollback.Append(row(strings.Repeat("x", width)))501 scrollback.Append(row(strings.Repeat("x", width)))
491 }502 }
D internal/usecase/copy/scrollback.go +0 -83
@@ -1,83 +0,0 @@
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-// Snapshot returns retained rows oldest-first.
45-//
46-// The returned outer slice is a copy, but row slices are shared with the
47-// scrollback storage. Callers must treat the returned rows as read-only.
48-func (s *Scrollback) Snapshot() [][]renderer.Cell {
49- if s.len == 0 || len(s.rows) == 0 {
50- return nil
51- }
52- rows := make([][]renderer.Cell, s.len)
53- for i := range s.len {
54- rows[i] = s.rows[(s.head+i)%len(s.rows)]
55- }
56- return rows
57-}
58-
59-// HistoryView is a frozen oldest-first view of scrollback rows.
60-//
61-// Its rows share cell slices with the scrollback at the time View is called.
62-// Callers must treat rows returned by Row as read-only.
63-type HistoryView struct {
64- rows [][]renderer.Cell
65-}
66-
67-// View returns a frozen scrollback view. Appending to the scrollback cannot
68-// alter the view because Append replaces ring slots rather than mutating rows.
69-func (s *Scrollback) View() HistoryView {
70- return HistoryView{rows: s.Snapshot()}
71-}
72-
73-// Len returns the number of rows in the view.
74-func (v HistoryView) Len() int { return len(v.rows) }
75-
76-// Row returns the row at logical index i, oldest first, or nil when i is out
77-// of range. Returned rows are shared with the view and must be read-only.
78-func (v HistoryView) Row(i int) []renderer.Cell {
79- if i < 0 || i >= len(v.rows) {
80- return nil
81- }
82- return v.rows[i]
83-}
@@ -1,83 +0,0 @@
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-// Snapshot returns retained rows oldest-first.
45-//
46-// The returned outer slice is a copy, but row slices are shared with the
47-// scrollback storage. Callers must treat the returned rows as read-only.
48-func (s *Scrollback) Snapshot() [][]renderer.Cell {
49- if s.len == 0 || len(s.rows) == 0 {
50- return nil
51- }
52- rows := make([][]renderer.Cell, s.len)
53- for i := range s.len {
54- rows[i] = s.rows[(s.head+i)%len(s.rows)]
55- }
56- return rows
57-}
58-
59-// HistoryView is a frozen oldest-first view of scrollback rows.
60-//
61-// Its rows share cell slices with the scrollback at the time View is called.
62-// Callers must treat rows returned by Row as read-only.
63-type HistoryView struct {
64- rows [][]renderer.Cell
65-}
66-
67-// View returns a frozen scrollback view. Appending to the scrollback cannot
68-// alter the view because Append replaces ring slots rather than mutating rows.
69-func (s *Scrollback) View() HistoryView {
70- return HistoryView{rows: s.Snapshot()}
71-}
72-
73-// Len returns the number of rows in the view.
74-func (v HistoryView) Len() int { return len(v.rows) }
75-
76-// Row returns the row at logical index i, oldest first, or nil when i is out
77-// of range. Returned rows are shared with the view and must be read-only.
78-func (v HistoryView) Row(i int) []renderer.Cell {
79- if i < 0 || i >= len(v.rows) {
80- return nil
81- }
82- return v.rows[i]
83-}
D internal/usecase/copy/scrollback_test.go +0 -209
@@ -1,209 +0,0 @@
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- runes := []rune(text)
11- cells := make([]renderer.Cell, len(runes))
12- for i, r := range runes {
13- cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
14- }
15- return cells
16-}
17-
18-func rowText(cells []renderer.Cell) string {
19- runes := make([]rune, len(cells))
20- for i, c := range cells {
21- runes[i] = c.Rune
22- }
23- return string(runes)
24-}
25-
26-func TestScrollbackRing(t *testing.T) {
27- tests := []struct {
28- name string
29- cap int
30- append []string
31- wantHead int
32- wantLen int
33- wantRows []string
34- }{
35- {
36- name: "empty ring drops rows",
37- cap: 0,
38- append: []string{"aa", "bb"},
39- wantHead: 0,
40- wantLen: 0,
41- },
42- {
43- name: "below capacity keeps head at zero",
44- cap: 3,
45- append: []string{"aa", "bb"},
46- wantHead: 0,
47- wantLen: 2,
48- wantRows: []string{"aa", "bb"},
49- },
50- {
51- name: "at capacity fills without rotating head",
52- cap: 3,
53- append: []string{"aa", "bb", "cc"},
54- wantHead: 0,
55- wantLen: 3,
56- wantRows: []string{"aa", "bb", "cc"},
57- },
58- {
59- name: "over capacity evicts oldest and rotates head",
60- cap: 3,
61- append: []string{"aa", "bb", "cc", "dd", "ee"},
62- wantHead: 2,
63- wantLen: 3,
64- wantRows: []string{"cc", "dd", "ee"},
65- },
66- }
67-
68- for _, tt := range tests {
69- t.Run(tt.name, func(t *testing.T) {
70- sb := NewScrollback(tt.cap)
71- for _, r := range tt.append {
72- sb.Append(row(r))
73- }
74-
75- if got := sb.Cap(); got != tt.cap {
76- t.Fatalf("Cap() = %d, want %d", got, tt.cap)
77- }
78- if got := sb.Head(); got != tt.wantHead {
79- t.Fatalf("Head() = %d, want %d", got, tt.wantHead)
80- }
81- if got := sb.Len(); got != tt.wantLen {
82- t.Fatalf("Len() = %d, want %d", got, tt.wantLen)
83- }
84- for i, want := range tt.wantRows {
85- if got := rowText(sb.View().Row(i)); got != want {
86- t.Fatalf("Row(%d) = %q, want %q", i, got, want)
87- }
88- }
89- })
90- }
91-}
92-
93-func TestScrollbackCopiesRows(t *testing.T) {
94- tests := []struct {
95- name string
96- }{
97- {name: "append copies caller row"},
98- }
99- for _, tt := range tests {
100- t.Run(tt.name, func(t *testing.T) {
101- sb := NewScrollback(1)
102- r := row("ab")
103- sb.Append(r)
104- r[0].Rune = 'z'
105-
106- if text := rowText(sb.View().Row(0)); text != "ab" {
107- t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text)
108- }
109- })
110- }
111-}
112-
113-func TestScrollbackSnapshot(t *testing.T) {
114- tests := []struct {
115- name string
116- cap int
117- append []string
118- wantRows []string
119- }{
120- {
121- name: "empty capacity snapshots no rows",
122- cap: 0,
123- append: []string{"aa", "bb"},
124- wantRows: nil,
125- },
126- {
127- name: "oldest first after ring wrap",
128- cap: 3,
129- append: []string{"aa", "bb", "cc", "dd"},
130- wantRows: []string{"bb", "cc", "dd"},
131- },
132- }
133-
134- for _, tt := range tests {
135- t.Run(tt.name, func(t *testing.T) {
136- sb := NewScrollback(tt.cap)
137- for _, r := range tt.append {
138- sb.Append(row(r))
139- }
140-
141- got := sb.Snapshot()
142- if len(got) != len(tt.wantRows) {
143- t.Fatalf("Snapshot() len = %d, want %d", len(got), len(tt.wantRows))
144- }
145- for i, want := range tt.wantRows {
146- if text := rowText(got[i]); text != want {
147- t.Fatalf("Snapshot()[%d] = %q, want %q", i, text, want)
148- }
149- }
150- if len(got) > 0 {
151- got[0] = row("zz")
152- if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] {
153- t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
154- }
155- }
156- })
157- }
158-}
159-
160-func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) {
161- sb := NewScrollback(2)
162- sb.Append(row("aa"))
163- sb.Append(row("bb"))
164- snapshot := sb.Snapshot()
165-
166- sb.Append(row("cc"))
167- sb.Append(row("dd"))
168-
169- if text := rowText(snapshot[0]); text != "aa" {
170- t.Fatalf("snapshot[0] after append = %q, want original row", text)
171- }
172- if text := rowText(snapshot[1]); text != "bb" {
173- t.Fatalf("snapshot[1] after append = %q, want original row", text)
174- }
175-}
176-
177-func TestScrollbackViewSurvivesCompleteRingOverwrite(t *testing.T) {
178- sb := NewScrollback(2)
179- sb.Append(row("aa"))
180- sb.Append(row("bb"))
181- view := sb.View()
182- if &view.Row(0)[0] != &sb.rows[0][0] {
183- t.Fatal("View() deep-copied cells, want shared row storage")
184- }
185-
186- sb.Append(row("cc"))
187- sb.Append(row("dd"))
188-
189- if got := view.Len(); got != 2 {
190- t.Fatalf("View().Len() = %d, want 2", got)
191- }
192- for i, want := range []string{"aa", "bb"} {
193- if got := rowText(view.Row(i)); got != want {
194- t.Fatalf("View().Row(%d) = %q, want %q", i, got, want)
195- }
196- }
197-}
198-
199-func TestHistoryViewRowBounds(t *testing.T) {
200- sb := NewScrollback(1)
201- sb.Append(row("aa"))
202- view := sb.View()
203-
204- for _, i := range []int{-1, 1} {
205- if got := view.Row(i); got != nil {
206- t.Errorf("View().Row(%d) = %v, want nil", i, got)
207- }
208- }
209-}
@@ -1,209 +0,0 @@
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- runes := []rune(text)
11- cells := make([]renderer.Cell, len(runes))
12- for i, r := range runes {
13- cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
14- }
15- return cells
16-}
17-
18-func rowText(cells []renderer.Cell) string {
19- runes := make([]rune, len(cells))
20- for i, c := range cells {
21- runes[i] = c.Rune
22- }
23- return string(runes)
24-}
25-
26-func TestScrollbackRing(t *testing.T) {
27- tests := []struct {
28- name string
29- cap int
30- append []string
31- wantHead int
32- wantLen int
33- wantRows []string
34- }{
35- {
36- name: "empty ring drops rows",
37- cap: 0,
38- append: []string{"aa", "bb"},
39- wantHead: 0,
40- wantLen: 0,
41- },
42- {
43- name: "below capacity keeps head at zero",
44- cap: 3,
45- append: []string{"aa", "bb"},
46- wantHead: 0,
47- wantLen: 2,
48- wantRows: []string{"aa", "bb"},
49- },
50- {
51- name: "at capacity fills without rotating head",
52- cap: 3,
53- append: []string{"aa", "bb", "cc"},
54- wantHead: 0,
55- wantLen: 3,
56- wantRows: []string{"aa", "bb", "cc"},
57- },
58- {
59- name: "over capacity evicts oldest and rotates head",
60- cap: 3,
61- append: []string{"aa", "bb", "cc", "dd", "ee"},
62- wantHead: 2,
63- wantLen: 3,
64- wantRows: []string{"cc", "dd", "ee"},
65- },
66- }
67-
68- for _, tt := range tests {
69- t.Run(tt.name, func(t *testing.T) {
70- sb := NewScrollback(tt.cap)
71- for _, r := range tt.append {
72- sb.Append(row(r))
73- }
74-
75- if got := sb.Cap(); got != tt.cap {
76- t.Fatalf("Cap() = %d, want %d", got, tt.cap)
77- }
78- if got := sb.Head(); got != tt.wantHead {
79- t.Fatalf("Head() = %d, want %d", got, tt.wantHead)
80- }
81- if got := sb.Len(); got != tt.wantLen {
82- t.Fatalf("Len() = %d, want %d", got, tt.wantLen)
83- }
84- for i, want := range tt.wantRows {
85- if got := rowText(sb.View().Row(i)); got != want {
86- t.Fatalf("Row(%d) = %q, want %q", i, got, want)
87- }
88- }
89- })
90- }
91-}
92-
93-func TestScrollbackCopiesRows(t *testing.T) {
94- tests := []struct {
95- name string
96- }{
97- {name: "append copies caller row"},
98- }
99- for _, tt := range tests {
100- t.Run(tt.name, func(t *testing.T) {
101- sb := NewScrollback(1)
102- r := row("ab")
103- sb.Append(r)
104- r[0].Rune = 'z'
105-
106- if text := rowText(sb.View().Row(0)); text != "ab" {
107- t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text)
108- }
109- })
110- }
111-}
112-
113-func TestScrollbackSnapshot(t *testing.T) {
114- tests := []struct {
115- name string
116- cap int
117- append []string
118- wantRows []string
119- }{
120- {
121- name: "empty capacity snapshots no rows",
122- cap: 0,
123- append: []string{"aa", "bb"},
124- wantRows: nil,
125- },
126- {
127- name: "oldest first after ring wrap",
128- cap: 3,
129- append: []string{"aa", "bb", "cc", "dd"},
130- wantRows: []string{"bb", "cc", "dd"},
131- },
132- }
133-
134- for _, tt := range tests {
135- t.Run(tt.name, func(t *testing.T) {
136- sb := NewScrollback(tt.cap)
137- for _, r := range tt.append {
138- sb.Append(row(r))
139- }
140-
141- got := sb.Snapshot()
142- if len(got) != len(tt.wantRows) {
143- t.Fatalf("Snapshot() len = %d, want %d", len(got), len(tt.wantRows))
144- }
145- for i, want := range tt.wantRows {
146- if text := rowText(got[i]); text != want {
147- t.Fatalf("Snapshot()[%d] = %q, want %q", i, text, want)
148- }
149- }
150- if len(got) > 0 {
151- got[0] = row("zz")
152- if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] {
153- t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
154- }
155- }
156- })
157- }
158-}
159-
160-func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) {
161- sb := NewScrollback(2)
162- sb.Append(row("aa"))
163- sb.Append(row("bb"))
164- snapshot := sb.Snapshot()
165-
166- sb.Append(row("cc"))
167- sb.Append(row("dd"))
168-
169- if text := rowText(snapshot[0]); text != "aa" {
170- t.Fatalf("snapshot[0] after append = %q, want original row", text)
171- }
172- if text := rowText(snapshot[1]); text != "bb" {
173- t.Fatalf("snapshot[1] after append = %q, want original row", text)
174- }
175-}
176-
177-func TestScrollbackViewSurvivesCompleteRingOverwrite(t *testing.T) {
178- sb := NewScrollback(2)
179- sb.Append(row("aa"))
180- sb.Append(row("bb"))
181- view := sb.View()
182- if &view.Row(0)[0] != &sb.rows[0][0] {
183- t.Fatal("View() deep-copied cells, want shared row storage")
184- }
185-
186- sb.Append(row("cc"))
187- sb.Append(row("dd"))
188-
189- if got := view.Len(); got != 2 {
190- t.Fatalf("View().Len() = %d, want 2", got)
191- }
192- for i, want := range []string{"aa", "bb"} {
193- if got := rowText(view.Row(i)); got != want {
194- t.Fatalf("View().Row(%d) = %q, want %q", i, got, want)
195- }
196- }
197-}
198-
199-func TestHistoryViewRowBounds(t *testing.T) {
200- sb := NewScrollback(1)
201- sb.Append(row("aa"))
202- view := sb.View()
203-
204- for _, i := range []int{-1, 1} {
205- if got := view.Row(i); got != nil {
206- t.Errorf("View().Row(%d) = %v, want nil", i, got)
207- }
208- }
209-}
D internal/usecase/copy/snapshot_test.go +0 -85
@@ -1,85 +0,0 @@
1-package copy
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {
10- sb := NewScrollback(1)
11- sb.Append(row("old"))
12- screen := renderer.NewFrame(3, 1)
13- copy(screen.Row(0), row("one"))
14-
15- snapshot := NewSnapshot(sb, screen)
16- if &snapshot.Row(0)[0] != &sb.View().Row(0)[0] {
17- t.Fatal("NewSnapshot() deep-copied immutable history")
18- }
19- sb.Append(row("new"))
20- screen.Set(0, 0, renderer.Cell{Rune: 'x'})
21-
22- if got := snapshot.Len(); got != 2 {
23- t.Fatalf("Len() = %d, want 2", got)
24- }
25- for i, want := range []string{"old", "one"} {
26- if got := rowText(snapshot.Row(i)); got != want {
27- t.Errorf("Row(%d) = %q, want %q", i, got, want)
28- }
29- }
30- for _, i := range []int{-1, 2} {
31- if got := snapshot.Row(i); got != nil {
32- t.Errorf("Row(%d) = %v, want nil", i, got)
33- }
34- }
35-}
36-
37-func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
38- rows := [][]renderer.Cell{row("one")}
39- snapshot := NewSnapshotFromRows(rows, 3, 1)
40- rows[0][0].Rune = 'x'
41- if got := rowText(snapshot.Row(0)); got != "one" {
42- t.Fatalf("Row(0) = %q, want deep-owned caller row", got)
43- }
44-}
45-
46-// This scaling gate deliberately compares equal row counts rather than an
47-// absolute budget, which varies between Go releases. A full history-row clone
48-// grows with row width; the immutable HistoryView allocation does not.
49-func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
50- if testing.Short() {
51- t.Skip("skipping benchmark-driven allocation gate in short mode")
52- }
53- narrow := benchmarkNewSnapshotBytes(16)
54- wide := benchmarkNewSnapshotBytes(512)
55- assertWidthIndependentAllocations(t, "NewSnapshot", narrow, wide)
56-}
57-
58-func benchmarkNewSnapshotBytes(width int) int64 {
59- const historyRows = 256
60-
61- scrollback := NewScrollback(historyRows)
62- for range historyRows {
63- scrollback.Append(make([]renderer.Cell, width))
64- }
65- // Keep visible-frame work constant so the comparison isolates history rows.
66- screen := renderer.NewFrame(8, 4)
67- result := testing.Benchmark(func(b *testing.B) {
68- b.ReportAllocs()
69- b.ResetTimer()
70- for b.Loop() {
71- benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
72- }
73- })
74- return result.AllocedBytesPerOp()
75-}
76-
77-func assertWidthIndependentAllocations(t *testing.T, operation string, narrow, wide int64) {
78- t.Helper()
79- // Two times permits allocator noise while decisively rejecting a cell-row
80- // copy, whose allocation grows by 32x for the widths above.
81- const conservativeWidthTolerance = 2
82- if wide > narrow*conservativeWidthTolerance {
83- t.Fatalf("%s B/op scaled with row width: narrow=%d wide=%d (limit=%d)", operation, narrow, wide, narrow*conservativeWidthTolerance)
84- }
85-}
@@ -1,85 +0,0 @@
1-package copy
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {
10- sb := NewScrollback(1)
11- sb.Append(row("old"))
12- screen := renderer.NewFrame(3, 1)
13- copy(screen.Row(0), row("one"))
14-
15- snapshot := NewSnapshot(sb, screen)
16- if &snapshot.Row(0)[0] != &sb.View().Row(0)[0] {
17- t.Fatal("NewSnapshot() deep-copied immutable history")
18- }
19- sb.Append(row("new"))
20- screen.Set(0, 0, renderer.Cell{Rune: 'x'})
21-
22- if got := snapshot.Len(); got != 2 {
23- t.Fatalf("Len() = %d, want 2", got)
24- }
25- for i, want := range []string{"old", "one"} {
26- if got := rowText(snapshot.Row(i)); got != want {
27- t.Errorf("Row(%d) = %q, want %q", i, got, want)
28- }
29- }
30- for _, i := range []int{-1, 2} {
31- if got := snapshot.Row(i); got != nil {
32- t.Errorf("Row(%d) = %v, want nil", i, got)
33- }
34- }
35-}
36-
37-func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
38- rows := [][]renderer.Cell{row("one")}
39- snapshot := NewSnapshotFromRows(rows, 3, 1)
40- rows[0][0].Rune = 'x'
41- if got := rowText(snapshot.Row(0)); got != "one" {
42- t.Fatalf("Row(0) = %q, want deep-owned caller row", got)
43- }
44-}
45-
46-// This scaling gate deliberately compares equal row counts rather than an
47-// absolute budget, which varies between Go releases. A full history-row clone
48-// grows with row width; the immutable HistoryView allocation does not.
49-func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
50- if testing.Short() {
51- t.Skip("skipping benchmark-driven allocation gate in short mode")
52- }
53- narrow := benchmarkNewSnapshotBytes(16)
54- wide := benchmarkNewSnapshotBytes(512)
55- assertWidthIndependentAllocations(t, "NewSnapshot", narrow, wide)
56-}
57-
58-func benchmarkNewSnapshotBytes(width int) int64 {
59- const historyRows = 256
60-
61- scrollback := NewScrollback(historyRows)
62- for range historyRows {
63- scrollback.Append(make([]renderer.Cell, width))
64- }
65- // Keep visible-frame work constant so the comparison isolates history rows.
66- screen := renderer.NewFrame(8, 4)
67- result := testing.Benchmark(func(b *testing.B) {
68- b.ReportAllocs()
69- b.ResetTimer()
70- for b.Loop() {
71- benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
72- }
73- })
74- return result.AllocedBytesPerOp()
75-}
76-
77-func assertWidthIndependentAllocations(t *testing.T, operation string, narrow, wide int64) {
78- t.Helper()
79- // Two times permits allocator noise while decisively rejecting a cell-row
80- // copy, whose allocation grows by 32x for the widths above.
81- const conservativeWidthTolerance = 2
82- if wide > narrow*conservativeWidthTolerance {
83- t.Fatalf("%s B/op scaled with row width: narrow=%d wide=%d (limit=%d)", operation, narrow, wide, narrow*conservativeWidthTolerance)
84- }
85-}
M internal/usecase/daemon/copymode.go +1 -1
@@ -66,7 +66,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
6666 // Capture the live pane under pane.mu; all subsequent copy interaction uses
6767 // the immutable document while holding only copyMu.
6868 p.mu.Lock()
69- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
69+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
7070 p.mu.Unlock()
7171 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
7272 return
@@ -66,7 +66,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
66 // Capture the live pane under pane.mu; all subsequent copy interaction uses66 // Capture the live pane under pane.mu; all subsequent copy interaction uses
67 // the immutable document while holding only copyMu.67 // the immutable document while holding only copyMu.
68 p.mu.Lock()68 p.mu.Lock()
69- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)69+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
70 p.mu.Unlock()70 p.mu.Unlock()
71 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {71 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
72 return72 return
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
@@ -54,7 +54,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
5454 sess.client = ac
5555 ac.setSession(sess)
5656 closing.mu.Lock()
57- document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame)
57+ document := scopy.NewSnapshot(closing.history, closing.screen.Frame)
5858 closing.mu.Unlock()
5959 ac.overlays.copyMu.Lock()
6060 ac.overlays.copyPane = closing
@@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
9494 tb := sess.activeTab()
9595 p := tb.focusedPane()
9696 p.mu.Lock()
97- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
97+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
9898 p.mu.Unlock()
9999
100100 // Hold session validation after publication and make the captured target
@@ -157,7 +157,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
157157 tb := sess.activeTab()
158158 p := tb.focusedPane()
159159 p.mu.Lock()
160- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
160+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
161161 p.mu.Unlock()
162162 tc.invalidate(sess, tb, p)
163163
@@ -54,7 +54,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
54 sess.client = ac54 sess.client = ac
55 ac.setSession(sess)55 ac.setSession(sess)
56 closing.mu.Lock()56 closing.mu.Lock()
57- document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame)57+ document := scopy.NewSnapshot(closing.history, closing.screen.Frame)
58 closing.mu.Unlock()58 closing.mu.Unlock()
59 ac.overlays.copyMu.Lock()59 ac.overlays.copyMu.Lock()
60 ac.overlays.copyPane = closing60 ac.overlays.copyPane = closing
@@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
94 tb := sess.activeTab()94 tb := sess.activeTab()
95 p := tb.focusedPane()95 p := tb.focusedPane()
96 p.mu.Lock()96 p.mu.Lock()
97- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)97+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
98 p.mu.Unlock()98 p.mu.Unlock()
99 99
100 // Hold session validation after publication and make the captured target100 // Hold session validation after publication and make the captured target
@@ -157,7 +157,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
157 tb := sess.activeTab()157 tb := sess.activeTab()
158 p := tb.focusedPane()158 p := tb.focusedPane()
159 p.mu.Lock()159 p.mu.Lock()
160- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)160+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
161 p.mu.Unlock()161 p.mu.Unlock()
162 tc.invalidate(sess, tb, p)162 tc.invalidate(sess, tb, p)
163 163
M internal/usecase/daemon/copymode_test.go +23 -23
@@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112112 tb := sess.activeTab()
113113 pane := tb.focusedPane()
114114 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
115+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116116 pane.mu.Unlock()
117117 mode := scopy.NewMode(snap)
118118 bars := barState{status: sess.statusSegments(true)}
@@ -147,7 +147,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
147147 tb := sess.activeTab()
148148 tb.focusedPane().screen = vt.NewScreen(12, 3)
149149 tb.focusedPane().screen.Write([]byte("live"))
150- snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
150+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame)
151151 mode := scopy.NewMode(snap)
152152
153153 bars := barState{status: sess.statusSegments(true)}
@@ -166,7 +166,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
166166 writes := make(chan []byte, 1)
167167 p, _ := newBlockingPTYWithWrites(t, writes)
168168 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
169- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
169+ sess.tabs[0].focusedPane().history = newTestHistory(4)
170170 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
171171
172172 d.handleInput(sess, ac, []byte("\x1b "))
@@ -293,8 +293,8 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
293293 p, _ := newBlockingPTY(t)
294294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
295295 pane := sess.tabs[0].focusedPane()
296- pane.scrollback = scopy.NewScrollback(4)
297- pane.scrollback.Append(testRow("old alpha"))
296+ pane.history = newTestHistory(4)
297+ pane.history.Append(testRow("old alpha"))
298298 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
299299
300300 d.enterCopyMode(sess, ac)
@@ -322,9 +322,9 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
322322 writes := make(chan []byte, 1)
323323 p, _ := newBlockingPTYWithWrites(t, writes)
324324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
326- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
327- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
325+ sess.tabs[0].focusedPane().history = newTestHistory(4)
326+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
327+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
328328 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
329329
330330 d.enterCopyMode(sess, ac)
@@ -445,7 +445,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
445445 }
446446 p.mu.Lock()
447447 defer p.mu.Unlock()
448- return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12
448+ return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12
449449 }, 2*time.Second, 5*time.Millisecond)
450450
451451 sess := firstSession(d)
@@ -481,7 +481,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
481481 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
482482 p, _ := newBlockingPTY(t)
483483 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
484- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
484+ sess.tabs[0].focusedPane().history = newTestHistory(4)
485485 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
486486
487487 d.enterCopyMode(sess, ac)
@@ -515,9 +515,9 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
515515 t.Run(tc.name, func(t *testing.T) {
516516 p, _ := newBlockingPTY(t)
517517 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
518- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
519- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
520- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
518+ sess.tabs[0].focusedPane().history = newTestHistory(4)
519+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
520+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
521521 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
522522
523523 d.enterCopyMode(sess, ac)
@@ -535,7 +535,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
535535 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
536536 p, _ := newBlockingPTY(t)
537537 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
538- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1)
538+ sess.tabs[0].focusedPane().history = newTestHistory(1)
539539 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
540540 sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
541541 copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
@@ -560,7 +560,7 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
560560 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
561561 clk := &signalClock{timers: make(chan *signalTimer, 1)}
562562 d.clock = clk
563- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
563+ sess.tabs[0].focusedPane().history = newTestHistory(4)
564564 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
565565
566566 d.enterCopyMode(sess, ac)
@@ -584,7 +584,7 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
584584 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
585585 clk := &signalClock{timers: make(chan *signalTimer, 1)}
586586 d.clock = clk
587- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
587+ sess.tabs[0].focusedPane().history = newTestHistory(4)
588588 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
589589
590590 d.enterCopyMode(sess, ac)
@@ -627,7 +627,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
627627 t.Run(tc.name, func(t *testing.T) {
628628 p, _ := newBlockingPTY(t)
629629 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
630- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
630+ sess.tabs[0].focusedPane().history = newTestHistory(4)
631631 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
632632
633633 d.enterCopyMode(sess, ac)
@@ -676,7 +676,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
676676 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
677677 p, _ := newBlockingPTY(t)
678678 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
679- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
679+ sess.tabs[0].focusedPane().history = newTestHistory(4)
680680 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
681681
682682 done := make(chan struct{})
@@ -750,11 +750,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
750750 var want *pane
751751 if tc.useFloating {
752752 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
753- want.scrollback.Append(testRow("flt-old"))
753+ want.history.Append(testRow("flt-old"))
754754 want.screen.Write([]byte("flt-live"))
755755 } else {
756756 want = main
757- want.scrollback.Append(testRow("one-old"))
757+ want.history.Append(testRow("one-old"))
758758 want.screen.Write([]byte("one-live"))
759759 }
760760
@@ -791,11 +791,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
791791 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
792792 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
793793 for i := range 30 {
794- fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i)))
794+ fp.history.Append(testRow(fmt.Sprintf("old-%02d", i)))
795795 }
796796 fp.screen.Write([]byte("live"))
797797 fp.mu.Lock()
798- total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len()
798+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len()
799799 fp.mu.Unlock()
800800
801801 d.enterCopyMode(sess, ac)
@@ -817,7 +817,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
817817 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
818818 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
819819 for i := range 5 {
820- fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i)))
820+ fp.history.Append(testRow(fmt.Sprintf("old-%d", i)))
821821 }
822822 fp.screen.Write([]byte("live"))
823823
@@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112 tb := sess.activeTab()112 tb := sess.activeTab()
113 pane := tb.focusedPane()113 pane := tb.focusedPane()
114 pane.mu.Lock()114 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)115+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116 pane.mu.Unlock()116 pane.mu.Unlock()
117 mode := scopy.NewMode(snap)117 mode := scopy.NewMode(snap)
118 bars := barState{status: sess.statusSegments(true)}118 bars := barState{status: sess.statusSegments(true)}
@@ -147,7 +147,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
147 tb := sess.activeTab()147 tb := sess.activeTab()
148 tb.focusedPane().screen = vt.NewScreen(12, 3)148 tb.focusedPane().screen = vt.NewScreen(12, 3)
149 tb.focusedPane().screen.Write([]byte("live"))149 tb.focusedPane().screen.Write([]byte("live"))
150- snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)150+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame)
151 mode := scopy.NewMode(snap)151 mode := scopy.NewMode(snap)
152 152
153 bars := barState{status: sess.statusSegments(true)}153 bars := barState{status: sess.statusSegments(true)}
@@ -166,7 +166,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
166 writes := make(chan []byte, 1)166 writes := make(chan []byte, 1)
167 p, _ := newBlockingPTYWithWrites(t, writes)167 p, _ := newBlockingPTYWithWrites(t, writes)
168 d, sess, ac, sends := newManualSessionWithPTYs(t, p)168 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
169- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)169+ sess.tabs[0].focusedPane().history = newTestHistory(4)
170 sess.tabs[0].focusedPane().screen.Write([]byte("live"))170 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
171 171
172 d.handleInput(sess, ac, []byte("\x1b "))172 d.handleInput(sess, ac, []byte("\x1b "))
@@ -293,8 +293,8 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
293 p, _ := newBlockingPTY(t)293 p, _ := newBlockingPTY(t)
294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
295 pane := sess.tabs[0].focusedPane()295 pane := sess.tabs[0].focusedPane()
296- pane.scrollback = scopy.NewScrollback(4)296+ pane.history = newTestHistory(4)
297- pane.scrollback.Append(testRow("old alpha"))297+ pane.history.Append(testRow("old alpha"))
298 copy(pane.screen.Frame.Row(0), testRow("live alpha"))298 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
299 299
300 d.enterCopyMode(sess, ac)300 d.enterCopyMode(sess, ac)
@@ -322,9 +322,9 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
322 writes := make(chan []byte, 1)322 writes := make(chan []byte, 1)
323 p, _ := newBlockingPTYWithWrites(t, writes)323 p, _ := newBlockingPTYWithWrites(t, writes)
324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)325+ sess.tabs[0].focusedPane().history = newTestHistory(4)
326- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))326+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
327- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))327+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
328 sess.tabs[0].focusedPane().screen.Write([]byte("live"))328 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
329 329
330 d.enterCopyMode(sess, ac)330 d.enterCopyMode(sess, ac)
@@ -445,7 +445,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
445 }445 }
446 p.mu.Lock()446 p.mu.Lock()
447 defer p.mu.Unlock()447 defer p.mu.Unlock()
448- return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12448+ return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12
449 }, 2*time.Second, 5*time.Millisecond)449 }, 2*time.Second, 5*time.Millisecond)
450 450
451 sess := firstSession(d)451 sess := firstSession(d)
@@ -481,7 +481,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
481 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {481 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
482 p, _ := newBlockingPTY(t)482 p, _ := newBlockingPTY(t)
483 d, sess, ac, sends := newManualSessionWithPTYs(t, p)483 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
484- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)484+ sess.tabs[0].focusedPane().history = newTestHistory(4)
485 sess.tabs[0].focusedPane().screen.Write([]byte("live"))485 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
486 486
487 d.enterCopyMode(sess, ac)487 d.enterCopyMode(sess, ac)
@@ -515,9 +515,9 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
515 t.Run(tc.name, func(t *testing.T) {515 t.Run(tc.name, func(t *testing.T) {
516 p, _ := newBlockingPTY(t)516 p, _ := newBlockingPTY(t)
517 d, sess, ac, sends := newManualSessionWithPTYs(t, p)517 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
518- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)518+ sess.tabs[0].focusedPane().history = newTestHistory(4)
519- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))519+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
520- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))520+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
521 sess.tabs[0].focusedPane().screen.Write([]byte("live"))521 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
522 522
523 d.enterCopyMode(sess, ac)523 d.enterCopyMode(sess, ac)
@@ -535,7 +535,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
535 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {535 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
536 p, _ := newBlockingPTY(t)536 p, _ := newBlockingPTY(t)
537 d, sess, ac, sends := newManualSessionWithPTYs(t, p)537 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
538- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1)538+ sess.tabs[0].focusedPane().history = newTestHistory(1)
539 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)539 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
540 sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)540 sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
541 copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))541 copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
@@ -560,7 +560,7 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
560 d, sess, ac, sends := newManualSessionWithPTYs(t, p)560 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
561 clk := &signalClock{timers: make(chan *signalTimer, 1)}561 clk := &signalClock{timers: make(chan *signalTimer, 1)}
562 d.clock = clk562 d.clock = clk
563- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)563+ sess.tabs[0].focusedPane().history = newTestHistory(4)
564 sess.tabs[0].focusedPane().screen.Write([]byte("live"))564 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
565 565
566 d.enterCopyMode(sess, ac)566 d.enterCopyMode(sess, ac)
@@ -584,7 +584,7 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
584 d, sess, ac, sends := newManualSessionWithPTYs(t, p)584 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
585 clk := &signalClock{timers: make(chan *signalTimer, 1)}585 clk := &signalClock{timers: make(chan *signalTimer, 1)}
586 d.clock = clk586 d.clock = clk
587- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)587+ sess.tabs[0].focusedPane().history = newTestHistory(4)
588 sess.tabs[0].focusedPane().screen.Write([]byte("live"))588 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
589 589
590 d.enterCopyMode(sess, ac)590 d.enterCopyMode(sess, ac)
@@ -627,7 +627,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
627 t.Run(tc.name, func(t *testing.T) {627 t.Run(tc.name, func(t *testing.T) {
628 p, _ := newBlockingPTY(t)628 p, _ := newBlockingPTY(t)
629 d, sess, ac, sends := newManualSessionWithPTYs(t, p)629 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
630- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)630+ sess.tabs[0].focusedPane().history = newTestHistory(4)
631 sess.tabs[0].focusedPane().screen.Write([]byte("live"))631 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
632 632
633 d.enterCopyMode(sess, ac)633 d.enterCopyMode(sess, ac)
@@ -676,7 +676,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
676 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {676 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
677 p, _ := newBlockingPTY(t)677 p, _ := newBlockingPTY(t)
678 d, sess, ac, sends := newManualSessionWithPTYs(t, p)678 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
679- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)679+ sess.tabs[0].focusedPane().history = newTestHistory(4)
680 sess.tabs[0].focusedPane().screen.Write([]byte("live"))680 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
681 681
682 done := make(chan struct{})682 done := make(chan struct{})
@@ -750,11 +750,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
750 var want *pane750 var want *pane
751 if tc.useFloating {751 if tc.useFloating {
752 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})752 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
753- want.scrollback.Append(testRow("flt-old"))753+ want.history.Append(testRow("flt-old"))
754 want.screen.Write([]byte("flt-live"))754 want.screen.Write([]byte("flt-live"))
755 } else {755 } else {
756 want = main756 want = main
757- want.scrollback.Append(testRow("one-old"))757+ want.history.Append(testRow("one-old"))
758 want.screen.Write([]byte("one-live"))758 want.screen.Write([]byte("one-live"))
759 }759 }
760 760
@@ -791,11 +791,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
791 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)791 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
792 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})792 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
793 for i := range 30 {793 for i := range 30 {
794- fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i)))794+ fp.history.Append(testRow(fmt.Sprintf("old-%02d", i)))
795 }795 }
796 fp.screen.Write([]byte("live"))796 fp.screen.Write([]byte("live"))
797 fp.mu.Lock()797 fp.mu.Lock()
798- total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len()798+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len()
799 fp.mu.Unlock()799 fp.mu.Unlock()
800 800
801 d.enterCopyMode(sess, ac)801 d.enterCopyMode(sess, ac)
@@ -817,7 +817,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
817 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)817 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
818 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})818 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
819 for i := range 5 {819 for i := range 5 {
820- fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i)))820+ fp.history.Append(testRow(fmt.Sprintf("old-%d", i)))
821 }821 }
822 fp.screen.Write([]byte("live"))822 fp.screen.Write([]byte("live"))
823 823
A internal/usecase/daemon/history_test_helper_test.go +5 -0
@@ -0,0 +1,5 @@
1+package daemon
2+
3+import "github.com/bnema/vev/pkg/vt"
4+
5+func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
@@ -0,0 +1,5 @@
1+package daemon
2+
3+import "github.com/bnema/vev/pkg/vt"
4+
5+func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
M internal/usecase/daemon/input.go +3 -3
@@ -202,8 +202,8 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
202202 mouseMode, mouseSGR := p.screen.MouseMode()
203203 altScreen := p.screen.AltScreenActive()
204204 scrollbackRows := 0
205- if p.scrollback != nil {
206- scrollbackRows = p.scrollback.Len()
205+ if p.history != nil {
206+ scrollbackRows = p.history.Len()
207207 }
208208 p.mu.Unlock()
209209
@@ -249,7 +249,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
249249
250250 // The immutable snapshot is the only live-pane access needed for drag entry.
251251 p.mu.Lock()
252- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
252+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
253253 p.mu.Unlock()
254254 tb := sess.activeTab()
255255 if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {
@@ -202,8 +202,8 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
202 mouseMode, mouseSGR := p.screen.MouseMode()202 mouseMode, mouseSGR := p.screen.MouseMode()
203 altScreen := p.screen.AltScreenActive()203 altScreen := p.screen.AltScreenActive()
204 scrollbackRows := 0204 scrollbackRows := 0
205- if p.scrollback != nil {205+ if p.history != nil {
206- scrollbackRows = p.scrollback.Len()206+ scrollbackRows = p.history.Len()
207 }207 }
208 p.mu.Unlock()208 p.mu.Unlock()
209 209
@@ -249,7 +249,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
249 249
250 // The immutable snapshot is the only live-pane access needed for drag entry.250 // The immutable snapshot is the only live-pane access needed for drag entry.
251 p.mu.Lock()251 p.mu.Lock()
252- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)252+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
253 p.mu.Unlock()253 p.mu.Unlock()
254 tb := sess.activeTab()254 tb := sess.activeTab()
255 if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {255 if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {
M internal/usecase/daemon/input_test.go +2 -3
@@ -14,7 +14,6 @@ import (
1414 "github.com/bnema/vev/internal/domain"
1515 "github.com/bnema/vev/internal/ports"
1616 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17- scopy "github.com/bnema/vev/internal/usecase/copy"
1817 "github.com/bnema/vev/internal/usecase/keys"
1918 "github.com/bnema/vev/internal/usecase/layout"
2019 "github.com/bnema/vev/internal/usecase/picker"
@@ -931,7 +930,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
931930 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
932931 p, _ := newBlockingPTY(t)
933932 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
934- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50)
933+ sess.tabs[0].focusedPane().history = newTestHistory(50)
935934 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
936935
937936 // Press on a content row while scrollback is empty: the anchor is
@@ -941,7 +940,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
941940 // Simulate 5 lines evicted into scrollback between the Press and the
942941 // first Motion (e.g. the child kept producing output).
943942 for range 5 {
944- sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))
943+ sess.tabs[0].focusedPane().history.Append(testRow("evicted"))
945944 }
946945
947946 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).
@@ -14,7 +14,6 @@ import (
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17- scopy "github.com/bnema/vev/internal/usecase/copy"
18 "github.com/bnema/vev/internal/usecase/keys"17 "github.com/bnema/vev/internal/usecase/keys"
19 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/bnema/vev/internal/usecase/picker"19 "github.com/bnema/vev/internal/usecase/picker"
@@ -931,7 +930,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
931 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {930 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
932 p, _ := newBlockingPTY(t)931 p, _ := newBlockingPTY(t)
933 d, sess, ac, _ := newManualSessionWithPTYs(t, p)932 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
934- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50)933+ sess.tabs[0].focusedPane().history = newTestHistory(50)
935 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")934 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
936 935
937 // Press on a content row while scrollback is empty: the anchor is936 // Press on a content row while scrollback is empty: the anchor is
@@ -941,7 +940,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
941 // Simulate 5 lines evicted into scrollback between the Press and the940 // Simulate 5 lines evicted into scrollback between the Press and the
942 // first Motion (e.g. the child kept producing output).941 // first Motion (e.g. the child kept producing output).
943 for range 5 {942 for range 5 {
944- sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))943+ sess.tabs[0].focusedPane().history.Append(testRow("evicted"))
945 }944 }
946 945
947 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).946 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
@@ -173,7 +173,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.
173173 func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {
174174 ac := &attachedClient{}
175175 ac.initOverlays()
176- history := scopy.NewScrollback(1)
176+ history := newTestHistory(1)
177177 history.Append(testRow("before"))
178178 document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))
179179
@@ -173,7 +173,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.
173 func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {173 func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {
174 ac := &attachedClient{}174 ac := &attachedClient{}
175 ac.initOverlays()175 ac.initOverlays()
176- history := scopy.NewScrollback(1)176+ history := newTestHistory(1)
177 history.Append(testRow("before"))177 history.Append(testRow("before"))
178 document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))178 document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))
179 179
M internal/usecase/daemon/pane.go +18 -19
@@ -6,7 +6,6 @@ import (
66
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
9- scopy "github.com/bnema/vev/internal/usecase/copy"
109 "github.com/bnema/vev/internal/usecase/layout"
1110 "github.com/bnema/vev/pkg/vt"
1211 )
@@ -16,15 +15,15 @@ import (
1615 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
1716 // The PTY reader takes only pane.mu, so child output never waits on client IO.
1817 type pane struct {
19- id layout.PaneID
20- stableID string
21- pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
23- resizeMu sync.Mutex // serializes PTY resizes without holding mu
24- screen *vt.Screen
25- scrollback *scopy.Scrollback
26- syncGen uint64
27- rect domain.Rect
18+ id layout.PaneID
19+ stableID string
20+ pty ports.PTY
21+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
22+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
23+ screen *vt.Screen
24+ history *vt.History
25+ syncGen uint64
26+ rect domain.Rect
2827 // resizeApplying gates VT parsing across PTY.Resize. The reader continues
2928 // draining into resizePending so output is replayed against the target (or
3029 // retained old) screen only after apply resolves.
@@ -48,16 +47,16 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
4847 }
4948
5049 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
51- sb := scopy.NewScrollback(defaultScrollbackRows)
50+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
5251 screen := vt.NewScreen(sz.Cols, sz.Rows)
53- screen.OnLineEvicted = sb.Append
52+ screen.OnLineEvicted = history.Append
5453 return &pane{
55- id: id,
56- stableID: stableID,
57- pty: pty,
58- screen: screen,
59- scrollback: sb,
60- rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
61- title: paneTitleState{displayFallback: "sh"},
54+ id: id,
55+ stableID: stableID,
56+ pty: pty,
57+ screen: screen,
58+ history: history,
59+ rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
60+ title: paneTitleState{displayFallback: "sh"},
6261 }
6362 }
@@ -6,7 +6,6 @@ import (
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9- scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/bnema/vev/pkg/vt"10 "github.com/bnema/vev/pkg/vt"
12 )11 )
@@ -16,15 +15,15 @@ import (
16 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.15 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
17 // The PTY reader takes only pane.mu, so child output never waits on client IO.16 // The PTY reader takes only pane.mu, so child output never waits on client IO.
18 type pane struct {17 type pane struct {
19- id layout.PaneID18+ id layout.PaneID
20- stableID string19+ stableID string
21- pty ports.PTY20+ pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title21+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
23- resizeMu sync.Mutex // serializes PTY resizes without holding mu22+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
24- screen *vt.Screen23+ screen *vt.Screen
25- scrollback *scopy.Scrollback24+ history *vt.History
26- syncGen uint6425+ syncGen uint64
27- rect domain.Rect26+ rect domain.Rect
28 // resizeApplying gates VT parsing across PTY.Resize. The reader continues27 // resizeApplying gates VT parsing across PTY.Resize. The reader continues
29 // draining into resizePending so output is replayed against the target (or28 // draining into resizePending so output is replayed against the target (or
30 // retained old) screen only after apply resolves.29 // retained old) screen only after apply resolves.
@@ -48,16 +47,16 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
48 }47 }
49 48
50 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {49 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
51- sb := scopy.NewScrollback(defaultScrollbackRows)50+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
52 screen := vt.NewScreen(sz.Cols, sz.Rows)51 screen := vt.NewScreen(sz.Cols, sz.Rows)
53- screen.OnLineEvicted = sb.Append52+ screen.OnLineEvicted = history.Append
54 return &pane{53 return &pane{
55- id: id,54+ id: id,
56- stableID: stableID,55+ stableID: stableID,
57- pty: pty,56+ pty: pty,
58- screen: screen,57+ screen: screen,
59- scrollback: sb,58+ history: history,
60- rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},59+ rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
61- title: paneTitleState{displayFallback: "sh"},60+ title: paneTitleState{displayFallback: "sh"},
62 }61 }
63 }62 }
M internal/usecase/daemon/performance_benchmark_test.go +2 -2
@@ -145,7 +145,7 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
145145 for _, tb := range fixture.sess.tabs {
146146 require.Len(t, tb.panes, 4)
147147 for _, p := range tb.panes {
148- require.Equal(t, 10_000, p.scrollback.Len())
148+ require.Equal(t, 10_000, p.history.Len())
149149 }
150150 }
151151 }
@@ -550,7 +550,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
550550 for _, p := range tb.panes {
551551 p.mu.Lock()
552552 width := p.screen.Frame.Width
553- for row := 0; p.scrollback.Len() < historyRows; row++ {
553+ for row := 0; p.history.Len() < historyRows; row++ {
554554 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
555555 }
556556 p.mu.Unlock()
@@ -145,7 +145,7 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
145 for _, tb := range fixture.sess.tabs {145 for _, tb := range fixture.sess.tabs {
146 require.Len(t, tb.panes, 4)146 require.Len(t, tb.panes, 4)
147 for _, p := range tb.panes {147 for _, p := range tb.panes {
148- require.Equal(t, 10_000, p.scrollback.Len())148+ require.Equal(t, 10_000, p.history.Len())
149 }149 }
150 }150 }
151 }151 }
@@ -550,7 +550,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
550 for _, p := range tb.panes {550 for _, p := range tb.panes {
551 p.mu.Lock()551 p.mu.Lock()
552 width := p.screen.Frame.Width552 width := p.screen.Frame.Width
553- for row := 0; p.scrollback.Len() < historyRows; row++ {553+ for row := 0; p.history.Len() < historyRows; row++ {
554 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))554 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
555 }555 }
556 p.mu.Unlock()556 p.mu.Unlock()
M internal/usecase/daemon/render_test.go +7 -7
@@ -572,14 +572,14 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
572572 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
573573 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
574574 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
575- require.Equal(t, 2, win.focusedPane().scrollback.Len())
576- require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0)))
577- require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1)))
575+ require.Equal(t, 2, win.focusedPane().history.Len())
576+ require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0)))
577+ require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1)))
578578
579579 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
580580 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
581581 require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))
582- require.Equal(t, 2, win.focusedPane().scrollback.Len())
582+ require.Equal(t, 2, win.focusedPane().history.Len())
583583 }
584584
585585 func frameRowString(f renderer.Frame, y int) string {
@@ -911,7 +911,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
911911 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },
912912 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },
913913 prepareScreen: func(sess *session) {
914- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
914+ sess.tabs[0].focusedPane().history = newTestHistory(4)
915915 },
916916 visible: "[SCROLL]",
917917 notVisible: "[SCROLL]",
@@ -1295,7 +1295,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
12951295 }
12961296 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
12971297 p.screen.Write([]byte("ab\r\ncd"))
1298- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
1298+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
12991299 mode := scopy.NewMode(document)
13001300 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
13011301
@@ -1355,7 +1355,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
13551355 defer releaseFloating()
13561356 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
13571357 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1358- fp.scrollback.Append(testRow("flt-old"))
1358+ fp.history.Append(testRow("flt-old"))
13591359 fp.screen.Write([]byte("flt-live"))
13601360 installTestFloating(sess.activeTab(), fp, true)
13611361
@@ -572,14 +572,14 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
572 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})572 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
573 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))573 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
574 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))574 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
575- require.Equal(t, 2, win.focusedPane().scrollback.Len())575+ require.Equal(t, 2, win.focusedPane().history.Len())
576- require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0)))576+ require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0)))
577- require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1)))577+ require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1)))
578 578
579 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})579 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
580 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))580 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
581 require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))581 require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))
582- require.Equal(t, 2, win.focusedPane().scrollback.Len())582+ require.Equal(t, 2, win.focusedPane().history.Len())
583 }583 }
584 584
585 func frameRowString(f renderer.Frame, y int) string {585 func frameRowString(f renderer.Frame, y int) string {
@@ -911,7 +911,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
911 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },911 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },
912 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },912 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },
913 prepareScreen: func(sess *session) {913 prepareScreen: func(sess *session) {
914- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)914+ sess.tabs[0].focusedPane().history = newTestHistory(4)
915 },915 },
916 visible: "[SCROLL]",916 visible: "[SCROLL]",
917 notVisible: "[SCROLL]",917 notVisible: "[SCROLL]",
@@ -1295,7 +1295,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1295 }1295 }
1296 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})1296 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1297 p.screen.Write([]byte("ab\r\ncd"))1297 p.screen.Write([]byte("ab\r\ncd"))
1298- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)1298+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
1299 mode := scopy.NewMode(document)1299 mode := scopy.NewMode(document)
1300 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1300 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1301 1301
@@ -1355,7 +1355,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1355 defer releaseFloating()1355 defer releaseFloating()
1356 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)1356 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
1357 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})1357 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1358- fp.scrollback.Append(testRow("flt-old"))1358+ fp.history.Append(testRow("flt-old"))
1359 fp.screen.Write([]byte("flt-live"))1359 fp.screen.Write([]byte("flt-live"))
1360 installTestFloating(sess.activeTab(), fp, true)1360 installTestFloating(sess.activeTab(), fp, true)
1361 1361
M internal/usecase/daemon/scrollback_test.go +5 -5
@@ -24,19 +24,19 @@ func TestNewTabInitializesScrollback(t *testing.T) {
2424 for _, tt := range tests {
2525 t.Run(tt.name, func(t *testing.T) {
2626 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.focusedPane().scrollback == nil {
27+ if win.focusedPane().history == nil {
2828 t.Fatal("scrollback is nil")
2929 }
30- if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {
31- t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)
30+ if win.focusedPane().history.Cap() != defaultScrollbackRows {
31+ t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows)
3232 }
3333
3434 win.focusedPane().screen.Write(tt.write)
3535
36- if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
36+ if got := win.focusedPane().history.Len(); got != tt.wantLen {
3737 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
3838 }
39- row := win.focusedPane().scrollback.View().Row(0)
39+ row := win.focusedPane().history.View().Row(0)
4040 runes := make([]rune, len(row))
4141 for i, c := range row {
4242 runes[i] = c.Rune
@@ -24,19 +24,19 @@ func TestNewTabInitializesScrollback(t *testing.T) {
24 for _, tt := range tests {24 for _, tt := range tests {
25 t.Run(tt.name, func(t *testing.T) {25 t.Run(tt.name, func(t *testing.T) {
26 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})26 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.focusedPane().scrollback == nil {27+ if win.focusedPane().history == nil {
28 t.Fatal("scrollback is nil")28 t.Fatal("scrollback is nil")
29 }29 }
30- if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {30+ if win.focusedPane().history.Cap() != defaultScrollbackRows {
31- t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)31+ t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows)
32 }32 }
33 33
34 win.focusedPane().screen.Write(tt.write)34 win.focusedPane().screen.Write(tt.write)
35 35
36- if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {36+ if got := win.focusedPane().history.Len(); got != tt.wantLen {
37 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)37 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
38 }38 }
39- row := win.focusedPane().scrollback.View().Row(0)39+ row := win.focusedPane().history.View().Row(0)
40 runes := make([]rune, len(row))40 runes := make([]rune, len(row))
41 for i, c := range row {41 for i, c := range row {
42 runes[i] = c.Rune42 runes[i] = c.Rune
M internal/usecase/daemon/snapshot.go +37 -39
@@ -7,10 +7,8 @@ import (
77 "time"
88
99 "github.com/bnema/vev/internal/domain"
10- scopy "github.com/bnema/vev/internal/usecase/copy"
1110 "github.com/bnema/vev/internal/usecase/layout"
1211 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13- "github.com/bnema/vev/pkg/renderer"
1412 "github.com/bnema/vev/pkg/vt"
1513 )
1614
@@ -149,7 +147,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
149147 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
150148 p.ctx, p.cancel = context.WithCancel(tb.ctx)
151149 p.rect = contentRect
152- seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
150+ if err := restorePaneTerminal(p, paneSnap); err != nil {
151+ closeOpened()
152+ return err
153+ }
153154 if restoreCommand != "" {
154155 if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
155156 d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
@@ -209,28 +210,30 @@ func markSnapshotDirty(sess *session) {
209210 }
210211 }
211212
212-func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) {
213+func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
213214 p.mu.Lock()
214215 defer p.mu.Unlock()
215- p.scrollback = scopy.NewScrollback(defaultScrollbackRows)
216- for _, row := range scrollback {
217- p.scrollback.Append(row)
218- }
219- if len(visible) > 0 {
220- p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
221- for y, row := range visible {
222- if y >= p.screen.Frame.Height {
223- break
224- }
225- for x, cell := range row {
226- if x >= p.screen.Frame.Width {
227- break
228- }
229- p.screen.Frame.Set(x, y, cell)
230- }
231- }
216+ // Unmarshal requires both blobs. The empty fallback only supports direct
217+ // in-process construction used by restore callers; it is unreachable for a
218+ // durable snapshot because the v3 manifest rejects missing blobs.
219+ tail := snap.Tail
220+ if len(tail) == 0 {
221+ tail, _ = vt.MarshalEmptyHistoryTail()
222+ }
223+ visible := snap.Visible
224+ if len(visible) == 0 {
225+ visible, _ = vt.MarshalVisible(p.screen.PrimaryVisibleFrame())
226+ }
227+ history, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, tail)
228+ if err != nil {
229+ return fmt.Errorf("snapshot history: %w", err)
230+ }
231+ if err := p.screen.RestorePrimaryVisible(visible); err != nil {
232+ return fmt.Errorf("snapshot visible: %w", err)
232233 }
233- p.screen.OnLineEvicted = p.scrollback.Append
234+ p.history = history
235+ p.screen.OnLineEvicted = history.Append
236+ return nil
234237 }
235238
236239 func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
@@ -296,13 +299,19 @@ func (d *Daemon) captureSession(sess *session) bool {
296299 if pty != nil {
297300 pid = pty.Pid()
298301 }
299- paneSnap := snapcodec.Pane{
300- ID: p.id,
301- StableID: p.stableID,
302- Scrollback: cloneRows(p.scrollback.Snapshot()),
303- Visible: cloneRows(p.screen.PrimaryVisibleRows()),
304- }
302+ historyView := p.history.SealAndView()
303+ visibleFrame := p.screen.PrimaryVisibleFrame()
304+ paneID, paneStableID := p.id, p.stableID
305305 p.mu.Unlock()
306+ // Sealed VT chunks are immutable and visibleFrame is the one required
307+ // screen clone, so both encoders run after pane.mu is released.
308+ sealed, tail, historyErr := vt.MarshalSealedHistory(historyView)
309+ visible, visibleErr := vt.MarshalVisible(visibleFrame)
310+ if historyErr != nil || visibleErr != nil {
311+ d.log.Warn("capturing terminal snapshot failed", "session", name, "pane", paneID, "history_err", historyErr, "visible_err", visibleErr)
312+ return false
313+ }
314+ paneSnap := snapcodec.Pane{ID: paneID, StableID: paneStableID, SealedChunks: sealed, Tail: tail, Visible: visible}
306315 paneSnap.Cwd = fallbackCwd
307316 if d.procCwd != nil && pid > 0 {
308317 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
@@ -378,17 +387,6 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
378387 }
379388 }
380389
381-func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell {
382- if len(rows) == 0 {
383- return nil
384- }
385- out := make([][]renderer.Cell, len(rows))
386- for i, row := range rows {
387- out[i] = append([]renderer.Cell(nil), row...)
388- }
389- return out
390-}
391-
392390 func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
393391 if n == nil {
394392 return false
@@ -7,10 +7,8 @@ import (
7 "time"7 "time"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- scopy "github.com/bnema/vev/internal/usecase/copy"
11 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"11 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13- "github.com/bnema/vev/pkg/renderer"
14 "github.com/bnema/vev/pkg/vt"12 "github.com/bnema/vev/pkg/vt"
15 )13 )
16 14
@@ -149,7 +147,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
149 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)147 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
150 p.ctx, p.cancel = context.WithCancel(tb.ctx)148 p.ctx, p.cancel = context.WithCancel(tb.ctx)
151 p.rect = contentRect149 p.rect = contentRect
152- seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)150+ if err := restorePaneTerminal(p, paneSnap); err != nil {
151+ closeOpened()
152+ return err
153+ }
153 if restoreCommand != "" {154 if restoreCommand != "" {
154 if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {155 if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
155 d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)156 d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
@@ -209,28 +210,30 @@ func markSnapshotDirty(sess *session) {
209 }210 }
210 }211 }
211 212
212-func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) {213+func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
213 p.mu.Lock()214 p.mu.Lock()
214 defer p.mu.Unlock()215 defer p.mu.Unlock()
215- p.scrollback = scopy.NewScrollback(defaultScrollbackRows)216+ // Unmarshal requires both blobs. The empty fallback only supports direct
216- for _, row := range scrollback {217+ // in-process construction used by restore callers; it is unreachable for a
217- p.scrollback.Append(row)218+ // durable snapshot because the v3 manifest rejects missing blobs.
218- }219+ tail := snap.Tail
219- if len(visible) > 0 {220+ if len(tail) == 0 {
220- p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)221+ tail, _ = vt.MarshalEmptyHistoryTail()
221- for y, row := range visible {222+ }
222- if y >= p.screen.Frame.Height {223+ visible := snap.Visible
223- break224+ if len(visible) == 0 {
224- }225+ visible, _ = vt.MarshalVisible(p.screen.PrimaryVisibleFrame())
225- for x, cell := range row {226+ }
226- if x >= p.screen.Frame.Width {227+ history, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, tail)
227- break228+ if err != nil {
228- }229+ return fmt.Errorf("snapshot history: %w", err)
229- p.screen.Frame.Set(x, y, cell)230+ }
230- }231+ if err := p.screen.RestorePrimaryVisible(visible); err != nil {
231- }232+ return fmt.Errorf("snapshot visible: %w", err)
232 }233 }
233- p.screen.OnLineEvicted = p.scrollback.Append234+ p.history = history
235+ p.screen.OnLineEvicted = history.Append
236+ return nil
234 }237 }
235 238
236 func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {239 func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
@@ -296,13 +299,19 @@ func (d *Daemon) captureSession(sess *session) bool {
296 if pty != nil {299 if pty != nil {
297 pid = pty.Pid()300 pid = pty.Pid()
298 }301 }
299- paneSnap := snapcodec.Pane{302+ historyView := p.history.SealAndView()
300- ID: p.id,303+ visibleFrame := p.screen.PrimaryVisibleFrame()
301- StableID: p.stableID,304+ paneID, paneStableID := p.id, p.stableID
302- Scrollback: cloneRows(p.scrollback.Snapshot()),
303- Visible: cloneRows(p.screen.PrimaryVisibleRows()),
304- }
305 p.mu.Unlock()305 p.mu.Unlock()
306+ // Sealed VT chunks are immutable and visibleFrame is the one required
307+ // screen clone, so both encoders run after pane.mu is released.
308+ sealed, tail, historyErr := vt.MarshalSealedHistory(historyView)
309+ visible, visibleErr := vt.MarshalVisible(visibleFrame)
310+ if historyErr != nil || visibleErr != nil {
311+ d.log.Warn("capturing terminal snapshot failed", "session", name, "pane", paneID, "history_err", historyErr, "visible_err", visibleErr)
312+ return false
313+ }
314+ paneSnap := snapcodec.Pane{ID: paneID, StableID: paneStableID, SealedChunks: sealed, Tail: tail, Visible: visible}
306 paneSnap.Cwd = fallbackCwd315 paneSnap.Cwd = fallbackCwd
307 if d.procCwd != nil && pid > 0 {316 if d.procCwd != nil && pid > 0 {
308 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {317 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
@@ -378,17 +387,6 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
378 }387 }
379 }388 }
380 389
381-func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell {
382- if len(rows) == 0 {
383- return nil
384- }
385- out := make([][]renderer.Cell, len(rows))
386- for i, row := range rows {
387- out[i] = append([]renderer.Cell(nil), row...)
388- }
389- return out
390-}
391-
392 func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {390 func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
393 if n == nil {391 if n == nil {
394 return false392 return false
M internal/usecase/daemon/snapshot_restore_test.go +38 -5
@@ -18,8 +18,26 @@ import (
1818 "github.com/bnema/vev/internal/usecase/layout"
1919 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2020 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
2122 )
2223
24+func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane {
25+ t.Helper()
26+ h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
27+ for _, row := range historyRows {
28+ h.Append(row)
29+ }
30+ sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
31+ require.NoError(t, err)
32+ frame := renderer.NewFrame(40, 24)
33+ for y, row := range visibleRows {
34+ copy(frame.Row(y), row)
35+ }
36+ visible, err := vt.MarshalVisible(frame)
37+ require.NoError(t, err)
38+ return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible}
39+}
40+
2341 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
2442 store := &restoreSnapshotStore{}
2543 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -37,8 +55,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
3755 layout.NewLeaf("pane-2"),
3856 }}},
3957 Panes: []snapcodec.Pane{
40- {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
41- {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
58+ terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}),
59+ terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}),
4260 },
4361 }},
4462 })}}
@@ -77,10 +95,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
7795 p := tb.panes["pane-2"]
7896 tb.mu.Unlock()
7997 p.mu.Lock()
80- require.Equal(t, "old2", rowText(p.scrollback.View().Row(0)))
81- require.Equal(t, 1, p.scrollback.Len())
98+ require.Equal(t, "old2", rowText(p.history.View().Row(0)))
99+ require.Equal(t, 1, p.history.Len())
82100 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
83- copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
101+ copySnap := scopy.NewSnapshot(p.history, p.screen.Frame)
84102 require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
85103 require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
86104 p.mu.Unlock()
@@ -417,6 +435,21 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
417435
418436 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
419437 t.Helper()
438+ for ti := range s.Tabs {
439+ for pi := range s.Tabs[ti].Panes {
440+ p := &s.Tabs[ti].Panes[pi]
441+ if len(p.Tail) != 0 {
442+ continue
443+ }
444+ h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
445+ sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
446+ require.NoError(t, err)
447+ frame := renderer.NewFrame(int(s.Tabs[ti].Cols), int(s.Tabs[ti].Rows))
448+ visible, err := vt.MarshalVisible(frame)
449+ require.NoError(t, err)
450+ p.SealedChunks, p.Tail, p.Visible = sealed, tail, visible
451+ }
452+ }
420453 b, err := snapcodec.Marshal(s)
421454 require.NoError(t, err)
422455 return b
@@ -18,8 +18,26 @@ import (
18 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20 "github.com/bnema/vev/pkg/renderer"20 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
21 )22 )
22 23
24+func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane {
25+ t.Helper()
26+ h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
27+ for _, row := range historyRows {
28+ h.Append(row)
29+ }
30+ sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
31+ require.NoError(t, err)
32+ frame := renderer.NewFrame(40, 24)
33+ for y, row := range visibleRows {
34+ copy(frame.Row(y), row)
35+ }
36+ visible, err := vt.MarshalVisible(frame)
37+ require.NoError(t, err)
38+ return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible}
39+}
40+
23 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {41 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
24 store := &restoreSnapshotStore{}42 store := &restoreSnapshotStore{}
25 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{43 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -37,8 +55,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
37 layout.NewLeaf("pane-2"),55 layout.NewLeaf("pane-2"),
38 }}},56 }}},
39 Panes: []snapcodec.Pane{57 Panes: []snapcodec.Pane{
40- {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},58+ terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}),
41- {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},59+ terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}),
42 },60 },
43 }},61 }},
44 })}}62 })}}
@@ -77,10 +95,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
77 p := tb.panes["pane-2"]95 p := tb.panes["pane-2"]
78 tb.mu.Unlock()96 tb.mu.Unlock()
79 p.mu.Lock()97 p.mu.Lock()
80- require.Equal(t, "old2", rowText(p.scrollback.View().Row(0)))98+ require.Equal(t, "old2", rowText(p.history.View().Row(0)))
81- require.Equal(t, 1, p.scrollback.Len())99+ require.Equal(t, 1, p.history.Len())
82 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))100 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
83- copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)101+ copySnap := scopy.NewSnapshot(p.history, p.screen.Frame)
84 require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))102 require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
85 require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))103 require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
86 p.mu.Unlock()104 p.mu.Unlock()
@@ -417,6 +435,21 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
417 435
418 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {436 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
419 t.Helper()437 t.Helper()
438+ for ti := range s.Tabs {
439+ for pi := range s.Tabs[ti].Panes {
440+ p := &s.Tabs[ti].Panes[pi]
441+ if len(p.Tail) != 0 {
442+ continue
443+ }
444+ h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
445+ sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
446+ require.NoError(t, err)
447+ frame := renderer.NewFrame(int(s.Tabs[ti].Cols), int(s.Tabs[ti].Rows))
448+ visible, err := vt.MarshalVisible(frame)
449+ require.NoError(t, err)
450+ p.SealedChunks, p.Tail, p.Visible = sealed, tail, visible
451+ }
452+ }
420 b, err := snapcodec.Marshal(s)453 b, err := snapcodec.Marshal(s)
421 require.NoError(t, err)454 require.NoError(t, err)
422 return b455 return b
M internal/usecase/daemon/snapshot_test.go +1 -1
@@ -434,7 +434,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
434434 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
435435 p := tb.panes["pane-1"]
436436 p.screen.Write([]byte("hello"))
437- p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
437+ p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
438438 sess := &session{
439439 id: domain.SessionID("sess-" + name),
440440 name: name,
@@ -434,7 +434,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
434 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})434 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
435 p := tb.panes["pane-1"]435 p := tb.panes["pane-1"]
436 p.screen.Write([]byte("hello"))436 p.screen.Write([]byte("hello"))
437- p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})437+ p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
438 sess := &session{438 sess := &session{
439 id: domain.SessionID("sess-" + name),439 id: domain.SessionID("sess-" + name),
440 name: name,440 name: name,
M internal/usecase/snapshot/codec.go +225 -267
@@ -1,17 +1,14 @@
11 package snapshot
22
33 import (
4- "bytes"
5- "compress/flate"
64 "encoding/binary"
75 "errors"
86 "fmt"
97 "hash/crc32"
10- "io"
118 "math"
129
1310 "github.com/bnema/vev/internal/usecase/layout"
14- "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
1512 )
1613
1714 var (
@@ -27,53 +24,34 @@ var (
2724
2825 const (
2926 magic = "VEVS"
30- version = uint16(2)
31- flagFlate = uint16(1 << 0)
32- flateCutoff = 4 << 10
27+ version = uint16(3)
3328 maxDecodedBodySize = 256 << 20
34- maxDecodedRows = 1 << 20
35- maxDecodedRuns = 16 << 20
36- maxDecodedCells = 16 << 20
29+ maxSnapshotBlobs = 1 << 16
30+ maxSnapshotBytes = 256 << 20
31+ maxSnapshotRows = 1 << 20
32+ maxSnapshotCells = 16 << 20
3733 )
3834
39-// Marshal encodes a v2 durable snapshot.
35+// Marshal encodes the strict v3, flags-zero durable envelope.
4036 func Marshal(s Session) ([]byte, error) {
4137 var w payloadWriter
4238 if err := writeSession(&w, s); err != nil {
4339 return nil, err
4440 }
45- body := w.b
46- flags := uint16(0)
47- if len(body) > flateCutoff {
48- var compressed bytes.Buffer
49- fw, err := flate.NewWriter(&compressed, flate.DefaultCompression)
50- if err != nil {
51- return nil, err
52- }
53- if _, err := fw.Write(body); err != nil {
54- _ = fw.Close()
55- return nil, err
56- }
57- if err := fw.Close(); err != nil {
58- return nil, err
59- }
60- body = compressed.Bytes()
61- flags |= flagFlate
62- }
63- if len(body) > math.MaxUint32 {
41+ if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 {
6442 return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
6543 }
66- out := make([]byte, 16+len(body))
44+ out := make([]byte, 16+len(w.b))
6745 copy(out[:4], magic)
6846 binary.BigEndian.PutUint16(out[4:6], version)
69- binary.BigEndian.PutUint16(out[6:8], flags)
70- binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
71- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
72- copy(out[16:], body)
47+ binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b)))
48+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b))
49+ copy(out[16:], w.b)
7350 return out, nil
7451 }
7552
76-// Unmarshal decodes a strict v2 durable snapshot.
53+// Unmarshal validates the complete manifest and all VT blobs without
54+// allocations first, then decodes the already preflighted data.
7755 func Unmarshal(b []byte) (Session, error) {
7856 if len(b) < 16 {
7957 return Session{}, ErrShortPayload
@@ -84,38 +62,25 @@ func Unmarshal(b []byte) (Session, error) {
8462 if binary.BigEndian.Uint16(b[4:6]) != version {
8563 return Session{}, ErrBadVersion
8664 }
87- flags := binary.BigEndian.Uint16(b[6:8])
88- if flags&^flagFlate != 0 {
89- return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData)
65+ if binary.BigEndian.Uint16(b[6:8]) != 0 {
66+ return Session{}, fmt.Errorf("%w: flags", ErrInvalidData)
9067 }
91- bodyLen := int(binary.BigEndian.Uint32(b[8:12]))
68+ bodyLen := binary.BigEndian.Uint32(b[8:12])
9269 if bodyLen > maxDecodedBodySize {
9370 return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData)
9471 }
95- if len(b) < 16+bodyLen {
72+ if uint64(len(b)) < 16+uint64(bodyLen) {
9673 return Session{}, ErrShortPayload
9774 }
98- if len(b) != 16+bodyLen {
75+ if uint64(len(b)) != 16+uint64(bodyLen) {
9976 return Session{}, ErrTrailingBytes
10077 }
10178 body := b[16:]
10279 if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) {
10380 return Session{}, ErrBadCRC
10481 }
105- if flags&flagFlate != 0 {
106- fr := flate.NewReader(bytes.NewReader(body))
107- decompressed, err := io.ReadAll(io.LimitReader(fr, maxDecodedBodySize+1))
108- cerr := fr.Close()
109- if err != nil {
110- return Session{}, err
111- }
112- if cerr != nil {
113- return Session{}, cerr
114- }
115- if len(decompressed) > maxDecodedBodySize {
116- return Session{}, fmt.Errorf("%w: decompressed body too large", ErrInvalidData)
117- }
118- body = decompressed
82+ if err := preflightSession(body); err != nil {
83+ return Session{}, err
11984 }
12085 r := payloadReader{b: body}
12186 s, err := readSession(&r)
@@ -240,6 +205,18 @@ func (r *payloadReader) getStrings() ([]string, error) {
240205 }
241206 return out, nil
242207 }
208+func (r *payloadReader) getBlob() ([]byte, error) {
209+ n, err := r.getUint32()
210+ if err != nil {
211+ return nil, err
212+ }
213+ if uint64(n) > uint64(len(r.b)) {
214+ return nil, ErrShortPayload
215+ }
216+ blob := append([]byte(nil), r.b[:n]...)
217+ r.b = r.b[n:]
218+ return blob, nil
219+}
243220 func (r *payloadReader) done() error {
244221 if len(r.b) != 0 {
245222 return ErrTrailingBytes
@@ -464,24 +441,27 @@ func writePane(w *payloadWriter, p Pane) error {
464441 if err := w.putString(p.Cwd); err != nil {
465442 return err
466443 }
467- styles := styleTable(p)
468- if len(styles) > math.MaxUint16 {
469- return fmt.Errorf("%w: too many styles", ErrInvalidData)
470- }
471- w.putUint16(uint16(len(styles)))
472- for _, s := range styles {
473- writeStyle(w, s)
444+ if len(p.SealedChunks) > maxSnapshotBlobs {
445+ return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
474446 }
475- idx := make(map[renderer.Style]uint16, len(styles))
476- for i, s := range styles {
477- idx[s] = uint16(i)
447+ w.putUint32(uint32(len(p.SealedChunks)))
448+ for _, blob := range p.SealedChunks {
449+ if len(blob) == 0 || len(blob) > math.MaxUint32 {
450+ return fmt.Errorf("%w: sealed chunk length", ErrInvalidData)
451+ }
452+ w.putUint32(uint32(len(blob)))
453+ w.b = append(w.b, blob...)
478454 }
479- if err := writeRows(w, p.Scrollback, idx, false); err != nil {
480- return err
455+ if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 {
456+ return fmt.Errorf("%w: tail length", ErrInvalidData)
481457 }
482- if err := writeRows(w, p.Visible, idx, true); err != nil {
483- return err
458+ w.putUint32(uint32(len(p.Tail)))
459+ w.b = append(w.b, p.Tail...)
460+ if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {
461+ return fmt.Errorf("%w: visible length", ErrInvalidData)
484462 }
463+ w.putUint32(uint32(len(p.Visible)))
464+ w.b = append(w.b, p.Visible...)
485465 return writeProcess(w, p.Process)
486466 }
487467
@@ -498,24 +478,24 @@ func readPane(r *payloadReader) (Pane, error) {
498478 if err != nil {
499479 return Pane{}, err
500480 }
501- ns, err := r.getUint16()
481+ n, err := r.getUint32()
502482 if err != nil {
503483 return Pane{}, err
504484 }
505- styles := make([]renderer.Style, ns)
506- for i := range styles {
507- s, err := readStyle(r)
508- if err != nil {
485+ if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
486+ return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData)
487+ }
488+ sealed := make([][]byte, n)
489+ for i := range sealed {
490+ if sealed[i], err = r.getBlob(); err != nil {
509491 return Pane{}, err
510492 }
511- styles[i] = s
512493 }
513- budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns}
514- sb, err := readRows(r, styles, &budget)
494+ tail, err := r.getBlob()
515495 if err != nil {
516496 return Pane{}, err
517497 }
518- vis, err := readRows(r, styles, &budget)
498+ visible, err := r.getBlob()
519499 if err != nil {
520500 return Pane{}, err
521501 }
@@ -523,7 +503,7 @@ func readPane(r *payloadReader) (Pane, error) {
523503 if err != nil {
524504 return Pane{}, err
525505 }
526- return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil
506+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil
527507 }
528508
529509 func writeProcess(w *payloadWriter, p *Process) error {
@@ -573,236 +553,214 @@ func readProcess(r *payloadReader) (*Process, error) {
573553 return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil
574554 }
575555
576-func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {
577- clean := trimRows(rows, trimBlankRows)
578- if len(clean) > math.MaxUint32 {
579- return fmt.Errorf("%w: too many rows", ErrInvalidData)
580- }
581- w.putUint32(uint32(len(clean)))
582- for _, row := range clean {
583- runs := makeRuns(row)
584- if len(runs) > math.MaxUint16 {
585- return fmt.Errorf("%w: too many runs", ErrInvalidData)
586- }
587- w.putUint16(uint16(len(runs)))
588- for _, run := range runs {
589- w.putUint16(uint16(run.len))
590- w.putUint32(uint32(run.cell.Rune))
591- w.putUint16(idx[run.cell.Style])
592- var flags uint8
593- if run.cell.Continuation {
594- flags |= 1
595- }
596- w.putUint8(flags)
556+// preflightSession walks framing and opaque VT blobs without constructing a
557+// Session. All count-derived allocation happens only in the second pass.
558+func preflightSession(body []byte) error {
559+ r := payloadReader{b: body}
560+ if err := skipString(&r); err != nil {
561+ return err
562+ }
563+ if _, err := r.getUint64(); err != nil {
564+ return err
565+ }
566+ if _, err := r.getUint16(); err != nil {
567+ return err
568+ }
569+ tabs, err := r.getUint16()
570+ if err != nil {
571+ return err
572+ }
573+ if uint64(tabs) > uint64(len(r.b))/2 {
574+ return ErrShortPayload
575+ }
576+ var totals vt.DecodeStats
577+ blobs := uint64(0)
578+ for range tabs {
579+ if err := preflightTab(&r, &totals, &blobs); err != nil {
580+ return fmt.Errorf("preflight tab: %w", err)
597581 }
598582 }
583+ if err := r.done(); err != nil {
584+ return err
585+ }
586+ if totals.Rows > maxSnapshotRows || totals.Cells > maxSnapshotCells || totals.Bytes > maxSnapshotBytes {
587+ return fmt.Errorf("%w: global VT budget", ErrInvalidData)
588+ }
599589 return nil
600590 }
601591
602-type rowBudget struct {
603- cells int
604- runs int
592+func skipString(r *payloadReader) error {
593+ n, err := r.getUint16()
594+ if err != nil {
595+ return err
596+ }
597+ if uint64(n) > uint64(len(r.b)) {
598+ return ErrShortPayload
599+ }
600+ r.b = r.b[n:]
601+ return nil
605602 }
606603
607-func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) {
608- n, err := r.getUint32()
609- if err != nil {
610- return nil, err
604+func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64) error {
605+ if err := skipString(r); err != nil {
606+ return err
611607 }
612- if n > maxDecodedRows {
613- return nil, fmt.Errorf("%w: too many rows", ErrInvalidData)
608+ if _, err := r.getUint16(); err != nil {
609+ return err
614610 }
615- if uint64(n) > uint64(len(r.b))/2 {
616- return nil, ErrShortPayload
611+ if _, err := r.getUint16(); err != nil {
612+ return err
617613 }
618- rows := make([][]renderer.Cell, int(n))
619- for i := range rows {
620- nr, err := r.getUint16()
621- if err != nil {
622- return nil, err
623- }
624- if budget != nil {
625- if int(nr) > budget.runs {
626- return nil, fmt.Errorf("%w: too many runs", ErrInvalidData)
627- }
628- budget.runs -= int(nr)
629- }
630- if uint64(nr) > uint64(len(r.b))/9 {
631- return nil, ErrShortPayload
632- }
633- rowCells := 0
634- for j := 0; j < int(nr); j++ {
635- ln, err := r.getUint16()
636- if err != nil {
637- return nil, err
638- }
639- rn, err := r.getUint32()
640- if err != nil {
641- return nil, err
642- }
643- si, err := r.getUint16()
644- if err != nil {
645- return nil, err
646- }
647- flags, err := r.getUint8()
648- if err != nil {
649- return nil, err
650- }
651- if int(si) >= len(styles) {
652- return nil, ErrStyleOOB
653- }
654- if flags&^1 != 0 {
655- return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData)
656- }
657- if budget != nil {
658- if int(ln) > budget.cells {
659- return nil, fmt.Errorf("%w: too many cells", ErrInvalidData)
660- }
661- budget.cells -= int(ln)
662- }
663- cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0}
664- rowCells += int(ln)
665- rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...)
666- for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ {
667- rows[i][k] = cell
668- }
669- }
670- if rowCells == 0 {
671- rows[i] = nil
672- }
614+ if _, err := r.getUint64(); err != nil {
615+ return err
673616 }
674- return rows, nil
675-}
676-
677-type cellRun struct {
678- len int
679- cell renderer.Cell
680-}
681-
682-func makeRuns(row []renderer.Cell) []cellRun {
683- if len(row) == 0 {
684- return nil
617+ if err := skipString(r); err != nil {
618+ return err
619+ }
620+ if err := preflightNode(r, 0); err != nil {
621+ return err
622+ }
623+ panes, err := r.getUint16()
624+ if err != nil {
625+ return err
626+ }
627+ if uint64(panes) > uint64(len(r.b))/2 {
628+ return ErrShortPayload
685629 }
686- runs := []cellRun{{len: 1, cell: row[0]}}
687- for _, c := range row[1:] {
688- last := &runs[len(runs)-1]
689- if last.cell.Equal(c) && last.len < math.MaxUint16 {
690- last.len++
691- } else {
692- runs = append(runs, cellRun{len: 1, cell: c})
630+ for range panes {
631+ if err := preflightPane(r, totals, blobs); err != nil {
632+ return err
693633 }
694634 }
695- return runs
635+ return nil
696636 }
697637
698-func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell {
699- out := make([][]renderer.Cell, len(rows))
700- for i, row := range rows {
701- out[i] = trimTrailingBlankCells(row)
702- }
703- if trimBlankRows {
704- for len(out) > 0 && len(out[len(out)-1]) == 0 {
705- out = out[:len(out)-1]
638+func preflightNode(r *payloadReader, depth int) error {
639+ if depth > 64 {
640+ return fmt.Errorf("%w: tree depth", ErrInvalidData)
641+ }
642+ kind, err := r.getUint8()
643+ if err != nil {
644+ return err
645+ }
646+ switch kind {
647+ case 0:
648+ return skipString(r)
649+ case 1:
650+ if _, err := r.getUint8(); err != nil {
651+ return err
706652 }
653+ case 2:
654+ if err := skipString(r); err != nil {
655+ return err
656+ }
657+ case 3:
658+ return nil
659+ default:
660+ return fmt.Errorf("%w: node kind", ErrInvalidData)
707661 }
708- return out
709-}
710-
711-func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell {
712- end := len(row)
713- for end > 0 && isBlank(row[end-1]) {
714- end--
662+ children, err := r.getUint16()
663+ if err != nil {
664+ return err
715665 }
716- return append([]renderer.Cell(nil), row[:end]...)
717-}
718-func isBlank(c renderer.Cell) bool {
719- return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle())
720-}
721-
722-func styleTable(p Pane) []renderer.Style {
723- seen := map[renderer.Style]bool{}
724- var styles []renderer.Style
725- addRows := func(rows [][]renderer.Cell, trimBlankRows bool) {
726- for _, row := range trimRows(rows, trimBlankRows) {
727- for _, c := range row {
728- if !seen[c.Style] {
729- seen[c.Style] = true
730- styles = append(styles, c.Style)
731- }
732- }
666+ if uint64(children) > uint64(len(r.b)) {
667+ return ErrShortPayload
668+ }
669+ for range children {
670+ if err := preflightNode(r, depth+1); err != nil {
671+ return err
733672 }
734673 }
735- addRows(p.Scrollback, false)
736- addRows(p.Visible, true)
737- return styles
674+ return nil
738675 }
739676
740-func writeStyle(w *payloadWriter, s renderer.Style) {
741- var flags uint8
742- if s.Bold {
743- flags |= 1
677+func preflightPane(r *payloadReader, totals *vt.DecodeStats, blobs *uint64) error {
678+ if err := skipString(r); err != nil {
679+ return err
680+ }
681+ if err := skipString(r); err != nil {
682+ return err
683+ }
684+ if err := skipString(r); err != nil {
685+ return err
744686 }
745- if s.Inverse {
746- flags |= 2
687+ n, err := r.getUint32()
688+ if err != nil {
689+ return err
747690 }
748- if s.HasForegroundRGB {
749- flags |= 4
691+ if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
692+ return fmt.Errorf("%w: sealed chunks", ErrInvalidData)
693+ }
694+ for range n {
695+ if err := preflightBlob(r, totals, blobs, true); err != nil {
696+ return err
697+ }
750698 }
751- if s.HasBackgroundRGB {
752- flags |= 8
699+ if err := preflightBlob(r, totals, blobs, true); err != nil {
700+ return err
753701 }
754- if s.Italic {
755- flags |= 16
702+ if err := preflightBlob(r, totals, blobs, false); err != nil {
703+ return err
756704 }
757- w.putUint8(flags)
758- w.putUint32(uint32(int32(s.Foreground)))
759- w.putUint32(uint32(int32(s.Background)))
760- w.putUint8(s.ForegroundRGB.R)
761- w.putUint8(s.ForegroundRGB.G)
762- w.putUint8(s.ForegroundRGB.B)
763- w.putUint8(s.BackgroundRGB.R)
764- w.putUint8(s.BackgroundRGB.G)
765- w.putUint8(s.BackgroundRGB.B)
705+ return preflightProcess(r)
766706 }
767707
768-func readStyle(r *payloadReader) (renderer.Style, error) {
769- flags, err := r.getUint8()
708+func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, history bool) error {
709+ n, err := r.getUint32()
770710 if err != nil {
771- return renderer.Style{}, err
711+ return err
772712 }
773- fg, err := r.getUint32()
774- if err != nil {
775- return renderer.Style{}, err
713+ if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes {
714+ return fmt.Errorf("%w: VT blob length", ErrInvalidData)
776715 }
777- bg, err := r.getUint32()
778- if err != nil {
779- return renderer.Style{}, err
716+ if *blobs >= maxSnapshotBlobs {
717+ return fmt.Errorf("%w: too many VT blobs", ErrInvalidData)
780718 }
781- fr, err := r.getUint8()
782- if err != nil {
783- return renderer.Style{}, err
719+ blob := r.b[:n]
720+ r.b = r.b[n:]
721+ var stats vt.DecodeStats
722+ if history {
723+ stats, err = vt.PreflightHistoryBlob(blob)
724+ } else {
725+ stats, err = vt.PreflightVisibleBlob(blob)
784726 }
785- fgc, err := r.getUint8()
786727 if err != nil {
787- return renderer.Style{}, err
728+ return fmt.Errorf("%w: VT blob", ErrInvalidData)
788729 }
789- fb, err := r.getUint8()
790- if err != nil {
791- return renderer.Style{}, err
730+ if !totals.Add(stats) {
731+ return fmt.Errorf("%w: VT budget overflow", ErrInvalidData)
792732 }
793- br, err := r.getUint8()
733+ *blobs++
734+ return nil
735+}
736+
737+func preflightProcess(r *payloadReader) error {
738+ present, err := r.getUint8()
794739 if err != nil {
795- return renderer.Style{}, err
740+ return err
796741 }
797- bgc, err := r.getUint8()
798- if err != nil {
799- return renderer.Style{}, err
742+ if present == 0 {
743+ return nil
744+ }
745+ if present != 1 {
746+ return fmt.Errorf("%w: process presence", ErrInvalidData)
800747 }
801- bb, err := r.getUint8()
748+ n, err := r.getUint16()
802749 if err != nil {
803- return renderer.Style{}, err
750+ return err
751+ }
752+ if n == 0 {
753+ return fmt.Errorf("%w: process argv empty", ErrInvalidData)
754+ }
755+ for range n {
756+ if err := skipString(r); err != nil {
757+ return err
758+ }
759+ }
760+ if err := skipString(r); err != nil {
761+ return err
804762 }
805- return renderer.Style{Bold: flags&1 != 0, Italic: flags&16 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil
763+ return skipString(r)
806764 }
807765
808766 func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
@@ -1,17 +1,14 @@
1 package snapshot1 package snapshot
2 2
3 import (3 import (
4- "bytes"
5- "compress/flate"
6 "encoding/binary"4 "encoding/binary"
7 "errors"5 "errors"
8 "fmt"6 "fmt"
9 "hash/crc32"7 "hash/crc32"
10- "io"
11 "math"8 "math"
12 9
13 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
14- "github.com/bnema/vev/pkg/renderer"11+ "github.com/bnema/vev/pkg/vt"
15 )12 )
16 13
17 var (14 var (
@@ -27,53 +24,34 @@ var (
27 24
28 const (25 const (
29 magic = "VEVS"26 magic = "VEVS"
30- version = uint16(2)27+ version = uint16(3)
31- flagFlate = uint16(1 << 0)
32- flateCutoff = 4 << 10
33 maxDecodedBodySize = 256 << 2028 maxDecodedBodySize = 256 << 20
34- maxDecodedRows = 1 << 2029+ maxSnapshotBlobs = 1 << 16
35- maxDecodedRuns = 16 << 2030+ maxSnapshotBytes = 256 << 20
36- maxDecodedCells = 16 << 2031+ maxSnapshotRows = 1 << 20
32+ maxSnapshotCells = 16 << 20
37 )33 )
38 34
39-// Marshal encodes a v2 durable snapshot.35+// Marshal encodes the strict v3, flags-zero durable envelope.
40 func Marshal(s Session) ([]byte, error) {36 func Marshal(s Session) ([]byte, error) {
41 var w payloadWriter37 var w payloadWriter
42 if err := writeSession(&w, s); err != nil {38 if err := writeSession(&w, s); err != nil {
43 return nil, err39 return nil, err
44 }40 }
45- body := w.b41+ if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 {
46- flags := uint16(0)
47- if len(body) > flateCutoff {
48- var compressed bytes.Buffer
49- fw, err := flate.NewWriter(&compressed, flate.DefaultCompression)
50- if err != nil {
51- return nil, err
52- }
53- if _, err := fw.Write(body); err != nil {
54- _ = fw.Close()
55- return nil, err
56- }
57- if err := fw.Close(); err != nil {
58- return nil, err
59- }
60- body = compressed.Bytes()
61- flags |= flagFlate
62- }
63- if len(body) > math.MaxUint32 {
64 return nil, fmt.Errorf("%w: body too large", ErrInvalidData)42 return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
65 }43 }
66- out := make([]byte, 16+len(body))44+ out := make([]byte, 16+len(w.b))
67 copy(out[:4], magic)45 copy(out[:4], magic)
68 binary.BigEndian.PutUint16(out[4:6], version)46 binary.BigEndian.PutUint16(out[4:6], version)
69- binary.BigEndian.PutUint16(out[6:8], flags)47+ binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b)))
70- binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))48+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b))
71- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))49+ copy(out[16:], w.b)
72- copy(out[16:], body)
73 return out, nil50 return out, nil
74 }51 }
75 52
76-// Unmarshal decodes a strict v2 durable snapshot.53+// Unmarshal validates the complete manifest and all VT blobs without
54+// allocations first, then decodes the already preflighted data.
77 func Unmarshal(b []byte) (Session, error) {55 func Unmarshal(b []byte) (Session, error) {
78 if len(b) < 16 {56 if len(b) < 16 {
79 return Session{}, ErrShortPayload57 return Session{}, ErrShortPayload
@@ -84,38 +62,25 @@ func Unmarshal(b []byte) (Session, error) {
84 if binary.BigEndian.Uint16(b[4:6]) != version {62 if binary.BigEndian.Uint16(b[4:6]) != version {
85 return Session{}, ErrBadVersion63 return Session{}, ErrBadVersion
86 }64 }
87- flags := binary.BigEndian.Uint16(b[6:8])65+ if binary.BigEndian.Uint16(b[6:8]) != 0 {
88- if flags&^flagFlate != 0 {66+ return Session{}, fmt.Errorf("%w: flags", ErrInvalidData)
89- return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData)
90 }67 }
91- bodyLen := int(binary.BigEndian.Uint32(b[8:12]))68+ bodyLen := binary.BigEndian.Uint32(b[8:12])
92 if bodyLen > maxDecodedBodySize {69 if bodyLen > maxDecodedBodySize {
93 return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData)70 return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData)
94 }71 }
95- if len(b) < 16+bodyLen {72+ if uint64(len(b)) < 16+uint64(bodyLen) {
96 return Session{}, ErrShortPayload73 return Session{}, ErrShortPayload
97 }74 }
98- if len(b) != 16+bodyLen {75+ if uint64(len(b)) != 16+uint64(bodyLen) {
99 return Session{}, ErrTrailingBytes76 return Session{}, ErrTrailingBytes
100 }77 }
101 body := b[16:]78 body := b[16:]
102 if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) {79 if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) {
103 return Session{}, ErrBadCRC80 return Session{}, ErrBadCRC
104 }81 }
105- if flags&flagFlate != 0 {82+ if err := preflightSession(body); err != nil {
106- fr := flate.NewReader(bytes.NewReader(body))83+ return Session{}, err
107- decompressed, err := io.ReadAll(io.LimitReader(fr, maxDecodedBodySize+1))
108- cerr := fr.Close()
109- if err != nil {
110- return Session{}, err
111- }
112- if cerr != nil {
113- return Session{}, cerr
114- }
115- if len(decompressed) > maxDecodedBodySize {
116- return Session{}, fmt.Errorf("%w: decompressed body too large", ErrInvalidData)
117- }
118- body = decompressed
119 }84 }
120 r := payloadReader{b: body}85 r := payloadReader{b: body}
121 s, err := readSession(&r)86 s, err := readSession(&r)
@@ -240,6 +205,18 @@ func (r *payloadReader) getStrings() ([]string, error) {
240 }205 }
241 return out, nil206 return out, nil
242 }207 }
208+func (r *payloadReader) getBlob() ([]byte, error) {
209+ n, err := r.getUint32()
210+ if err != nil {
211+ return nil, err
212+ }
213+ if uint64(n) > uint64(len(r.b)) {
214+ return nil, ErrShortPayload
215+ }
216+ blob := append([]byte(nil), r.b[:n]...)
217+ r.b = r.b[n:]
218+ return blob, nil
219+}
243 func (r *payloadReader) done() error {220 func (r *payloadReader) done() error {
244 if len(r.b) != 0 {221 if len(r.b) != 0 {
245 return ErrTrailingBytes222 return ErrTrailingBytes
@@ -464,24 +441,27 @@ func writePane(w *payloadWriter, p Pane) error {
464 if err := w.putString(p.Cwd); err != nil {441 if err := w.putString(p.Cwd); err != nil {
465 return err442 return err
466 }443 }
467- styles := styleTable(p)444+ if len(p.SealedChunks) > maxSnapshotBlobs {
468- if len(styles) > math.MaxUint16 {445+ return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
469- return fmt.Errorf("%w: too many styles", ErrInvalidData)
470- }
471- w.putUint16(uint16(len(styles)))
472- for _, s := range styles {
473- writeStyle(w, s)
474 }446 }
475- idx := make(map[renderer.Style]uint16, len(styles))447+ w.putUint32(uint32(len(p.SealedChunks)))
476- for i, s := range styles {448+ for _, blob := range p.SealedChunks {
477- idx[s] = uint16(i)449+ if len(blob) == 0 || len(blob) > math.MaxUint32 {
450+ return fmt.Errorf("%w: sealed chunk length", ErrInvalidData)
451+ }
452+ w.putUint32(uint32(len(blob)))
453+ w.b = append(w.b, blob...)
478 }454 }
479- if err := writeRows(w, p.Scrollback, idx, false); err != nil {455+ if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 {
480- return err456+ return fmt.Errorf("%w: tail length", ErrInvalidData)
481 }457 }
482- if err := writeRows(w, p.Visible, idx, true); err != nil {458+ w.putUint32(uint32(len(p.Tail)))
483- return err459+ w.b = append(w.b, p.Tail...)
460+ if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {
461+ return fmt.Errorf("%w: visible length", ErrInvalidData)
484 }462 }
463+ w.putUint32(uint32(len(p.Visible)))
464+ w.b = append(w.b, p.Visible...)
485 return writeProcess(w, p.Process)465 return writeProcess(w, p.Process)
486 }466 }
487 467
@@ -498,24 +478,24 @@ func readPane(r *payloadReader) (Pane, error) {
498 if err != nil {478 if err != nil {
499 return Pane{}, err479 return Pane{}, err
500 }480 }
501- ns, err := r.getUint16()481+ n, err := r.getUint32()
502 if err != nil {482 if err != nil {
503 return Pane{}, err483 return Pane{}, err
504 }484 }
505- styles := make([]renderer.Style, ns)485+ if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
506- for i := range styles {486+ return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData)
507- s, err := readStyle(r)487+ }
508- if err != nil {488+ sealed := make([][]byte, n)
489+ for i := range sealed {
490+ if sealed[i], err = r.getBlob(); err != nil {
509 return Pane{}, err491 return Pane{}, err
510 }492 }
511- styles[i] = s
512 }493 }
513- budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns}494+ tail, err := r.getBlob()
514- sb, err := readRows(r, styles, &budget)
515 if err != nil {495 if err != nil {
516 return Pane{}, err496 return Pane{}, err
517 }497 }
518- vis, err := readRows(r, styles, &budget)498+ visible, err := r.getBlob()
519 if err != nil {499 if err != nil {
520 return Pane{}, err500 return Pane{}, err
521 }501 }
@@ -523,7 +503,7 @@ func readPane(r *payloadReader) (Pane, error) {
523 if err != nil {503 if err != nil {
524 return Pane{}, err504 return Pane{}, err
525 }505 }
526- return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil506+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil
527 }507 }
528 508
529 func writeProcess(w *payloadWriter, p *Process) error {509 func writeProcess(w *payloadWriter, p *Process) error {
@@ -573,236 +553,214 @@ func readProcess(r *payloadReader) (*Process, error) {
573 return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil553 return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil
574 }554 }
575 555
576-func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {556+// preflightSession walks framing and opaque VT blobs without constructing a
577- clean := trimRows(rows, trimBlankRows)557+// Session. All count-derived allocation happens only in the second pass.
578- if len(clean) > math.MaxUint32 {558+func preflightSession(body []byte) error {
579- return fmt.Errorf("%w: too many rows", ErrInvalidData)559+ r := payloadReader{b: body}
580- }560+ if err := skipString(&r); err != nil {
581- w.putUint32(uint32(len(clean)))561+ return err
582- for _, row := range clean {562+ }
583- runs := makeRuns(row)563+ if _, err := r.getUint64(); err != nil {
584- if len(runs) > math.MaxUint16 {564+ return err
585- return fmt.Errorf("%w: too many runs", ErrInvalidData)565+ }
586- }566+ if _, err := r.getUint16(); err != nil {
587- w.putUint16(uint16(len(runs)))567+ return err
588- for _, run := range runs {568+ }
589- w.putUint16(uint16(run.len))569+ tabs, err := r.getUint16()
590- w.putUint32(uint32(run.cell.Rune))570+ if err != nil {
591- w.putUint16(idx[run.cell.Style])571+ return err
592- var flags uint8572+ }
593- if run.cell.Continuation {573+ if uint64(tabs) > uint64(len(r.b))/2 {
594- flags |= 1574+ return ErrShortPayload
595- }575+ }
596- w.putUint8(flags)576+ var totals vt.DecodeStats
577+ blobs := uint64(0)
578+ for range tabs {
579+ if err := preflightTab(&r, &totals, &blobs); err != nil {
580+ return fmt.Errorf("preflight tab: %w", err)
597 }581 }
598 }582 }
583+ if err := r.done(); err != nil {
584+ return err
585+ }
586+ if totals.Rows > maxSnapshotRows || totals.Cells > maxSnapshotCells || totals.Bytes > maxSnapshotBytes {
587+ return fmt.Errorf("%w: global VT budget", ErrInvalidData)
588+ }
599 return nil589 return nil
600 }590 }
601 591
602-type rowBudget struct {592+func skipString(r *payloadReader) error {
603- cells int593+ n, err := r.getUint16()
604- runs int594+ if err != nil {
595+ return err
596+ }
597+ if uint64(n) > uint64(len(r.b)) {
598+ return ErrShortPayload
599+ }
600+ r.b = r.b[n:]
601+ return nil
605 }602 }
606 603
607-func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) {604+func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64) error {
608- n, err := r.getUint32()605+ if err := skipString(r); err != nil {
609- if err != nil {606+ return err
610- return nil, err
611 }607 }
612- if n > maxDecodedRows {608+ if _, err := r.getUint16(); err != nil {
613- return nil, fmt.Errorf("%w: too many rows", ErrInvalidData)609+ return err
614 }610 }
615- if uint64(n) > uint64(len(r.b))/2 {611+ if _, err := r.getUint16(); err != nil {
616- return nil, ErrShortPayload612+ return err
617 }613 }
618- rows := make([][]renderer.Cell, int(n))614+ if _, err := r.getUint64(); err != nil {
619- for i := range rows {615+ return err
620- nr, err := r.getUint16()
621- if err != nil {
622- return nil, err
623- }
624- if budget != nil {
625- if int(nr) > budget.runs {
626- return nil, fmt.Errorf("%w: too many runs", ErrInvalidData)
627- }
628- budget.runs -= int(nr)
629- }
630- if uint64(nr) > uint64(len(r.b))/9 {
631- return nil, ErrShortPayload
632- }
633- rowCells := 0
634- for j := 0; j < int(nr); j++ {
635- ln, err := r.getUint16()
636- if err != nil {
637- return nil, err
638- }
639- rn, err := r.getUint32()
640- if err != nil {
641- return nil, err
642- }
643- si, err := r.getUint16()
644- if err != nil {
645- return nil, err
646- }
647- flags, err := r.getUint8()
648- if err != nil {
649- return nil, err
650- }
651- if int(si) >= len(styles) {
652- return nil, ErrStyleOOB
653- }
654- if flags&^1 != 0 {
655- return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData)
656- }
657- if budget != nil {
658- if int(ln) > budget.cells {
659- return nil, fmt.Errorf("%w: too many cells", ErrInvalidData)
660- }
661- budget.cells -= int(ln)
662- }
663- cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0}
664- rowCells += int(ln)
665- rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...)
666- for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ {
667- rows[i][k] = cell
668- }
669- }
670- if rowCells == 0 {
671- rows[i] = nil
672- }
673 }616 }
674- return rows, nil617+ if err := skipString(r); err != nil {
675-}618+ return err
676-619+ }
677-type cellRun struct {620+ if err := preflightNode(r, 0); err != nil {
678- len int621+ return err
679- cell renderer.Cell622+ }
680-}623+ panes, err := r.getUint16()
681-624+ if err != nil {
682-func makeRuns(row []renderer.Cell) []cellRun {625+ return err
683- if len(row) == 0 {626+ }
684- return nil627+ if uint64(panes) > uint64(len(r.b))/2 {
628+ return ErrShortPayload
685 }629 }
686- runs := []cellRun{{len: 1, cell: row[0]}}630+ for range panes {
687- for _, c := range row[1:] {631+ if err := preflightPane(r, totals, blobs); err != nil {
688- last := &runs[len(runs)-1]632+ return err
689- if last.cell.Equal(c) && last.len < math.MaxUint16 {
690- last.len++
691- } else {
692- runs = append(runs, cellRun{len: 1, cell: c})
693 }633 }
694 }634 }
695- return runs635+ return nil
696 }636 }
697 637
698-func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell {638+func preflightNode(r *payloadReader, depth int) error {
699- out := make([][]renderer.Cell, len(rows))639+ if depth > 64 {
700- for i, row := range rows {640+ return fmt.Errorf("%w: tree depth", ErrInvalidData)
701- out[i] = trimTrailingBlankCells(row)641+ }
702- }642+ kind, err := r.getUint8()
703- if trimBlankRows {643+ if err != nil {
704- for len(out) > 0 && len(out[len(out)-1]) == 0 {644+ return err
705- out = out[:len(out)-1]645+ }
646+ switch kind {
647+ case 0:
648+ return skipString(r)
649+ case 1:
650+ if _, err := r.getUint8(); err != nil {
651+ return err
706 }652 }
653+ case 2:
654+ if err := skipString(r); err != nil {
655+ return err
656+ }
657+ case 3:
658+ return nil
659+ default:
660+ return fmt.Errorf("%w: node kind", ErrInvalidData)
707 }661 }
708- return out662+ children, err := r.getUint16()
709-}663+ if err != nil {
710-664+ return err
711-func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell {
712- end := len(row)
713- for end > 0 && isBlank(row[end-1]) {
714- end--
715 }665 }
716- return append([]renderer.Cell(nil), row[:end]...)666+ if uint64(children) > uint64(len(r.b)) {
717-}667+ return ErrShortPayload
718-func isBlank(c renderer.Cell) bool {668+ }
719- return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle())669+ for range children {
720-}670+ if err := preflightNode(r, depth+1); err != nil {
721-671+ return err
722-func styleTable(p Pane) []renderer.Style {
723- seen := map[renderer.Style]bool{}
724- var styles []renderer.Style
725- addRows := func(rows [][]renderer.Cell, trimBlankRows bool) {
726- for _, row := range trimRows(rows, trimBlankRows) {
727- for _, c := range row {
728- if !seen[c.Style] {
729- seen[c.Style] = true
730- styles = append(styles, c.Style)
731- }
732- }
733 }672 }
734 }673 }
735- addRows(p.Scrollback, false)674+ return nil
736- addRows(p.Visible, true)
737- return styles
738 }675 }
739 676
740-func writeStyle(w *payloadWriter, s renderer.Style) {677+func preflightPane(r *payloadReader, totals *vt.DecodeStats, blobs *uint64) error {
741- var flags uint8678+ if err := skipString(r); err != nil {
742- if s.Bold {679+ return err
743- flags |= 1680+ }
681+ if err := skipString(r); err != nil {
682+ return err
683+ }
684+ if err := skipString(r); err != nil {
685+ return err
744 }686 }
745- if s.Inverse {687+ n, err := r.getUint32()
746- flags |= 2688+ if err != nil {
689+ return err
747 }690 }
748- if s.HasForegroundRGB {691+ if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
749- flags |= 4692+ return fmt.Errorf("%w: sealed chunks", ErrInvalidData)
693+ }
694+ for range n {
695+ if err := preflightBlob(r, totals, blobs, true); err != nil {
696+ return err
697+ }
750 }698 }
751- if s.HasBackgroundRGB {699+ if err := preflightBlob(r, totals, blobs, true); err != nil {
752- flags |= 8700+ return err
753 }701 }
754- if s.Italic {702+ if err := preflightBlob(r, totals, blobs, false); err != nil {
755- flags |= 16703+ return err
756 }704 }
757- w.putUint8(flags)705+ return preflightProcess(r)
758- w.putUint32(uint32(int32(s.Foreground)))
759- w.putUint32(uint32(int32(s.Background)))
760- w.putUint8(s.ForegroundRGB.R)
761- w.putUint8(s.ForegroundRGB.G)
762- w.putUint8(s.ForegroundRGB.B)
763- w.putUint8(s.BackgroundRGB.R)
764- w.putUint8(s.BackgroundRGB.G)
765- w.putUint8(s.BackgroundRGB.B)
766 }706 }
767 707
768-func readStyle(r *payloadReader) (renderer.Style, error) {708+func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, history bool) error {
769- flags, err := r.getUint8()709+ n, err := r.getUint32()
770 if err != nil {710 if err != nil {
771- return renderer.Style{}, err711+ return err
772 }712 }
773- fg, err := r.getUint32()713+ if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes {
774- if err != nil {714+ return fmt.Errorf("%w: VT blob length", ErrInvalidData)
775- return renderer.Style{}, err
776 }715 }
777- bg, err := r.getUint32()716+ if *blobs >= maxSnapshotBlobs {
778- if err != nil {717+ return fmt.Errorf("%w: too many VT blobs", ErrInvalidData)
779- return renderer.Style{}, err
780 }718 }
781- fr, err := r.getUint8()719+ blob := r.b[:n]
782- if err != nil {720+ r.b = r.b[n:]
783- return renderer.Style{}, err721+ var stats vt.DecodeStats
722+ if history {
723+ stats, err = vt.PreflightHistoryBlob(blob)
724+ } else {
725+ stats, err = vt.PreflightVisibleBlob(blob)
784 }726 }
785- fgc, err := r.getUint8()
786 if err != nil {727 if err != nil {
787- return renderer.Style{}, err728+ return fmt.Errorf("%w: VT blob", ErrInvalidData)
788 }729 }
789- fb, err := r.getUint8()730+ if !totals.Add(stats) {
790- if err != nil {731+ return fmt.Errorf("%w: VT budget overflow", ErrInvalidData)
791- return renderer.Style{}, err
792 }732 }
793- br, err := r.getUint8()733+ *blobs++
734+ return nil
735+}
736+
737+func preflightProcess(r *payloadReader) error {
738+ present, err := r.getUint8()
794 if err != nil {739 if err != nil {
795- return renderer.Style{}, err740+ return err
796 }741 }
797- bgc, err := r.getUint8()742+ if present == 0 {
798- if err != nil {743+ return nil
799- return renderer.Style{}, err744+ }
745+ if present != 1 {
746+ return fmt.Errorf("%w: process presence", ErrInvalidData)
800 }747 }
801- bb, err := r.getUint8()748+ n, err := r.getUint16()
802 if err != nil {749 if err != nil {
803- return renderer.Style{}, err750+ return err
751+ }
752+ if n == 0 {
753+ return fmt.Errorf("%w: process argv empty", ErrInvalidData)
754+ }
755+ for range n {
756+ if err := skipString(r); err != nil {
757+ return err
758+ }
759+ }
760+ if err := skipString(r); err != nil {
761+ return err
804 }762 }
805- return renderer.Style{Bold: flags&1 != 0, Italic: flags&16 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil763+ return skipString(r)
806 }764 }
807 765
808 func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {766 func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
M internal/usecase/snapshot/codec_test.go +106 -552
@@ -1,7 +1,6 @@
11 package snapshot
22
33 import (
4- "bytes"
54 "encoding/binary"
65 "errors"
76 "hash/crc32"
@@ -10,199 +9,69 @@ import (
109
1110 "github.com/bnema/vev/internal/usecase/layout"
1211 "github.com/bnema/vev/pkg/renderer"
13-)
14-
15-func TestMarshalMinimalGolden(t *testing.T) {
16- got, err := Marshal(Session{Name: "s", CreatedAt: 7})
17- if err != nil {
18- t.Fatalf("Marshal() error = %v", err)
19- }
20- want := []byte{
21- 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
22- 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,
23- }
24- if !bytes.Equal(got, want) {
25- t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want)
26- }
27-}
28-
29-func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
30- cases := []struct {
31- name string
32- tab Tab
33- }{
34- {name: "nil tree", tab: Tab{Tree: nil}},
35- {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
36- }
37- for _, tc := range cases {
38- t.Run(tc.name, func(t *testing.T) {
39- b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
40- if err != nil {
41- t.Fatalf("Marshal() error = %v", err)
42- }
43- got, err := Unmarshal(b)
44- if err != nil {
45- t.Fatalf("Unmarshal() error = %v", err)
46- }
47- if len(got.Tabs) != 1 {
48- t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
49- }
50- if got.Tabs[0].Tree == nil {
51- t.Fatalf("Tree = nil, want tree with nil root")
52- }
53- if got.Tabs[0].Tree.Root != nil {
54- t.Fatalf("Root = %#v, want nil", got.Tabs[0].Tree.Root)
55- }
56- })
57- }
58-}
59-
60-func TestRoundTripSessions(t *testing.T) {
61- boldRGB := renderer.DefaultStyle()
62- boldRGB.Bold = true
63- boldRGB.HasForegroundRGB = true
64- boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
65- indexed := renderer.DefaultStyle()
66- indexed.Foreground = 2
67- indexed.Background = 4
68- indexed.Inverse = true
69- indexed.Italic = true
70- deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
71- layout.NewLeaf("1"),
72- &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}},
73- }}}
74- cjkVisible := [][]renderer.Cell{{
75- {Rune: '好', Style: boldRGB},
76- {Continuation: true, Style: boldRGB},
77- {Rune: 'x', Style: indexed},
78- }}
79- multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
80- {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}},
81- {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
82- }}
83- empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
84- large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}}
85- cases := []struct {
86- name string
87- s Session
88- }{
89- {name: "multi tab deep tree", s: multi},
90- {name: "empty and all blank", s: empty},
91- {name: "large scrollback", s: large},
92- }
93- for _, tc := range cases {
94- t.Run(tc.name, func(t *testing.T) {
95- b, err := Marshal(tc.s)
96- if err != nil {
97- t.Fatalf("Marshal() error = %v", err)
98- }
99- got, err := Unmarshal(b)
100- if err != nil {
101- t.Fatalf("Unmarshal() error = %v", err)
102- }
103- if !equalSession(got, trimSession(tc.s)) {
104- t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s))
105- }
106- })
107- }
108-}
109-
110-// V3 preserves the v2 16-byte big-endian envelope, but replaces its
111-// compressed row payload with a flags-zero manifest of opaque VT blobs.
112-// Snapshot owns only the manifest: sealed chunks are oldest-first and all
113-// chunk, tail, and visible payloads remain self-contained VT bytes.
114-const (
115- snapshotV3Version = uint16(3)
116- v3EnvelopeSize = 16
12+ "github.com/bnema/vev/pkg/vt"
11713 )
11814
11915 func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
12016 indexed := renderer.DefaultStyle()
121- indexed.Bold = true
122- indexed.Inverse = true
17+ indexed.Bold, indexed.Inverse = true, true
12318 indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough
124- indexed.Foreground = 196
125- indexed.Background = 17
126- indexed.UnderlineStyle = renderer.UnderlineCurly
127- indexed.HasUnderlineColor = true
128- indexed.UnderlineColor = 203
129-
19+ indexed.Foreground, indexed.Background = 196, 17
20+ indexed.UnderlineStyle, indexed.HasUnderlineColor, indexed.UnderlineColor = renderer.UnderlineCurly, true, 203
13021 rgb := renderer.DefaultStyle()
131- rgb.Italic = true
132- rgb.HasForegroundRGB = true
133- rgb.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
134- rgb.HasBackgroundRGB = true
135- rgb.BackgroundRGB = renderer.RGB{R: 4, G: 5, B: 6}
136- rgb.UnderlineStyle = renderer.UnderlineDashed
137- rgb.HasUnderlineColorRGB = true
138- rgb.UnderlineColorRGB = renderer.RGB{R: 7, G: 8, B: 9}
139-
140- want := Session{Name: "v3 exact", Tabs: []Tab{{
141- Focus: "p",
142- Tree: layout.NewTree("p"),
143- Panes: []Pane{{ID: "p", Scrollback: [][]renderer.Cell{{
144- {Rune: 'I', Style: indexed},
145- {Rune: '好', Style: rgb},
146- {Continuation: true, Style: rgb},
147- }}, Visible: [][]renderer.Cell{{
148- {Rune: 'V', Style: rgb},
149- {Rune: '界', Style: indexed},
150- {Continuation: true, Style: indexed},
151- }}}},
152- }}}
153-
22+ rgb.Italic, rgb.HasForegroundRGB, rgb.ForegroundRGB = true, true, renderer.RGB{R: 1, G: 2, B: 3}
23+ rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6}
24+ rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9}
25+ sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}})
26+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}})
27+ want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}
15428 encoded, err := Marshal(want)
15529 if err != nil {
156- t.Fatalf("Marshal() error = %v", err)
30+ t.Fatal(err)
15731 }
15832 requireV3Envelope(t, encoded)
15933 got, err := Unmarshal(encoded)
16034 if err != nil {
161- t.Fatalf("Unmarshal() error = %v", err)
35+ t.Fatal(err)
36+ }
37+ if len(got.Tabs) != 1 || len(got.Tabs[0].Panes) != 1 || got.Tabs[0].Panes[0].ID != "p" {
38+ t.Fatalf("manifest round trip = %#v", got)
16239 }
163- if !equalSession(got, want) {
164- t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, want)
40+ if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil {
41+ t.Fatalf("history decode: %v", err)
42+ }
43+ frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible)
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation {
48+ t.Fatalf("visible terminal data lost: %#v", frame.Row(0))
16549 }
16650 }
16751
16852 func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {
169- for _, tc := range []struct {
170- name string
171- version uint16
172- }{
173- {name: "v1", version: 1},
174- {name: "v2", version: 2},
175- } {
176- t.Run(tc.name, func(t *testing.T) {
177- _, err := Unmarshal(v3Envelope(tc.version, 0, nil))
178- if !errors.Is(err, ErrBadVersion) {
179- t.Fatalf("Unmarshal() error = %v, want %v", err, ErrBadVersion)
180- }
181- })
53+ for _, version := range []uint16{1, 2} {
54+ if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
55+ t.Fatalf("version %d: %v", version, err)
56+ }
18257 }
18358 }
18459
18560 func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {
18661 body := v3Manifest(0, nil, nil, 0, nil, 0, nil)
187- valid := v3Envelope(snapshotV3Version, 0, body)
62+ valid := v3Envelope(version, 0, body)
18863 badCRC := append([]byte(nil), valid...)
18964 badCRC[15] ^= 1
190-
19165 for _, tc := range []struct {
19266 name string
19367 data []byte
19468 want error
19569 }{
196- {name: "short envelope", data: valid[:v3EnvelopeSize-1], want: ErrShortPayload},
197- {name: "bad magic", data: append([]byte("NOPE"), valid[4:]...), want: ErrBadMagic},
198- {name: "future version", data: v3Envelope(snapshotV3Version+1, 0, body), want: ErrBadVersion},
199- {name: "nonzero flags", data: v3Envelope(snapshotV3Version, 1, body), want: ErrInvalidData},
200- {name: "bad checksum", data: badCRC, want: ErrBadCRC},
201- {name: "oversized body declaration", data: v3EnvelopeWithLength(snapshotV3Version, 0, nil, math.MaxUint32), want: ErrInvalidData},
70+ {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic},
71+ {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData},
72+ {"crc", badCRC, ErrBadCRC}, {"body", v3EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData},
20273 } {
203- t.Run(tc.name, func(t *testing.T) {
204- assertV3RejectsWithoutPanic(t, tc.data, tc.want)
205- })
74+ t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) })
20675 }
20776 }
20877
@@ -211,442 +80,127 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin
21180 name string
21281 body []byte
21382 }{
214- {name: "sealed chunk count", body: v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},
215- {name: "sealed blob length", body: v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},
216- {name: "tail blob length", body: v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},
217- {name: "visible blob length", body: v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},
218- // Aggregate accounting is snapshot-owned even though each payload is
219- // VT-owned opaque data; declarations must be bounded before a blob can
220- // cause a count-driven allocation.
221- {name: "aggregate opaque blob declarations", body: v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)},
83+ {"count", v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},
84+ {"sealed", v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},
85+ {"tail", v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},
86+ {"visible", v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},
22287 } {
223- t.Run(tc.name, func(t *testing.T) {
224- assertV3RejectsWithoutPanic(t, v3Envelope(snapshotV3Version, 0, tc.body), ErrInvalidData)
225- })
88+ t.Run(tc.name, func(t *testing.T) { reject(t, v3Envelope(version, 0, tc.body), ErrInvalidData) })
22689 }
22790 }
22891
22992 func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
230- encoded, err := Marshal(Session{Name: "v3 prefixes", Tabs: []Tab{{
231- Focus: "p",
232- Tree: layout.NewTree("p"),
233- Panes: []Pane{{ID: "p", Scrollback: rows("history"), Visible: rows("visible")}},
234- }}})
93+ sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})
94+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
95+ encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
23596 if err != nil {
236- t.Fatalf("Marshal() error = %v", err)
97+ t.Fatal(err)
23798 }
238- requireV3Envelope(t, encoded)
23999 for n := range len(encoded) {
240- assertV3RejectsWithoutPanic(t, encoded[:n], nil)
100+ reject(t, encoded[:n], nil)
241101 }
242- assertV3RejectsWithoutPanic(t, append(append([]byte(nil), encoded...), 0), ErrTrailingBytes)
102+ reject(t, append(encoded, 0), ErrTrailingBytes)
243103 }
244104
245-func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
246- good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})
105+func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
106+ sealed, tail := historyBlobs(t, nil)
107+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
108+ encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
247109 if err != nil {
248110 t.Fatal(err)
249111 }
250- cases := []struct {
251- name string
252- data []byte
253- }{
254- {"bad magic", append([]byte("NOPE"), good[4:]...)},
255- {"bad version", replaceU16(good, 4, 1)},
256- {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
257- {"trailing", append(append([]byte(nil), good...), 0)},
258- {"body len overrun", replaceU32(good, 8, 999999)},
259- {"prefix", good[:len(good)/2]},
260- {"style oob", styleOOBSnapshot(t)},
261- {"unknown pane in tree", unknownPaneSnapshot(t)},
262- }
263- for _, tc := range cases {
264- t.Run(tc.name, func(t *testing.T) {
265- defer func() {
266- if r := recover(); r != nil {
267- t.Fatalf("Unmarshal panicked: %v", r)
268- }
269- }()
270- if _, err := Unmarshal(tc.data); err == nil {
271- t.Fatalf("Unmarshal() error = nil")
272- }
273- })
274- }
275- for i := 0; i < len(good); i++ {
276- if _, err := Unmarshal(good[:i]); err == nil {
277- t.Fatalf("prefix length %d unexpectedly succeeded", i)
278- }
279- }
280-}
281-
282-func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
283- _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}})
284- if !errors.Is(err, ErrInvalidData) {
285- t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
286- }
287-}
288-
289-func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
290- var w payloadWriter
291- _ = w.putString("x")
292- w.putUint64(0)
293- w.putUint16(0)
294- w.putUint16(1)
295- _ = w.putString("t")
296- w.putUint16(80)
297- w.putUint16(24)
298- w.putUint64(1)
299- _ = w.putString("p")
300- _ = writeNode(&w, layout.NewLeaf("p"))
301- w.putUint16(1)
302- _ = w.putString("p")
303- _ = w.putString("p_stable")
304- _ = w.putString("")
305- w.putUint16(0)
306- w.putUint32(0)
307- w.putUint32(0)
308- w.putUint8(1)
309- w.putUint16(0)
310- _ = w.putString("generic")
311- _ = w.putString("")
312- _, err := Unmarshal(reheader(w.b, 0))
313- if !errors.Is(err, ErrInvalidData) {
314- t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
315- }
316-}
317-
318-func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
319- body := []byte{0}
320- oversizedRaw := reheader(body, 0)
321- binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1))
322- oversizedFlate := reheader(body, flagFlate)
323- binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1))
324- cases := []struct {
325- name string
326- data []byte
327- }{
328- {name: "raw", data: oversizedRaw},
329- {name: "flate", data: oversizedFlate},
330- }
331- for _, tc := range cases {
332- t.Run(tc.name, func(t *testing.T) {
333- _, err := Unmarshal(tc.data)
334- if !errors.Is(err, ErrInvalidData) {
335- t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
336- }
337- })
112+ if _, err := Unmarshal(encoded); !errors.Is(err, ErrUnknownPane) {
113+ t.Fatalf("error = %v", err)
338114 }
339115 }
340116
341-func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) {
342- cases := []struct {
343- name string
344- data []byte
345- want error
346- }{
347- {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData},
348- {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload},
349- }
350- for _, tc := range cases {
351- t.Run(tc.name, func(t *testing.T) {
352- defer func() {
353- if r := recover(); r != nil {
354- t.Fatalf("Unmarshal panicked: %v", r)
355- }
356- }()
357- _, err := Unmarshal(tc.data)
358- if !errors.Is(err, tc.want) {
359- t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want)
360- }
361- })
362- }
363-}
364-
365-func TestFlateOnOff(t *testing.T) {
366- small, err := Marshal(Session{Name: "small"})
367- if err != nil {
368- t.Fatal(err)
369- }
370- if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 {
371- t.Fatalf("small flags = %#x, want 0", flags)
117+func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
118+ t.Helper()
119+ h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
120+ for _, row := range rows {
121+ h.Append(row)
372122 }
373- large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}})
123+ sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
374124 if err != nil {
375125 t.Fatal(err)
376126 }
377- if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 {
378- t.Fatalf("large flags = %#x, want flate bit", flags)
379- }
127+ return sealed, tail
380128 }
381-
382-func BenchmarkMarshal10KRows(b *testing.B) {
383- s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}}
384- b.ReportAllocs()
385- for i := 0; i < b.N; i++ {
386- if _, err := Marshal(s); err != nil {
387- b.Fatal(err)
388- }
389- }
390-}
391-
392-func rows(lines ...string) [][]renderer.Cell {
393- out := make([][]renderer.Cell, len(lines))
394- for i, line := range lines {
395- for _, r := range line {
396- out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
397- }
398- }
399- return out
400-}
401-
402-func manyRows(n int) [][]renderer.Cell {
403- out := make([][]renderer.Cell, n)
404- for i := range out {
405- out[i] = rows("row data row data row data")[0]
406- }
407- return out
408-}
409-
410-func equalSession(a, b Session) bool {
411- if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {
412- return false
413- }
414- for i := range a.Tabs {
415- if !equalTab(a.Tabs[i], b.Tabs[i]) {
416- return false
417- }
418- }
419- return true
420-}
421-
422-func equalTab(a, b Tab) bool {
423- if a.StableID != b.StableID || a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
424- return false
425- }
426- for i := range a.Panes {
427- if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].StableID != b.Panes[i].StableID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalProcess(a.Panes[i].Process, b.Panes[i].Process) || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
428- return false
429- }
430- }
431- return true
432-}
433-
434-func equalProcess(a, b *Process) bool {
435- if a == nil || b == nil {
436- return a == b
437- }
438- if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) {
439- return false
440- }
441- for i := range a.Argv {
442- if a.Argv[i] != b.Argv[i] {
443- return false
444- }
445- }
446- return true
447-}
448-
449-func treeRoot(t *layout.Tree) *layout.Node {
450- if t == nil {
451- return nil
452- }
453- return t.Root
454-}
455-
456-func equalNode(a, b *layout.Node) bool {
457- if a == nil || b == nil {
458- return a == b
459- }
460- if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) {
461- return false
462- }
463- for i := range a.Children {
464- if !equalNode(a.Children[i], b.Children[i]) {
465- return false
466- }
467- }
468- return true
469-}
470-
471-func equalRows(a, b [][]renderer.Cell) bool {
472- if len(a) != len(b) {
473- return false
474- }
475- for i := range a {
476- if len(a[i]) != len(b[i]) {
477- return false
478- }
479- for j := range a[i] {
480- if !a[i][j].Equal(b[i][j]) {
481- return false
482- }
483- }
484- }
485- return true
486-}
487-
488-func trimSession(s Session) Session {
489- for ti := range s.Tabs {
490- for pi := range s.Tabs[ti].Panes {
491- s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false)
492- s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true)
493- }
494- }
495- return s
496-}
497-
498-func reheader(body []byte, flags uint16) []byte {
499- out := make([]byte, 16+len(body))
500- copy(out[:4], []byte("VEVS"))
501- binary.BigEndian.PutUint16(out[4:6], version)
502- binary.BigEndian.PutUint16(out[6:8], flags)
503- binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
504- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
505- copy(out[16:], body)
506- return out
507-}
508-
509-func replaceU16(b []byte, off int, v uint16) []byte {
510- out := append([]byte(nil), b...)
511- binary.BigEndian.PutUint16(out[off:], v)
512- return out
513-}
514-func replaceU32(b []byte, off int, v uint32) []byte {
515- out := append([]byte(nil), b...)
516- binary.BigEndian.PutUint32(out[off:], v)
517- return out
518-}
519-
520-func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
521- var w payloadWriter
522- _ = w.putString("x")
523- w.putUint64(0)
524- w.putUint16(0)
525- w.putUint16(1)
526- _ = w.putString("")
527- w.putUint16(0)
528- w.putUint16(0)
529- w.putUint64(0)
530- _ = w.putString("p")
531- _ = writeNode(&w, layout.NewLeaf("p"))
532- w.putUint16(1)
533- _ = w.putString("p")
534- _ = w.putString("")
535- _ = w.putString("")
536- w.putUint16(0)
537- w.putUint32(rowCount)
538- if rowCount > 0 {
539- w.putUint16(runCount)
540- }
541- return reheader(w.b, 0)
542-}
543-
544-func styleOOBSnapshot(t *testing.T) []byte {
545- b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}})
546- if err != nil {
547- t.Fatal(err)
129+func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte {
130+ t.Helper()
131+ width := 0
132+ for _, row := range rows {
133+ width = max(width, len(row))
548134 }
549- bodyLen := int(binary.BigEndian.Uint32(b[8:12]))
550- body := append([]byte(nil), b[16:16+bodyLen]...)
551- for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0
552- if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' {
553- body[i+6] = 0
554- body[i+7] = 2
555- return reheader(body, 0)
556- }
135+ f := renderer.NewFrame(width, len(rows))
136+ for y, row := range rows {
137+ copy(f.Row(y), row)
557138 }
558- t.Fatal("run not found")
559- return nil
560-}
561-
562-func unknownPaneSnapshot(t *testing.T) []byte {
563- b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}})
139+ b, err := vt.MarshalVisible(f)
564140 if err != nil {
565141 t.Fatal(err)
566142 }
567143 return b
568144 }
569-
570145 func requireV3Envelope(t *testing.T, data []byte) {
571146 t.Helper()
572- if len(data) < v3EnvelopeSize {
573- t.Fatalf("encoded snapshot is %d bytes, want at least %d", len(data), v3EnvelopeSize)
574- }
575- if got := binary.BigEndian.Uint16(data[4:6]); got != snapshotV3Version {
576- t.Fatalf("snapshot version = %d, want v3", got)
577- }
578- if got := binary.BigEndian.Uint16(data[6:8]); got != 0 {
579- t.Fatalf("snapshot flags = %#x, want 0", got)
147+ if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 {
148+ t.Fatalf("not v3 envelope: % x", data)
580149 }
581150 }
582-
583-func assertV3RejectsWithoutPanic(t *testing.T, data []byte, want error) {
151+func reject(t *testing.T, data []byte, want error) {
584152 t.Helper()
585153 defer func() {
586154 if r := recover(); r != nil {
587- t.Fatalf("Unmarshal panicked: %v", r)
155+ t.Fatalf("panic: %v", r)
588156 }
589157 }()
590158 _, err := Unmarshal(data)
591159 if err == nil {
592- t.Fatal("Unmarshal() error = nil")
160+ t.Fatal("accepted malformed input")
593161 }
594162 if want != nil && !errors.Is(err, want) {
595- t.Fatalf("Unmarshal() error = %v, want %v", err, want)
163+ t.Fatalf("error = %v, want %v", err, want)
596164 }
597165 }
598-
599-func v3Envelope(version, flags uint16, body []byte) []byte {
600- return v3EnvelopeWithLength(version, flags, body, uint32(len(body)))
166+func v3Envelope(v, flags uint16, body []byte) []byte {
167+ return v3EnvelopeWithLength(v, flags, body, uint32(len(body)))
601168 }
602-
603-func v3EnvelopeWithLength(version, flags uint16, body []byte, bodyLen uint32) []byte {
604- out := make([]byte, v3EnvelopeSize+len(body))
605- copy(out[:4], magic)
606- binary.BigEndian.PutUint16(out[4:6], version)
169+func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
170+ out := make([]byte, 16+len(body))
171+ copy(out, magic)
172+ binary.BigEndian.PutUint16(out[4:6], v)
607173 binary.BigEndian.PutUint16(out[6:8], flags)
608- binary.BigEndian.PutUint32(out[8:12], bodyLen)
174+ binary.BigEndian.PutUint32(out[8:12], length)
609175 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
610- copy(out[v3EnvelopeSize:], body)
176+ copy(out[16:], body)
611177 return out
612178 }
613179
614-// v3Manifest writes only snapshot-owned fields. Blob contents are deliberately
615-// opaque: VT validates their style, row, run, cell, and decoded-byte budgets.
616-func v3Manifest(
617- sealedCount uint32,
618- sealedLengths []uint32,
619- sealedBlobs [][]byte,
620- tailLength uint32,
621- tailBlob []byte,
622- visibleLength uint32,
623- visibleBlob []byte,
624-) []byte {
625- var w payloadWriter
626- _ = w.putString("v3")
627- w.putUint64(0)
628- w.putUint16(0)
629- w.putUint16(1)
630- _ = w.putString("tab")
631- w.putUint16(80)
632- w.putUint16(24)
633- w.putUint64(1)
634- _ = w.putString("p")
635- _ = writeNode(&w, layout.NewLeaf("p"))
636- w.putUint16(1)
637- _ = w.putString("p")
638- _ = w.putString("")
639- _ = w.putString("")
640- w.putUint32(sealedCount)
641- for i := range sealedLengths {
642- w.putUint32(sealedLengths[i])
643- if i < len(sealedBlobs) {
644- w.b = append(w.b, sealedBlobs[i]...)
645- }
646- }
647- w.putUint32(tailLength)
648- w.b = append(w.b, tailBlob...)
649- w.putUint32(visibleLength)
650- w.b = append(w.b, visibleBlob...)
651- return w.b
180+// v3Manifest creates just enough session/tab/pane framing for hostile declarations.
181+func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte {
182+ var b []byte
183+ putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
184+ putS("s")
185+ b = append(b, make([]byte, 8+2)...)
186+ b = binary.BigEndian.AppendUint16(b, 1)
187+ putS("")
188+ b = append(b, make([]byte, 2+2+8)...)
189+ putS("p")
190+ b = append(b, 0)
191+ putS("p")
192+ b = binary.BigEndian.AppendUint16(b, 1)
193+ putS("p")
194+ putS("")
195+ putS("")
196+ b = binary.BigEndian.AppendUint32(b, sealed)
197+ for _, l := range sealedLens {
198+ b = binary.BigEndian.AppendUint32(b, l)
199+ }
200+ b = append(b, sealedData...)
201+ b = binary.BigEndian.AppendUint32(b, tailLen)
202+ b = append(b, tailData...)
203+ b = binary.BigEndian.AppendUint32(b, visibleLen)
204+ b = append(b, visibleData...)
205+ return b
652206 }
@@ -1,7 +1,6 @@
1 package snapshot1 package snapshot
2 2
3 import (3 import (
4- "bytes"
5 "encoding/binary"4 "encoding/binary"
6 "errors"5 "errors"
7 "hash/crc32"6 "hash/crc32"
@@ -10,199 +9,69 @@ import (
10 9
11 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
13-)12+ "github.com/bnema/vev/pkg/vt"
14-
15-func TestMarshalMinimalGolden(t *testing.T) {
16- got, err := Marshal(Session{Name: "s", CreatedAt: 7})
17- if err != nil {
18- t.Fatalf("Marshal() error = %v", err)
19- }
20- want := []byte{
21- 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
22- 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,
23- }
24- if !bytes.Equal(got, want) {
25- t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want)
26- }
27-}
28-
29-func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
30- cases := []struct {
31- name string
32- tab Tab
33- }{
34- {name: "nil tree", tab: Tab{Tree: nil}},
35- {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
36- }
37- for _, tc := range cases {
38- t.Run(tc.name, func(t *testing.T) {
39- b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
40- if err != nil {
41- t.Fatalf("Marshal() error = %v", err)
42- }
43- got, err := Unmarshal(b)
44- if err != nil {
45- t.Fatalf("Unmarshal() error = %v", err)
46- }
47- if len(got.Tabs) != 1 {
48- t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
49- }
50- if got.Tabs[0].Tree == nil {
51- t.Fatalf("Tree = nil, want tree with nil root")
52- }
53- if got.Tabs[0].Tree.Root != nil {
54- t.Fatalf("Root = %#v, want nil", got.Tabs[0].Tree.Root)
55- }
56- })
57- }
58-}
59-
60-func TestRoundTripSessions(t *testing.T) {
61- boldRGB := renderer.DefaultStyle()
62- boldRGB.Bold = true
63- boldRGB.HasForegroundRGB = true
64- boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
65- indexed := renderer.DefaultStyle()
66- indexed.Foreground = 2
67- indexed.Background = 4
68- indexed.Inverse = true
69- indexed.Italic = true
70- deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
71- layout.NewLeaf("1"),
72- &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}},
73- }}}
74- cjkVisible := [][]renderer.Cell{{
75- {Rune: '好', Style: boldRGB},
76- {Continuation: true, Style: boldRGB},
77- {Rune: 'x', Style: indexed},
78- }}
79- multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
80- {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}},
81- {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
82- }}
83- empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
84- large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}}
85- cases := []struct {
86- name string
87- s Session
88- }{
89- {name: "multi tab deep tree", s: multi},
90- {name: "empty and all blank", s: empty},
91- {name: "large scrollback", s: large},
92- }
93- for _, tc := range cases {
94- t.Run(tc.name, func(t *testing.T) {
95- b, err := Marshal(tc.s)
96- if err != nil {
97- t.Fatalf("Marshal() error = %v", err)
98- }
99- got, err := Unmarshal(b)
100- if err != nil {
101- t.Fatalf("Unmarshal() error = %v", err)
102- }
103- if !equalSession(got, trimSession(tc.s)) {
104- t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s))
105- }
106- })
107- }
108-}
109-
110-// V3 preserves the v2 16-byte big-endian envelope, but replaces its
111-// compressed row payload with a flags-zero manifest of opaque VT blobs.
112-// Snapshot owns only the manifest: sealed chunks are oldest-first and all
113-// chunk, tail, and visible payloads remain self-contained VT bytes.
114-const (
115- snapshotV3Version = uint16(3)
116- v3EnvelopeSize = 16
117 )13 )
118 14
119 func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {15 func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
120 indexed := renderer.DefaultStyle()16 indexed := renderer.DefaultStyle()
121- indexed.Bold = true17+ indexed.Bold, indexed.Inverse = true, true
122- indexed.Inverse = true
123 indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough18 indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough
124- indexed.Foreground = 19619+ indexed.Foreground, indexed.Background = 196, 17
125- indexed.Background = 1720+ indexed.UnderlineStyle, indexed.HasUnderlineColor, indexed.UnderlineColor = renderer.UnderlineCurly, true, 203
126- indexed.UnderlineStyle = renderer.UnderlineCurly
127- indexed.HasUnderlineColor = true
128- indexed.UnderlineColor = 203
129-
130 rgb := renderer.DefaultStyle()21 rgb := renderer.DefaultStyle()
131- rgb.Italic = true22+ rgb.Italic, rgb.HasForegroundRGB, rgb.ForegroundRGB = true, true, renderer.RGB{R: 1, G: 2, B: 3}
132- rgb.HasForegroundRGB = true23+ rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6}
133- rgb.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}24+ rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9}
134- rgb.HasBackgroundRGB = true25+ sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}})
135- rgb.BackgroundRGB = renderer.RGB{R: 4, G: 5, B: 6}26+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}})
136- rgb.UnderlineStyle = renderer.UnderlineDashed27+ want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}
137- rgb.HasUnderlineColorRGB = true
138- rgb.UnderlineColorRGB = renderer.RGB{R: 7, G: 8, B: 9}
139-
140- want := Session{Name: "v3 exact", Tabs: []Tab{{
141- Focus: "p",
142- Tree: layout.NewTree("p"),
143- Panes: []Pane{{ID: "p", Scrollback: [][]renderer.Cell{{
144- {Rune: 'I', Style: indexed},
145- {Rune: '好', Style: rgb},
146- {Continuation: true, Style: rgb},
147- }}, Visible: [][]renderer.Cell{{
148- {Rune: 'V', Style: rgb},
149- {Rune: '界', Style: indexed},
150- {Continuation: true, Style: indexed},
151- }}}},
152- }}}
153-
154 encoded, err := Marshal(want)28 encoded, err := Marshal(want)
155 if err != nil {29 if err != nil {
156- t.Fatalf("Marshal() error = %v", err)30+ t.Fatal(err)
157 }31 }
158 requireV3Envelope(t, encoded)32 requireV3Envelope(t, encoded)
159 got, err := Unmarshal(encoded)33 got, err := Unmarshal(encoded)
160 if err != nil {34 if err != nil {
161- t.Fatalf("Unmarshal() error = %v", err)35+ t.Fatal(err)
36+ }
37+ if len(got.Tabs) != 1 || len(got.Tabs[0].Panes) != 1 || got.Tabs[0].Panes[0].ID != "p" {
38+ t.Fatalf("manifest round trip = %#v", got)
162 }39 }
163- if !equalSession(got, want) {40+ if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil {
164- t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, want)41+ t.Fatalf("history decode: %v", err)
42+ }
43+ frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible)
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation {
48+ t.Fatalf("visible terminal data lost: %#v", frame.Row(0))
165 }49 }
166 }50 }
167 51
168 func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {52 func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {
169- for _, tc := range []struct {53+ for _, version := range []uint16{1, 2} {
170- name string54+ if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
171- version uint1655+ t.Fatalf("version %d: %v", version, err)
172- }{56+ }
173- {name: "v1", version: 1},
174- {name: "v2", version: 2},
175- } {
176- t.Run(tc.name, func(t *testing.T) {
177- _, err := Unmarshal(v3Envelope(tc.version, 0, nil))
178- if !errors.Is(err, ErrBadVersion) {
179- t.Fatalf("Unmarshal() error = %v, want %v", err, ErrBadVersion)
180- }
181- })
182 }57 }
183 }58 }
184 59
185 func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {60 func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {
186 body := v3Manifest(0, nil, nil, 0, nil, 0, nil)61 body := v3Manifest(0, nil, nil, 0, nil, 0, nil)
187- valid := v3Envelope(snapshotV3Version, 0, body)62+ valid := v3Envelope(version, 0, body)
188 badCRC := append([]byte(nil), valid...)63 badCRC := append([]byte(nil), valid...)
189 badCRC[15] ^= 164 badCRC[15] ^= 1
190-
191 for _, tc := range []struct {65 for _, tc := range []struct {
192 name string66 name string
193 data []byte67 data []byte
194 want error68 want error
195 }{69 }{
196- {name: "short envelope", data: valid[:v3EnvelopeSize-1], want: ErrShortPayload},70+ {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic},
197- {name: "bad magic", data: append([]byte("NOPE"), valid[4:]...), want: ErrBadMagic},71+ {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData},
198- {name: "future version", data: v3Envelope(snapshotV3Version+1, 0, body), want: ErrBadVersion},72+ {"crc", badCRC, ErrBadCRC}, {"body", v3EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData},
199- {name: "nonzero flags", data: v3Envelope(snapshotV3Version, 1, body), want: ErrInvalidData},
200- {name: "bad checksum", data: badCRC, want: ErrBadCRC},
201- {name: "oversized body declaration", data: v3EnvelopeWithLength(snapshotV3Version, 0, nil, math.MaxUint32), want: ErrInvalidData},
202 } {73 } {
203- t.Run(tc.name, func(t *testing.T) {74+ t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) })
204- assertV3RejectsWithoutPanic(t, tc.data, tc.want)
205- })
206 }75 }
207 }76 }
208 77
@@ -211,442 +80,127 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin
211 name string80 name string
212 body []byte81 body []byte
213 }{82 }{
214- {name: "sealed chunk count", body: v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},83+ {"count", v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},
215- {name: "sealed blob length", body: v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},84+ {"sealed", v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},
216- {name: "tail blob length", body: v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},85+ {"tail", v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},
217- {name: "visible blob length", body: v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},86+ {"visible", v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},
218- // Aggregate accounting is snapshot-owned even though each payload is
219- // VT-owned opaque data; declarations must be bounded before a blob can
220- // cause a count-driven allocation.
221- {name: "aggregate opaque blob declarations", body: v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)},
222 } {87 } {
223- t.Run(tc.name, func(t *testing.T) {88+ t.Run(tc.name, func(t *testing.T) { reject(t, v3Envelope(version, 0, tc.body), ErrInvalidData) })
224- assertV3RejectsWithoutPanic(t, v3Envelope(snapshotV3Version, 0, tc.body), ErrInvalidData)
225- })
226 }89 }
227 }90 }
228 91
229 func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {92 func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
230- encoded, err := Marshal(Session{Name: "v3 prefixes", Tabs: []Tab{{93+ sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})
231- Focus: "p",94+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
232- Tree: layout.NewTree("p"),95+ encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
233- Panes: []Pane{{ID: "p", Scrollback: rows("history"), Visible: rows("visible")}},
234- }}})
235 if err != nil {96 if err != nil {
236- t.Fatalf("Marshal() error = %v", err)97+ t.Fatal(err)
237 }98 }
238- requireV3Envelope(t, encoded)
239 for n := range len(encoded) {99 for n := range len(encoded) {
240- assertV3RejectsWithoutPanic(t, encoded[:n], nil)100+ reject(t, encoded[:n], nil)
241 }101 }
242- assertV3RejectsWithoutPanic(t, append(append([]byte(nil), encoded...), 0), ErrTrailingBytes)102+ reject(t, append(encoded, 0), ErrTrailingBytes)
243 }103 }
244 104
245-func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {105+func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
246- good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})106+ sealed, tail := historyBlobs(t, nil)
107+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
108+ encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
247 if err != nil {109 if err != nil {
248 t.Fatal(err)110 t.Fatal(err)
249 }111 }
250- cases := []struct {112+ if _, err := Unmarshal(encoded); !errors.Is(err, ErrUnknownPane) {
251- name string113+ t.Fatalf("error = %v", err)
252- data []byte
253- }{
254- {"bad magic", append([]byte("NOPE"), good[4:]...)},
255- {"bad version", replaceU16(good, 4, 1)},
256- {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
257- {"trailing", append(append([]byte(nil), good...), 0)},
258- {"body len overrun", replaceU32(good, 8, 999999)},
259- {"prefix", good[:len(good)/2]},
260- {"style oob", styleOOBSnapshot(t)},
261- {"unknown pane in tree", unknownPaneSnapshot(t)},
262- }
263- for _, tc := range cases {
264- t.Run(tc.name, func(t *testing.T) {
265- defer func() {
266- if r := recover(); r != nil {
267- t.Fatalf("Unmarshal panicked: %v", r)
268- }
269- }()
270- if _, err := Unmarshal(tc.data); err == nil {
271- t.Fatalf("Unmarshal() error = nil")
272- }
273- })
274- }
275- for i := 0; i < len(good); i++ {
276- if _, err := Unmarshal(good[:i]); err == nil {
277- t.Fatalf("prefix length %d unexpectedly succeeded", i)
278- }
279- }
280-}
281-
282-func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
283- _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}})
284- if !errors.Is(err, ErrInvalidData) {
285- t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
286- }
287-}
288-
289-func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
290- var w payloadWriter
291- _ = w.putString("x")
292- w.putUint64(0)
293- w.putUint16(0)
294- w.putUint16(1)
295- _ = w.putString("t")
296- w.putUint16(80)
297- w.putUint16(24)
298- w.putUint64(1)
299- _ = w.putString("p")
300- _ = writeNode(&w, layout.NewLeaf("p"))
301- w.putUint16(1)
302- _ = w.putString("p")
303- _ = w.putString("p_stable")
304- _ = w.putString("")
305- w.putUint16(0)
306- w.putUint32(0)
307- w.putUint32(0)
308- w.putUint8(1)
309- w.putUint16(0)
310- _ = w.putString("generic")
311- _ = w.putString("")
312- _, err := Unmarshal(reheader(w.b, 0))
313- if !errors.Is(err, ErrInvalidData) {
314- t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
315- }
316-}
317-
318-func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
319- body := []byte{0}
320- oversizedRaw := reheader(body, 0)
321- binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1))
322- oversizedFlate := reheader(body, flagFlate)
323- binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1))
324- cases := []struct {
325- name string
326- data []byte
327- }{
328- {name: "raw", data: oversizedRaw},
329- {name: "flate", data: oversizedFlate},
330- }
331- for _, tc := range cases {
332- t.Run(tc.name, func(t *testing.T) {
333- _, err := Unmarshal(tc.data)
334- if !errors.Is(err, ErrInvalidData) {
335- t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
336- }
337- })
338 }114 }
339 }115 }
340 116
341-func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) {117+func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
342- cases := []struct {118+ t.Helper()
343- name string119+ h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
344- data []byte120+ for _, row := range rows {
345- want error121+ h.Append(row)
346- }{
347- {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData},
348- {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload},
349- }
350- for _, tc := range cases {
351- t.Run(tc.name, func(t *testing.T) {
352- defer func() {
353- if r := recover(); r != nil {
354- t.Fatalf("Unmarshal panicked: %v", r)
355- }
356- }()
357- _, err := Unmarshal(tc.data)
358- if !errors.Is(err, tc.want) {
359- t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want)
360- }
361- })
362- }
363-}
364-
365-func TestFlateOnOff(t *testing.T) {
366- small, err := Marshal(Session{Name: "small"})
367- if err != nil {
368- t.Fatal(err)
369- }
370- if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 {
371- t.Fatalf("small flags = %#x, want 0", flags)
372 }122 }
373- large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}})123+ sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
374 if err != nil {124 if err != nil {
375 t.Fatal(err)125 t.Fatal(err)
376 }126 }
377- if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 {127+ return sealed, tail
378- t.Fatalf("large flags = %#x, want flate bit", flags)
379- }
380 }128 }
381-129+func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte {
382-func BenchmarkMarshal10KRows(b *testing.B) {130+ t.Helper()
383- s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}}131+ width := 0
384- b.ReportAllocs()132+ for _, row := range rows {
385- for i := 0; i < b.N; i++ {133+ width = max(width, len(row))
386- if _, err := Marshal(s); err != nil {
387- b.Fatal(err)
388- }
389- }
390-}
391-
392-func rows(lines ...string) [][]renderer.Cell {
393- out := make([][]renderer.Cell, len(lines))
394- for i, line := range lines {
395- for _, r := range line {
396- out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
397- }
398- }
399- return out
400-}
401-
402-func manyRows(n int) [][]renderer.Cell {
403- out := make([][]renderer.Cell, n)
404- for i := range out {
405- out[i] = rows("row data row data row data")[0]
406- }
407- return out
408-}
409-
410-func equalSession(a, b Session) bool {
411- if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {
412- return false
413- }
414- for i := range a.Tabs {
415- if !equalTab(a.Tabs[i], b.Tabs[i]) {
416- return false
417- }
418- }
419- return true
420-}
421-
422-func equalTab(a, b Tab) bool {
423- if a.StableID != b.StableID || a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
424- return false
425- }
426- for i := range a.Panes {
427- if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].StableID != b.Panes[i].StableID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalProcess(a.Panes[i].Process, b.Panes[i].Process) || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
428- return false
429- }
430- }
431- return true
432-}
433-
434-func equalProcess(a, b *Process) bool {
435- if a == nil || b == nil {
436- return a == b
437- }
438- if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) {
439- return false
440- }
441- for i := range a.Argv {
442- if a.Argv[i] != b.Argv[i] {
443- return false
444- }
445- }
446- return true
447-}
448-
449-func treeRoot(t *layout.Tree) *layout.Node {
450- if t == nil {
451- return nil
452- }
453- return t.Root
454-}
455-
456-func equalNode(a, b *layout.Node) bool {
457- if a == nil || b == nil {
458- return a == b
459- }
460- if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) {
461- return false
462- }
463- for i := range a.Children {
464- if !equalNode(a.Children[i], b.Children[i]) {
465- return false
466- }
467- }
468- return true
469-}
470-
471-func equalRows(a, b [][]renderer.Cell) bool {
472- if len(a) != len(b) {
473- return false
474- }
475- for i := range a {
476- if len(a[i]) != len(b[i]) {
477- return false
478- }
479- for j := range a[i] {
480- if !a[i][j].Equal(b[i][j]) {
481- return false
482- }
483- }
484- }
485- return true
486-}
487-
488-func trimSession(s Session) Session {
489- for ti := range s.Tabs {
490- for pi := range s.Tabs[ti].Panes {
491- s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false)
492- s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true)
493- }
494- }
495- return s
496-}
497-
498-func reheader(body []byte, flags uint16) []byte {
499- out := make([]byte, 16+len(body))
500- copy(out[:4], []byte("VEVS"))
501- binary.BigEndian.PutUint16(out[4:6], version)
502- binary.BigEndian.PutUint16(out[6:8], flags)
503- binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
504- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
505- copy(out[16:], body)
506- return out
507-}
508-
509-func replaceU16(b []byte, off int, v uint16) []byte {
510- out := append([]byte(nil), b...)
511- binary.BigEndian.PutUint16(out[off:], v)
512- return out
513-}
514-func replaceU32(b []byte, off int, v uint32) []byte {
515- out := append([]byte(nil), b...)
516- binary.BigEndian.PutUint32(out[off:], v)
517- return out
518-}
519-
520-func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
521- var w payloadWriter
522- _ = w.putString("x")
523- w.putUint64(0)
524- w.putUint16(0)
525- w.putUint16(1)
526- _ = w.putString("")
527- w.putUint16(0)
528- w.putUint16(0)
529- w.putUint64(0)
530- _ = w.putString("p")
531- _ = writeNode(&w, layout.NewLeaf("p"))
532- w.putUint16(1)
533- _ = w.putString("p")
534- _ = w.putString("")
535- _ = w.putString("")
536- w.putUint16(0)
537- w.putUint32(rowCount)
538- if rowCount > 0 {
539- w.putUint16(runCount)
540- }
541- return reheader(w.b, 0)
542-}
543-
544-func styleOOBSnapshot(t *testing.T) []byte {
545- b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}})
546- if err != nil {
547- t.Fatal(err)
548 }134 }
549- bodyLen := int(binary.BigEndian.Uint32(b[8:12]))135+ f := renderer.NewFrame(width, len(rows))
550- body := append([]byte(nil), b[16:16+bodyLen]...)136+ for y, row := range rows {
551- for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0137+ copy(f.Row(y), row)
552- if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' {
553- body[i+6] = 0
554- body[i+7] = 2
555- return reheader(body, 0)
556- }
557 }138 }
558- t.Fatal("run not found")139+ b, err := vt.MarshalVisible(f)
559- return nil
560-}
561-
562-func unknownPaneSnapshot(t *testing.T) []byte {
563- b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}})
564 if err != nil {140 if err != nil {
565 t.Fatal(err)141 t.Fatal(err)
566 }142 }
567 return b143 return b
568 }144 }
569-
570 func requireV3Envelope(t *testing.T, data []byte) {145 func requireV3Envelope(t *testing.T, data []byte) {
571 t.Helper()146 t.Helper()
572- if len(data) < v3EnvelopeSize {147+ if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 {
573- t.Fatalf("encoded snapshot is %d bytes, want at least %d", len(data), v3EnvelopeSize)148+ t.Fatalf("not v3 envelope: % x", data)
574- }
575- if got := binary.BigEndian.Uint16(data[4:6]); got != snapshotV3Version {
576- t.Fatalf("snapshot version = %d, want v3", got)
577- }
578- if got := binary.BigEndian.Uint16(data[6:8]); got != 0 {
579- t.Fatalf("snapshot flags = %#x, want 0", got)
580 }149 }
581 }150 }
582-151+func reject(t *testing.T, data []byte, want error) {
583-func assertV3RejectsWithoutPanic(t *testing.T, data []byte, want error) {
584 t.Helper()152 t.Helper()
585 defer func() {153 defer func() {
586 if r := recover(); r != nil {154 if r := recover(); r != nil {
587- t.Fatalf("Unmarshal panicked: %v", r)155+ t.Fatalf("panic: %v", r)
588 }156 }
589 }()157 }()
590 _, err := Unmarshal(data)158 _, err := Unmarshal(data)
591 if err == nil {159 if err == nil {
592- t.Fatal("Unmarshal() error = nil")160+ t.Fatal("accepted malformed input")
593 }161 }
594 if want != nil && !errors.Is(err, want) {162 if want != nil && !errors.Is(err, want) {
595- t.Fatalf("Unmarshal() error = %v, want %v", err, want)163+ t.Fatalf("error = %v, want %v", err, want)
596 }164 }
597 }165 }
598-166+func v3Envelope(v, flags uint16, body []byte) []byte {
599-func v3Envelope(version, flags uint16, body []byte) []byte {167+ return v3EnvelopeWithLength(v, flags, body, uint32(len(body)))
600- return v3EnvelopeWithLength(version, flags, body, uint32(len(body)))
601 }168 }
602-169+func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
603-func v3EnvelopeWithLength(version, flags uint16, body []byte, bodyLen uint32) []byte {170+ out := make([]byte, 16+len(body))
604- out := make([]byte, v3EnvelopeSize+len(body))171+ copy(out, magic)
605- copy(out[:4], magic)172+ binary.BigEndian.PutUint16(out[4:6], v)
606- binary.BigEndian.PutUint16(out[4:6], version)
607 binary.BigEndian.PutUint16(out[6:8], flags)173 binary.BigEndian.PutUint16(out[6:8], flags)
608- binary.BigEndian.PutUint32(out[8:12], bodyLen)174+ binary.BigEndian.PutUint32(out[8:12], length)
609 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))175 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
610- copy(out[v3EnvelopeSize:], body)176+ copy(out[16:], body)
611 return out177 return out
612 }178 }
613 179
614-// v3Manifest writes only snapshot-owned fields. Blob contents are deliberately180+// v3Manifest creates just enough session/tab/pane framing for hostile declarations.
615-// opaque: VT validates their style, row, run, cell, and decoded-byte budgets.181+func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte {
616-func v3Manifest(182+ var b []byte
617- sealedCount uint32,183+ putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
618- sealedLengths []uint32,184+ putS("s")
619- sealedBlobs [][]byte,185+ b = append(b, make([]byte, 8+2)...)
620- tailLength uint32,186+ b = binary.BigEndian.AppendUint16(b, 1)
621- tailBlob []byte,187+ putS("")
622- visibleLength uint32,188+ b = append(b, make([]byte, 2+2+8)...)
623- visibleBlob []byte,189+ putS("p")
624-) []byte {190+ b = append(b, 0)
625- var w payloadWriter191+ putS("p")
626- _ = w.putString("v3")192+ b = binary.BigEndian.AppendUint16(b, 1)
627- w.putUint64(0)193+ putS("p")
628- w.putUint16(0)194+ putS("")
629- w.putUint16(1)195+ putS("")
630- _ = w.putString("tab")196+ b = binary.BigEndian.AppendUint32(b, sealed)
631- w.putUint16(80)197+ for _, l := range sealedLens {
632- w.putUint16(24)198+ b = binary.BigEndian.AppendUint32(b, l)
633- w.putUint64(1)199+ }
634- _ = w.putString("p")200+ b = append(b, sealedData...)
635- _ = writeNode(&w, layout.NewLeaf("p"))201+ b = binary.BigEndian.AppendUint32(b, tailLen)
636- w.putUint16(1)202+ b = append(b, tailData...)
637- _ = w.putString("p")203+ b = binary.BigEndian.AppendUint32(b, visibleLen)
638- _ = w.putString("")204+ b = append(b, visibleData...)
639- _ = w.putString("")205+ return b
640- w.putUint32(sealedCount)
641- for i := range sealedLengths {
642- w.putUint32(sealedLengths[i])
643- if i < len(sealedBlobs) {
644- w.b = append(w.b, sealedBlobs[i]...)
645- }
646- }
647- w.putUint32(tailLength)
648- w.b = append(w.b, tailBlob...)
649- w.putUint32(visibleLength)
650- w.b = append(w.b, visibleBlob...)
651- return w.b
652 }206 }
M internal/usecase/snapshot/snapshot.go +10 -12
@@ -1,9 +1,6 @@
11 package snapshot
22
3-import (
4- "github.com/bnema/vev/internal/usecase/layout"
5- "github.com/bnema/vev/pkg/renderer"
6-)
3+import "github.com/bnema/vev/internal/usecase/layout"
74
85 // Session is the durable transfer representation for a named daemon session.
96 type Session struct {
@@ -24,15 +21,16 @@ type Tab struct {
2421 Panes []Pane
2522 }
2623
27-// Pane captures terminal state needed to restore a fresh shell in the same cwd
28-// with its scrollback and final visible primary screen.
24+// Pane captures terminal state as opaque, canonical VT blobs. Snapshot owns
25+// their length framing and order; pkg/vt owns their contents and validation.
2926 type Pane struct {
30- ID layout.PaneID
31- StableID string
32- Cwd string
33- Scrollback [][]renderer.Cell
34- Visible [][]renderer.Cell
35- Process *Process
27+ ID layout.PaneID
28+ StableID string
29+ Cwd string
30+ SealedChunks [][]byte // oldest-first, each a self-contained VT blob
31+ Tail []byte // mandatory nonempty canonical VT blob
32+ Visible []byte // mandatory nonempty canonical VT blob
33+ Process *Process
3634 }
3735
3836 // Process captures restorable foreground process metadata.
@@ -1,9 +1,6 @@
1 package snapshot1 package snapshot
2 2
3-import (3+import "github.com/bnema/vev/internal/usecase/layout"
4- "github.com/bnema/vev/internal/usecase/layout"
5- "github.com/bnema/vev/pkg/renderer"
6-)
7 4
8 // Session is the durable transfer representation for a named daemon session.5 // Session is the durable transfer representation for a named daemon session.
9 type Session struct {6 type Session struct {
@@ -24,15 +21,16 @@ type Tab struct {
24 Panes []Pane21 Panes []Pane
25 }22 }
26 23
27-// Pane captures terminal state needed to restore a fresh shell in the same cwd24+// Pane captures terminal state as opaque, canonical VT blobs. Snapshot owns
28-// with its scrollback and final visible primary screen.25+// their length framing and order; pkg/vt owns their contents and validation.
29 type Pane struct {26 type Pane struct {
30- ID layout.PaneID27+ ID layout.PaneID
31- StableID string28+ StableID string
32- Cwd string29+ Cwd string
33- Scrollback [][]renderer.Cell30+ SealedChunks [][]byte // oldest-first, each a self-contained VT blob
34- Visible [][]renderer.Cell31+ Tail []byte // mandatory nonempty canonical VT blob
35- Process *Process32+ Visible []byte // mandatory nonempty canonical VT blob
33+ Process *Process
36 }34 }
37 35
38 // Process captures restorable foreground process metadata.36 // Process captures restorable foreground process metadata.
M internal/usecase/visualsearch/model_test.go +2 -2
@@ -61,14 +61,14 @@ func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
6161 require.Equal(t, "alpha", matches[0].Text)
6262 }
6363
64-func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) {
64+func TestVisualSearchCloneRetainsDocumentAndKeepsUIStateIndependent(t *testing.T) {
6565 m := New(testSnapshot("alpha", "beta alpha"))
6666 for _, r := range "alpha" {
6767 m.Insert(r)
6868 }
6969 clone := m.Clone()
7070
71- require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0])
71+ require.Equal(t, m.snapshot.Row(0), clone.snapshot.Row(0))
7272 clone.Down()
7373 clone.Insert('z')
7474
@@ -61,14 +61,14 @@ func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
61 require.Equal(t, "alpha", matches[0].Text)61 require.Equal(t, "alpha", matches[0].Text)
62 }62 }
63 63
64-func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) {64+func TestVisualSearchCloneRetainsDocumentAndKeepsUIStateIndependent(t *testing.T) {
65 m := New(testSnapshot("alpha", "beta alpha"))65 m := New(testSnapshot("alpha", "beta alpha"))
66 for _, r := range "alpha" {66 for _, r := range "alpha" {
67 m.Insert(r)67 m.Insert(r)
68 }68 }
69 clone := m.Clone()69 clone := m.Clone()
70 70
71- require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0])71+ require.Equal(t, m.snapshot.Row(0), clone.snapshot.Row(0))
72 clone.Down()72 clone.Down()
73 clone.Insert('z')73 clone.Insert('z')
74 74
M pkg/vt/history.go +25 -0
@@ -76,6 +76,15 @@ func (h *History) evict() {
7676 }
7777 }
7878
79+// SealAndView rotates the mutable tail into an immutable chunk, then captures
80+// the sealed chunks by identity. Callers must synchronize access to History.
81+func (h *History) SealAndView() HistoryView {
82+ if h != nil {
83+ h.sealTail()
84+ }
85+ return h.View()
86+}
87+
7988 // View captures the current history. Sealed chunks are shared by identity; a
8089 // partially-filled tail is copied into a new immutable chunk for this view.
8190 func (h *History) View() HistoryView {
@@ -93,6 +102,22 @@ func (h *History) View() HistoryView {
93102 return HistoryView{chunks: chunks, rows: h.rows}
94103 }
95104
105+// Len returns the currently retained history row count.
106+func (h *History) Len() int {
107+ if h == nil {
108+ return 0
109+ }
110+ return h.rows
111+}
112+
113+// Cap returns the configured bounded row capacity.
114+func (h *History) Cap() int {
115+ if h == nil {
116+ return 0
117+ }
118+ return h.maxRows
119+}
120+
96121 func (v HistoryView) Len() int { return v.rows }
97122 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
98123
@@ -76,6 +76,15 @@ func (h *History) evict() {
76 }76 }
77 }77 }
78 78
79+// SealAndView rotates the mutable tail into an immutable chunk, then captures
80+// the sealed chunks by identity. Callers must synchronize access to History.
81+func (h *History) SealAndView() HistoryView {
82+ if h != nil {
83+ h.sealTail()
84+ }
85+ return h.View()
86+}
87+
79 // View captures the current history. Sealed chunks are shared by identity; a88 // View captures the current history. Sealed chunks are shared by identity; a
80 // partially-filled tail is copied into a new immutable chunk for this view.89 // partially-filled tail is copied into a new immutable chunk for this view.
81 func (h *History) View() HistoryView {90 func (h *History) View() HistoryView {
@@ -93,6 +102,22 @@ func (h *History) View() HistoryView {
93 return HistoryView{chunks: chunks, rows: h.rows}102 return HistoryView{chunks: chunks, rows: h.rows}
94 }103 }
95 104
105+// Len returns the currently retained history row count.
106+func (h *History) Len() int {
107+ if h == nil {
108+ return 0
109+ }
110+ return h.rows
111+}
112+
113+// Cap returns the configured bounded row capacity.
114+func (h *History) Cap() int {
115+ if h == nil {
116+ return 0
117+ }
118+ return h.maxRows
119+}
120+
96 func (v HistoryView) Len() int { return v.rows }121 func (v HistoryView) Len() int { return v.rows }
97 func (v HistoryView) ChunkCount() int { return len(v.chunks) }122 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
98 123
M pkg/vt/snapshot.go +193 -1
@@ -1,6 +1,180 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "math"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+const visibleMagic = "VTV1"
12+
13+// DecodeStats describes resources declared by one canonical VT blob.
14+type DecodeStats struct {
15+ Chunks uint64
16+ Rows uint64
17+ Cells uint64
18+ Styles uint64
19+ Bytes uint64
20+}
21+
22+// Add adds another preflight result with overflow checking.
23+func (s *DecodeStats) Add(other DecodeStats) bool {
24+ for _, totals := range [][2]*uint64{{&s.Chunks, &other.Chunks}, {&s.Rows, &other.Rows}, {&s.Cells, &other.Cells}, {&s.Styles, &other.Styles}, {&s.Bytes, &other.Bytes}} {
25+ if math.MaxUint64-*totals[0] < *totals[1] {
26+ return false
27+ }
28+ *totals[0] += *totals[1]
29+ }
30+ return true
31+}
32+
33+// PreflightHistoryBlob validates one self-contained history blob without
34+// allocating decoded rows.
35+func PreflightHistoryBlob(data []byte) (DecodeStats, error) {
36+ if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
37+ return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory)
38+ }
39+ stats, ok := preflightHistory(data[5:])
40+ if !ok {
41+ return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory)
42+ }
43+ return DecodeStats{Chunks: stats.chunks, Rows: stats.rows, Cells: stats.cells, Styles: stats.styles, Bytes: stats.bytes}, nil
44+}
45+
46+// MarshalHistoryChunk encodes one immutable chunk as a self-contained blob.
47+func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) {
48+ if chunk == nil {
49+ return nil, fmt.Errorf("marshal history chunk: %w", errInvalidHistory)
50+ }
51+ return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: len(chunk.rows)})
52+}
53+
54+// MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob.
55+func MarshalEmptyHistoryTail() ([]byte, error) { return MarshalHistory(HistoryView{}) }
56+
57+// MarshalSealedHistory serializes a SealAndView result as oldest-first,
58+// self-contained sealed blobs plus a mandatory empty canonical tail blob.
59+func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
60+ if view.rows != historyViewRowCount(view) {
61+ return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory)
62+ }
63+ sealed := make([][]byte, len(view.chunks))
64+ for i, chunk := range view.chunks {
65+ blob, err := MarshalHistoryChunk(chunk)
66+ if err != nil {
67+ return nil, nil, err
68+ }
69+ sealed[i] = blob
70+ }
71+ tail, err := MarshalEmptyHistoryTail()
72+ if err != nil {
73+ return nil, nil, err
74+ }
75+ return sealed, tail, nil
76+}
77+
78+// HistoryFromBlobs restores history directly from sealed, oldest-first blobs
79+// and the mandatory tail blob. It never feeds decoded rows through Append.
80+func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
81+ h := NewHistory(config)
82+ for _, blob := range sealed {
83+ view, err := UnmarshalHistory(blob)
84+ if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {
85+ return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
86+ }
87+ h.chunks = append(h.chunks, view.chunks[0])
88+ h.rows += len(view.chunks[0].rows)
89+ }
90+ view, err := UnmarshalHistory(tail)
91+ if err != nil || len(view.chunks) > 1 {
92+ return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)
93+ }
94+ if len(view.chunks) == 1 {
95+ h.tail = view.chunks[0].rows
96+ h.rows += len(h.tail)
97+ }
98+ h.evict()
99+ return h, nil
100+}
101+
102+// MarshalVisible encodes the exact visible primary frame, including every
103+// blank, row width, and terminal cell attribute.
104+func MarshalVisible(frame renderer.Frame) ([]byte, error) {
105+ if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 {
106+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
107+ }
108+ cells := uint64(frame.Width) * uint64(frame.Height)
109+ bytes, ok := historyCellByteCount(cells)
110+ if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {
111+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
112+ }
113+ out := make([]byte, 0, 13+bytes)
114+ out = append(out, visibleMagic...)
115+ out = append(out, historyVersion)
116+ out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))
117+ out = binary.BigEndian.AppendUint32(out, uint32(frame.Height))
118+ for y := range frame.Height {
119+ for _, cell := range frame.Row(y) {
120+ if !validHistoryCell(cell) {
121+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
122+ }
123+ out = appendHistoryCell(out, cell)
124+ }
125+ }
126+ return out, nil
127+}
128+
129+// PreflightVisibleBlob validates dimensions and cell declarations without
130+// allocating a frame.
131+func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
132+ if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion {
133+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
134+ }
135+ width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))
136+ if width != 0 && height > math.MaxUint64/width {
137+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
138+ }
139+ cells := width * height
140+ bytes, ok := historyCellByteCount(cells)
141+ if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes {
142+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
143+ }
144+ p := historyParser{data: data[13:]}
145+ for range cells {
146+ cell, ok := p.cell()
147+ if !ok || !validHistoryCell(cell) {
148+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
149+ }
150+ }
151+ return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil
152+}
153+
154+// UnmarshalVisible decodes a preflighted exact visible frame.
155+func UnmarshalVisible(data []byte) (renderer.Frame, error) {
156+ if _, err := PreflightVisibleBlob(data); err != nil {
157+ return renderer.Frame{}, err
158+ }
159+ width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13]))
160+ frame := renderer.NewFrame(width, height)
161+ p := historyParser{data: data[13:]}
162+ for y := range height {
163+ for x := range width {
164+ cell, _ := p.cell()
165+ frame.Set(x, y, cell)
166+ }
167+ }
168+ return frame, nil
169+}
170+
171+// PrimaryVisibleFrame returns a deep copy of the exact visible primary frame.
172+func (s *Screen) PrimaryVisibleFrame() renderer.Frame {
173+ if s.alternate != nil {
174+ return s.alternate.frame.Clone()
175+ }
176+ return s.Frame.Clone()
177+}
4178
5179 // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows.
6180 // When the alternate screen is active, it snapshots the saved primary screen
@@ -16,3 +190,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell {
16190 }
17191 return rows
18192 }
193+
194+// RestorePrimaryVisible replaces the primary frame from an exact visible blob.
195+func (s *Screen) RestorePrimaryVisible(blob []byte) error {
196+ frame, err := UnmarshalVisible(blob)
197+ if err != nil {
198+ return err
199+ }
200+ // A persisted primary frame is authoritative: restoring it directly keeps
201+ // exact row widths and height even when a collapsed layout chose a smaller
202+ // initial PTY size. A subsequent resize reconciles it with live geometry.
203+ if s.alternate != nil {
204+ s.alternate.frame = frame
205+ } else {
206+ s.Frame = frame
207+ }
208+ s.fullRedraw()
209+ return nil
210+}
@@ -1,6 +1,180 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "math"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+const visibleMagic = "VTV1"
12+
13+// DecodeStats describes resources declared by one canonical VT blob.
14+type DecodeStats struct {
15+ Chunks uint64
16+ Rows uint64
17+ Cells uint64
18+ Styles uint64
19+ Bytes uint64
20+}
21+
22+// Add adds another preflight result with overflow checking.
23+func (s *DecodeStats) Add(other DecodeStats) bool {
24+ for _, totals := range [][2]*uint64{{&s.Chunks, &other.Chunks}, {&s.Rows, &other.Rows}, {&s.Cells, &other.Cells}, {&s.Styles, &other.Styles}, {&s.Bytes, &other.Bytes}} {
25+ if math.MaxUint64-*totals[0] < *totals[1] {
26+ return false
27+ }
28+ *totals[0] += *totals[1]
29+ }
30+ return true
31+}
32+
33+// PreflightHistoryBlob validates one self-contained history blob without
34+// allocating decoded rows.
35+func PreflightHistoryBlob(data []byte) (DecodeStats, error) {
36+ if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
37+ return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory)
38+ }
39+ stats, ok := preflightHistory(data[5:])
40+ if !ok {
41+ return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory)
42+ }
43+ return DecodeStats{Chunks: stats.chunks, Rows: stats.rows, Cells: stats.cells, Styles: stats.styles, Bytes: stats.bytes}, nil
44+}
45+
46+// MarshalHistoryChunk encodes one immutable chunk as a self-contained blob.
47+func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) {
48+ if chunk == nil {
49+ return nil, fmt.Errorf("marshal history chunk: %w", errInvalidHistory)
50+ }
51+ return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: len(chunk.rows)})
52+}
53+
54+// MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob.
55+func MarshalEmptyHistoryTail() ([]byte, error) { return MarshalHistory(HistoryView{}) }
56+
57+// MarshalSealedHistory serializes a SealAndView result as oldest-first,
58+// self-contained sealed blobs plus a mandatory empty canonical tail blob.
59+func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
60+ if view.rows != historyViewRowCount(view) {
61+ return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory)
62+ }
63+ sealed := make([][]byte, len(view.chunks))
64+ for i, chunk := range view.chunks {
65+ blob, err := MarshalHistoryChunk(chunk)
66+ if err != nil {
67+ return nil, nil, err
68+ }
69+ sealed[i] = blob
70+ }
71+ tail, err := MarshalEmptyHistoryTail()
72+ if err != nil {
73+ return nil, nil, err
74+ }
75+ return sealed, tail, nil
76+}
77+
78+// HistoryFromBlobs restores history directly from sealed, oldest-first blobs
79+// and the mandatory tail blob. It never feeds decoded rows through Append.
80+func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
81+ h := NewHistory(config)
82+ for _, blob := range sealed {
83+ view, err := UnmarshalHistory(blob)
84+ if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {
85+ return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
86+ }
87+ h.chunks = append(h.chunks, view.chunks[0])
88+ h.rows += len(view.chunks[0].rows)
89+ }
90+ view, err := UnmarshalHistory(tail)
91+ if err != nil || len(view.chunks) > 1 {
92+ return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)
93+ }
94+ if len(view.chunks) == 1 {
95+ h.tail = view.chunks[0].rows
96+ h.rows += len(h.tail)
97+ }
98+ h.evict()
99+ return h, nil
100+}
101+
102+// MarshalVisible encodes the exact visible primary frame, including every
103+// blank, row width, and terminal cell attribute.
104+func MarshalVisible(frame renderer.Frame) ([]byte, error) {
105+ if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 {
106+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
107+ }
108+ cells := uint64(frame.Width) * uint64(frame.Height)
109+ bytes, ok := historyCellByteCount(cells)
110+ if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {
111+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
112+ }
113+ out := make([]byte, 0, 13+bytes)
114+ out = append(out, visibleMagic...)
115+ out = append(out, historyVersion)
116+ out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))
117+ out = binary.BigEndian.AppendUint32(out, uint32(frame.Height))
118+ for y := range frame.Height {
119+ for _, cell := range frame.Row(y) {
120+ if !validHistoryCell(cell) {
121+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
122+ }
123+ out = appendHistoryCell(out, cell)
124+ }
125+ }
126+ return out, nil
127+}
128+
129+// PreflightVisibleBlob validates dimensions and cell declarations without
130+// allocating a frame.
131+func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
132+ if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion {
133+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
134+ }
135+ width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))
136+ if width != 0 && height > math.MaxUint64/width {
137+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
138+ }
139+ cells := width * height
140+ bytes, ok := historyCellByteCount(cells)
141+ if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes {
142+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
143+ }
144+ p := historyParser{data: data[13:]}
145+ for range cells {
146+ cell, ok := p.cell()
147+ if !ok || !validHistoryCell(cell) {
148+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
149+ }
150+ }
151+ return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil
152+}
153+
154+// UnmarshalVisible decodes a preflighted exact visible frame.
155+func UnmarshalVisible(data []byte) (renderer.Frame, error) {
156+ if _, err := PreflightVisibleBlob(data); err != nil {
157+ return renderer.Frame{}, err
158+ }
159+ width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13]))
160+ frame := renderer.NewFrame(width, height)
161+ p := historyParser{data: data[13:]}
162+ for y := range height {
163+ for x := range width {
164+ cell, _ := p.cell()
165+ frame.Set(x, y, cell)
166+ }
167+ }
168+ return frame, nil
169+}
170+
171+// PrimaryVisibleFrame returns a deep copy of the exact visible primary frame.
172+func (s *Screen) PrimaryVisibleFrame() renderer.Frame {
173+ if s.alternate != nil {
174+ return s.alternate.frame.Clone()
175+ }
176+ return s.Frame.Clone()
177+}
4 178
5 // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows.179 // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows.
6 // When the alternate screen is active, it snapshots the saved primary screen180 // When the alternate screen is active, it snapshots the saved primary screen
@@ -16,3 +190,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell {
16 }190 }
17 return rows191 return rows
18 }192 }
193+
194+// RestorePrimaryVisible replaces the primary frame from an exact visible blob.
195+func (s *Screen) RestorePrimaryVisible(blob []byte) error {
196+ frame, err := UnmarshalVisible(blob)
197+ if err != nil {
198+ return err
199+ }
200+ // A persisted primary frame is authoritative: restoring it directly keeps
201+ // exact row widths and height even when a collapsed layout chose a smaller
202+ // initial PTY size. A subsequent resize reconciles it with live geometry.
203+ if s.alternate != nil {
204+ s.alternate.frame = frame
205+ } else {
206+ s.Frame = frame
207+ }
208+ s.fullRedraw()
209+ return nil
210+}

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