picker_parity_test.go RawHistoryFolder

1package client
2
3import (
4	"strings"
5	"testing"
6	"time"
7
8	"github.com/stretchr/testify/require"
9
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/ports"
12	"github.com/bnema/vev/internal/protocol"
13	"github.com/bnema/vev/internal/protocol/catalogue"
14	"github.com/bnema/vev/internal/usecase/ui"
15)
16
17// Picker parity with main (Plan 003 B1-B4, C1). These port the behaviors the
18// deleted daemon picker tests pinned on main (picker_interaction_test.go,
19// remote_picker_test.go, picker_lines.go) onto the client-owned catalogue.
20
21func pickerTestTab(id, name, detail string, attention bool) catalogue.RemoteCatalogTab {
22	return catalogue.RemoteCatalogTab{ID: id, Name: name, Detail: detail, Attention: attention}
23}
24
25func pickerTestTabbed(name string, seed byte, state catalogue.RemoteCatalogSessionState, lastUsed uint64, active string, tabs ...catalogue.RemoteCatalogTab) catalogue.RemoteCatalogSession {
26	session := pickerTestSession(name, seed, state)
27	session.LastUsedSeq = lastUsed
28	session.ActiveTabID = active
29	for i := range tabs {
30		tabs[i].Index = uint16(i)
31	}
32	session.Tabs = tabs
33	return session
34}
35
36func pickerLinesOfKind(lines []protocol.PickerLine, kind protocol.PickerLineKind) []protocol.PickerLine {
37	out := make([]protocol.PickerLine, 0)
38	for _, line := range lines {
39		if line.Kind == kind {
40			out = append(out, line)
41		}
42	}
43	return out
44}
45
46// pickerTabKey returns the key of the tab row labelled label that follows the
47// session header labelled session.
48func pickerTabKey(t *testing.T, lines []protocol.PickerLine, session, label string) string {
49	t.Helper()
50	inSession := false
51	for _, line := range lines {
52		switch line.Kind {
53		case protocol.PickerLineSession, protocol.PickerLineHost, protocol.PickerLineSection:
54			inSession = line.Kind == protocol.PickerLineSession && line.Label == session
55		case protocol.PickerLineTab:
56			if inSession && line.Label == label {
57				return line.Key
58			}
59		}
60	}
61	t.Fatalf("no tab %q under session %q", label, session)
62	return ""
63}
64
65// TestPickerCatalogueTabRows ports main's picker_lines.go tab rows: a session
66// with tabs is a non-focusable header naming its tabs; each tab row is a
67// destination with its pane-title detail and its own bell, and the header
68// rings when any tab does.
69func TestPickerCatalogueTabRows(t *testing.T) {
70	now := time.Unix(1000, 0)
71	navigateKill := protocol.PickerCanNavigate | protocol.PickerCanKill
72	tests := []struct {
73		name          string
74		daemon        ports.BrokerDaemonObservation
75		session       string
76		wantHeaderBel bool
77		wantTabs      []protocol.PickerLine // Label, Detail, Attention, Actions, Stopped
78	}{
79		{
80			name: "live local session",
81			daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 1, catalogue_Up, 3, "t2",
82				pickerTestTab("t1", "shell", "(zsh)", false),
83				pickerTestTab("t2", "build", "(make)", true),
84				pickerTestTab("t3", "", "", false),
85			)),
86			session:       "work",
87			wantHeaderBel: true,
88			wantTabs: []protocol.PickerLine{
89				{Label: "shell", Detail: "(zsh)", Actions: navigateKill},
90				{Label: "build", Detail: "(make)", Attention: true, Actions: navigateKill},
91				{Label: "3", Actions: navigateKill},
92			},
93		},
94		{
95			name: "stopped local session keeps its tabs",
96			daemon: pickerTestLocalObservation(now, pickerTestTabbed("old", 2, catalogue_Down, 1, "",
97				pickerTestTab("t1", "notes", "", false),
98			)),
99			session: "old",
100			wantTabs: []protocol.PickerLine{
101				{Label: "notes", Actions: navigateKill, Stopped: true},
102			},
103		},
104		{
105			name: "remote tabs navigate but never kill",
106			daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1",
107				pickerTestTab("r1", "logs", "(tail)", false),
108				pickerTestTab("", "anon", "", false),
109			)),
110			session: "remote-a",
111			wantTabs: []protocol.PickerLine{
112				{Label: "logs", Detail: "(tail)", Actions: protocol.PickerCanNavigate},
113				// A live tab without a stable ID cannot be targeted exactly.
114				{Label: "anon"},
115			},
116		},
117	}
118	for _, tt := range tests {
119		t.Run(tt.name, func(t *testing.T) {
120			catalogue, _ := pickerTestCatalogue(t)
121			require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
122			for _, lines := range [][]protocol.PickerLine{catalogue.Lines(), catalogue.RecentLines()} {
123				header, ok := pickerLineByLabel(lines, tt.session)
124				require.True(t, ok)
125				require.Equal(t, protocol.PickerLineSession, header.Kind)
126				require.False(t, header.Focusable, "a tabbed session header is not a destination")
127				require.Zero(t, header.Actions)
128				require.Equal(t, tt.wantHeaderBel, header.Attention, "the header rings when any tab does")
129
130				tabs := pickerLinesOfKind(lines, protocol.PickerLineTab)
131				require.Len(t, tabs, len(tt.wantTabs))
132				for i, want := range tt.wantTabs {
133					got := tabs[i]
134					require.Equal(t, want.Label, got.Label)
135					require.Equal(t, want.Detail, got.Detail)
136					require.Equal(t, want.Attention, got.Attention)
137					require.Equal(t, want.Actions, got.Actions, "tab %q actions", got.Label)
138					require.Equal(t, want.Stopped, got.Stopped)
139					require.True(t, got.Focusable, "every tab row is reachable")
140					require.NotEqual(t, header.Key, got.Key)
141				}
142			}
143		})
144	}
145}
146
147// TestPickerCatalogueResolvesTabTargets pins exact tab targets: live and
148// stopped-local tabs prefer their stable tab, a stopped remote tab restores
149// through its exact selector (stable ID, else ordinal), and a tab that left
150// the session is gone.
151func TestPickerCatalogueResolvesTabTargets(t *testing.T) {
152	now := time.Unix(1000, 0)
153	lifecycle := pickerTestLifecycle(3)
154	tests := []struct {
155		name    string
156		daemon  ports.BrokerDaemonObservation
157		session string
158		tab     string
159		after   *ports.BrokerDaemonObservation
160		want    attachmentTab
161		wantErr pickerCatalogueErrorCode
162	}{
163		{
164			name:    "live local tab",
165			daemon:  pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
166			session: "work", tab: "build",
167			want: attachmentTab{preferred: "t2"},
168		},
169		{
170			name:    "stopped local tab",
171			daemon:  pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Down, 1, "", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
172			session: "work", tab: "build",
173			want: attachmentTab{preferred: "t2"},
174		},
175		{
176			name:    "live remote tab",
177			daemon:  pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))),
178			session: "remote-a", tab: "edit",
179			want: attachmentTab{preferred: "r2"},
180		},
181		{
182			name:    "stopped remote tab by stable id",
183			daemon:  pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))),
184			session: "remote-a", tab: "edit",
185			want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabID: "r2", TabIndex: protocol.NoTabIndex, Stopped: true}},
186		},
187		{
188			name:    "stopped remote tab by ordinal",
189			daemon:  pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("", "logs", "", false), pickerTestTab("", "edit", "", false))),
190			session: "remote-a", tab: "edit",
191			want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true}},
192		},
193		{
194			name:    "tab closed after projection",
195			daemon:  pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
196			session: "work", tab: "build",
197			after: func() *ports.BrokerDaemonObservation {
198				o := pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false)))
199				return &o
200			}(),
201			wantErr: pickerCatalogueGone,
202		},
203	}
204	for _, tt := range tests {
205		t.Run(tt.name, func(t *testing.T) {
206			catalogue, _ := pickerTestCatalogue(t)
207			require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
208			key := pickerTabKey(t, catalogue.Lines(), tt.session, tt.tab)
209			if tt.after != nil {
210				require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}}))
211			}
212			request, tab, err := catalogue.ResolveTarget(key, pickerTestBase())
213			if tt.wantErr != 0 {
214				require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
215				return
216			}
217			require.NoError(t, err)
218			require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
219			require.Equal(t, lifecycle, request.Target.LifecycleID)
220			require.Equal(t, tt.want, tab)
221		})
222	}
223}
224
225// TestPickerCatalogueRecentAndGroupedProjections ports main's
226// TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections plus the MRU
227// order of picker.go: Recent is flat (live by recency, then remote, then
228// stopped by recency); Grouped is sectioned per daemon; both carry the same
229// destinations and actions.
230func TestPickerCatalogueRecentAndGroupedProjections(t *testing.T) {
231	now := time.Unix(1000, 0)
232	local := pickerTestLocalObservation(now,
233		pickerTestTabbed("alpha", 1, catalogue_Up, 5, "", pickerTestTab("a1", "sh", "", false)),
234		pickerTestTabbed("beta", 2, catalogue_Up, 9, "", pickerTestTab("b1", "sh", "", false)),
235		pickerTestTabbed("gamma", 3, catalogue_Down, 7, ""),
236		pickerTestTabbed("delta", 4, catalogue_Down, 8, ""),
237	)
238	remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 99, "", pickerTestTab("r1", "sh", "", false)))
239	catalogue, _ := pickerTestCatalogue(t)
240	require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}}))
241	recent, grouped := catalogue.projections()
242
243	tests := []struct {
244		name         string
245		lines        []protocol.PickerLine
246		wantSessions []string
247		wantSections []string
248	}{
249		{name: "recent", lines: recent.Lines, wantSessions: []string{"beta", "alpha", "remote-a", "delta", "gamma"}, wantSections: []string{}},
250		{name: "grouped", lines: grouped.Lines, wantSessions: []string{"beta", "alpha", "delta", "gamma", "remote-a"}, wantSections: []string{"local", "user@arch"}},
251	}
252	for _, tt := range tests {
253		t.Run(tt.name, func(t *testing.T) {
254			require.Equal(t, tt.wantSessions, pickerSessionLabels(tt.lines))
255			require.Equal(t, tt.wantSections, pickerSectionLabels(tt.lines))
256		})
257	}
258	actions := func(lines []protocol.PickerLine) map[string]protocol.PickerLineActions {
259		out := make(map[string]protocol.PickerLineActions)
260		for _, line := range lines {
261			if line.Key != "" {
262				out[line.Key] = line.Actions
263			}
264		}
265		return out
266	}
267	require.Equal(t, actions(recent.Lines), actions(grouped.Lines), "projections disagree on destination actions")
268	require.NotEqual(t, recent.Lines, grouped.Lines, "both projections carry the same sort input")
269	remoteRecent, ok := pickerLineByLabel(recent.Lines, "remote-a")
270	require.True(t, ok)
271	require.Contains(t, remoteRecent.Detail, "@user@arch", "a flat remote row names its origin")
272	require.NoError(t, protocol.ValidatePickerSnapshot(protocol.PickerSnapshot{InteractionID: 1, SourceID: pickerCatalogueSourceID, SourceRevision: 1, Status: protocol.PickerSourceOK, Recent: recent, Grouped: grouped}))
273}
274
275// TestPickerCatalogueCursorStartsOnCurrent ports main's pickerSelectionMatches
276// and default cursor: the attached tab, else the attached session's active
277// tab, else the most recent session's active tab.
278func TestPickerCatalogueCursorStartsOnCurrent(t *testing.T) {
279	now := time.Unix(1000, 0)
280	local := pickerTestLocalObservation(now,
281		pickerTestTabbed("alpha", 1, catalogue_Up, 5, "a2", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)),
282		pickerTestTabbed("beta", 2, catalogue_Up, 9, "b2", pickerTestTab("b1", "one", "", false), pickerTestTab("b2", "two", "", false)),
283	)
284	remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "r1", pickerTestTab("r1", "one", "", false), pickerTestTab("r2", "two", "", false)))
285	tests := []struct {
286		name        string
287		current     pickerCurrent
288		wantSession string
289		wantTab     string
290	}{
291		{name: "no attachment: most recent active tab", wantSession: "beta", wantTab: "two"},
292		{name: "attached tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a1"}, wantSession: "alpha", wantTab: "one"},
293		{name: "attached session, unknown tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1)}, wantSession: "alpha", wantTab: "two"},
294		{name: "attached remote tab", current: pickerCurrent{known: true, endpoint: "user@arch", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "remote-a", wantTab: "two"},
295		{name: "other endpoint never matches", current: pickerCurrent{known: true, endpoint: "user@other", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "beta", wantTab: "two"},
296	}
297	for _, tt := range tests {
298		t.Run(tt.name, func(t *testing.T) {
299			catalogue, _ := pickerTestCatalogue(t)
300			catalogue.SetCurrent(tt.current)
301			require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}}))
302			recent, grouped := catalogue.projections()
303			for _, projection := range []protocol.PickerProjection{recent, grouped} {
304				want := pickerTabKey(t, projection.Lines, tt.wantSession, tt.wantTab)
305				require.Equal(t, want, projection.Cursor.Key)
306				require.Equal(t, want, projection.Lines[projection.Cursor.Index].Key)
307			}
308		})
309	}
310}
311
312// TestPickerControllerOverlayOpensOnCurrentTab pins that SetCurrent restarts
313// the presentation on the attached tab and drops a leftover search.
314func TestPickerControllerOverlayOpensOnCurrentTab(t *testing.T) {
315	controller, clock := pickerTestController(t)
316	controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(),
317		pickerTestTabbed("alpha", 1, catalogue_Up, 1, "a1", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)),
318		pickerTestTabbed("beta", 2, catalogue_Up, 9, "b1", pickerTestTab("b1", "one", "", false)),
319	)}})
320	require.True(t, controller.ConsumeTerminalRead([]byte("/on")))
321	require.True(t, controller.modelSearchActive(t))
322
323	controller.SetCurrent(pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a2"})
324	require.False(t, controller.modelSearchActive(t), "a fresh overlay starts without the previous search")
325	require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "alpha", "two"), mustCursorKey(t, controller))
326}
327
328// TestPickerControllerSearchesTabNamesAndPaneTitles pins that tab rows are
329// searchable by their tab name and their pane-title detail.
330func TestPickerControllerSearchesTabNamesAndPaneTitles(t *testing.T) {
331	tests := []struct {
332		name  string
333		query string
334		want  string
335	}{
336		{name: "tab name", query: "deploy", want: "deploy"},
337		{name: "pane title", query: "htop", want: "monitor"},
338	}
339	for _, tt := range tests {
340		t.Run(tt.name, func(t *testing.T) {
341			controller, clock := pickerTestController(t)
342			controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(),
343				pickerTestTabbed("work", 1, catalogue_Up, 1, "w1",
344					pickerTestTab("w1", "shell", "(zsh)", false),
345					pickerTestTab("w2", "deploy", "(ansible)", false),
346					pickerTestTab("w3", "monitor", "(htop)", false),
347				),
348			)}})
349			require.True(t, controller.ConsumeTerminalRead([]byte("/"+tt.query)))
350			require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "work", tt.want), mustCursorKey(t, controller))
351		})
352	}
353}
354
355// TestPickerControllerSortToggleSwitchesProjection pins that `s` switches
356// between the distinct Recent and Grouped projections.
357func TestPickerControllerSortToggleSwitchesProjection(t *testing.T) {
358	controller, clock := pickerTestController(t)
359	controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
360		pickerTestLocalObservation(clock.Now(), pickerTestTabbed("alpha", 1, catalogue_Up, 5, ""), pickerTestTabbed("gamma", 3, catalogue_Down, 1, "")),
361		pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "")),
362	}})
363	order := func() []string {
364		frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30}))
365		positions := map[string]int{}
366		for _, label := range []string{"alpha", "gamma", "remote-a"} {
367			positions[label] = strings.Index(frame, label)
368			require.GreaterOrEqual(t, positions[label], 0, "%q is rendered", label)
369		}
370		labels := []string{"alpha", "gamma", "remote-a"}
371		for i := range labels {
372			for j := i + 1; j < len(labels); j++ {
373				if positions[labels[j]] < positions[labels[i]] {
374					labels[i], labels[j] = labels[j], labels[i]
375				}
376			}
377		}
378		return labels
379	}
380	grouped := []string{"alpha", "gamma", "remote-a"}
381	recent := []string{"alpha", "remote-a", "gamma"}
382	require.Equal(t, grouped, order(), "default grouped: local section, then remote")
383	require.True(t, controller.ConsumeTerminalRead([]byte("s")))
384	require.Equal(t, recent, order(), "recent: live, remote, stopped")
385
386	// Reopening the picker keeps this client's chosen sort.
387	controller.SetCurrent(pickerCurrent{})
388	require.Equal(t, recent, order(), "sort survives a reopen")
389	require.True(t, controller.ConsumeTerminalRead([]byte("s")))
390	require.Equal(t, grouped, order())
391}
392
393// TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's
394// TestPickerSnapshotCarriesAttentionBellsToTheClient and
395// TestAttentionPulseRepublishesTheOpenPickerSource: a bell raised by a later
396// publication reaches the rendered picker.
397func TestPickerControllerRendersBellsAndRepaintsOnPublication(t *testing.T) {
398	controller, clock := pickerTestController(t)
399	quiet := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false)))
400	controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{quiet}})
401	bell := string(ui.AttentionGlyph)
402	require.NotContains(t, string(controller.Render(domain.Size{Cols: 100, Rows: 30})), bell)
403
404	ringing := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", true)))
405	controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ringing}})
406	controller.invalidatePresentation()
407	frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30}))
408	require.Equal(t, 2, strings.Count(frame, bell), "the header and its tab row both ring")
409}
410
411// TestPickerCatalogueResolveKill pins which rows `x` may destroy: local live
412// and stopped sessions (by exact lifecycle and name), never a remote row, and
413// never a lifecycle that left or was replaced.
414func TestPickerCatalogueResolveKill(t *testing.T) {
415	now := time.Unix(1000, 0)
416	tests := []struct {
417		name        string
418		daemon      ports.BrokerDaemonObservation
419		label       string
420		after       *ports.BrokerDaemonObservation
421		wantStopped bool
422		wantErr     pickerCatalogueErrorCode
423	}{
424		{name: "live local", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha"},
425		{name: "stopped local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Down)), label: "alpha", wantStopped: true},
426		{name: "broken local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken)), label: "alpha", wantStopped: true},
427		{name: "remote is refused", daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha", wantErr: pickerCatalogueUnavailable},
428		{
429			name: "gone", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha",
430			after:   func() *ports.BrokerDaemonObservation { o := pickerTestLocalObservation(now); return &o }(),
431			wantErr: pickerCatalogueGone,
432		},
433		{
434			name: "replaced by the same name", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha",
435			after: func() *ports.BrokerDaemonObservation {
436				o := pickerTestLocalObservation(now, pickerTestSession("alpha", 2, catalogue_Up))
437				return &o
438			}(),
439			wantErr: pickerCatalogueReplaced,
440		},
441	}
442	for _, tt := range tests {
443		t.Run(tt.name, func(t *testing.T) {
444			catalogue, _ := pickerTestCatalogue(t)
445			require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
446			line, ok := pickerLineByLabel(catalogue.Lines(), tt.label)
447			require.True(t, ok)
448			if tt.after != nil {
449				require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}}))
450			}
451			target, err := catalogue.ResolveKill(line.Key)
452			if tt.wantErr != 0 {
453				require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
454				return
455			}
456			require.NoError(t, err)
457			require.Equal(t, BrokerOperationRoute{Epoch: 3, Destination: ports.BrokerEndpointFence{Local: true}}, target.route)
458			require.NoError(t, target.route.Validate())
459			require.Equal(t, tt.label, target.name)
460			require.Equal(t, tt.wantStopped, target.stopped)
461		})
462	}
463}
464
465// TestPickerControllerKillKeyWakesSupervisor pins that `x` wakes the
466// supervisor with the killed row's key, only on a row that authorises it, and
467// types into an active search instead.
468func TestPickerControllerKillKeyWakesSupervisor(t *testing.T) {
469	tests := []struct {
470		name     string
471		daemon   func(time.Time) ports.BrokerDaemonObservation
472		input    string
473		wantKill bool
474		wantKey  bool
475	}{
476		{name: "local row", daemon: func(now time.Time) ports.BrokerDaemonObservation {
477			return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
478		}, input: "x", wantKill: true, wantKey: true},
479		{name: "remote row authorises no kill", daemon: func(now time.Time) ports.BrokerDaemonObservation {
480			return pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up))
481		}, input: "x", wantKill: true},
482		{name: "search types x", daemon: func(now time.Time) ports.BrokerDaemonObservation {
483			return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
484		}, input: "/x"},
485	}
486	for _, tt := range tests {
487		t.Run(tt.name, func(t *testing.T) {
488			controller, clock := pickerTestController(t)
489			controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon(clock.Now())}})
490			require.True(t, controller.ConsumeTerminalRead([]byte(tt.input)))
491			if tt.wantKill {
492				select {
493				case <-controller.OpsReady():
494				default:
495					t.Fatal("a kill decision must wake the supervisor")
496				}
497			}
498			op, key := controller.TakeOp()
499			require.Equal(t, tt.wantKill, op.kill)
500			if tt.wantKey {
501				require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), key)
502			} else {
503				require.Empty(t, key)
504			}
505		})
506	}
507}
508
509// TestPickerControllerRecoveryReplacesVisibleFailure checks that a host's
510// recovery toast replaces its failure toast and leaves other hosts' toasts.
511func TestPickerControllerRecoveryReplacesVisibleFailure(t *testing.T) {
512	controller, clock := pickerTestController(t)
513	down := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
514	down.Availability = domain.RemoteAvailabilityUnreachable
515	down.FailureEpisode = 1
516	other := pickerTestRemoteObservation("user@mule", 2, 2, clock.Now())
517	other.Availability = domain.RemoteAvailabilityUnreachable
518	other.FailureEpisode = 1
519	controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{down, other}})
520
521	up := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
522	up.FailureEpisode = 1
523	controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{up, other}})
524
525	messages := controller.state().notices
526	require.Len(t, messages, 2, "each host keeps one toast")
527	require.Contains(t, messages, "Remote host reconnected: user@arch")
528	for _, message := range messages {
529		require.NotContains(t, message, "Remote check failed: user@arch", "the recovery replaced the failure")
530	}
531}
532
533// TestPickerControllerHostFailureToastOncePerEpisode ports main's
534// TestRemoteFailureNoticeEmittedOncePerFailureEpisode and
535// TestRemoteFailureNoticesKeepEndpointsDistinct.
536func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) {
537	controller, clock := pickerTestController(t)
538	failing := func(endpoint string, seed byte, episode uint64, kind domain.RemoteFailureKind) ports.BrokerDaemonObservation {
539		o := pickerTestRemoteObservation(endpoint, seed, seed, clock.Now())
540		o.Availability = domain.RemoteAvailabilityUnreachable
541		o.ConsecutiveFailures = 1
542		o.FailureEpisode = episode
543		o.LastFailure = domain.RemoteFailure{Kind: kind}
544		return o
545	}
546	revision := ports.BrokerRevision(0)
547	apply := func(daemons ...ports.BrokerDaemonObservation) []ui.QueuedToast[ui.Toast] {
548		revision++
549		controller.mu.Lock()
550		controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible})
551		controller.mu.Unlock()
552		controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: daemons})
553		controller.mu.Lock()
554		defer controller.mu.Unlock()
555		return controller.notices.Visible(clock.Now())
556	}
557
558	toasts := apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust))
559	require.Len(t, toasts, 1)
560	require.Contains(t, toasts[0].Value.Message, "SSH host verification failed")
561	require.Contains(t, toasts[0].Value.Message, "user@arch")
562	require.Empty(t, apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)), "the same episode toasts once")
563	require.Len(t, apply(failing("user@arch", 1, 2, domain.RemoteFailureTrust)), 1, "a new episode toasts again")
564
565	recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second))
566	recovered.FailureEpisode = 2
567	toasts = apply(recovered)
568	require.Len(t, toasts, 1, "a recovery toasts once")
569	require.Equal(t, "Remote host reconnected: user@arch", toasts[0].Value.Message)
570	require.Empty(t, apply(recovered), "a steady healthy host is silent")
571
572	require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1)
573	unknown := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(2*time.Second))
574	unknown.Availability = domain.RemoteAvailabilityUnknown
575	unknown.FailureEpisode = 3
576	require.Empty(t, apply(unknown), "an in-between state is silent")
577	recovered = pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(3*time.Second))
578	recovered.FailureEpisode = 3
579	require.Len(t, apply(recovered), 1, "failing → unknown → reachable still reports recovery")
580	require.Len(t, apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again")
581
582	toasts = apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication))
583	require.Len(t, toasts, 1, "only the newly failing endpoint toasts")
584	require.Contains(t, toasts[0].Value.Message, "user@mule")
585}
586
587// TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a
588// remote host the broker observed failing refuses at once with main's notice
589// instead of dialing, while a stale-but-reachable host stays attemptable.
590func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) {
591	tests := []struct {
592		name         string
593		availability domain.RemoteAvailability
594		wantNotice   string
595	}{
596		{name: "unreachable", availability: domain.RemoteAvailabilityUnreachable, wantNotice: "Remote session unavailable: remote-a@user@arch — host unreachable"},
597		{name: "authentication", availability: domain.RemoteAvailabilityAuthFailed, wantNotice: "Remote session unavailable: remote-a@user@arch — authentication failed"},
598		{name: "reachable", availability: domain.RemoteAvailabilityReachable},
599	}
600	for _, tt := range tests {
601		t.Run(tt.name, func(t *testing.T) {
602			controller, clock := pickerTestController(t)
603			o := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(-time.Hour), pickerTestSession("remote-a", 3, catalogue_Up))
604			o.Availability = tt.availability
605			controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{o}})
606			controller.mu.Lock()
607			controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible})
608			controller.mu.Unlock()
609
610			_, _, err := controller.ResolveCommit(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase())
611			if tt.wantNotice == "" {
612				require.NoError(t, err)
613				return
614			}
615			require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnavailable))
616			controller.mu.Lock()
617			active := controller.notices.Visible(clock.Now())
618			controller.mu.Unlock()
619			require.Len(t, active, 1)
620			require.Equal(t, tt.wantNotice, active[0].Value.Message)
621		})
622	}
623}
624
625// TestSessionAttachmentHelloCarriesTab pins that a committed tab reaches the
626// daemon: a stable tab is preferred, a stopped remote tab is restored through
627// its exact selector, and a session-level commit carries neither.
628func TestSessionAttachmentHelloCarriesTab(t *testing.T) {
629	stopped := protocol.SessionAttachTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true}
630	tests := []struct {
631		name          string
632		local         bool
633		tab           attachmentTab
634		wantPreferred domain.TabStableID
635		wantTarget    *protocol.SessionAttachTarget
636	}{
637		{name: "session level", local: true},
638		{name: "local tab", local: true, tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"},
639		{name: "remote live tab", tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"},
640		{name: "remote stopped tab", tab: attachmentTab{stopped: &stopped}, wantTarget: &stopped},
641	}
642	for _, tt := range tests {
643		t.Run(tt.name, func(t *testing.T) {
644			request := sessionTestRequest(tt.local)
645			provenance := SessionEnvironmentRemote
646			if tt.local {
647				provenance = SessionEnvironmentLocalPicker
648			}
649			worker, err := newSessionAttachmentWorker(sessionAttachmentConfig{Request: request, Geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, SessionEnvironment: SessionEnvironment{Provenance: provenance}, Tab: tt.tab})
650			require.NoError(t, err)
651			hello := worker.hello(newSessionTestStream())
652			require.Equal(t, tt.wantPreferred, hello.PreferredTabID)
653			require.Equal(t, tt.wantTarget, hello.SessionTarget)
654			require.NoError(t, protocol.ValidateHello(hello))
655		})
656	}
657}

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