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

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