detached d0d286e

refactor(copy): add immutable history views

commit
d0d286eb6dde8f9a31756369b2638762ce7f7569
author
brice · · 2026-07-11 05:55 UTC
committer
brice · · 2026-07-11 06:15 UTC
parent
296e94c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +120 -64
M internal/usecase/copy/mode.go +51 -27
@@ -15,29 +15,52 @@ import (
1515 // specific continuation protocol is supported.
1616 const OSC52MaxPayloadBytes = 75_000
1717
18-// Snapshot is the scrollback-mode document: scrollback followed by the live screen.
18+// Snapshot is the immutable scrollback-mode document: history followed by the
19+// visible screen. History rows are immutable by Scrollback's append contract;
20+// the visible frame is cloned once when the snapshot is constructed.
1921 type Snapshot struct {
20- Rows [][]renderer.Cell
21- Width int
22- Height int
22+ history HistoryView
23+ screen renderer.Frame
24+ Width int
25+ Height int
2326 }
2427
25-// NewSnapshot copies rows from scrollback and then the live screen frame.
28+// NewSnapshot freezes the current scrollback view and clones the visible screen.
2629 func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
27- scrollRows := 0
30+ var history HistoryView
2831 if sb != nil {
29- scrollRows = sb.Len()
32+ history = sb.View()
3033 }
31- rows := make([][]renderer.Cell, 0, scrollRows+screen.Height)
32- if sb != nil {
33- for i := range sb.Len() {
34- rows = append(rows, sb.Row(i))
35- }
34+ return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
35+}
36+
37+// NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.
38+// It is intended for callers that already have a complete document rather than
39+// a scrollback and visible frame.
40+func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
41+ owned := make([][]renderer.Cell, len(rows))
42+ for i, row := range rows {
43+ owned[i] = append([]renderer.Cell(nil), row...)
3644 }
37- for y := range screen.Height {
38- rows = append(rows, append([]renderer.Cell(nil), screen.Row(y)...))
45+ return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height}
46+}
47+
48+// Len returns the number of document rows.
49+func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
50+
51+// Row returns document row i, or nil when i is out of range.
52+func (s Snapshot) Row(i int) []renderer.Cell {
53+ if i < 0 {
54+ return nil
55+ }
56+ if i < s.history.Len() {
57+ return s.history.Row(i)
58+ }
59+ i -= s.history.Len()
60+ if i >= s.screen.Height {
61+ return nil
3962 }
40- return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height}
63+ return s.screen.Row(i)
4164 }
4265
4366 // Mode stores per-client scrollback viewport and line-selection state.
@@ -60,8 +83,8 @@ type Mode struct {
6083
6184 func NewMode(s Snapshot) *Mode {
6285 m := &Mode{Anchor: -1}
63- m.ViewportTop = max(len(s.Rows)-s.Height, 0)
64- m.Cursor = max(len(s.Rows)-1, 0)
86+ m.ViewportTop = max(s.Len()-s.Height, 0)
87+ m.Cursor = max(s.Len()-1, 0)
6588 m.clamp(s)
6689 return m
6790 }
@@ -69,7 +92,7 @@ func NewMode(s Snapshot) *Mode {
6992 func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) }
7093 func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) }
7194 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }
72-func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) }
95+func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, s.Len()-1) }
7396
7497 func FindMatches(s Snapshot, query string) []SearchMatch {
7598 query = strings.TrimSpace(query)
@@ -78,7 +101,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
78101 }
79102 needle := lowerRunes(query)
80103 matches := make([]SearchMatch, 0)
81- for row, cells := range s.Rows {
104+ for row := range s.Len() {
105+ cells := s.Row(row)
82106 haystack, cellIndexes := searchableCells(cells)
83107 var text string
84108 for start := 0; start+len(needle) <= len(haystack); {
@@ -190,7 +214,7 @@ func (m *Mode) StartSelectionAt(s Snapshot, row int) {
190214 func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) }
191215
192216 func (m *Mode) AtBottom(s Snapshot) bool {
193- total := len(s.Rows)
217+ total := s.Len()
194218 if total == 0 {
195219 return m.Cursor == 0 && m.ViewportTop == 0
196220 }
@@ -220,7 +244,7 @@ func (m *Mode) setCursor(s Snapshot, cursor int) {
220244 }
221245
222246 func (m *Mode) clamp(s Snapshot) {
223- total := len(s.Rows)
247+ total := s.Len()
224248 if total == 0 {
225249 m.Cursor, m.ViewportTop = 0, 0
226250 return
@@ -243,14 +267,14 @@ func (m *Mode) SelectedBounds() (int, int, bool) {
243267
244268 func (m *Mode) SelectedText(s Snapshot) string {
245269 lo, hi, ok := m.SelectedBounds()
246- if !ok || len(s.Rows) == 0 {
270+ if !ok || s.Len() == 0 {
247271 return ""
248272 }
249273 lo = max(lo, 0)
250- hi = min(hi, len(s.Rows)-1)
274+ hi = min(hi, s.Len()-1)
251275 lines := make([]string, 0, hi-lo+1)
252276 for i := lo; i <= hi; i++ {
253- lines = append(lines, rowString(s.Rows[i]))
277+ lines = append(lines, rowString(s.Row(i)))
254278 }
255279 return strings.Join(lines, "\n")
256280 }
@@ -262,11 +286,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
262286 selectionStyle, hasSelectionStyle := optionalStyle(styles, 1)
263287 for y := range s.Height {
264288 src := m.ViewportTop + y
265- if src >= len(s.Rows) {
289+ if src >= s.Len() {
266290 break
267291 }
268292 row := frame.Row(y)
269- copy(row, s.Rows[src])
293+ copy(row, s.Row(src))
270294 lineSelected := selected && src >= lo && src <= hi
271295 if lineSelected {
272296 for x := range row {
@@ -286,7 +310,7 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
286310 if len(styles) > 0 {
287311 style = styles[0]
288312 }
289- drawCopyStatus(frame.Row(s.Height), m, len(s.Rows), style)
313+ drawCopyStatus(frame.Row(s.Height), m, s.Len(), style)
290314 return frame
291315 }
292316
@@ -15,29 +15,52 @@ import (
15 // specific continuation protocol is supported.15 // specific continuation protocol is supported.
16 const OSC52MaxPayloadBytes = 75_00016 const OSC52MaxPayloadBytes = 75_000
17 17
18-// Snapshot is the scrollback-mode document: scrollback followed by the live screen.18+// Snapshot is the immutable scrollback-mode document: history followed by the
19+// visible screen. History rows are immutable by Scrollback's append contract;
20+// the visible frame is cloned once when the snapshot is constructed.
19 type Snapshot struct {21 type Snapshot struct {
20- Rows [][]renderer.Cell22+ history HistoryView
21- Width int23+ screen renderer.Frame
22- Height int24+ Width int
25+ Height int
23 }26 }
24 27
25-// NewSnapshot copies rows from scrollback and then the live screen frame.28+// NewSnapshot freezes the current scrollback view and clones the visible screen.
26 func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {29 func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
27- scrollRows := 030+ var history HistoryView
28 if sb != nil {31 if sb != nil {
29- scrollRows = sb.Len()32+ history = sb.View()
30 }33 }
31- rows := make([][]renderer.Cell, 0, scrollRows+screen.Height)34+ return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
32- if sb != nil {35+}
33- for i := range sb.Len() {36+
34- rows = append(rows, sb.Row(i))37+// NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.
35- }38+// It is intended for callers that already have a complete document rather than
39+// a scrollback and visible frame.
40+func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
41+ owned := make([][]renderer.Cell, len(rows))
42+ for i, row := range rows {
43+ owned[i] = append([]renderer.Cell(nil), row...)
36 }44 }
37- for y := range screen.Height {45+ return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height}
38- rows = append(rows, append([]renderer.Cell(nil), screen.Row(y)...))46+}
47+
48+// Len returns the number of document rows.
49+func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
50+
51+// Row returns document row i, or nil when i is out of range.
52+func (s Snapshot) Row(i int) []renderer.Cell {
53+ if i < 0 {
54+ return nil
55+ }
56+ if i < s.history.Len() {
57+ return s.history.Row(i)
58+ }
59+ i -= s.history.Len()
60+ if i >= s.screen.Height {
61+ return nil
39 }62 }
40- return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height}63+ return s.screen.Row(i)
41 }64 }
42 65
43 // Mode stores per-client scrollback viewport and line-selection state.66 // Mode stores per-client scrollback viewport and line-selection state.
@@ -60,8 +83,8 @@ type Mode struct {
60 83
61 func NewMode(s Snapshot) *Mode {84 func NewMode(s Snapshot) *Mode {
62 m := &Mode{Anchor: -1}85 m := &Mode{Anchor: -1}
63- m.ViewportTop = max(len(s.Rows)-s.Height, 0)86+ m.ViewportTop = max(s.Len()-s.Height, 0)
64- m.Cursor = max(len(s.Rows)-1, 0)87+ m.Cursor = max(s.Len()-1, 0)
65 m.clamp(s)88 m.clamp(s)
66 return m89 return m
67 }90 }
@@ -69,7 +92,7 @@ func NewMode(s Snapshot) *Mode {
69 func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) }92 func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) }
70 func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) }93 func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) }
71 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }94 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }
72-func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) }95+func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, s.Len()-1) }
73 96
74 func FindMatches(s Snapshot, query string) []SearchMatch {97 func FindMatches(s Snapshot, query string) []SearchMatch {
75 query = strings.TrimSpace(query)98 query = strings.TrimSpace(query)
@@ -78,7 +101,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
78 }101 }
79 needle := lowerRunes(query)102 needle := lowerRunes(query)
80 matches := make([]SearchMatch, 0)103 matches := make([]SearchMatch, 0)
81- for row, cells := range s.Rows {104+ for row := range s.Len() {
105+ cells := s.Row(row)
82 haystack, cellIndexes := searchableCells(cells)106 haystack, cellIndexes := searchableCells(cells)
83 var text string107 var text string
84 for start := 0; start+len(needle) <= len(haystack); {108 for start := 0; start+len(needle) <= len(haystack); {
@@ -190,7 +214,7 @@ func (m *Mode) StartSelectionAt(s Snapshot, row int) {
190 func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) }214 func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) }
191 215
192 func (m *Mode) AtBottom(s Snapshot) bool {216 func (m *Mode) AtBottom(s Snapshot) bool {
193- total := len(s.Rows)217+ total := s.Len()
194 if total == 0 {218 if total == 0 {
195 return m.Cursor == 0 && m.ViewportTop == 0219 return m.Cursor == 0 && m.ViewportTop == 0
196 }220 }
@@ -220,7 +244,7 @@ func (m *Mode) setCursor(s Snapshot, cursor int) {
220 }244 }
221 245
222 func (m *Mode) clamp(s Snapshot) {246 func (m *Mode) clamp(s Snapshot) {
223- total := len(s.Rows)247+ total := s.Len()
224 if total == 0 {248 if total == 0 {
225 m.Cursor, m.ViewportTop = 0, 0249 m.Cursor, m.ViewportTop = 0, 0
226 return250 return
@@ -243,14 +267,14 @@ func (m *Mode) SelectedBounds() (int, int, bool) {
243 267
244 func (m *Mode) SelectedText(s Snapshot) string {268 func (m *Mode) SelectedText(s Snapshot) string {
245 lo, hi, ok := m.SelectedBounds()269 lo, hi, ok := m.SelectedBounds()
246- if !ok || len(s.Rows) == 0 {270+ if !ok || s.Len() == 0 {
247 return ""271 return ""
248 }272 }
249 lo = max(lo, 0)273 lo = max(lo, 0)
250- hi = min(hi, len(s.Rows)-1)274+ hi = min(hi, s.Len()-1)
251 lines := make([]string, 0, hi-lo+1)275 lines := make([]string, 0, hi-lo+1)
252 for i := lo; i <= hi; i++ {276 for i := lo; i <= hi; i++ {
253- lines = append(lines, rowString(s.Rows[i]))277+ lines = append(lines, rowString(s.Row(i)))
254 }278 }
255 return strings.Join(lines, "\n")279 return strings.Join(lines, "\n")
256 }280 }
@@ -262,11 +286,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
262 selectionStyle, hasSelectionStyle := optionalStyle(styles, 1)286 selectionStyle, hasSelectionStyle := optionalStyle(styles, 1)
263 for y := range s.Height {287 for y := range s.Height {
264 src := m.ViewportTop + y288 src := m.ViewportTop + y
265- if src >= len(s.Rows) {289+ if src >= s.Len() {
266 break290 break
267 }291 }
268 row := frame.Row(y)292 row := frame.Row(y)
269- copy(row, s.Rows[src])293+ copy(row, s.Row(src))
270 lineSelected := selected && src >= lo && src <= hi294 lineSelected := selected && src >= lo && src <= hi
271 if lineSelected {295 if lineSelected {
272 for x := range row {296 for x := range row {
@@ -286,7 +310,7 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
286 if len(styles) > 0 {310 if len(styles) > 0 {
287 style = styles[0]311 style = styles[0]
288 }312 }
289- drawCopyStatus(frame.Row(s.Height), m, len(s.Rows), style)313+ drawCopyStatus(frame.Row(s.Height), m, s.Len(), style)
290 return frame314 return frame
291 }315 }
292 316
M internal/usecase/copy/mode_test.go +4 -4
@@ -15,7 +15,7 @@ func snapshot(lines []string, height int) Snapshot {
1515 for i, line := range lines {
1616 rows[i] = row(line)
1717 }
18- return Snapshot{Rows: rows, Width: 16, Height: height}
18+ return NewSnapshotFromRows(rows, 16, height)
1919 }
2020
2121 func inverseRow(text string) []renderer.Cell {
@@ -226,7 +226,7 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) {
226226
227227 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
228228 s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
229- s.Rows[1] = inverseRow("beta ")
229+ s = NewSnapshotFromRows([][]renderer.Cell{row("alpha "), inverseRow("beta "), row("gamma ")}, 16, 2)
230230 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
231231 m.Move(s, 1)
232232
@@ -274,7 +274,7 @@ func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) {
274274 // flip it back to non-inverse and hide the highlight.
275275 rows := [][]renderer.Cell{row("alpha "), row("beta ")}
276276 rows[0][0].Style.Inverse = true
277- s := Snapshot{Rows: rows, Width: 16, Height: 2}
277+ s := NewSnapshotFromRows(rows, 16, 2)
278278 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
279279
280280 frame := m.Render(s)
@@ -365,7 +365,7 @@ func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
365365 {Rune: 'h'},
366366 {Rune: 'a'},
367367 }
368- s := Snapshot{Rows: [][]renderer.Cell{row}, Width: 8, Height: 1}
368+ s := NewSnapshotFromRows([][]renderer.Cell{row}, 8, 1)
369369
370370 wideMatches := FindMatches(s, "界")
371371 require.Len(t, wideMatches, 1)
@@ -15,7 +15,7 @@ func snapshot(lines []string, height int) Snapshot {
15 for i, line := range lines {15 for i, line := range lines {
16 rows[i] = row(line)16 rows[i] = row(line)
17 }17 }
18- return Snapshot{Rows: rows, Width: 16, Height: height}18+ return NewSnapshotFromRows(rows, 16, height)
19 }19 }
20 20
21 func inverseRow(text string) []renderer.Cell {21 func inverseRow(text string) []renderer.Cell {
@@ -226,7 +226,7 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) {
226 226
227 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {227 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
228 s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)228 s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
229- s.Rows[1] = inverseRow("beta ")229+ s = NewSnapshotFromRows([][]renderer.Cell{row("alpha "), inverseRow("beta "), row("gamma ")}, 16, 2)
230 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}230 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
231 m.Move(s, 1)231 m.Move(s, 1)
232 232
@@ -274,7 +274,7 @@ func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) {
274 // flip it back to non-inverse and hide the highlight.274 // flip it back to non-inverse and hide the highlight.
275 rows := [][]renderer.Cell{row("alpha "), row("beta ")}275 rows := [][]renderer.Cell{row("alpha "), row("beta ")}
276 rows[0][0].Style.Inverse = true276 rows[0][0].Style.Inverse = true
277- s := Snapshot{Rows: rows, Width: 16, Height: 2}277+ s := NewSnapshotFromRows(rows, 16, 2)
278 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}278 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
279 279
280 frame := m.Render(s)280 frame := m.Render(s)
@@ -365,7 +365,7 @@ func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
365 {Rune: 'h'},365 {Rune: 'h'},
366 {Rune: 'a'},366 {Rune: 'a'},
367 }367 }
368- s := Snapshot{Rows: [][]renderer.Cell{row}, Width: 8, Height: 1}368+ s := NewSnapshotFromRows([][]renderer.Cell{row}, 8, 1)
369 369
370 wideMatches := FindMatches(s, "界")370 wideMatches := FindMatches(s, "界")
371 require.Len(t, wideMatches, 1)371 require.Len(t, wideMatches, 1)
M internal/usecase/copy/scrollback.go +0 -9
@@ -81,12 +81,3 @@ func (v HistoryView) Row(i int) []renderer.Cell {
8181 }
8282 return v.rows[i]
8383 }
84-
85-// Row returns a copy of the retained row at logical index i, oldest first.
86-func (s *Scrollback) Row(i int) []renderer.Cell {
87- if i < 0 || i >= s.len || len(s.rows) == 0 {
88- return nil
89- }
90- row := s.rows[(s.head+i)%len(s.rows)]
91- return append([]renderer.Cell(nil), row...)
92-}
@@ -81,12 +81,3 @@ func (v HistoryView) Row(i int) []renderer.Cell {
81 }81 }
82 return v.rows[i]82 return v.rows[i]
83 }83 }
84-
85-// Row returns a copy of the retained row at logical index i, oldest first.
86-func (s *Scrollback) Row(i int) []renderer.Cell {
87- if i < 0 || i >= s.len || len(s.rows) == 0 {
88- return nil
89- }
90- row := s.rows[(s.head+i)%len(s.rows)]
91- return append([]renderer.Cell(nil), row...)
92-}
M internal/usecase/copy/scrollback_test.go +4 -6
@@ -82,7 +82,7 @@ func TestScrollbackRing(t *testing.T) {
8282 t.Fatalf("Len() = %d, want %d", got, tt.wantLen)
8383 }
8484 for i, want := range tt.wantRows {
85- if got := rowText(sb.Row(i)); got != want {
85+ if got := rowText(sb.View().Row(i)); got != want {
8686 t.Fatalf("Row(%d) = %q, want %q", i, got, want)
8787 }
8888 }
@@ -103,10 +103,8 @@ func TestScrollbackCopiesRows(t *testing.T) {
103103 sb.Append(r)
104104 r[0].Rune = 'z'
105105
106- got := sb.Row(0)
107- got[1].Rune = 'y'
108- if text := rowText(sb.Row(0)); text != "ab" {
109- t.Fatalf("stored row = %q, want copy unaffected by caller/read mutation", text)
106+ if text := rowText(sb.View().Row(0)); text != "ab" {
107+ t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text)
110108 }
111109 })
112110 }
@@ -151,7 +149,7 @@ func TestScrollbackSnapshot(t *testing.T) {
151149 }
152150 if len(got) > 0 {
153151 got[0] = row("zz")
154- if text := rowText(sb.Row(0)); text != tt.wantRows[0] {
152+ if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] {
155153 t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
156154 }
157155 }
@@ -82,7 +82,7 @@ func TestScrollbackRing(t *testing.T) {
82 t.Fatalf("Len() = %d, want %d", got, tt.wantLen)82 t.Fatalf("Len() = %d, want %d", got, tt.wantLen)
83 }83 }
84 for i, want := range tt.wantRows {84 for i, want := range tt.wantRows {
85- if got := rowText(sb.Row(i)); got != want {85+ if got := rowText(sb.View().Row(i)); got != want {
86 t.Fatalf("Row(%d) = %q, want %q", i, got, want)86 t.Fatalf("Row(%d) = %q, want %q", i, got, want)
87 }87 }
88 }88 }
@@ -103,10 +103,8 @@ func TestScrollbackCopiesRows(t *testing.T) {
103 sb.Append(r)103 sb.Append(r)
104 r[0].Rune = 'z'104 r[0].Rune = 'z'
105 105
106- got := sb.Row(0)106+ if text := rowText(sb.View().Row(0)); text != "ab" {
107- got[1].Rune = 'y'107+ t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text)
108- if text := rowText(sb.Row(0)); text != "ab" {
109- t.Fatalf("stored row = %q, want copy unaffected by caller/read mutation", text)
110 }108 }
111 })109 })
112 }110 }
@@ -151,7 +149,7 @@ func TestScrollbackSnapshot(t *testing.T) {
151 }149 }
152 if len(got) > 0 {150 if len(got) > 0 {
153 got[0] = row("zz")151 got[0] = row("zz")
154- if text := rowText(sb.Row(0)); text != tt.wantRows[0] {152+ if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] {
155 t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])153 t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
156 }154 }
157 }155 }
A internal/usecase/copy/snapshot_test.go +44 -0
@@ -0,0 +1,44 @@
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+}
@@ -0,0 +1,44 @@
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+}
M internal/usecase/daemon/copymode_test.go +2 -2
@@ -403,7 +403,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
403403 }
404404 p.mu.Lock()
405405 defer p.mu.Unlock()
406- return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12
406+ return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12
407407 }, 2*time.Second, 5*time.Millisecond)
408408
409409 sess := firstSession(d)
@@ -721,7 +721,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
721721 }
722722 fp.screen.Write([]byte("live"))
723723 fp.mu.Lock()
724- total := len(scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Rows)
724+ total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len()
725725 fp.mu.Unlock()
726726
727727 d.enterCopyMode(sess, ac)
@@ -403,7 +403,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
403 }403 }
404 p.mu.Lock()404 p.mu.Lock()
405 defer p.mu.Unlock()405 defer p.mu.Unlock()
406- return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12406+ return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12
407 }, 2*time.Second, 5*time.Millisecond)407 }, 2*time.Second, 5*time.Millisecond)
408 408
409 sess := firstSession(d)409 sess := firstSession(d)
@@ -721,7 +721,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
721 }721 }
722 fp.screen.Write([]byte("live"))722 fp.screen.Write([]byte("live"))
723 fp.mu.Lock()723 fp.mu.Lock()
724- total := len(scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Rows)724+ total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len()
725 fp.mu.Unlock()725 fp.mu.Unlock()
726 726
727 d.enterCopyMode(sess, ac)727 d.enterCopyMode(sess, ac)
M internal/usecase/daemon/input.go +1 -1
@@ -277,7 +277,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
277277 rt.copyMu.Lock()
278278 mode := scopy.NewMode(snap)
279279 mode.StartSelectionAt(snap, pressTop+pressRow)
280- mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)
280+ mode.ExtendTo(snap, snap.Len()-snap.Height+ev.Row)
281281 rt.copyMode = mode
282282 rt.copyPane = p
283283 rt.copyPressRow = pressTop + pressRow
@@ -277,7 +277,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
277 rt.copyMu.Lock()277 rt.copyMu.Lock()
278 mode := scopy.NewMode(snap)278 mode := scopy.NewMode(snap)
279 mode.StartSelectionAt(snap, pressTop+pressRow)279 mode.StartSelectionAt(snap, pressTop+pressRow)
280- mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)280+ mode.ExtendTo(snap, snap.Len()-snap.Height+ev.Row)
281 rt.copyMode = mode281 rt.copyMode = mode
282 rt.copyPane = p282 rt.copyPane = p
283 rt.copyPressRow = pressTop + pressRow283 rt.copyPressRow = pressTop + pressRow
M internal/usecase/daemon/overlay_runtime_test.go +2 -2
@@ -113,7 +113,7 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
113113 func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {
114114 ac := &attachedClient{}
115115 ac.initOverlays()
116- snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, Width: 16, Height: 2}
116+ snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, 16, 2)
117117 mode := scopy.NewMode(snap)
118118 require.True(t, mode.Search(snap, "alpha"))
119119 search := visualsearch.New(snap)
@@ -149,7 +149,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyPaneAndClearReleasesIt(t *testing.T) {
149149 ac := &attachedClient{}
150150 ac.initOverlays()
151151 p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})
152- snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("row")}, Width: 4, Height: 2}
152+ snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2)
153153
154154 ac.overlays.copyMu.Lock()
155155 ac.overlays.copyMode = scopy.NewMode(snap)
@@ -113,7 +113,7 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
113 func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {113 func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {
114 ac := &attachedClient{}114 ac := &attachedClient{}
115 ac.initOverlays()115 ac.initOverlays()
116- snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, Width: 16, Height: 2}116+ snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, 16, 2)
117 mode := scopy.NewMode(snap)117 mode := scopy.NewMode(snap)
118 require.True(t, mode.Search(snap, "alpha"))118 require.True(t, mode.Search(snap, "alpha"))
119 search := visualsearch.New(snap)119 search := visualsearch.New(snap)
@@ -149,7 +149,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyPaneAndClearReleasesIt(t *testing.T) {
149 ac := &attachedClient{}149 ac := &attachedClient{}
150 ac.initOverlays()150 ac.initOverlays()
151 p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})151 p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})
152- snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("row")}, Width: 4, Height: 2}152+ snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2)
153 153
154 ac.overlays.copyMu.Lock()154 ac.overlays.copyMu.Lock()
155 ac.overlays.copyMode = scopy.NewMode(snap)155 ac.overlays.copyMode = scopy.NewMode(snap)
M internal/usecase/daemon/render_test.go +2 -2
@@ -373,8 +373,8 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
373373 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
374374 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
375375 require.Equal(t, 2, win.focusedPane().scrollback.Len())
376- require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.Row(0)))
377- require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.Row(1)))
376+ require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0)))
377+ require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1)))
378378
379379 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
380380 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
@@ -373,8 +373,8 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
373 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))373 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
374 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))374 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
375 require.Equal(t, 2, win.focusedPane().scrollback.Len())375 require.Equal(t, 2, win.focusedPane().scrollback.Len())
376- require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.Row(0)))376+ require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0)))
377- require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.Row(1)))377+ require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1)))
378 378
379 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})379 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
380 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))380 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
M internal/usecase/daemon/scrollback_test.go +1 -1
@@ -36,7 +36,7 @@ func TestNewTabInitializesScrollback(t *testing.T) {
3636 if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
3737 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
3838 }
39- row := win.focusedPane().scrollback.Row(0)
39+ row := win.focusedPane().scrollback.View().Row(0)
4040 runes := make([]rune, len(row))
4141 for i, c := range row {
4242 runes[i] = c.Rune
@@ -36,7 +36,7 @@ func TestNewTabInitializesScrollback(t *testing.T) {
36 if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {36 if got := win.focusedPane().scrollback.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.Row(0)39+ row := win.focusedPane().scrollback.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_restore_test.go +3 -3
@@ -77,12 +77,12 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
7777 p := tb.panes["pane-2"]
7878 tb.mu.Unlock()
7979 p.mu.Lock()
80- require.Equal(t, "old2", rowText(p.scrollback.Row(0)))
80+ require.Equal(t, "old2", rowText(p.scrollback.View().Row(0)))
8181 require.Equal(t, 1, p.scrollback.Len())
8282 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
8383 copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
84- require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))
85- require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))
84+ require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
85+ require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
8686 p.mu.Unlock()
8787
8888 tb.mu.Lock()
@@ -77,12 +77,12 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
77 p := tb.panes["pane-2"]77 p := tb.panes["pane-2"]
78 tb.mu.Unlock()78 tb.mu.Unlock()
79 p.mu.Lock()79 p.mu.Lock()
80- require.Equal(t, "old2", rowText(p.scrollback.Row(0)))80+ require.Equal(t, "old2", rowText(p.scrollback.View().Row(0)))
81 require.Equal(t, 1, p.scrollback.Len())81 require.Equal(t, 1, p.scrollback.Len())
82 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))82 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
83 copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)83 copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
84- require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))84+ require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
85- require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))85+ require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
86 p.mu.Unlock()86 p.mu.Unlock()
87 87
88 tb.mu.Lock()88 tb.mu.Lock()
M internal/usecase/visualsearch/model.go +5 -6
@@ -36,7 +36,7 @@ func (m *Model) Clone() *Model {
3636
3737 func (m *Model) Snapshot() scopy.Snapshot {
3838 if m == nil {
39- return scopy.Snapshot{}
39+ return scopy.NewSnapshotFromRows(nil, 0, 0)
4040 }
4141 return cloneSnapshot(m.snapshot)
4242 }
@@ -147,12 +147,11 @@ func (m *Model) ensureVisible(visible int) {
147147 }
148148
149149 func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot {
150- clone := s
151- clone.Rows = make([][]renderer.Cell, len(s.Rows))
152- for i, row := range s.Rows {
153- clone.Rows[i] = append([]renderer.Cell(nil), row...)
150+ rows := make([][]renderer.Cell, s.Len())
151+ for i := range rows {
152+ rows[i] = s.Row(i)
154153 }
155- return clone
154+ return scopy.NewSnapshotFromRows(rows, s.Width, s.Height)
156155 }
157156
158157 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
@@ -36,7 +36,7 @@ func (m *Model) Clone() *Model {
36 36
37 func (m *Model) Snapshot() scopy.Snapshot {37 func (m *Model) Snapshot() scopy.Snapshot {
38 if m == nil {38 if m == nil {
39- return scopy.Snapshot{}39+ return scopy.NewSnapshotFromRows(nil, 0, 0)
40 }40 }
41 return cloneSnapshot(m.snapshot)41 return cloneSnapshot(m.snapshot)
42 }42 }
@@ -147,12 +147,11 @@ func (m *Model) ensureVisible(visible int) {
147 }147 }
148 148
149 func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot {149 func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot {
150- clone := s150+ rows := make([][]renderer.Cell, s.Len())
151- clone.Rows = make([][]renderer.Cell, len(s.Rows))151+ for i := range rows {
152- for i, row := range s.Rows {152+ rows[i] = s.Row(i)
153- clone.Rows[i] = append([]renderer.Cell(nil), row...)
154 }153 }
155- return clone154+ return scopy.NewSnapshotFromRows(rows, s.Width, s.Height)
156 }155 }
157 156
158 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {157 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
M internal/usecase/visualsearch/model_test.go +1 -1
@@ -20,7 +20,7 @@ func testSnapshot(lines ...string) scopy.Snapshot {
2020 rows[i][x] = renderer.Cell{Rune: r}
2121 }
2222 }
23- return scopy.Snapshot{Rows: rows, Width: 40, Height: 4}
23+ return scopy.NewSnapshotFromRows(rows, 40, 4)
2424 }
2525
2626 func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
@@ -20,7 +20,7 @@ func testSnapshot(lines ...string) scopy.Snapshot {
20 rows[i][x] = renderer.Cell{Rune: r}20 rows[i][x] = renderer.Cell{Rune: r}
21 }21 }
22 }22 }
23- return scopy.Snapshot{Rows: rows, Width: 40, Height: 4}23+ return scopy.NewSnapshotFromRows(rows, 40, 4)
24 }24 }
25 25
26 func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {26 func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close