detached 2f5b567

perf(search): share immutable copy documents

commit
2f5b5672ca1a814d9488448787afcd4231f9d976
author
brice · · 2026-07-11 06:06 UTC
committer
brice · · 2026-07-11 06:15 UTC
parent
459c8d4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +22 -13
M internal/usecase/visualsearch/model.go +2 -11
@@ -18,7 +18,7 @@ type Model struct {
1818 }
1919
2020 func New(snapshot scopy.Snapshot) *Model {
21- m := &Model{snapshot: cloneSnapshot(snapshot), selected: -1}
21+ m := &Model{snapshot: snapshot, selected: -1}
2222 m.refresh()
2323 return m
2424 }
@@ -28,7 +28,6 @@ func (m *Model) Clone() *Model {
2828 return nil
2929 }
3030 clone := *m
31- clone.snapshot = cloneSnapshot(m.snapshot)
3231 clone.input.SetValue(m.input.Value())
3332 clone.matches = append([]scopy.SearchMatch(nil), m.matches...)
3433 return &clone
@@ -38,7 +37,7 @@ func (m *Model) Snapshot() scopy.Snapshot {
3837 if m == nil {
3938 return scopy.NewSnapshotFromRows(nil, 0, 0)
4039 }
41- return cloneSnapshot(m.snapshot)
40+ return m.snapshot
4241 }
4342
4443 func (m *Model) Insert(r rune) {
@@ -146,14 +145,6 @@ func (m *Model) ensureVisible(visible int) {
146145 }
147146 }
148147
149-func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot {
150- rows := make([][]renderer.Cell, s.Len())
151- for i := range rows {
152- rows[i] = s.Row(i)
153- }
154- return scopy.NewSnapshotFromRows(rows, s.Width, s.Height)
155-}
156-
157148 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
158149 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
159150 if frame.Width == 0 || frame.Height == 0 {
@@ -18,7 +18,7 @@ type Model struct {
18 }18 }
19 19
20 func New(snapshot scopy.Snapshot) *Model {20 func New(snapshot scopy.Snapshot) *Model {
21- m := &Model{snapshot: cloneSnapshot(snapshot), selected: -1}21+ m := &Model{snapshot: snapshot, selected: -1}
22 m.refresh()22 m.refresh()
23 return m23 return m
24 }24 }
@@ -28,7 +28,6 @@ func (m *Model) Clone() *Model {
28 return nil28 return nil
29 }29 }
30 clone := *m30 clone := *m
31- clone.snapshot = cloneSnapshot(m.snapshot)
32 clone.input.SetValue(m.input.Value())31 clone.input.SetValue(m.input.Value())
33 clone.matches = append([]scopy.SearchMatch(nil), m.matches...)32 clone.matches = append([]scopy.SearchMatch(nil), m.matches...)
34 return &clone33 return &clone
@@ -38,7 +37,7 @@ func (m *Model) Snapshot() scopy.Snapshot {
38 if m == nil {37 if m == nil {
39 return scopy.NewSnapshotFromRows(nil, 0, 0)38 return scopy.NewSnapshotFromRows(nil, 0, 0)
40 }39 }
41- return cloneSnapshot(m.snapshot)40+ return m.snapshot
42 }41 }
43 42
44 func (m *Model) Insert(r rune) {43 func (m *Model) Insert(r rune) {
@@ -146,14 +145,6 @@ func (m *Model) ensureVisible(visible int) {
146 }145 }
147 }146 }
148 147
149-func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot {
150- rows := make([][]renderer.Cell, s.Len())
151- for i := range rows {
152- rows[i] = s.Row(i)
153- }
154- return scopy.NewSnapshotFromRows(rows, s.Width, s.Height)
155-}
156-
157 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {148 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
158 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))149 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
159 if frame.Width == 0 || frame.Height == 0 {150 if frame.Width == 0 || frame.Height == 0 {
M internal/usecase/visualsearch/model_test.go +20 -2
@@ -45,18 +45,36 @@ func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
4545 require.Equal(t, matches[1], selected)
4646 }
4747
48-func TestVisualSearchCloneDoesNotAliasInput(t *testing.T) {
48+func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
49+ rows := [][]renderer.Cell{{{Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}}
50+ m := New(scopy.NewSnapshotFromRows(rows, 40, 1))
51+
52+ rows[0][0].Rune = 'z'
53+ for _, r := range "alpha" {
54+ m.Insert(r)
55+ }
56+
57+ matches := m.Matches()
58+ require.Len(t, matches, 1)
59+ require.Equal(t, "alpha", matches[0].Text)
60+}
61+
62+func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) {
4963 m := New(testSnapshot("alpha", "beta alpha"))
5064 for _, r := range "alpha" {
5165 m.Insert(r)
5266 }
5367 clone := m.Clone()
5468
69+ require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0])
70+ clone.Down()
5571 clone.Insert('z')
5672
5773 require.Equal(t, "alpha", m.Query())
58- require.Equal(t, "alphaz", clone.Query())
74+ require.Equal(t, 0, m.SelectedIndex())
5975 require.Len(t, m.Matches(), 2)
76+ require.Equal(t, "alphaz", clone.Query())
77+ require.Equal(t, -1, clone.SelectedIndex())
6078 require.Len(t, clone.Matches(), 0)
6179 }
6280
@@ -45,18 +45,36 @@ func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
45 require.Equal(t, matches[1], selected)45 require.Equal(t, matches[1], selected)
46 }46 }
47 47
48-func TestVisualSearchCloneDoesNotAliasInput(t *testing.T) {48+func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
49+ rows := [][]renderer.Cell{{{Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}}
50+ m := New(scopy.NewSnapshotFromRows(rows, 40, 1))
51+
52+ rows[0][0].Rune = 'z'
53+ for _, r := range "alpha" {
54+ m.Insert(r)
55+ }
56+
57+ matches := m.Matches()
58+ require.Len(t, matches, 1)
59+ require.Equal(t, "alpha", matches[0].Text)
60+}
61+
62+func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) {
49 m := New(testSnapshot("alpha", "beta alpha"))63 m := New(testSnapshot("alpha", "beta alpha"))
50 for _, r := range "alpha" {64 for _, r := range "alpha" {
51 m.Insert(r)65 m.Insert(r)
52 }66 }
53 clone := m.Clone()67 clone := m.Clone()
54 68
69+ require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0])
70+ clone.Down()
55 clone.Insert('z')71 clone.Insert('z')
56 72
57 require.Equal(t, "alpha", m.Query())73 require.Equal(t, "alpha", m.Query())
58- require.Equal(t, "alphaz", clone.Query())74+ require.Equal(t, 0, m.SelectedIndex())
59 require.Len(t, m.Matches(), 2)75 require.Len(t, m.Matches(), 2)
76+ require.Equal(t, "alphaz", clone.Query())
77+ require.Equal(t, -1, clone.SelectedIndex())
60 require.Len(t, clone.Matches(), 0)78 require.Len(t, clone.Matches(), 0)
61 }79 }
62 80

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