picker_overlay_test.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"strings"
6	"sync"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/ports"
14	"github.com/bnema/vev/internal/protocol"
15)
16
17// Picker overlay and move picker tests (Plan 003 A1/A2). They drive the real
18// supervisor, host, foreground, and worker over the in-package broker fakes
19// with the scripted test clock: no sleeps, and every wait is bounded.
20
21// recordOp records one picker decision on the scripted picker, exactly like
22// the controller's coalesced wake.
23func (p *attachTestPicker) recordOp(op pickerOp, key string, request ports.BrokerOpenStreamRequest) {
24	p.mu.Lock()
25	p.op = op
26	p.key = key
27	p.request = request
28	p.mu.Unlock()
29	select {
30	case p.opsReady <- struct{}{}:
31	default:
32	}
33}
34
35// opTaken reports whether the supervisor consumed the recorded decision.
36func (p *attachTestPicker) opTaken() bool {
37	p.mu.Lock()
38	defer p.mu.Unlock()
39	return p.op == (pickerOp{})
40}
41
42// attachLiveSession commits one attachment on the harness and returns its
43// stream once the attachment is presented.
44func attachLiveSession(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream {
45	t.Helper()
46	stream := newSessionTestStream()
47	harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
48		return stream, nil
49	})
50	picker.commit(sessionTestRequest(true))
51	awaitStreamHello(t, &sync.Mutex{}, &stream)
52	deliverReadyStream(t, stream)
53	awaitAttachedState(t, harness.sup)
54	return stream
55}
56
57func awaitPresentation(t *testing.T, sup *Supervisor, want Presentation) {
58	t.Helper()
59	require.Eventually(t, func() bool { return sup.State().Presentation == want }, 5*time.Second, time.Millisecond, "presentation never became %s", want)
60}
61
62// awaitSent waits for the first client message matching match.
63func awaitSent(t *testing.T, stream *sessionTestStream, what string, match func(protocol.ClientMessage) bool) protocol.ClientMessage {
64	t.Helper()
65	var found protocol.ClientMessage
66	require.Eventually(t, func() bool {
67		for _, message := range stream.messages() {
68			if match(message) {
69				found = message
70				return true
71			}
72		}
73		return false
74	}, 5*time.Second, time.Millisecond, "client never sent %s", what)
75	return found
76}
77
78func countSent[T protocol.ClientMessage](stream *sessionTestStream) int {
79	count := 0
80	for _, message := range stream.messages() {
81		if _, ok := message.(T); ok {
82			count++
83		}
84	}
85	return count
86}
87
88func isSent[T protocol.ClientMessage](message protocol.ClientMessage) bool {
89	_, ok := message.(T)
90	return ok
91}
92
93// laterOutput is a full frame on a later epoch, which the attachment accepts
94// after its initial publication.
95func laterOutput(epoch, publication uint64, data string) protocol.Output {
96	output := sessionTestOutput(publication, data)
97	output.Epoch = epoch
98	return output
99}
100
101func navigationOffer(id uint64) protocol.PickerOffer {
102	return protocol.PickerOffer{InteractionID: id, Intent: protocol.PickerIntentNavigation, Title: " Sessions · recent "}
103}
104
105// TestPickerOverlayReturnsToLiveAttachment pins PA1: SSP composes the picker
106// over the live attachment, its input and output stay with the picker, and
107// every cancel (and a commit to the attached session) returns to the very same
108// attachment without reconnecting, then asks for an authoritative repaint.
109func TestPickerOverlayReturnsToLiveAttachment(t *testing.T) {
110	tests := []struct {
111		name string
112		op   pickerOp
113		key  string
114	}{
115		{name: "escape or q cancels", op: pickerOp{close: true}},
116		{name: "ctrl+c cancels without exiting", op: pickerOp{close: true, exit: true}},
117		{name: "commit to the attached session closes", op: pickerOp{commit: true}, key: "row"},
118	}
119	for _, tt := range tests {
120		t.Run(tt.name, func(t *testing.T) {
121			picker := newAttachTestPicker()
122			harness := startAttachHarness(t, picker)
123			stream := attachLiveSession(t, harness, picker)
124			foreground := harness.sup.attachments.authority.foreground()
125			require.NotNil(t, foreground)
126			require.False(t, picker.owns())
127
128			stream.deliver(navigationOffer(1))
129			awaitPresentation(t, harness.sup, PresentAttachedPicker)
130			require.True(t, picker.owns(), "the picker owns input while it is open")
131
132			// Input belongs to the picker: it is consumed, never sent as session
133			// Input.
134			harness.reader.push([]byte("j"))
135			require.Eventually(t, func() bool { return picker.consumedCount() == 1 }, 5*time.Second, time.Millisecond)
136			require.Zero(t, countSent[protocol.Input](stream), "picker input must never reach the session")
137
138			// Attachment output is applied and acknowledged, never written over
139			// the picker.
140			acks := countSent[protocol.Ack](stream)
141			stream.deliver(laterOutput(2, 2, "hidden-frame"))
142			require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond)
143			require.NotContains(t, harness.terminal.written(), "hidden-frame")
144
145			picker.recordOp(tt.op, tt.key, sessionTestRequest(true))
146			awaitPresentation(t, harness.sup, PresentAttached)
147			awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
148
149			require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "cancel must keep the same live attachment")
150			require.False(t, stream.closedNow())
151			require.Len(t, harness.service.openedRequests(), 1, "cancel must not reconnect")
152			require.Zero(t, countSent[protocol.Detach](stream))
153			require.False(t, picker.owns())
154
155			// The session owns input again.
156			harness.reader.push([]byte("z"))
157			input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input)
158			require.Equal(t, []byte("z"), input.Data)
159			select {
160			case <-harness.runDone:
161				t.Fatal("cancelling the overlay ended the process")
162			default:
163			}
164		})
165	}
166}
167
168// TestPickerOverlayCommitElsewhereSwapsAttachment pins the swap: a commit to
169// another session detaches the live attachment cleanly and attaches the
170// committed target through Connecting, without passing through the plain
171// picker.
172func TestPickerOverlayCommitElsewhereSwapsAttachment(t *testing.T) {
173	picker := newAttachTestPicker()
174	notices := make(chan LifecycleNotice, 8)
175	harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
176		cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
177	})
178	first := attachLiveSession(t, harness, picker)
179	first.deliver(navigationOffer(1))
180	awaitPresentation(t, harness.sup, PresentAttachedPicker)
181
182	var mu sync.Mutex
183	second := newSessionTestStream()
184	harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
185		return second, nil
186	})
187	other := sessionTestRequest(true)
188	other.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
189	picker.recordOp(pickerOp{commit: true}, "row", other)
190
191	awaitSent(t, first, "Detach", isSent[protocol.Detach])
192	awaitStreamHello(t, &mu, &second)
193	second.deliver(protocol.Welcome{SessionName: "beta"})
194	output := sessionTestOutput(1, "\x1b[Hbeta")
195	output.Context.Route.Target = other.Target
196	second.deliver(output)
197	awaitAttachedState(t, harness.sup)
198
199	opened := harness.service.openedRequests()
200	require.Len(t, opened, 2)
201	require.Equal(t, other.Target, opened[1].Target)
202	require.True(t, first.closedNow(), "the previous attachment is released")
203	select {
204	case notice := <-notices:
205		t.Fatalf("a swap must not report a lifecycle transition to the picker: %+v", notice)
206	default:
207	}
208}
209
210// TestPickerOverlaySettlesUIActions pins UI-driver settlement across the
211// client picker overlay: the action that opened it settles on the daemon
212// receipt fenced on output the overlay applied without writing, an action the
213// picker consumes settles at the supervisor boundary without a daemon fence,
214// and a picker commit elsewhere follows the swap and settles on the
215// destination's first committed publication.
216func TestPickerOverlaySettlesUIActions(t *testing.T) {
217	alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
218	beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
219	type actionOutcome struct {
220		result ports.UIActionResult
221		err    error
222	}
223	tests := []struct {
224		name string
225		// overlayFirst opens the overlay before the action is admitted.
226		overlayFirst bool
227		keys         []string
228		// commit scripts a picker commit elsewhere for the consumed read.
229		commit     bool
230		wantTarget protocol.ExactSessionTarget
231		wantFence  bool
232	}{
233		{name: "the opening action settles on its suppressed receipt", keys: []string{"Enter"}, wantTarget: alpha, wantFence: true},
234		{name: "a picker-consumed action settles without a daemon fence", overlayFirst: true, keys: []string{"j"}, wantTarget: alpha},
235		{name: "a picker commit elsewhere settles on the destination", overlayFirst: true, keys: []string{"Enter"}, commit: true, wantTarget: beta},
236	}
237	for _, tt := range tests {
238		t.Run(tt.name, func(t *testing.T) {
239			picker := newAttachTestPicker()
240			var ui *UI
241			harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
242				ui = NewUI(cfg.Terminal.(ports.UIState), cfg.Clock)
243				cfg.UI = ui
244			})
245			stream := attachLiveSession(t, harness, picker)
246			attached := sessionTestOutput(2, "\x1b[Hattached")
247			attached.Full, attached.Base, attached.New = false, 1, 2
248			stream.deliver(attached)
249			var snapshot ports.UISnapshot
250			require.Eventually(t, func() bool {
251				var err error
252				snapshot, err = ui.Capture(ui.Handle())
253				return err == nil && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication == 2
254			}, 5*time.Second, time.Millisecond)
255			if tt.overlayFirst {
256				stream.deliver(navigationOffer(1))
257				awaitPresentation(t, harness.sup, PresentAttachedPicker)
258				// The composition paints the overlay with the live attachment's
259				// context, so the attachment stays the actionable generation.
260				overlayContext, ok := ui.OverlayContext()
261				require.True(t, ok)
262				require.Equal(t, ports.UIStatusAttached, overlayContext.Status)
263				require.Equal(t, snapshot.Context.Generation, overlayContext.Generation)
264				require.Equal(t, alpha, overlayContext.Route.Target)
265			}
266			var mu sync.Mutex
267			second := newSessionTestStream()
268			if tt.commit {
269				harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
270					return second, nil
271				})
272				other := sessionTestRequest(true)
273				other.Target = beta
274				picker.decideOnConsume(pickerOp{commit: true}, other)
275			}
276			fences := countSent[protocol.UIFence](stream)
277			consumed := picker.consumedCount()
278
279			done := make(chan actionOutcome, 1)
280			go func() {
281				result, err := ui.Action(t.Context(), ports.UIActionRequest{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Keys: tt.keys})
282				done <- actionOutcome{result: result, err: err}
283			}()
284
285			if !tt.overlayFirst {
286				// The Enter reaches the daemon, whose command offers the picker;
287				// the palette-close output then arrives under the overlay.
288				fence := awaitSent(t, stream, "UIFence", isSent[protocol.UIFence]).(protocol.UIFence)
289				stream.deliver(navigationOffer(1))
290				awaitPresentation(t, harness.sup, PresentAttachedPicker)
291				hidden := sessionTestOutput(3, "\x1b[Hhidden")
292				hidden.Full, hidden.Base, hidden.New = false, 2, 3
293				stream.deliver(hidden)
294				require.Eventually(t, func() bool {
295					ui.mu.Lock()
296					defer ui.mu.Unlock()
297					return ui.suppressed.ViewPublication == 3
298				}, 5*time.Second, time.Millisecond, "the suppressed output was never applied")
299				stream.deliver(protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 3, ViewPublication: 3, Outcome: protocol.UIReceiptProcessed})
300			}
301			if tt.commit {
302				awaitSent(t, stream, "Detach", isSent[protocol.Detach])
303				awaitStreamHello(t, &mu, &second)
304				second.deliver(protocol.Welcome{SessionName: "beta"})
305				output := sessionTestOutput(1, "\x1b[Hbeta")
306				output.Context.Route.Target = beta
307				second.deliver(output)
308			}
309
310			var outcome actionOutcome
311			select {
312			case outcome = <-done:
313			case <-time.After(5 * time.Second):
314				t.Fatal("the UI action never settled across the picker overlay")
315			}
316			require.NoError(t, outcome.err)
317			require.Equal(t, ports.UIActionProcessed, outcome.result.Status)
318			require.Equal(t, ports.UIStatusAttached, outcome.result.Context.Status)
319			require.Equal(t, tt.wantTarget, outcome.result.Context.Route.Target)
320			require.NotContains(t, harness.terminal.written(), "hidden", "suppressed output is never written over the picker")
321			if tt.overlayFirst {
322				require.Equal(t, consumed+1, picker.consumedCount(), "the picker consumed the action's input")
323			}
324			if !tt.wantFence {
325				require.Equal(t, fences, countSent[protocol.UIFence](stream), "a picker-consumed action never fences through the daemon")
326				require.Zero(t, countSent[protocol.Input](stream), "picker input never reaches the session")
327			}
328			if tt.commit {
329				require.Greater(t, outcome.result.Context.Generation, snapshot.Context.Generation, "the swap commits a new generation")
330				opened := harness.service.openedRequests()
331				require.Len(t, opened, 2)
332				require.Equal(t, beta, opened[1].Target)
333				require.Greater(t, opened[1].Stream, picker.lastResolvedStream(),
334					"the swap opens a stream identity allocated at open time, not the one reserved at commit")
335			}
336		})
337	}
338}
339
340// TestPickerCloseWithoutAttachment pins the unattached decision: with nothing
341// to return to, Escape and q keep the picker (and the process) alive; only the
342// explicit Ctrl+C exit ends the run.
343func TestPickerCloseWithoutAttachment(t *testing.T) {
344	tests := []struct {
345		name     string
346		op       pickerOp
347		wantExit bool
348	}{
349		{name: "escape or q keeps the picker", op: pickerOp{close: true}},
350		{name: "ctrl+c exits explicitly", op: pickerOp{close: true, exit: true}, wantExit: true},
351	}
352	for _, tt := range tests {
353		t.Run(tt.name, func(t *testing.T) {
354			picker := newAttachTestPicker()
355			harness := startAttachHarness(t, picker)
356			picker.recordOp(tt.op, "", ports.BrokerOpenStreamRequest{})
357			if tt.wantExit {
358				require.NoError(t, harness.waitRun(t))
359				return
360			}
361			// Prove the close was consumed, then that the run survived it: a
362			// later commit is still admitted.
363			require.Eventually(t, picker.opTaken, 5*time.Second, time.Millisecond)
364			stream := newSessionTestStream()
365			harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
366				return stream, nil
367			})
368			picker.commit(sessionTestRequest(true))
369			awaitStreamHello(t, &sync.Mutex{}, &stream)
370			select {
371			case <-harness.runDone:
372				t.Fatal("a cancel with no attachment ended the process")
373			default:
374			}
375		})
376	}
377}
378
379// moveSnapshot is the two-destination publication a move interaction opens
380// with; both rows authorise a move.
381func moveSnapshot(interaction, revision uint64) protocol.PickerSnapshot {
382	return protocol.PickerSnapshot{
383		InteractionID: interaction, SourceID: "serving", SourceRevision: revision, Status: protocol.PickerSourceOK,
384		Lines: []protocol.PickerLine{
385			{Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanMove},
386			{Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanMove},
387		},
388		Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0},
389	}
390}
391
392func moveOffer(interaction uint64) protocol.PickerOffer {
393	return protocol.PickerOffer{InteractionID: interaction, Intent: protocol.PickerIntentMovePane, Title: " Move "}
394}
395
396// openMovePicker opens one move interaction on a live attachment and waits
397// until its box is drawn.
398func openMovePicker(t *testing.T, harness *attachTestHarness, stream *sessionTestStream, interaction uint64) {
399	t.Helper()
400	stream.deliver(moveOffer(interaction))
401	stream.deliver(moveSnapshot(interaction, 1))
402	require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "second") }, 5*time.Second, time.Millisecond, "the move picker was never drawn")
403}
404
405// TestMovePickerOfferIsPresentedAndAnswered pins PA3: the daemon's move offer
406// opens a working destination picker whose input never reaches the session,
407// and every exit path answers with the typed replies and hands input back.
408func TestMovePickerOfferIsPresentedAndAnswered(t *testing.T) {
409	tests := []struct {
410		name  string
411		keys  []string
412		check func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream)
413	}{
414		{
415			name: "enter commits the cursor destination",
416			keys: []string{"\x1b[B", "\r"},
417			check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
418				selection := awaitSent(t, stream, "PickerSelection", isSent[protocol.PickerSelection]).(protocol.PickerSelection)
419				require.Equal(t, uint64(7), selection.InteractionID)
420				require.Equal(t, "serving", selection.SourceID)
421				require.Equal(t, uint64(1), selection.SourceRevision)
422				require.Equal(t, "bb/second", selection.Key)
423				require.Equal(t, protocol.PickerActionMove, selection.Action)
424				// The daemon closes the interaction; the terminal returns to the
425				// attachment with an authoritative repaint.
426				stream.deliver(protocol.PickerClosed{InteractionID: 7})
427				awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
428			},
429		},
430		{
431			name: "q cancels with a typed close",
432			keys: []string{"q"},
433			check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
434				closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
435				require.Equal(t, uint64(7), closed.InteractionID)
436				awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
437			},
438		},
439		{
440			name: "a lone escape cancels once its window elapses",
441			keys: []string{"\x1b"},
442			check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
443				// The input path first withholds the Escape as a possible
444				// DECRQM reply prefix; its ambiguity window hands it on.
445				var marker *supervisorTestTimer
446				require.Eventually(t, func() bool {
447					for _, timer := range harness.clock.snapshotTimers() {
448						if timer.delay == paletteMarkerAmbiguityDeadline && !timer.stopped() {
449							marker = timer
450							return true
451						}
452					}
453					return false
454				}, 5*time.Second, time.Millisecond, "the marker ambiguity window was never armed")
455				marker.fire()
456				var escape *supervisorTestTimer
457				require.Eventually(t, func() bool {
458					for _, timer := range harness.clock.snapshotTimers() {
459						if timer.delay == pickerEscapeDeadline && timer != marker && !timer.stopped() {
460							escape = timer
461							return true
462						}
463					}
464					return false
465				}, 5*time.Second, time.Millisecond, "the escape window was never armed")
466				require.Zero(t, countSent[protocol.PickerClose](stream), "a withheld escape must not close early")
467				escape.fire()
468				closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
469				require.Equal(t, uint64(7), closed.InteractionID)
470			},
471		},
472	}
473	for _, tt := range tests {
474		t.Run(tt.name, func(t *testing.T) {
475			picker := newAttachTestPicker()
476			harness := startAttachHarness(t, picker)
477			stream := attachLiveSession(t, harness, picker)
478			openMovePicker(t, harness, stream, 7)
479
480			// Output under the box is applied and acknowledged, never written.
481			acks := countSent[protocol.Ack](stream)
482			stream.deliver(laterOutput(2, 2, "hidden-frame"))
483			require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond)
484			require.NotContains(t, harness.terminal.written(), "hidden-frame")
485
486			for _, key := range tt.keys {
487				harness.reader.push([]byte(key))
488			}
489			tt.check(t, harness, stream)
490			require.Zero(t, countSent[protocol.Input](stream), "move picker input must never reach the session")
491
492			// Input is never swallowed after the picker ended: the next key is
493			// session Input again.
494			harness.reader.push([]byte("z"))
495			input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input)
496			require.Equal(t, []byte("z"), input.Data)
497			require.Equal(t, PresentAttached, harness.sup.State().Presentation)
498		})
499	}
500}
501
502// TestMovePickerOfferDeclinedUnderSessionPicker pins the exit path when the
503// move picker cannot be presented: the daemon's interaction is closed at once
504// instead of swallowing input with nothing on screen.
505func TestMovePickerOfferDeclinedUnderSessionPicker(t *testing.T) {
506	picker := newAttachTestPicker()
507	harness := startAttachHarness(t, picker)
508	stream := attachLiveSession(t, harness, picker)
509	stream.deliver(navigationOffer(1))
510	awaitPresentation(t, harness.sup, PresentAttachedPicker)
511
512	stream.deliver(moveOffer(9))
513	closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
514	require.Equal(t, uint64(9), closed.InteractionID)
515	require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation)
516}
517
518// TestSupervisorReducerPickerOverlay pins the Attached+Picker transitions and
519// the paint fence: only a committed attachment hosts the overlay, cancel
520// returns to Attached, and a settled attachment falls back to the plain
521// picker.
522func TestSupervisorReducerPickerOverlay(t *testing.T) {
523	tests := []struct {
524		name      string
525		from      Presentation
526		event     supervisorEventKind
527		want      Presentation
528		wantPaint bool
529	}{
530		{name: "attached opens the overlay", from: PresentAttached, event: supervisorOverlayOpened, want: PresentAttachedPicker, wantPaint: true},
531		{name: "connecting cannot host the overlay", from: PresentConnecting, event: supervisorOverlayOpened, want: PresentConnecting},
532		{name: "plain picker ignores an overlay request", from: PresentPicker, event: supervisorOverlayOpened, want: PresentPicker, wantPaint: true},
533		{name: "cancel returns to the attachment", from: PresentAttachedPicker, event: supervisorOverlayClosed, want: PresentAttached},
534		{name: "close outside the overlay is inert", from: PresentAttached, event: supervisorOverlayClosed, want: PresentAttached},
535		{name: "attachment end falls back to the picker", from: PresentAttachedPicker, event: supervisorAttachEnded, want: PresentPicker, wantPaint: true},
536		{name: "swap goes through connecting", from: PresentAttachedPicker, event: supervisorAttachBegin, want: PresentConnecting},
537	}
538	for _, tt := range tests {
539		t.Run(tt.name, func(t *testing.T) {
540			state := reduceSupervisor(State{Presentation: tt.from, Connectivity: ConnectivityReady}, supervisorEvent{kind: tt.event})
541			require.Equal(t, tt.want, state.Presentation)
542			require.Equal(t, ConnectivityReady, state.Connectivity)
543			require.Equal(t, tt.wantPaint, PickerPresentation(state))
544		})
545	}
546}
547
548// TestMovePickerDrawsAFloatingModalOverTheSession ports main's modal
549// presentation pin: the move picker is a bordered box over the session, never
550// a full-screen takeover, so it neither clears the screen nor spans it.
551func TestMovePickerDrawsAFloatingModalOverTheSession(t *testing.T) {
552	picker := newAttachTestPicker()
553	harness := startAttachHarness(t, picker)
554	stream := attachLiveSession(t, harness, picker)
555	before := len(harness.terminal.written())
556	openMovePicker(t, harness, stream, 7)
557
558	frame := harness.terminal.written()[before:]
559	require.Contains(t, frame, "first", "the destination rows must be drawn")
560	require.Contains(t, frame, "─", "the modal border must be drawn")
561	require.NotContains(t, frame, "\x1b[2J", "the box must not clear the session under it")
562	require.Contains(t, harness.terminal.written(), "ready", "the session frame stays under the box")
563}
564
565// TestPickerControllerLoneEscapeResolvesOnItsWindow pins that a real Escape
566// keypress (one lone byte) cancels once the disambiguation window elapses on
567// the controller clock, and that a following byte disarms the window instead.
568func TestPickerControllerLoneEscapeResolvesOnItsWindow(t *testing.T) {
569	tests := []struct {
570		name      string
571		follow    []byte
572		wantClose bool
573	}{
574		{name: "window elapses into Escape", wantClose: true},
575		{name: "a following byte resolves the prefix itself", follow: []byte("[B")},
576	}
577	for _, tt := range tests {
578		t.Run(tt.name, func(t *testing.T) {
579			controller, clock := pickerTestController(t)
580			controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
581
582			require.True(t, controller.ConsumeTerminalRead([]byte("\x1b")))
583			escape := clock.awaitTimer(t)
584			require.Equal(t, pickerEscapeDeadline, escape.delay)
585			if tt.follow != nil {
586				require.True(t, controller.ConsumeTerminalRead(tt.follow))
587				require.Eventually(t, escape.stopped, 5*time.Second, time.Millisecond, "a resolving read must disarm the window")
588				op, _ := controller.TakeOp()
589				require.False(t, op.close)
590				return
591			}
592			escape.fire()
593			select {
594			case <-controller.OpsReady():
595			case <-time.After(5 * time.Second):
596				t.Fatal("the elapsed window never woke the supervisor")
597			}
598			op, _ := controller.TakeOp()
599			require.Equal(t, tt.wantClose, op.close)
600			require.False(t, op.exit, "Escape is a cancel, never the explicit exit")
601		})
602	}
603}

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