v0.3.0 99dc266

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
239func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
240	// pad renders s into a width-w row, blank-filled like a real terminal grid.
241	pad := func(s string, w int) []renderer.Cell {
242		cells := make([]renderer.Cell, w)
243		for i := range cells {
244			cells[i] = renderer.BlankCell()
245		}
246		for i, r := range []rune(s) {
247			if i >= w {
248				break
249			}
250			cells[i] = renderer.Cell{Rune: r}
251		}
252		return cells
253	}
254
255	tests := []struct {
256		name   string
257		rows   [][]renderer.Cell
258		bounds []vt.LineBound
259		ranges []CellRange
260		want   string
261	}{
262		{
263			name:   "hard row drops its padding",
264			rows:   [][]renderer.Cell{pad("ab", 8)},
265			bounds: []vt.LineBound{{End: 2}},
266			ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
267			want:   "ab",
268		},
269		{
270			name:   "soft row joins without a newline",
271			rows:   [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
272			bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
273			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
274			want:   "abcdefghij",
275		},
276		{
277			name:   "soft row keeps a real trailing space",
278			rows:   [][]renderer.Cell{pad("abc     ", 8), pad("de", 8)},
279			bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
280			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
281			want:   "abc     de",
282		},
283		{
284			name: "three consecutive soft rows",
285			rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)},
286			bounds: []vt.LineBound{
287				{End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
288			},
289			ranges: []CellRange{
290				{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3},
291				{Row: 2, Start: 0, End: 3}, {Row: 3, Start: 0, End: 3},
292			},
293			want: "aaaabbbbccccdd",
294		},
295		{
296			name:   "hard rows keep their newline",
297			rows:   [][]renderer.Cell{pad("ab", 8), pad("cd", 8)},
298			bounds: []vt.LineBound{{End: 2}, {End: 2}},
299			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
300			want:   "ab\ncd",
301		},
302		{
303			name:   "sparse ranges do not join across a row gap",
304			rows:   [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)},
305			bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
306			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
307			want:   "ab\ncd",
308		},
309		{
310			name:   "invalid trailing range does not prevent final emitted row trimming",
311			rows:   [][]renderer.Cell{pad("ab", 8)},
312			bounds: []vt.LineBound{{End: 8, Soft: true}},
313			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
314			want:   "ab",
315		},
316		{
317			name:   "blank row survives as an empty line",
318			rows:   [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)},
319			bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
320			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
321			want:   "ab\n\ncd",
322		},
323		{
324			name:   "the last row is trimmed even when soft",
325			rows:   [][]renderer.Cell{pad("ab", 8), pad("cd      ", 8)},
326			bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
327			ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
328			want:   "ab\ncd",
329		},
330		{
331			name: "wide glyph pushed off a soft row's edge",
332			// "a界" occupies columns 0..2; the wide rune did not fit at column 3,
333			// so End stops at 3 and column 3 is abandoned padding.
334			rows: [][]renderer.Cell{
335				{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
336				pad("b", 4),
337			},
338			bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
339			ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
340			want:   "a界b",
341		},
342		{
343			name:   "selection stopping mid soft row",
344			rows:   [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
345			bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
346			ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
347			want:   "cde",
348		},
349	}
350
351	for _, tc := range tests {
352		t.Run(tc.name, func(t *testing.T) {
353			doc := NewDocument(NewSnapshotFromLines(tc.rows, tc.bounds, 8, 0), "")
354			if got := doc.Extract(tc.ranges); got != tc.want {
355				t.Errorf("Extract() = %q, want %q", got, tc.want)
356			}
357		})
358	}
359}
360
361var benchmarkDocumentWordPos Pos
362
363func BenchmarkDocumentWordMovementLocal(b *testing.B) {
364	lines := make([]string, 32_768)
365	lines[0] = "alpha/beta"
366	for i := 1; i < len(lines); i++ {
367		lines[i] = "unrelated"
368	}
369	doc := documentFromStrings(lines, "/")
370	moves := []func(Pos) (Pos, bool){
371		doc.NextWordStart,
372		doc.PreviousWordStart,
373		doc.NextWordEnd,
374	}
375
376	b.ReportAllocs()
377	b.ResetTimer()
378	for i := 0; i < b.N; i++ {
379		for _, move := range moves {
380			pos, ok := move(Pos{Row: 0, Col: 2})
381			if !ok {
382				b.Fatal("local word movement failed")
383			}
384			benchmarkDocumentWordPos = pos
385		}
386	}
387}
388
389func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) {
390	rows := [][]renderer.Cell{documentCells("one")}
391	doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_")
392	rows[0][0].Rune = 'x'
393
394	require.Equal(t, 1, doc.Len())
395	require.Equal(t, 9, doc.Width())
396	require.Equal(t, 2, doc.Height())
397	require.Equal(t, "one", doc.LineText(0))
398	require.Equal(t, doc.Snapshot().Row(0), doc.Row(0))
399	require.Nil(t, doc.Row(1))
400}

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