detached e522e52

test(copy): cover unwrapping across the history/screen boundary

commit
e522e52a44f834264e7491ed49d56db8dbcce0b7
author
brice · · 2026-07-25 09:47 UTC
parent
b33d82f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +98 -0
M internal/usecase/copy/mode_test.go +98 -0
@@ -319,3 +319,101 @@ func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
319319
320320 require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
321321 }
322+
323+func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
324+ row := func(s string, w int) []renderer.Cell {
325+ cells := make([]renderer.Cell, w)
326+ for i := range cells {
327+ cells[i] = renderer.BlankCell()
328+ }
329+ for i, r := range []rune(s) {
330+ if i >= w {
331+ break
332+ }
333+ cells[i] = renderer.Cell{Rune: r}
334+ }
335+ return cells
336+ }
337+
338+ const width = 4
339+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
340+ // The last history row wrapped into what is now the first live screen row.
341+ if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {
342+ t.Fatalf("append: %v", err)
343+ }
344+ screen := renderer.NewFrame(width, 2)
345+ copy(screen.Row(0), row("ef", width))
346+ copy(screen.Row(1), row("gh", width))
347+
348+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}})
349+ doc := NewDocument(snapshot, "")
350+
351+ ranges := []CellRange{
352+ {Row: 0, Start: 0, End: width - 1},
353+ {Row: 1, Start: 0, End: width - 1},
354+ {Row: 2, Start: 0, End: width - 1},
355+ }
356+ const want = "abcdef\ngh"
357+ if got := doc.Extract(ranges); got != want {
358+ t.Errorf("Extract() = %q, want %q", got, want)
359+ }
360+}
361+
362+// TestSelectedTextJoinsRowsWrappedByTheVT drives the whole chain with no
363+// hand-written bounds: a real vt.Screen wraps one logical line itself, its
364+// LineBound travels through eviction into history or stays on the live grid,
365+// and Extract must give the line back exactly as the application printed it.
366+func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
367+ const (
368+ width = 8
369+ height = 4
370+ line = "abcdefghij"
371+ )
372+ cases := []struct {
373+ name string
374+ // input is written verbatim to the VT; the wrap and every scroll it
375+ // implies are the emulator's own decisions.
376+ input string
377+ // wantHistoryLen pins where the history/screen seam falls, so a change
378+ // in scroll behavior fails loudly instead of silently retargeting the
379+ // case onto rows the seam no longer separates.
380+ wantHistoryLen int
381+ // wrapRow is the first snapshot row of the wrapped logical line.
382+ wrapRow int
383+ }{
384+ {
385+ name: "wrapped line straddles the history and screen seam",
386+ input: line + "\r\nx\r\ny\r\n",
387+ wantHistoryLen: 1,
388+ wrapRow: 0,
389+ },
390+ {
391+ name: "wrapped line sits entirely in sealed history",
392+ input: line + "\r\nx\r\ny\r\nz\r\n",
393+ wantHistoryLen: 2,
394+ wrapRow: 0,
395+ },
396+ {
397+ name: "wrapped line sits entirely on the live screen",
398+ input: "1\r\n2\r\n3\r\n4\r\n5\r\n\x1b[2;1H" + line,
399+ wantHistoryLen: 2,
400+ wrapRow: 3,
401+ },
402+ }
403+ for _, tc := range cases {
404+ t.Run(tc.name, func(t *testing.T) {
405+ screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})
406+ screen.Write([]byte(tc.input))
407+
408+ snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds())
409+ require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
410+ doc := NewDocument(snapshot, domain.DefaultWordSeparators)
411+
412+ got := doc.Extract([]CellRange{
413+ {Row: tc.wrapRow, Start: 0, End: width - 1},
414+ {Row: tc.wrapRow + 1, Start: 0, End: width - 1},
415+ })
416+ require.Equal(t, line, got, "the two physical rows must rejoin without a newline or grid padding")
417+ })
418+ }
419+}
@@ -319,3 +319,101 @@ func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
319 319
320 require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")320 require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
321 }321 }
322+
323+func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
324+ row := func(s string, w int) []renderer.Cell {
325+ cells := make([]renderer.Cell, w)
326+ for i := range cells {
327+ cells[i] = renderer.BlankCell()
328+ }
329+ for i, r := range []rune(s) {
330+ if i >= w {
331+ break
332+ }
333+ cells[i] = renderer.Cell{Rune: r}
334+ }
335+ return cells
336+ }
337+
338+ const width = 4
339+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
340+ // The last history row wrapped into what is now the first live screen row.
341+ if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {
342+ t.Fatalf("append: %v", err)
343+ }
344+ screen := renderer.NewFrame(width, 2)
345+ copy(screen.Row(0), row("ef", width))
346+ copy(screen.Row(1), row("gh", width))
347+
348+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}})
349+ doc := NewDocument(snapshot, "")
350+
351+ ranges := []CellRange{
352+ {Row: 0, Start: 0, End: width - 1},
353+ {Row: 1, Start: 0, End: width - 1},
354+ {Row: 2, Start: 0, End: width - 1},
355+ }
356+ const want = "abcdef\ngh"
357+ if got := doc.Extract(ranges); got != want {
358+ t.Errorf("Extract() = %q, want %q", got, want)
359+ }
360+}
361+
362+// TestSelectedTextJoinsRowsWrappedByTheVT drives the whole chain with no
363+// hand-written bounds: a real vt.Screen wraps one logical line itself, its
364+// LineBound travels through eviction into history or stays on the live grid,
365+// and Extract must give the line back exactly as the application printed it.
366+func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
367+ const (
368+ width = 8
369+ height = 4
370+ line = "abcdefghij"
371+ )
372+ cases := []struct {
373+ name string
374+ // input is written verbatim to the VT; the wrap and every scroll it
375+ // implies are the emulator's own decisions.
376+ input string
377+ // wantHistoryLen pins where the history/screen seam falls, so a change
378+ // in scroll behavior fails loudly instead of silently retargeting the
379+ // case onto rows the seam no longer separates.
380+ wantHistoryLen int
381+ // wrapRow is the first snapshot row of the wrapped logical line.
382+ wrapRow int
383+ }{
384+ {
385+ name: "wrapped line straddles the history and screen seam",
386+ input: line + "\r\nx\r\ny\r\n",
387+ wantHistoryLen: 1,
388+ wrapRow: 0,
389+ },
390+ {
391+ name: "wrapped line sits entirely in sealed history",
392+ input: line + "\r\nx\r\ny\r\nz\r\n",
393+ wantHistoryLen: 2,
394+ wrapRow: 0,
395+ },
396+ {
397+ name: "wrapped line sits entirely on the live screen",
398+ input: "1\r\n2\r\n3\r\n4\r\n5\r\n\x1b[2;1H" + line,
399+ wantHistoryLen: 2,
400+ wrapRow: 3,
401+ },
402+ }
403+ for _, tc := range cases {
404+ t.Run(tc.name, func(t *testing.T) {
405+ screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})
406+ screen.Write([]byte(tc.input))
407+
408+ snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds())
409+ require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
410+ doc := NewDocument(snapshot, domain.DefaultWordSeparators)
411+
412+ got := doc.Extract([]CellRange{
413+ {Row: tc.wrapRow, Start: 0, End: width - 1},
414+ {Row: tc.wrapRow + 1, Start: 0, End: width - 1},
415+ })
416+ require.Equal(t, line, got, "the two physical rows must rejoin without a newline or grid padding")
417+ })
418+ }
419+}

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