detached e769ef3

fix(copy): preserve selection during search navigation

commit
e769ef38ca3d997d0e7c3782dcfdba7c69971129
author
brice · · 2026-07-15 07:12 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
ef1f76f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +45 -7
M internal/usecase/copy/mode.go +18 -7
@@ -248,7 +248,7 @@ func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int)
248248 }
249249 m.Searches = append(m.Searches[:0], matches...)
250250 m.SearchIndex = min(max(index, 0), len(matches)-1)
251- m.SetPosition(Pos{Row: matches[m.SearchIndex].Row, Col: matches[m.SearchIndex].Start})
251+ m.moveToSearchMatch(matches[m.SearchIndex])
252252 return true
253253 }
254254 func (m *Mode) NextSearchMatch(delta int) bool {
@@ -260,9 +260,18 @@ func (m *Mode) NextSearchMatch(delta int) bool {
260260 m.SearchIndex += len(m.Searches)
261261 }
262262 match := m.Searches[m.SearchIndex]
263- m.SetPosition(Pos{Row: match.Row, Col: match.Start})
263+ m.moveToSearchMatch(match)
264264 return true
265265 }
266+func (m *Mode) moveToSearchMatch(match SearchMatch) {
267+ if m == nil || m.document == nil {
268+ return
269+ }
270+ if m.navigator.Set(m.document, Pos{Row: match.Row, Col: match.Start}) && m.selection.Enabled {
271+ m.selection.Extend(m.navigator.Pos)
272+ }
273+ m.adjustViewport()
274+}
266275 func (m *Mode) SelectedText() string {
267276 if m == nil {
268277 return ""
@@ -349,6 +358,7 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
349358 selected[r.Row] = append(selected[r.Row], r)
350359 }
351360 selection, hasSelection := optionalStyle(styles, 1)
361+ cursor, cursorValid := d.Normalize(m.navigator.Pos)
352362 for y := range d.Height() {
353363 src := m.ViewportTop + y
354364 if src >= d.Len() {
@@ -361,16 +371,17 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
361371 applySelectionStyle(&row[x].Style, selection, hasSelection)
362372 }
363373 }
364- covered := false
374+ cursorCovered := false
365375 for _, r := range selected[src] {
366- covered = true
367376 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
368377 applySelectionStyle(&row[x].Style, selection, hasSelection)
369378 }
379+ if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
380+ cursorCovered = true
381+ }
370382 }
371- if src == m.navigator.Pos.Row && !covered && len(row) > 0 {
372- col := min(max(m.navigator.Pos.Col, 0), len(row)-1)
373- applySelectionStyle(&row[col].Style, selection, hasSelection)
383+ if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 {
384+ applySelectionStyle(&row[cursor.Col].Style, selection, hasSelection)
374385 }
375386 }
376387 status := inverseStyle()
@@ -248,7 +248,7 @@ func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int)
248 }248 }
249 m.Searches = append(m.Searches[:0], matches...)249 m.Searches = append(m.Searches[:0], matches...)
250 m.SearchIndex = min(max(index, 0), len(matches)-1)250 m.SearchIndex = min(max(index, 0), len(matches)-1)
251- m.SetPosition(Pos{Row: matches[m.SearchIndex].Row, Col: matches[m.SearchIndex].Start})251+ m.moveToSearchMatch(matches[m.SearchIndex])
252 return true252 return true
253 }253 }
254 func (m *Mode) NextSearchMatch(delta int) bool {254 func (m *Mode) NextSearchMatch(delta int) bool {
@@ -260,9 +260,18 @@ func (m *Mode) NextSearchMatch(delta int) bool {
260 m.SearchIndex += len(m.Searches)260 m.SearchIndex += len(m.Searches)
261 }261 }
262 match := m.Searches[m.SearchIndex]262 match := m.Searches[m.SearchIndex]
263- m.SetPosition(Pos{Row: match.Row, Col: match.Start})263+ m.moveToSearchMatch(match)
264 return true264 return true
265 }265 }
266+func (m *Mode) moveToSearchMatch(match SearchMatch) {
267+ if m == nil || m.document == nil {
268+ return
269+ }
270+ if m.navigator.Set(m.document, Pos{Row: match.Row, Col: match.Start}) && m.selection.Enabled {
271+ m.selection.Extend(m.navigator.Pos)
272+ }
273+ m.adjustViewport()
274+}
266 func (m *Mode) SelectedText() string {275 func (m *Mode) SelectedText() string {
267 if m == nil {276 if m == nil {
268 return ""277 return ""
@@ -349,6 +358,7 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
349 selected[r.Row] = append(selected[r.Row], r)358 selected[r.Row] = append(selected[r.Row], r)
350 }359 }
351 selection, hasSelection := optionalStyle(styles, 1)360 selection, hasSelection := optionalStyle(styles, 1)
361+ cursor, cursorValid := d.Normalize(m.navigator.Pos)
352 for y := range d.Height() {362 for y := range d.Height() {
353 src := m.ViewportTop + y363 src := m.ViewportTop + y
354 if src >= d.Len() {364 if src >= d.Len() {
@@ -361,16 +371,17 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
361 applySelectionStyle(&row[x].Style, selection, hasSelection)371 applySelectionStyle(&row[x].Style, selection, hasSelection)
362 }372 }
363 }373 }
364- covered := false374+ cursorCovered := false
365 for _, r := range selected[src] {375 for _, r := range selected[src] {
366- covered = true
367 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {376 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
368 applySelectionStyle(&row[x].Style, selection, hasSelection)377 applySelectionStyle(&row[x].Style, selection, hasSelection)
369 }378 }
379+ if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
380+ cursorCovered = true
381+ }
370 }382 }
371- if src == m.navigator.Pos.Row && !covered && len(row) > 0 {383+ if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 {
372- col := min(max(m.navigator.Pos.Col, 0), len(row)-1)384+ applySelectionStyle(&row[cursor.Col].Style, selection, hasSelection)
373- applySelectionStyle(&row[col].Style, selection, hasSelection)
374 }385 }
375 }386 }
376 status := inverseStyle()387 status := inverseStyle()
M internal/usecase/copy/mode_test.go +27 -0
@@ -76,6 +76,33 @@ func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) {
7676 require.True(t, f.At(1, 0).Style.HasBackgroundRGB)
7777 }
7878
79+func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) {
80+ matches := []SearchMatch{{Row: 1, Start: 1, End: 2}, {Row: 2, Start: 2, End: 3}}
81+ m := modeFor([]string{"zero", "one", "two"}, 3)
82+
83+ m.SetPosition(Pos{Row: 0})
84+ m.ToggleLineSelection()
85+ require.True(t, m.SetSearchMatches("match", matches, 0))
86+ require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 1, Col: 1}, Granularity: Line, Enabled: true}, m.Selection())
87+
88+ require.True(t, m.Right())
89+ require.Equal(t, Character, m.Selection().Granularity)
90+ require.True(t, m.NextSearchMatch(1))
91+ require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection())
92+}
93+
94+func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) {
95+ cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}}
96+ m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), ""))
97+ require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 2}))
98+ require.True(t, m.SetPosition(Pos{Row: 0, Col: 1})) // Normalize the wide-glyph continuation to its head.
99+
100+ selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
101+ f := m.Render(renderer.DefaultStyle(), selection)
102+ require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
103+ require.True(t, f.At(2, 0).Style.HasBackgroundRGB)
104+}
105+
79106 func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
80107 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}
81108 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "")
@@ -76,6 +76,33 @@ func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) {
76 require.True(t, f.At(1, 0).Style.HasBackgroundRGB)76 require.True(t, f.At(1, 0).Style.HasBackgroundRGB)
77 }77 }
78 78
79+func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) {
80+ matches := []SearchMatch{{Row: 1, Start: 1, End: 2}, {Row: 2, Start: 2, End: 3}}
81+ m := modeFor([]string{"zero", "one", "two"}, 3)
82+
83+ m.SetPosition(Pos{Row: 0})
84+ m.ToggleLineSelection()
85+ require.True(t, m.SetSearchMatches("match", matches, 0))
86+ require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 1, Col: 1}, Granularity: Line, Enabled: true}, m.Selection())
87+
88+ require.True(t, m.Right())
89+ require.Equal(t, Character, m.Selection().Granularity)
90+ require.True(t, m.NextSearchMatch(1))
91+ require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection())
92+}
93+
94+func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) {
95+ cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}}
96+ m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), ""))
97+ require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 2}))
98+ require.True(t, m.SetPosition(Pos{Row: 0, Col: 1})) // Normalize the wide-glyph continuation to its head.
99+
100+ selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
101+ f := m.Render(renderer.DefaultStyle(), selection)
102+ require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
103+ require.True(t, f.At(2, 0).Style.HasBackgroundRGB)
104+}
105+
79 func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {106 func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
80 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}107 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}
81 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "")108 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "")

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