document_test.go RawHistoryFolder

1package copy
2
3import (
4	"testing"
5
6	vt "github.com/bnema/vev-vt"
7	renderer "github.com/bnema/vev-vt/ansi"
8	"github.com/stretchr/testify/require"
9)
10
11func documentCells(text string) []renderer.Cell {
12	cells := make([]renderer.Cell, 0, len([]rune(text)))
13	for _, r := range text {
14		cells = append(cells, renderer.Cell{Rune: r})
15	}
16	return cells
17}
18
19func documentFromStrings(lines []string, separators string) *Document {
20	rows := make([][]renderer.Cell, len(lines))
21	for i, line := range lines {
22		rows[i] = documentCells(line)
23	}
24	return NewDocument(NewSnapshotFromRows(rows, 32, 4), separators)
25}
26
27func TestDocumentNormalize(t *testing.T) {
28	wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
29	doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}}, 5, 1), " -_@")
30
31	tests := []struct {
32		name string
33		in   Pos
34		want Pos
35		ok   bool
36	}{
37		{"glyph head", Pos{0, 1}, Pos{0, 1}, true},
38		{"wide continuation", Pos{0, 2}, Pos{0, 1}, true},
39		{"empty row stable position", Pos{1, 0}, Pos{1, 0}, true},
40		{"negative row", Pos{-1, 0}, Pos{}, false},
41		{"past row", Pos{2, 0}, Pos{}, false},
42		{"negative column", Pos{0, -1}, Pos{}, false},
43		{"past column", Pos{0, 5}, Pos{}, false},
44		{"empty row past column", Pos{1, 1}, Pos{}, false},
45	}
46	for _, tt := range tests {
47		t.Run(tt.name, func(t *testing.T) {
48			got, ok := doc.Normalize(tt.in)
49			require.Equal(t, tt.ok, ok)
50			if ok {
51				require.Equal(t, tt.want, got)
52			}
53		})
54	}
55}
56
57func TestDocumentGlyphNavigation(t *testing.T) {
58	wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
59	doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide}, 5, 1), " -_@")
60
61	tests := []struct {
62		name string
63		fn   func(Pos) (Pos, bool)
64		in   Pos
65		want Pos
66		ok   bool
67	}{
68		{"next narrow to wide", doc.NextGlyph, Pos{0, 0}, Pos{0, 1}, true},
69		{"next skips continuation", doc.NextGlyph, Pos{0, 1}, Pos{0, 3}, true},
70		{"next at end", doc.NextGlyph, Pos{0, 4}, Pos{}, false},
71		{"previous skips continuation", doc.PrevGlyph, Pos{0, 3}, Pos{0, 1}, true},
72		{"previous continuation normalizes", doc.PrevGlyph, Pos{0, 2}, Pos{0, 0}, true},
73		{"previous at start", doc.PrevGlyph, Pos{0, 0}, Pos{}, false},
74	}
75	for _, tt := range tests {
76		t.Run(tt.name, func(t *testing.T) {
77			got, ok := tt.fn(tt.in)
78			require.Equal(t, tt.ok, ok)
79			if ok {
80				require.Equal(t, tt.want, got)
81			}
82		})
83	}
84}
85
86func TestDocumentWordBounds(t *testing.T) {
87	doc := documentFromStrings([]string{"alpha/beta gamma", "été\u2003東京", ""}, "/")
88
89	tests := []struct {
90		name  string
91		in    Pos
92		start Pos
93		end   Pos
94		ok    bool
95	}{
96		{"configured separator", Pos{0, 5}, Pos{}, Pos{}, false},
97		{"second word", Pos{0, 8}, Pos{0, 6}, Pos{0, 9}, true},
98		{"unicode whitespace", Pos{1, 3}, Pos{}, Pos{}, false},
99		{"unicode word", Pos{1, 5}, Pos{1, 4}, Pos{1, 5}, true},
100		{"empty row", Pos{2, 0}, Pos{}, Pos{}, false},
101		{"out of range", Pos{4, 0}, Pos{}, Pos{}, false},
102	}
103	for _, tt := range tests {
104		t.Run(tt.name, func(t *testing.T) {
105			start, end, ok := doc.WordBounds(tt.in)
106			require.Equal(t, tt.ok, ok)
107			if ok {
108				require.Equal(t, tt.start, start)
109				require.Equal(t, tt.end, end)
110			}
111		})
112	}
113}
114
115func TestDocumentWordMovement(t *testing.T) {
116	doc := documentFromStrings([]string{"alpha/beta", " gamma", "delta"}, "/")
117
118	tests := []struct {
119		name string
120		fn   func(Pos) (Pos, bool)
121		in   Pos
122		want Pos
123	}{
124		{"next skips current word and separator", doc.NextWordStart, Pos{0, 2}, Pos{0, 6}},
125		{"next crosses rows", doc.NextWordStart, Pos{0, 7}, Pos{1, 1}},
126		{"previous returns current word start", doc.PreviousWordStart, Pos{1, 3}, Pos{1, 1}},
127		{"previous crosses rows", doc.PreviousWordStart, Pos{1, 1}, Pos{0, 6}},
128		{"next end returns current word end", doc.NextWordEnd, Pos{0, 2}, Pos{0, 4}},
129		{"next end skips separators", doc.NextWordEnd, Pos{0, 5}, Pos{0, 9}},
130	}
131	for _, tt := range tests {
132		t.Run(tt.name, func(t *testing.T) {
133			got, ok := tt.fn(tt.in)
134			require.True(t, ok)
135			require.Equal(t, tt.want, got)
136		})
137	}
138}
139
140func TestDocumentNextWordEndAdvancesPastCompletedWord(t *testing.T) {
141	doc := documentFromStrings([]string{"alpha beta"}, "/")
142
143	got, ok := doc.NextWordEnd(Pos{Row: 0, Col: 4})
144
145	require.True(t, ok)
146	require.Equal(t, Pos{Row: 0, Col: 9}, got)
147}
148
149func TestDocumentNextWordEndCrossesEmptyRow(t *testing.T) {
150	doc := documentFromStrings([]string{"alpha", "", "beta"}, "/")
151
152	got, ok := doc.NextWordEnd(Pos{Row: 0, Col: 4})
153
154	require.True(t, ok)
155	require.Equal(t, Pos{Row: 2, Col: 3}, got)
156}
157
158func TestDocumentWordMovementPreservesWideSeparatorsEmptyRowsAndEndProgression(t *testing.T) {
159	wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: '/'}, {Rune: 'b'}}
160	doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}, documentCells(" cd")}, 5, 3), "/")
161
162	tests := []struct {
163		name string
164		move func(Pos) (Pos, bool)
165		in   Pos
166		want Pos
167	}{
168		{"next start skips wide word and separator", doc.NextWordStart, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}},
169		{"previous start crosses separator", doc.PreviousWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 0, Col: 0}},
170		{"end advances from wide word", doc.NextWordEnd, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}},
171		{"next start crosses empty row", doc.NextWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 1}},
172		{"previous start crosses empty row", doc.PreviousWordStart, Pos{Row: 2, Col: 1}, Pos{Row: 0, Col: 4}},
173		{"end crosses empty row", doc.NextWordEnd, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 2}},
174	}
175	for _, tt := range tests {
176		t.Run(tt.name, func(t *testing.T) {
177			got, ok := tt.move(tt.in)
178			require.True(t, ok)
179			require.Equal(t, tt.want, got)
180		})
181	}
182}
183
184func TestDocumentWordMovementDoesNotAllocateWholeDocument(t *testing.T) {
185	lines := make([]string, 8_192)
186	lines[0] = "alpha/beta"
187	for i := 1; i < len(lines); i++ {
188		lines[i] = "unrelated"
189	}
190	doc := documentFromStrings(lines, "/")
191
192	tests := []struct {
193		name string
194		move func(Pos) (Pos, bool)
195		in   Pos
196		want Pos
197	}{
198		{"next start", doc.NextWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 6}},
199		{"previous start", doc.PreviousWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 0}},
200		{"next end", doc.NextWordEnd, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 4}},
201	}
202	for _, tt := range tests {
203		t.Run(tt.name, func(t *testing.T) {
204			allocs := testing.AllocsPerRun(100, func() {
205				got, ok := tt.move(tt.in)
206				if !ok || got != tt.want {
207					t.Fatalf("move() = (%v, %t), want (%v, true)", got, ok, tt.want)
208				}
209			})
210			require.Zero(t, allocs, "local word movement must not materialize the document")
211		})
212	}
213}
214
215func TestDocumentExtractRanges(t *testing.T) {
216	wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
217	doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha  "), documentCells("beta"), wide, {}}, 7, 4), "")
218
219	tests := []struct {
220		name   string
221		ranges []CellRange
222		want   string
223	}{
224		{"inclusive partial multiline drops each row's padding", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, "pha\nbe"},
225		{"whole row drops trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, "alpha"},
226		{"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, "界"},
227		{"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, "界"},
228		{"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, ""},
229		{"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, ""},
230		{"no ranges", nil, ""},
231	}
232	for _, tt := range tests {
233		t.Run(tt.name, func(t *testing.T) {
234			require.Equal(t, tt.want, doc.Extract(tt.ranges))
235		})
236	}
237}
238
239// padRow blank-fills a terminal row before adding text.
240func padRow(s string, w int) []renderer.Cell {
241	cells := make([]renderer.Cell, w)
242	for i := range cells {
243		cells[i] = renderer.BlankCell()
244	}
245	for i, r := range []rune(s) {
246		if i >= w {
247			break
248		}
249		cells[i] = renderer.Cell{Rune: r}
250	}
251	return cells
252}
253
254func TestDocumentContentEnd(t *testing.T) {
255	tests := []struct {
256		name   string
257		cells  []renderer.Cell
258		bounds []vt.LineBound
259		want   int
260	}{
261		{"hard short", padRow("ab", 8), []vt.LineBound{{End: 2}}, 2},
262		{"inner spaces", padRow("a b", 8), []vt.LineBound{{End: 3}}, 3},
263		{"blank", padRow("", 8), []vt.LineBound{{}}, 0},
264		{"soft trailing spaces", padRow("abc     ", 8), []vt.LineBound{{End: 8, Soft: true}}, 8},
265		{"soft partial bound", padRow("abc", 8), []vt.LineBound{{End: 5, Soft: true}}, 5},
266		{"wide glyph", []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell(), renderer.BlankCell(), renderer.BlankCell()}, []vt.LineBound{{End: 3}}, 3},
267		{"nil bounds", padRow("ab", 8), nil, 2},
268	}
269	for _, tt := range tests {
270		t.Run(tt.name, func(t *testing.T) {
271			doc := NewDocument(NewSnapshotFromLines([][]renderer.Cell{tt.cells}, tt.bounds, 8, 1), "")
272			require.Equal(t, tt.want, doc.contentEnd(0))
273		})
274	}
275}
276
277func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
278
279	tests := []struct {
280		name   string
281		rows   [][]renderer.Cell
282		bounds []vt.LineBound
283		ranges []CellRange
284		want   string
285	}{
286		{
287			name:   "hard row drops its padding",
288			rows:   [][]renderer.Cell{padRow("ab", 8)},
289			bounds: []vt.LineBound{{End: 2}},
290			ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
291			want:   "ab",
292		},
293		{
294			name:   "soft row joins without a newline",
295			rows:   [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
296			bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
297			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
298			want:   "abcdefghij",
299		},
300		{
301			name:   "soft row keeps a real trailing space",
302			rows:   [][]renderer.Cell{padRow("abc     ", 8), padRow("de", 8)},
303			bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
304			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
305			want:   "abc     de",
306		},
307		{
308			name: "three consecutive soft rows",
309			rows: [][]renderer.Cell{padRow("aaaa", 4), padRow("bbbb", 4), padRow("cccc", 4), padRow("dd", 4)},
310			bounds: []vt.LineBound{
311				{End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
312			},
313			ranges: []CellRange{
314				{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3},
315				{Row: 2, Start: 0, End: 3}, {Row: 3, Start: 0, End: 3},
316			},
317			want: "aaaabbbbccccdd",
318		},
319		{
320			name:   "hard rows keep their newline",
321			rows:   [][]renderer.Cell{padRow("ab", 8), padRow("cd", 8)},
322			bounds: []vt.LineBound{{End: 2}, {End: 2}},
323			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
324			want:   "ab\ncd",
325		},
326		{
327			name:   "sparse ranges do not join across a row gap",
328			rows:   [][]renderer.Cell{padRow("ab", 8), padRow("ignored", 8), padRow("cd", 8)},
329			bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
330			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
331			want:   "ab\ncd",
332		},
333		{
334			name:   "invalid trailing range does not prevent final emitted row trimming",
335			rows:   [][]renderer.Cell{padRow("ab", 8)},
336			bounds: []vt.LineBound{{End: 8, Soft: true}},
337			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
338			want:   "ab",
339		},
340		{
341			name:   "blank row survives as an empty line",
342			rows:   [][]renderer.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)},
343			bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
344			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
345			want:   "ab\n\ncd",
346		},
347		{
348			name:   "the last row is trimmed even when soft",
349			rows:   [][]renderer.Cell{padRow("ab", 8), padRow("cd      ", 8)},
350			bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
351			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
352			want:   "ab\ncd",
353		},
354		{
355			name: "wide glyph pushed off a soft row's edge",
356			// "a界" occupies columns 0..2; the wide rune did not fit at column 3,
357			// so End stops at 3 and column 3 is abandoned padding.
358			rows: [][]renderer.Cell{
359				{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
360				padRow("b", 4),
361			},
362			bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
363			ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
364			want:   "a界b",
365		},
366		{
367			name:   "selection stopping mid soft row",
368			rows:   [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
369			bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
370			ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
371			want:   "cde",
372		},
373	}
374
375	for _, tc := range tests {
376		t.Run(tc.name, func(t *testing.T) {
377			doc := NewDocument(NewSnapshotFromLines(tc.rows, tc.bounds, 8, 0), "")
378			if got := doc.Extract(tc.ranges); got != tc.want {
379				t.Errorf("Extract() = %q, want %q", got, tc.want)
380			}
381		})
382	}
383}
384
385var benchmarkDocumentWordPos Pos
386
387func BenchmarkDocumentWordMovementLocal(b *testing.B) {
388	lines := make([]string, 32_768)
389	lines[0] = "alpha/beta"
390	for i := 1; i < len(lines); i++ {
391		lines[i] = "unrelated"
392	}
393	doc := documentFromStrings(lines, "/")
394	moves := []func(Pos) (Pos, bool){
395		doc.NextWordStart,
396		doc.PreviousWordStart,
397		doc.NextWordEnd,
398	}
399
400	b.ReportAllocs()
401	b.ResetTimer()
402	for i := 0; i < b.N; i++ {
403		for _, move := range moves {
404			pos, ok := move(Pos{Row: 0, Col: 2})
405			if !ok {
406				b.Fatal("local word movement failed")
407			}
408			benchmarkDocumentWordPos = pos
409		}
410	}
411}
412
413func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) {
414	rows := [][]renderer.Cell{documentCells("one")}
415	doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_")
416	rows[0][0].Rune = 'x'
417
418	require.Equal(t, 1, doc.Len())
419	require.Equal(t, 9, doc.Width())
420	require.Equal(t, 2, doc.Height())
421	require.Equal(t, "one", doc.LineText(0))
422	require.Equal(t, doc.Snapshot().Row(0), doc.Row(0))
423	require.Nil(t, doc.Row(1))
424}

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