main ece7682

mode_test.go RawHistoryFolder

1package copy
2
3import (
4	"bytes"
5	"encoding/base64"
6	"strings"
7	"testing"
8
9	vt "github.com/bnema/vev-vt"
10	renderer "github.com/bnema/vev-vt/ansi"
11	"github.com/bnema/vev/internal/domain"
12	"github.com/stretchr/testify/require"
13)
14
15func row(text string) []renderer.Cell {
16	cells := make([]renderer.Cell, 0, len([]rune(text)))
17	for _, r := range text {
18		cells = append(cells, renderer.Cell{Rune: r})
19	}
20	return cells
21}
22func modeFor(lines []string, height int) *Mode {
23	rows := make([][]renderer.Cell, len(lines))
24	for i := range lines {
25		rows[i] = row(lines[i])
26	}
27	return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators))
28}
29
30func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
31	wide := make([]renderer.Cell, 321)
32	for i := range wide {
33		wide[i] = renderer.Cell{Rune: rune('a' + i%26)}
34	}
35	rows := [][]renderer.Cell{wide, row("tail")}
36
37	snapshot := NewSnapshotFromRows(rows, len(wide), len(rows))
38
39	require.Equal(t, len(rows), snapshot.history.Len())
40	for i, want := range rows {
41		require.Equal(t, want, snapshot.history.Row(i))
42	}
43}
44
45func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
46	history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
47	require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48	require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
49	screen := renderer.NewFrame(4, 2)
50	screen.WriteRow(0, 0, row("live"))
51	screen.WriteRow(1, 0, row("more"))
52	rowIDs := []vt.RowID{21, 22}
53
54	snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs)
55	rowIDs[0] = 99
56	doc := NewDocument(snapshot, "")
57
58	require.Equal(t, []vt.RowID{11, 12, 21, 22}, snapshot.RowIDs())
59	ids := snapshot.RowIDs()
60	ids[0] = 100
61	require.Equal(t, vt.RowID(11), snapshot.RowID(0))
62	require.Equal(t, vt.RowID(21), doc.RowID(2))
63	require.Equal(t, 2, doc.FindRowID(vt.RowID(21)))
64	require.Equal(t, 3, doc.FindRowID(vt.RowID(22)))
65	require.Equal(t, -1, doc.FindRowID(vt.RowID(99)))
66}
67
68func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
69	row := func(s string) []renderer.Cell {
70		cells := make([]renderer.Cell, 0, len(s))
71		for _, r := range s {
72			cells = append(cells, renderer.Cell{Rune: r})
73		}
74		return cells
75	}
76
77	history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
78	if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil {
79		t.Fatalf("append: %v", err)
80	}
81	screen := renderer.NewFrame(4, 2)
82	screen.WriteRow(0, 0, row("live"))
83
84	snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil)
85
86	tests := []struct {
87		name string
88		i    int
89		want vt.LineBound
90	}{
91		{name: "history row", i: 0, want: vt.LineBound{End: 4, Soft: true}},
92		{name: "first screen row", i: 1, want: vt.LineBound{End: 4}},
93		{name: "second screen row", i: 2, want: vt.LineBound{End: 0, Soft: true}},
94		{name: "past the end", i: 3, want: vt.LineBound{}},
95		{name: "negative", i: -1, want: vt.LineBound{}},
96	}
97	for _, tc := range tests {
98		t.Run(tc.name, func(t *testing.T) {
99			if got := snap.Bound(tc.i); got != tc.want {
100				t.Errorf("Bound(%d) = %+v, want %+v", tc.i, got, tc.want)
101			}
102		})
103	}
104}
105
106func TestCopyModeKeyboardSelection(t *testing.T) {
107	m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)
108	require.Equal(t, Pos{Row: 3}, m.Cursor())
109	m.Top()
110	m.ToggleLineSelection()
111	m.Down()
112	require.Equal(t, Line, m.Selection().Granularity)
113	require.Equal(t, "alpha\nxy", m.SelectedText())
114	m.Right()
115	require.Equal(t, Character, m.Selection().Granularity)
116	require.Equal(t, Pos{Row: 0}, m.Selection().Anchor)
117	require.Equal(t, Pos{Row: 1, Col: 1}, m.Selection().Active)
118	require.True(t, m.Left())
119	require.Equal(t, Pos{Row: 1}, m.Cursor())
120	require.True(t, m.Right())
121	require.True(t, m.Up())
122	require.Equal(t, Pos{Row: 0, Col: 1}, m.Cursor())
123	require.True(t, m.Down())
124	require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor())
125	require.True(t, m.WordNext())
126	require.Equal(t, Pos{Row: 2}, m.Cursor())
127	require.True(t, m.WordEnd())
128	require.Equal(t, Pos{Row: 2, Col: 4}, m.Cursor())
129	require.True(t, m.WordBackward())
130	require.Equal(t, Pos{Row: 2}, m.Cursor())
131}
132
133func TestCopyModePreferredColumnAndPartialSpaces(t *testing.T) {
134	m := modeFor([]string{"abcdef", "xy", "abcdef"}, 2)
135	require.True(t, m.SetPosition(Pos{Row: 0, Col: 5}))
136	m.Down()
137	require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor())
138	m.Down()
139	require.Equal(t, Pos{Row: 2, Col: 5}, m.Cursor())
140	m.SetPosition(Pos{Row: 0, Col: 1})
141	require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 1}))
142	require.True(t, m.ExtendCharacterSelection(Pos{Row: 0, Col: 4}))
143	require.Equal(t, "bcde", m.SelectedText())
144}
145
146func TestCopyModeRenderSelectionInvertsSearchAndWideGlyph(t *testing.T) {
147	cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}}
148	m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), ""))
149	require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0))
150	require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 0}))
151	f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}})
152	require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
153	require.True(t, f.At(1, 0).Style.HasBackgroundRGB)
154	require.True(t, f.At(0, 0).Style.Inverse)
155	require.True(t, f.At(1, 0).Style.Inverse)
156}
157
158func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) {
159	matches := []SearchMatch{{Row: 1, Start: 1, End: 2}, {Row: 2, Start: 2, End: 3}}
160	m := modeFor([]string{"zero", "one", "two"}, 3)
161
162	m.SetPosition(Pos{Row: 0})
163	m.ToggleLineSelection()
164	require.True(t, m.SetSearchMatches("match", matches, 0))
165	require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 1, Col: 1}, Granularity: Line, Enabled: true}, m.Selection())
166
167	require.True(t, m.Right())
168	require.Equal(t, Character, m.Selection().Granularity)
169	require.True(t, m.NextSearchMatch(1))
170	require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection())
171}
172
173func TestCopyModeSearchExtendsSelectionWhenMatchIsAlreadyAtCursor(t *testing.T) {
174	m := modeFor([]string{"zero", "one"}, 2)
175	require.True(t, m.StartCharacterSelection(Pos{Row: 0}))
176	require.True(t, m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}))
177	require.True(t, m.SetPosition(Pos{Row: 0}))
178
179	require.True(t, m.SetSearchMatches("zero", []SearchMatch{{Row: 0, Start: 0, End: 4}}, 0))
180	require.Equal(t, Pos{Row: 0}, m.Selection().Active)
181}
182
183func TestCopyModeSearchNilMode(t *testing.T) {
184	var mode *Mode
185	require.False(t, mode.Search("anything"))
186}
187
188func TestCopyModeRenderBoundsCursorToRenderedRow(t *testing.T) {
189	m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("alpha")}, 2, 1), ""))
190	require.True(t, m.SetPosition(Pos{Row: 0, Col: 4}), "cursor remains valid in the document despite a narrow render frame")
191
192	selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
193	require.NotPanics(t, func() { _ = m.Render(renderer.DefaultStyle(), selection) })
194	frame := m.Render(renderer.DefaultStyle(), selection)
195	require.True(t, frame.At(1, 0).Style.HasBackgroundRGB, "a valid off-frame cursor is rendered at the visible edge")
196}
197
198func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) {
199	cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}}
200	m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), ""))
201	require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 2}))
202	require.True(t, m.SetPosition(Pos{Row: 0, Col: 1})) // Normalize the wide-glyph continuation to its head.
203
204	selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
205	f := m.Render(renderer.DefaultStyle(), selection)
206	require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
207	require.True(t, f.At(2, 0).Style.Inverse)
208	require.False(t, f.At(2, 0).Style.HasBackgroundRGB)
209}
210
211func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
212	cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}
213	doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "")
214	require.Equal(t, []SearchMatch{{Row: 0, Start: 2, End: 7, Text: "界alpha"}}, FindMatches(doc, "alpha"))
215}
216
217func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) {
218	cells := []renderer.Cell{
219		{Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
220		{Rune: 'ä'}, {Rune: '界'}, {Continuation: true},
221		{Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
222	}
223	doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("aaaa"), cells}, 9, 2), "")
224
225	for _, tt := range []struct {
226		name, query string
227		want        []SearchMatch
228	}{
229		{
230			name:  "repeated non-overlapping",
231			query: "aa",
232			want: []SearchMatch{
233				{Row: 0, Start: 0, End: 2, Text: "aaaa"},
234				{Row: 0, Start: 2, End: 4, Text: "aaaa"},
235			},
236		},
237		{
238			name:  "unicode display cells",
239			query: "Ä界",
240			want: []SearchMatch{
241				{Row: 1, Start: 0, End: 3, Text: "Ä界ä界Ä界"},
242				{Row: 1, Start: 3, End: 6, Text: "Ä界ä界Ä界"},
243				{Row: 1, Start: 6, End: 9, Text: "Ä界ä界Ä界"},
244			},
245		},
246	} {
247		t.Run(tt.name, func(t *testing.T) {
248			require.Equal(t, tt.want, FindMatches(doc, tt.query))
249		})
250	}
251}
252
253func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
254	const rows = 10_000
255	history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
256	unmatched, target := row("unmatched"), row("target")
257	for range rows {
258		require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
259	}
260	require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
261	snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil, nil)
262	view := history.SealAndView()
263	require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
264
265	doc := NewDocument(snapshot, "")
266	require.Equal(t, []SearchMatch{{Row: rows, Start: 0, End: 6, Text: "target"}}, FindMatches(doc, "target"))
267}
268
269func TestCopyModeRenderSelectionAllocationsBoundedToViewport(t *testing.T) {
270	const (
271		rows      = 10_000
272		height    = 4
273		maxAllocs = 20
274	)
275	lines := make([][]renderer.Cell, rows)
276	for i := range lines {
277		lines[i] = row("selection")
278	}
279	m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
280	require.True(t, m.StartCharacterSelection(Pos{}))
281	require.True(t, m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}))
282
283	allocs := testing.AllocsPerRun(10, func() {
284		_ = m.Render()
285	})
286	require.LessOrEqual(t, allocs, float64(maxAllocs), "rendering a %d-line selection must only allocate for its %d-line viewport", rows, height)
287}
288
289func BenchmarkCopyModeRenderLargeSelection(b *testing.B) {
290	const (
291		rows   = 10_000
292		height = 4
293	)
294	lines := make([][]renderer.Cell, rows)
295	for i := range lines {
296		lines[i] = row("selection")
297	}
298	m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
299	if !m.StartCharacterSelection(Pos{}) || !m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}) {
300		b.Fatal("could not create large selection")
301	}
302
303	b.ReportAllocs()
304	b.ResetTimer()
305	for range b.N {
306		_ = m.Render()
307	}
308}
309
310func TestCopyModeOSC52SelectionMatrix(t *testing.T) {
311	cases := []struct {
312		name     string
313		selectFn func(*Mode)
314		want     string
315	}{
316		{"line", func(m *Mode) { m.SetPosition(Pos{}); m.ToggleLineSelection(); m.Down() }, "alpha\nbravo"},
317		{"horizontal", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 1}); m.ExtendCharacterSelection(Pos{Col: 3}) }, "lph"},
318		{"multiline", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 2}); m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}) }, "pha\nbr"},
319		{"reverse", func(m *Mode) { m.StartCharacterSelection(Pos{Row: 1, Col: 1}); m.ExtendCharacterSelection(Pos{Col: 2}) }, "pha\nbr"},
320		{"word", func(m *Mode) { m.SelectWordAt(Pos{Row: 1, Col: 2}) }, "bravo"},
321	}
322	for _, tc := range cases {
323		t.Run(tc.name, func(t *testing.T) {
324			m := modeFor([]string{"alpha", "bravo"}, 2)
325			tc.selectFn(m)
326			got := OSC52(m.SelectedText())
327			require.Len(t, got, 1)
328			want := []byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.want)) + "\x07")
329			require.True(t, bytes.Equal(want, got[0]))
330		})
331	}
332}
333
334func TestOSC52LimitsAndBase64(t *testing.T) {
335	require.Nil(t, OSC52(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
336	encoded := base64.StdEncoding.EncodeToString([]byte("hello"))
337	require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded))
338}
339
340func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
341	m := modeFor([]string{"alpha"}, 5)
342	status := renderer.Style{Foreground: 1, Background: 2}
343	selection := renderer.Style{Foreground: 3, Background: 4}
344	frame := m.Render(status, selection)
345
346	require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
347}
348
349func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
350	row := func(s string, w int) []renderer.Cell {
351		cells := make([]renderer.Cell, w)
352		for i := range cells {
353			cells[i] = renderer.BlankCell()
354		}
355		for i, r := range []rune(s) {
356			if i >= w {
357				break
358			}
359			cells[i] = renderer.Cell{Rune: r}
360		}
361		return cells
362	}
363
364	const width = 4
365	history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
366	// The last history row wrapped into what is now the first live screen row.
367	if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {
368		t.Fatalf("append: %v", err)
369	}
370	screen := renderer.NewFrame(width, 2)
371	screen.WriteRow(0, 0, row("ef", width))
372	screen.WriteRow(1, 0, row("gh", width))
373
374	snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)
375	doc := NewDocument(snapshot, "")
376
377	ranges := []CellRange{
378		{Row: 0, Start: 0, End: width - 1},
379		{Row: 1, Start: 0, End: width - 1},
380		{Row: 2, Start: 0, End: width - 1},
381	}
382	const want = "abcdef\ngh"
383	if got := doc.Extract(ranges); got != want {
384		t.Errorf("Extract() = %q, want %q", got, want)
385	}
386}
387
388// TestSelectedTextJoinsRowsWrappedByTheVT drives the whole chain with no
389// hand-written bounds: a real vt.Screen wraps one logical line itself, its
390// LineBound travels through eviction into history or stays on the live grid,
391// and Extract must give the line back exactly as the application printed it.
392func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
393	const (
394		width  = 8
395		height = 4
396		line   = "abcdefghij"
397	)
398	cases := []struct {
399		name string
400		// input is written verbatim to the VT; the wrap and every scroll it
401		// implies are the emulator's own decisions.
402		input string
403		// wantHistoryLen pins where the history/screen seam falls, so a change
404		// in scroll behavior fails loudly instead of silently retargeting the
405		// case onto rows the seam no longer separates.
406		wantHistoryLen int
407		// wrapRow is the first snapshot row of the wrapped logical line.
408		wrapRow int
409	}{
410		{
411			name:           "wrapped line straddles the history and screen seam",
412			input:          line + "\r\nx\r\ny\r\n",
413			wantHistoryLen: 1,
414			wrapRow:        0,
415		},
416		{
417			name:           "wrapped line sits entirely in sealed history",
418			input:          line + "\r\nx\r\ny\r\nz\r\n",
419			wantHistoryLen: 2,
420			wrapRow:        0,
421		},
422		{
423			name:           "wrapped line sits entirely on the live screen",
424			input:          "1\r\n2\r\n3\r\n4\r\n5\r\n\x1b[2;1H" + line,
425			wantHistoryLen: 2,
426			wrapRow:        3,
427		},
428		{
429			// The common case: the pane is full, so the cursor is on the last
430			// row and the wrap scrolls the screen as it happens. This is what a
431			// long line printed at a shell prompt actually does.
432			name:           "wrapped line wraps out of the bottom row of a full pane",
433			input:          "1\r\n2\r\n3\r\n4\r\n5\r\n" + line,
434			wantHistoryLen: 3,
435			wrapRow:        5,
436		},
437	}
438	for _, tc := range cases {
439		t.Run(tc.name, func(t *testing.T) {
440			screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64})
441			screen.Write([]byte(tc.input))
442
443			snapshot := NewSnapshot(screen.History(), screen, screen.LineBounds(), nil)
444			require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
445			doc := NewDocument(snapshot, domain.DefaultWordSeparators)
446
447			got := doc.Extract([]CellRange{
448				{Row: tc.wrapRow, Start: 0, End: width - 1},
449				{Row: tc.wrapRow + 1, Start: 0, End: width - 1},
450			})
451			require.Equal(t, line, got, "the two physical rows must rejoin without a newline or grid padding")
452		})
453	}
454}

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