v0.5.0 c37833b

picker_interaction_test.go RawHistoryFolder

1package client
2
3import (
4	"fmt"
5	"strings"
6	"testing"
7
8	renderer "github.com/bnema/vev-vt"
9	ansirenderer "github.com/bnema/vev-vt/ansi"
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/protocol"
12	"github.com/bnema/vev/internal/usecase/picker"
13	"github.com/stretchr/testify/require"
14)
15
16func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) {
17	loop := pickerLoopFixture(t)
18	r := newPickerRenderer(ansirenderer.ColorProfileTrueColor)
19	screen := renderer.NewScreen(100, 30)
20	paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S')
21
22	screen.Write(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
23	require.NotNil(t, r.prevBounds)
24	oldBounds := *r.prevBounds
25
26	screen.Resize(80, 30)
27	screen.Write(r.render(loop, domain.Size{Cols: 80, Rows: 30}, emptyPickerPreview()))
28	require.NotNil(t, r.prevBounds)
29	newBounds := *r.prevBounds
30
31	require.Greater(t, oldBounds.X+oldBounds.Width, newBounds.X+newBounds.Width)
32	snapshot := screen.Snapshot()
33	for y := oldBounds.Y; y < min(oldBounds.Y+oldBounds.Height, snapshot.Rows()); y++ {
34		for x := newBounds.X + newBounds.Width; x < min(oldBounds.X+oldBounds.Width, snapshot.Columns()); x++ {
35			require.Equal(t, ' ', snapshot.Row(y)[x].Rune, "stale modal cell at (%d,%d)", x, y)
36		}
37	}
38	require.Equal(t, 'S', snapshot.Row(0)[0].Rune, "render must preserve session content outside picker damage")
39	require.Equal(t, '┌', snapshot.Row(newBounds.Y)[newBounds.X].Rune, "new picker border must be rendered")
40}
41
42func TestPickerRendererPreservesANSI256ProfileAcrossResize(t *testing.T) {
43	loop := pickerLoopFixture(t)
44	styles := picker.RenderStyles{
45		Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(),
46		Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(),
47		Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(),
48		SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
49	}
50
51	indexed := newPickerRenderer(ansirenderer.ColorProfileANSI256)
52	indexed.renderStyles = []picker.RenderStyles{styles}
53	for _, size := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 80, Rows: 24}} {
54		output := string(indexed.render(loop, size, emptyPickerPreview()))
55		require.Regexp(t, `(?:38|48);5;`, output, "picker did not emit indexed color at %v", size)
56		require.NotRegexp(t, `(?:38|48|58);2;`, output, "picker emitted truecolor at %v", size)
57	}
58
59	truecolor := newPickerRenderer(ansirenderer.ColorProfileTrueColor)
60	truecolor.renderStyles = []picker.RenderStyles{styles}
61	require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())))
62}
63
64// TestPickerRenderersFollowTerminalColorProfile guards #280 on the broker
65// client: both the session picker and the move picker render with the
66// terminal's detected color profile, so an ANSI-256 terminal never receives
67// RGB SGR sequences from a picker box.
68func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) {
69	loop := pickerLoopFixture(t)
70	styles := picker.RenderStyles{
71		Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(),
72		Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(),
73		Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(),
74		SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
75	}
76	renderers := map[string]func(trueColor bool) *pickerRenderer{
77		"session picker": func(trueColor bool) *pickerRenderer {
78			return NewPicker(nil, 0, trueColor).controller.renderer
79		},
80		"move picker": func(trueColor bool) *pickerRenderer {
81			return newMovePickerOverlay(trueColor).renderer
82		},
83	}
84	for name, build := range renderers {
85		for _, tc := range []struct {
86			trueColor bool
87			profile   ansirenderer.ColorProfile
88			want      string
89			forbidden string
90		}{
91			{trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},
92			{trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},
93		} {
94			t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) {
95				r := build(tc.trueColor)
96				require.Equal(t, tc.profile, r.profile)
97				r.renderStyles = []picker.RenderStyles{styles}
98				output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
99				require.Regexp(t, tc.want, output)
100				require.NotRegexp(t, tc.forbidden, output)
101			})
102		}
103	}
104}
105
106func rgbPickerStyle() renderer.Style {
107	style := renderer.DefaultStyle()
108	style.HasForegroundRGB = true
109	style.ForegroundRGB = renderer.RGB{R: 12, G: 120, B: 231}
110	style.HasBackgroundRGB = true
111	style.BackgroundRGB = renderer.RGB{R: 23, G: 45, B: 67}
112	return style
113}
114
115func paintScreen(screen *renderer.Screen, size domain.Size, fill rune) {
116	for y := 0; y < size.Rows; y++ {
117		screen.Write([]byte(fmt.Sprintf("\x1b[%d;1H%s", y+1, strings.Repeat(string(fill), size.Cols))))
118	}
119}
120
121func pickerSnapshotFixture() protocol.PickerSnapshot {
122	return protocol.PickerSnapshot{
123		InteractionID: 11, SourceID: "serving", SourceRevision: 3, Status: protocol.PickerSourceOK,
124		Lines: []protocol.PickerLine{
125			{Key: "aa/work", Kind: protocol.PickerLineSession, Label: "work", Detail: "2 tabs", Focusable: true, Actions: protocol.PickerCanNavigate | protocol.PickerCanKill},
126			{Key: "bb/perso", Kind: protocol.PickerLineSession, Label: "perso", Detail: "stopped", Stopped: true, Focusable: true, Actions: protocol.PickerCanNavigate},
127			{Key: "cc/remote", Kind: protocol.PickerLineSession, Label: "remote@host", Focusable: true, Actions: protocol.PickerCanNavigate},
128		},
129		Cursor: protocol.PickerCursor{Key: "aa/work", Index: 0},
130	}
131}
132
133func pickerLoopFixture(t *testing.T) *pickerLoop {
134	t.Helper()
135	return pickerLoopFromSnapshot(pickerSnapshotFixture(), protocol.PickerIntentNavigation, picker.SortRecent)
136}
137
138func pickedKey(t *testing.T, loop *pickerLoop) string {
139	t.Helper()
140	selection, ok := loop.selection(protocol.PickerActionNavigate, 0)
141	require.True(t, ok)
142	return selection.Key
143}
144
145func TestPickerInteractionAdmitsLatestSourceRevision(t *testing.T) {
146	var rel *pickerInteraction
147	require.False(t, rel.admitSnapshot(pickerSnapshotFixture(), false))
148
149	rel = &pickerInteraction{}
150	snapshot := pickerSnapshotFixture()
151	require.False(t, rel.admitSnapshot(snapshot, false), "closed interaction must not admit")
152
153	rel.setOpen(true, snapshot.InteractionID)
154	require.True(t, rel.admitSnapshot(snapshot, false))
155	require.Equal(t, snapshot.SourceRevision, rel.sourceRevisions[snapshot.SourceID])
156
157	older := snapshot
158	older.SourceRevision = snapshot.SourceRevision - 1
159	require.False(t, rel.admitSnapshot(older, false), "stale source revision must not step backward")
160
161	foreign := snapshot
162	foreign.InteractionID = snapshot.InteractionID + 1
163	require.False(t, rel.admitSnapshot(foreign, false), "foreign interaction must drop")
164
165	rel.setOpen(false, snapshot.InteractionID)
166	require.False(t, rel.admitSnapshot(snapshot, false), "closed interaction drops late snapshots")
167}
168
169func TestPickerInteractionKeepsPerSourceRevisions(t *testing.T) {
170	rel := &pickerInteraction{}
171	snapshot := pickerSnapshotFixture()
172	rel.setOpen(true, snapshot.InteractionID)
173	require.True(t, rel.admitSnapshot(snapshot, false))
174
175	// Another source publishes its own first revision: it must be admitted
176	// even though the serving source is far ahead.
177	other := snapshot
178	other.SourceID = "host-2"
179	other.SourceRevision = 1
180	require.True(t, rel.admitSnapshot(other, false))
181	require.Equal(t, uint64(3), rel.sourceRevisions["serving"])
182	require.Equal(t, uint64(1), rel.sourceRevisions["host-2"])
183}
184
185func TestPickerLoopMergesSourcesAndCommitsOwningIdentity(t *testing.T) {
186	local := pickerSnapshotFixture()
187	local.SourceID = "authority/local"
188	local.Lines = local.Lines[:1]
189	local.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0}
190	loop := pickerLoopFromSnapshot(local, protocol.PickerIntentNavigation, picker.SortRecent)
191
192	remote := local
193	remote.SourceID = "authority/remote-a"
194	remote.SourceRevision = 1
195	remote.Lines = []protocol.PickerLine{{Key: "aa/work", Kind: protocol.PickerLineSession, Label: "work@remote-a", Focusable: true, Actions: protocol.PickerCanNavigate}}
196	remote.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0}
197	loop.replaceLines(remote)
198
199	loop.down()
200	selection, ok := loop.selection(protocol.PickerActionNavigate, 9)
201	require.True(t, ok)
202	require.Equal(t, "authority/remote-a", selection.SourceID)
203	require.Equal(t, uint64(1), selection.SourceRevision)
204	require.Equal(t, "aa/work", selection.Key)
205}
206
207func TestPickerLoopRefreshesOneSourceWithoutInvalidatingAnother(t *testing.T) {
208	local := pickerSnapshotFixture()
209	local.SourceID = "authority/local"
210	local.Lines = local.Lines[:1]
211	local.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0}
212	loop := pickerLoopFromSnapshot(local, protocol.PickerIntentNavigation, picker.SortRecent)
213
214	remote := local
215	remote.SourceID = "authority/remote-a"
216	remote.SourceRevision = 1
217	remote.Lines = []protocol.PickerLine{{Key: "remote/session", Kind: protocol.PickerLineSession, Label: "remote", Focusable: true, Actions: protocol.PickerCanNavigate}}
218	remote.Cursor = protocol.PickerCursor{Key: "remote/session", Index: 0}
219	loop.replaceLines(remote)
220	loop.down()
221
222	local.SourceRevision++
223	local.Lines[0].Detail = "refreshed"
224	loop.replaceLines(local)
225	selection, ok := loop.selection(protocol.PickerActionNavigate, 0)
226	require.True(t, ok)
227	require.Equal(t, "authority/remote-a", selection.SourceID)
228	require.Equal(t, uint64(1), selection.SourceRevision)
229}
230
231func TestPickerInteractionRetirementIsPermanent(t *testing.T) {
232	snapshot := pickerSnapshotFixture()
233	var rel pickerInteraction
234	rel.setOpen(true, snapshot.InteractionID)
235	require.True(t, rel.admitSnapshot(snapshot, false))
236
237	// Closing retires the namespace: an in-flight snapshot for the same
238	// interaction can never reopen it.
239	rel.setOpen(false, snapshot.InteractionID)
240	require.Equal(t, snapshot.InteractionID, rel.retired)
241	require.False(t, rel.admitSnapshot(snapshot, false))
242	rel.setOpen(true, snapshot.InteractionID)
243	require.False(t, rel.admitSnapshot(snapshot, false), "a retired interaction is never reopened")
244
245	// A newer interaction is a new namespace and admits normally.
246	next := snapshot
247	next.InteractionID = snapshot.InteractionID + 1
248	next.SourceRevision = 1
249	rel.setOpen(true, next.InteractionID)
250	require.True(t, rel.admitSnapshot(next, false))
251	require.False(t, rel.admitSnapshot(snapshot, false), "the retired namespace stays closed")
252}
253
254func TestPickerLoopCursorSearchCommit(t *testing.T) {
255	loop := pickerLoopFixture(t)
256	require.NotNil(t, loop.model)
257
258	// Cursor starts at the published cursor key (first row).
259	require.Equal(t, "aa/work", pickedKey(t, loop))
260
261	// Cursor movement selects the non-initial row: the committed key follows
262	// the client cursor, never the daemon cursor.
263	loop.down()
264	require.Equal(t, "bb/perso", pickedKey(t, loop))
265	loop.down()
266	require.Equal(t, "cc/remote", pickedKey(t, loop))
267	loop.up()
268	require.Equal(t, "bb/perso", pickedKey(t, loop))
269}
270
271func TestPickerLoopSearchExitVsClose(t *testing.T) {
272	loop := pickerLoopFixture(t)
273
274	// Text enters search; Escape exits search without closing.
275	loop.insert('w')
276	require.True(t, loop.model.SearchActive())
277	require.False(t, loop.escape(), "search-exit Escape must not close")
278	require.False(t, loop.model.SearchActive())
279
280	// Escape with no search reports picker-close.
281	require.True(t, loop.escape(), "idle Escape must close")
282
283	// Literal s/x in search are text; backspace deletes.
284	loop.insert('s')
285	loop.insert('x')
286	require.True(t, loop.model.SearchActive())
287	require.Equal(t, "sx", loop.model.Query())
288	loop.backspace()
289	require.Equal(t, "s", loop.model.Query())
290}
291
292func TestPickerLoopKillRequiresAuthorisedRow(t *testing.T) {
293	loop := pickerLoopFixture(t)
294
295	// The first row authorises destruction; its typed kill carries the
296	// displayed source revision and the fatal action.
297	selection, ok := loop.selection(protocol.PickerActionKill, 0)
298	require.True(t, ok)
299	require.Equal(t, protocol.PickerActionKill, selection.Action)
300	require.Equal(t, "aa/work", selection.Key)
301	require.Equal(t, uint64(3), selection.SourceRevision)
302
303	// The stopped row is navigable but not destructible from here.
304	loop.down()
305	require.Equal(t, "bb/perso", pickedKey(t, loop))
306	_, ok = loop.selection(protocol.PickerActionKill, 0)
307	require.False(t, ok, "a row without the kill action must not be destroyed")
308}
309
310// TestPickerDecisionCapturesRowWithDecision proves the key handed to a driver
311// is the row selected when the decision was recorded, and that a commit wins
312// over a kill in the same accumulated decision.
313func TestPickerDecisionCapturesRowWithDecision(t *testing.T) {
314	tests := []struct {
315		name       string
316		ops        []pickerOp
317		moveAfter  bool
318		wantKey    string
319		wantAction bool
320	}{
321		{name: "no decision", ops: []pickerOp{{}}},
322		{name: "commit captures cursor row", ops: []pickerOp{{commit: true}}, moveAfter: true, wantKey: "aa/work", wantAction: true},
323		{name: "kill captures cursor row", ops: []pickerOp{{kill: true}}, moveAfter: true, wantKey: "aa/work", wantAction: true},
324		{name: "close carries no row", ops: []pickerOp{{close: true}}, wantAction: true},
325		{name: "commit wins over kill", ops: []pickerOp{{kill: true}, {commit: true}}, wantKey: "aa/work", wantAction: true},
326	}
327	for _, tt := range tests {
328		t.Run(tt.name, func(t *testing.T) {
329			loop := pickerLoopFixture(t)
330			var decision pickerDecision
331			for _, op := range tt.ops {
332				decision.record(op, loop)
333			}
334			if tt.moveAfter {
335				loop.down()
336			}
337			require.Equal(t, tt.wantAction, decision.actionable())
338			require.Equal(t, tt.wantKey, decision.key())
339		})
340	}
341}
342
343func TestPickerLoopKeepsPublishedLineOrderAndKeys(t *testing.T) {
344	snapshot := pickerSnapshotFixture()
345	loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent)
346
347	// Every published line commits its own opaque key without parsing display
348	// text, in published order.
349	loop.model.SelectNearestRow(0)
350	keys := []string{pickedKey(t, loop)}
351	for range len(snapshot.Lines) - 1 {
352		loop.down()
353		keys = append(keys, pickedKey(t, loop))
354	}
355	require.Equal(t, []string{"aa/work", "bb/perso", "cc/remote"}, keys)
356}
357
358func TestPickerDriverOpReportsActionsAndLocalSort(t *testing.T) {
359	loop := pickerLoopFixture(t)
360
361	// Normal mode: arrows and j/k move; Enter asks to commit; x asks to kill.
362	op, changed := loop.drive([]string{"Down"}, "")
363	require.False(t, op.commit)
364	require.False(t, op.close)
365	require.True(t, changed)
366	require.Equal(t, "bb/perso", pickedKey(t, loop))
367	op, _ = loop.drive([]string{"k"}, "")
368	require.False(t, op.commit)
369	require.Equal(t, "aa/work", pickedKey(t, loop))
370
371	op, _ = loop.drive([]string{"Enter"}, "")
372	require.True(t, op.commit)
373	op, _ = loop.drive([]string{"x"}, "")
374	require.True(t, op.kill)
375	require.False(t, op.close)
376
377	// q and Ctrl+C close in normal mode.
378	for _, key := range []string{"q", "Ctrl+C"} {
379		op, _ = loop.drive([]string{key}, "")
380		require.True(t, op.close, "%s must close the picker", key)
381	}
382
383	// s reorders locally without asking the daemon, and keeps the selection.
384	op, changed = loop.drive([]string{"s"}, "")
385	require.False(t, op.commit)
386	require.False(t, op.kill)
387	require.False(t, op.close)
388	require.True(t, changed)
389	require.Equal(t, picker.SortGrouped, loop.model.SortMode())
390	require.Equal(t, "aa/work", pickedKey(t, loop))
391
392	// While searching, Enter still commits and printable keys are text.
393	loop.insert('w')
394	_, changed = loop.drive([]string{"o", "r"}, "")
395	require.True(t, changed)
396	require.Equal(t, "wor", loop.model.Query())
397}

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