detached 296e94c

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
5656 return rows
5757 }
5858
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+
5985 // Row returns a copy of the retained row at logical index i, oldest first.
6086 func (s *Scrollback) Row(i int) []renderer.Cell {
6187 if i < 0 || i >= s.len || len(s.rows) == 0 {
@@ -56,6 +56,32 @@ func (s *Scrollback) Snapshot() [][]renderer.Cell {
56 return rows56 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) {
175175 t.Fatalf("snapshot[1] after append = %q, want original row", text)
176176 }
177177 }
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+}

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