detached cb18da6

fix(copy): retain content bounds for resized history rows

commit
cb18da6f5e345386b65715c5d12e8b1e565b9836
author
brice · · 2026-10-05 07:29 UTC
parent
1324577
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +24 -4
M internal/usecase/copy/content_render_test.go +16 -0
@@ -69,6 +69,22 @@ func TestRenderRowsSelectionHighlightsContentOnly(t *testing.T) {
6969 }
7070 }
7171
72+func TestRenderRowsSelectionAfterHistoryResize(t *testing.T) {
73+ screen := vt.NewScreenWithHistory(12, 2, vt.HistoryConfig{MaxRows: 64})
74+ screen.Write([]byte("abc xyz\r\nq\r\nr\r\ns"))
75+ screen.Resize(8, 2)
76+ doc := NewDocument(NewSnapshot(screen.History(), screen, screen.LineBounds(), nil), "")
77+ m := NewMode(doc)
78+ m.navigator.Pos = Pos{0, 0}
79+ m.ViewportTop = 0
80+ m.selection = Selection{Anchor: Pos{0, 0}, Active: Pos{1, 0}, Granularity: Character, Enabled: true}
81+ f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true})
82+ require.Equal(t, "abc xyz\nq", m.SelectedText())
83+ for x := 0; x < 8; x++ {
84+ require.True(t, f.At(x, 0).Style.Inverse, "visible content column %d", x)
85+ }
86+}
87+
7288 func TestRenderRowsSelectionPreservesCellColors(t *testing.T) {
7389 styles := []vt.Style{
7490 {HasForegroundRGB: true, ForegroundRGB: vt.RGB{G: 255, B: 255}, Bold: true},
@@ -69,6 +69,22 @@ func TestRenderRowsSelectionHighlightsContentOnly(t *testing.T) {
69 }69 }
70 }70 }
71 71
72+func TestRenderRowsSelectionAfterHistoryResize(t *testing.T) {
73+ screen := vt.NewScreenWithHistory(12, 2, vt.HistoryConfig{MaxRows: 64})
74+ screen.Write([]byte("abc xyz\r\nq\r\nr\r\ns"))
75+ screen.Resize(8, 2)
76+ doc := NewDocument(NewSnapshot(screen.History(), screen, screen.LineBounds(), nil), "")
77+ m := NewMode(doc)
78+ m.navigator.Pos = Pos{0, 0}
79+ m.ViewportTop = 0
80+ m.selection = Selection{Anchor: Pos{0, 0}, Active: Pos{1, 0}, Granularity: Character, Enabled: true}
81+ f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true})
82+ require.Equal(t, "abc xyz\nq", m.SelectedText())
83+ for x := 0; x < 8; x++ {
84+ require.True(t, f.At(x, 0).Style.Inverse, "visible content column %d", x)
85+ }
86+}
87+
72 func TestRenderRowsSelectionPreservesCellColors(t *testing.T) {88 func TestRenderRowsSelectionPreservesCellColors(t *testing.T) {
73 styles := []vt.Style{89 styles := []vt.Style{
74 {HasForegroundRGB: true, ForegroundRGB: vt.RGB{G: 255, B: 255}, Bold: true},90 {HasForegroundRGB: true, ForegroundRGB: vt.RGB{G: 255, B: 255}, Bold: true},
M internal/usecase/copy/document.go +2 -1
@@ -16,7 +16,8 @@ type Pos struct {
1616 }
1717
1818 // CellRange is an inclusive display-cell range on one physical document row.
19-// End == Start-1 with an in-bounds Start denotes a selected row with no cells.
19+// End == Start-1 with an in-bounds Start denotes a selected row with no cells;
20+// this adjacent pair is never interpreted as a reversed range.
2021 type CellRange struct {
2122 Row int
2223 Start int
@@ -16,7 +16,8 @@ type Pos struct {
16 }16 }
17 17
18 // CellRange is an inclusive display-cell range on one physical document row.18 // CellRange is an inclusive display-cell range on one physical document row.
19-// End == Start-1 with an in-bounds Start denotes a selected row with no cells.19+// End == Start-1 with an in-bounds Start denotes a selected row with no cells;
20+// this adjacent pair is never interpreted as a reversed range.
20 type CellRange struct {21 type CellRange struct {
21 Row int22 Row int
22 Start int23 Start int
M internal/usecase/copy/mode.go +6 -3
@@ -596,7 +596,7 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
596596 paint(y, row)
597597 continue
598598 }
599- d.snapshot.CopyRow(src, row)
599+ n := d.snapshot.CopyRow(src, row)
600600 if match, ok := m.currentSearchMatchForRow(src); ok {
601601 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
602602 applySelectionStyle(&row[x].Style, selection, hasSelection)
@@ -605,12 +605,15 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
605605 cursorCovered := false
606606 if hasSelectionBounds {
607607 bound := d.snapshot.Bound(src)
608- limit := contentEndOf(row, bound)
608+ limit := contentEndOf(row[:n], bound)
609+ if n < d.RowWidth(src) {
610+ limit = d.contentEnd(src)
611+ }
609612 if r, ok := selectionBounds.rangeForCells(src, row, limit); ok {
610613 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
611614 toggleInverse(&row[x].Style)
612615 }
613- if col, ok := selectionBounds.newlineMarkerAt(src, limit, d.Width(), bound.Soft); ok && col < len(row) {
616+ if col, ok := selectionBounds.newlineMarkerAt(src, limit, d.RowWidth(src), bound.Soft); ok && col < len(row) {
614617 toggleInverse(&row[col].Style)
615618 if cursorValid && cursor.Row == src && cursor.Col == col {
616619 cursorCovered = true
@@ -596,7 +596,7 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
596 paint(y, row)596 paint(y, row)
597 continue597 continue
598 }598 }
599- d.snapshot.CopyRow(src, row)599+ n := d.snapshot.CopyRow(src, row)
600 if match, ok := m.currentSearchMatchForRow(src); ok {600 if match, ok := m.currentSearchMatchForRow(src); ok {
601 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {601 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
602 applySelectionStyle(&row[x].Style, selection, hasSelection)602 applySelectionStyle(&row[x].Style, selection, hasSelection)
@@ -605,12 +605,15 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
605 cursorCovered := false605 cursorCovered := false
606 if hasSelectionBounds {606 if hasSelectionBounds {
607 bound := d.snapshot.Bound(src)607 bound := d.snapshot.Bound(src)
608- limit := contentEndOf(row, bound)608+ limit := contentEndOf(row[:n], bound)
609+ if n < d.RowWidth(src) {
610+ limit = d.contentEnd(src)
611+ }
609 if r, ok := selectionBounds.rangeForCells(src, row, limit); ok {612 if r, ok := selectionBounds.rangeForCells(src, row, limit); ok {
610 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {613 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
611 toggleInverse(&row[x].Style)614 toggleInverse(&row[x].Style)
612 }615 }
613- if col, ok := selectionBounds.newlineMarkerAt(src, limit, d.Width(), bound.Soft); ok && col < len(row) {616+ if col, ok := selectionBounds.newlineMarkerAt(src, limit, d.RowWidth(src), bound.Soft); ok && col < len(row) {
614 toggleInverse(&row[col].Style)617 toggleInverse(&row[col].Style)
615 if cursorValid && cursor.Row == src && cursor.Col == col {618 if cursorValid && cursor.Row == src && cursor.Col == col {
616 cursorCovered = true619 cursorCovered = true

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