refactor(copy): add immutable history views
- commit
- 296e94c10516e6bc5579af126bd487940e5abf12
- author
- brice · · 2026-07-11 05:51 UTC
- committer
- brice · · 2026-07-11 06:15 UTC
- parent
- bf7a30f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +60 -0
M internal/usecase/copy/scrollback.go +26 -0
| @@ -56,6 +56,32 @@ func (s *Scrollback) Snapshot() [][]renderer.Cell { | |||
| 56 | 56 | return rows | |
| 57 | 57 | } | |
| 58 | 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 | + | } | |
| 84 | + | ||
| 59 | 85 | // Row returns a copy of the retained row at logical index i, oldest first. | |
| 60 | 86 | func (s *Scrollback) Row(i int) []renderer.Cell { | |
| 61 | 87 | if i < 0 || i >= s.len || len(s.rows) == 0 { | |
| @@ -56,6 +56,32 @@ func (s *Scrollback) Snapshot() [][]renderer.Cell { | |||||
| 56 | return rows | 56 | return rows | ||
| 57 | } | 57 | } | ||
| 58 | 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 | + | } | |||
| 84 | + | ||||
| 59 | // Row returns a copy of the retained row at logical index i, oldest first. | 85 | // Row returns a copy of the retained row at logical index i, oldest first. | ||
| 60 | func (s *Scrollback) Row(i int) []renderer.Cell { | 86 | func (s *Scrollback) Row(i int) []renderer.Cell { | ||
| 61 | if i < 0 || i >= s.len || len(s.rows) == 0 { | 87 | if i < 0 || i >= s.len || len(s.rows) == 0 { | ||
M internal/usecase/copy/scrollback_test.go +34 -0
| @@ -175,3 +175,37 @@ func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) { | |||
| 175 | 175 | t.Fatalf("snapshot[1] after append = %q, want original row", text) | |
| 176 | 176 | } | |
| 177 | 177 | } | |
| 178 | + | ||
| 179 | + | func TestScrollbackViewSurvivesCompleteRingOverwrite(t *testing.T) { | |
| 180 | + | sb := NewScrollback(2) | |
| 181 | + | sb.Append(row("aa")) | |
| 182 | + | sb.Append(row("bb")) | |
| 183 | + | view := sb.View() | |
| 184 | + | if &view.Row(0)[0] != &sb.rows[0][0] { | |
| 185 | + | t.Fatal("View() deep-copied cells, want shared row storage") | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | sb.Append(row("cc")) | |
| 189 | + | sb.Append(row("dd")) | |
| 190 | + | ||
| 191 | + | if got := view.Len(); got != 2 { | |
| 192 | + | t.Fatalf("View().Len() = %d, want 2", got) | |
| 193 | + | } | |
| 194 | + | for i, want := range []string{"aa", "bb"} { | |
| 195 | + | if got := rowText(view.Row(i)); got != want { | |
| 196 | + | t.Fatalf("View().Row(%d) = %q, want %q", i, got, want) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestHistoryViewRowBounds(t *testing.T) { | |
| 202 | + | sb := NewScrollback(1) | |
| 203 | + | sb.Append(row("aa")) | |
| 204 | + | view := sb.View() | |
| 205 | + | ||
| 206 | + | for _, i := range []int{-1, 1} { | |
| 207 | + | if got := view.Row(i); got != nil { | |
| 208 | + | t.Errorf("View().Row(%d) = %v, want nil", i, got) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | } | |
| @@ -175,3 +175,37 @@ func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) { | |||||
| 175 | t.Fatalf("snapshot[1] after append = %q, want original row", text) | 175 | t.Fatalf("snapshot[1] after append = %q, want original row", text) | ||
| 176 | } | 176 | } | ||
| 177 | } | 177 | } | ||
| 178 | + | ||||
| 179 | + | func TestScrollbackViewSurvivesCompleteRingOverwrite(t *testing.T) { | |||
| 180 | + | sb := NewScrollback(2) | |||
| 181 | + | sb.Append(row("aa")) | |||
| 182 | + | sb.Append(row("bb")) | |||
| 183 | + | view := sb.View() | |||
| 184 | + | if &view.Row(0)[0] != &sb.rows[0][0] { | |||
| 185 | + | t.Fatal("View() deep-copied cells, want shared row storage") | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | sb.Append(row("cc")) | |||
| 189 | + | sb.Append(row("dd")) | |||
| 190 | + | ||||
| 191 | + | if got := view.Len(); got != 2 { | |||
| 192 | + | t.Fatalf("View().Len() = %d, want 2", got) | |||
| 193 | + | } | |||
| 194 | + | for i, want := range []string{"aa", "bb"} { | |||
| 195 | + | if got := rowText(view.Row(i)); got != want { | |||
| 196 | + | t.Fatalf("View().Row(%d) = %q, want %q", i, got, want) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestHistoryViewRowBounds(t *testing.T) { | |||
| 202 | + | sb := NewScrollback(1) | |||
| 203 | + | sb.Append(row("aa")) | |||
| 204 | + | view := sb.View() | |||
| 205 | + | ||||
| 206 | + | for _, i := range []int{-1, 1} { | |||
| 207 | + | if got := view.Row(i); got != nil { | |||
| 208 | + | t.Errorf("View().Row(%d) = %v, want nil", i, got) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | } | |||