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 TestCopyModeRenderSelectionWinsSearchAndWideGlyph(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}
155
156func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) {
157	matches := []SearchMatch{{Row: 1, Start: 1, End: 2}, {Row: 2, Start: 2, End: 3}}
158	m := modeFor([]string{"zero", "one", "two"}, 3)
159
160	m.SetPosition(Pos{Row: 0})
161	m.ToggleLineSelection()
162	require.True(t, m.SetSearchMatches("match", matches, 0))
163	require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 1, Col: 1}, Granularity: Line, Enabled: true}, m.Selection())
164
165	require.True(t, m.Right())
166	require.Equal(t, Character, m.Selection().Granularity)
167	require.True(t, m.NextSearchMatch(1))
168	require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection())
169}
170
171func TestCopyModeSearchExtendsSelectionWhenMatchIsAlreadyAtCursor(t *testing.T) {
172	m := modeFor([]string{"zero", "one"}, 2)
173	require.True(t, m.StartCharacterSelection(Pos{Row: 0}))
174	require.True(t, m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}))
175	require.True(t, m.SetPosition(Pos{Row: 0}))
176
177	require.True(t, m.SetSearchMatches("zero", []SearchMatch{{Row: 0, Start: 0, End: 4}}, 0))
178	require.Equal(t, Pos{Row: 0}, m.Selection().Active)
179}
180
181func TestCopyModeSearchNilMode(t *testing.T) {
182	var mode *Mode
183	require.False(t, mode.Search("anything"))
184}
185
186func TestCopyModeRenderBoundsCursorToRenderedRow(t *testing.T) {
187	m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("alpha")}, 2, 1), ""))
188	require.True(t, m.SetPosition(Pos{Row: 0, Col: 4}), "cursor remains valid in the document despite a narrow render frame")
189
190	selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
191	require.NotPanics(t, func() { _ = m.Render(renderer.DefaultStyle(), selection) })
192	frame := m.Render(renderer.DefaultStyle(), selection)
193	require.True(t, frame.At(1, 0).Style.HasBackgroundRGB, "a valid off-frame cursor is rendered at the visible edge")
194}
195
196func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) {
197	cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}}
198	m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), ""))
199	require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 2}))
200	require.True(t, m.SetPosition(Pos{Row: 0, Col: 1})) // Normalize the wide-glyph continuation to its head.
201
202	selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
203	f := m.Render(renderer.DefaultStyle(), selection)
204	require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
205	require.True(t, f.At(2, 0).Style.HasBackgroundRGB)
206}
207
208func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
209	cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}
210	doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "")
211	require.Equal(t, []SearchMatch{{Row: 0, Start: 2, End: 7, Text: "界alpha"}}, FindMatches(doc, "alpha"))
212}
213
214func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) {
215	cells := []renderer.Cell{
216		{Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
217		{Rune: 'ä'}, {Rune: '界'}, {Continuation: true},
218		{Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
219	}
220	doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("aaaa"), cells}, 9, 2), "")
221
222	for _, tt := range []struct {
223		name, query string
224		want        []SearchMatch
225	}{
226		{
227			name:  "repeated non-overlapping",
228			query: "aa",
229			want: []SearchMatch{
230				{Row: 0, Start: 0, End: 2, Text: "aaaa"},
231				{Row: 0, Start: 2, End: 4, Text: "aaaa"},
232			},
233		},
234		{
235			name:  "unicode display cells",
236			query: "Ä界",
237			want: []SearchMatch{
238				{Row: 1, Start: 0, End: 3, Text: "Ä界ä界Ä界"},
239				{Row: 1, Start: 3, End: 6, Text: "Ä界ä界Ä界"},
240				{Row: 1, Start: 6, End: 9, Text: "Ä界ä界Ä界"},
241			},
242		},
243	} {
244		t.Run(tt.name, func(t *testing.T) {
245			require.Equal(t, tt.want, FindMatches(doc, tt.query))
246		})
247	}
248}
249
250func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
251	const rows = 10_000
252	history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
253	unmatched, target := row("unmatched"), row("target")
254	for range rows {
255		require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
256	}
257	require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
258	snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil, nil)
259	view := history.SealAndView()
260	require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
261
262	doc := NewDocument(snapshot, "")
263	require.Equal(t, []SearchMatch{{Row: rows, Start: 0, End: 6, Text: "target"}}, FindMatches(doc, "target"))
264}
265
266func TestCopyModeRenderSelectionAllocationsBoundedToViewport(t *testing.T) {
267	const (
268		rows      = 10_000
269		height    = 4
270		maxAllocs = 20
271	)
272	lines := make([][]renderer.Cell, rows)
273	for i := range lines {
274		lines[i] = row("selection")
275	}
276	m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
277	require.True(t, m.StartCharacterSelection(Pos{}))
278	require.True(t, m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}))
279
280	allocs := testing.AllocsPerRun(10, func() {
281		_ = m.Render()
282	})
283	require.LessOrEqual(t, allocs, float64(maxAllocs), "rendering a %d-line selection must only allocate for its %d-line viewport", rows, height)
284}
285
286func BenchmarkCopyModeRenderLargeSelection(b *testing.B) {
287	const (
288		rows   = 10_000
289		height = 4
290	)
291	lines := make([][]renderer.Cell, rows)
292	for i := range lines {
293		lines[i] = row("selection")
294	}
295	m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
296	if !m.StartCharacterSelection(Pos{}) || !m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}) {
297		b.Fatal("could not create large selection")
298	}
299
300	b.ReportAllocs()
301	b.ResetTimer()
302	for range b.N {
303		_ = m.Render()
304	}
305}
306
307func TestCopyModeOSC52SelectionMatrix(t *testing.T) {
308	cases := []struct {
309		name     string
310		selectFn func(*Mode)
311		want     string
312	}{
313		{"line", func(m *Mode) { m.SetPosition(Pos{}); m.ToggleLineSelection(); m.Down() }, "alpha\nbravo"},
314		{"horizontal", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 1}); m.ExtendCharacterSelection(Pos{Col: 3}) }, "lph"},
315		{"multiline", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 2}); m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}) }, "pha\nbr"},
316		{"reverse", func(m *Mode) { m.StartCharacterSelection(Pos{Row: 1, Col: 1}); m.ExtendCharacterSelection(Pos{Col: 2}) }, "pha\nbr"},
317		{"word", func(m *Mode) { m.SelectWordAt(Pos{Row: 1, Col: 2}) }, "bravo"},
318	}
319	for _, tc := range cases {
320		t.Run(tc.name, func(t *testing.T) {
321			m := modeFor([]string{"alpha", "bravo"}, 2)
322			tc.selectFn(m)
323			got := OSC52(m.SelectedText())
324			require.Len(t, got, 1)
325			want := []byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.want)) + "\x07")
326			require.True(t, bytes.Equal(want, got[0]))
327		})
328	}
329}
330
331func TestOSC52LimitsAndBase64(t *testing.T) {
332	require.Nil(t, OSC52(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
333	encoded := base64.StdEncoding.EncodeToString([]byte("hello"))
334	require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded))
335}
336
337func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
338	m := modeFor([]string{"alpha"}, 5)
339	status := renderer.Style{Foreground: 1, Background: 2}
340	selection := renderer.Style{Foreground: 3, Background: 4}
341	frame := m.Render(status, selection)
342
343	require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
344}
345
346func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
347	row := func(s string, w int) []renderer.Cell {
348		cells := make([]renderer.Cell, w)
349		for i := range cells {
350			cells[i] = renderer.BlankCell()
351		}
352		for i, r := range []rune(s) {
353			if i >= w {
354				break
355			}
356			cells[i] = renderer.Cell{Rune: r}
357		}
358		return cells
359	}
360
361	const width = 4
362	history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
363	// The last history row wrapped into what is now the first live screen row.
364	if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {
365		t.Fatalf("append: %v", err)
366	}
367	screen := renderer.NewFrame(width, 2)
368	screen.WriteRow(0, 0, row("ef", width))
369	screen.WriteRow(1, 0, row("gh", width))
370
371	snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)
372	doc := NewDocument(snapshot, "")
373
374	ranges := []CellRange{
375		{Row: 0, Start: 0, End: width - 1},
376		{Row: 1, Start: 0, End: width - 1},
377		{Row: 2, Start: 0, End: width - 1},
378	}
379	const want = "abcdef\ngh"
380	if got := doc.Extract(ranges); got != want {
381		t.Errorf("Extract() = %q, want %q", got, want)
382	}
383}
384
385// TestSelectedTextJoinsRowsWrappedByTheVT drives the whole chain with no
386// hand-written bounds: a real vt.Screen wraps one logical line itself, its
387// LineBound travels through eviction into history or stays on the live grid,
388// and Extract must give the line back exactly as the application printed it.
389func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
390	const (
391		width  = 8
392		height = 4
393		line   = "abcdefghij"
394	)
395	cases := []struct {
396		name string
397		// input is written verbatim to the VT; the wrap and every scroll it
398		// implies are the emulator's own decisions.
399		input string
400		// wantHistoryLen pins where the history/screen seam falls, so a change
401		// in scroll behavior fails loudly instead of silently retargeting the
402		// case onto rows the seam no longer separates.
403		wantHistoryLen int
404		// wrapRow is the first snapshot row of the wrapped logical line.
405		wrapRow int
406	}{
407		{
408			name:           "wrapped line straddles the history and screen seam",
409			input:          line + "\r\nx\r\ny\r\n",
410			wantHistoryLen: 1,
411			wrapRow:        0,
412		},
413		{
414			name:           "wrapped line sits entirely in sealed history",
415			input:          line + "\r\nx\r\ny\r\nz\r\n",
416			wantHistoryLen: 2,
417			wrapRow:        0,
418		},
419		{
420			name:           "wrapped line sits entirely on the live screen",
421			input:          "1\r\n2\r\n3\r\n4\r\n5\r\n\x1b[2;1H" + line,
422			wantHistoryLen: 2,
423			wrapRow:        3,
424		},
425		{
426			// The common case: the pane is full, so the cursor is on the last
427			// row and the wrap scrolls the screen as it happens. This is what a
428			// long line printed at a shell prompt actually does.
429			name:           "wrapped line wraps out of the bottom row of a full pane",
430			input:          "1\r\n2\r\n3\r\n4\r\n5\r\n" + line,
431			wantHistoryLen: 3,
432			wrapRow:        5,
433		},
434	}
435	for _, tc := range cases {
436		t.Run(tc.name, func(t *testing.T) {
437			screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64})
438			screen.Write([]byte(tc.input))
439
440			snapshot := NewSnapshot(screen.History(), screen, screen.LineBounds(), nil)
441			require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
442			doc := NewDocument(snapshot, domain.DefaultWordSeparators)
443
444			got := doc.Extract([]CellRange{
445				{Row: tc.wrapRow, Start: 0, End: width - 1},
446				{Row: tc.wrapRow + 1, Start: 0, End: width - 1},
447			})
448			require.Equal(t, line, got, "the two physical rows must rejoin without a newline or grid padding")
449		})
450	}
451}

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