picker_controller.go RawHistoryFolder

1package client
2
3import (
4	"bytes"
5	"errors"
6	"slices"
7	"sync"
8	"time"
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	pickerusecase "github.com/bnema/vev/internal/usecase/picker"
15	"github.com/bnema/vev/internal/usecase/ui"
16)
17
18// Client-owned picker controller.
19//
20// This file is the narrow seam between the autonomous supervisor and the
21// client-owned catalogue. The supervisor owns raw mode, the single terminal
22// input lifetime, and the broker service; the controller owns the catalogue
23// projection, the presentation model (reusing the picker package's cursor,
24// search, sort, and rendering machinery), and the input decisions.
25//
26// Two invariants make the picker autonomous:
27//
28//   - Zero session bytes. The controller has no session, stream, or PTY
29//     reference. A consumed terminal read is decoded into picker events and
30//     applied to the model; nothing is ever written toward a daemon. A
31//     selection resolves to a ports.BrokerOpenStreamRequest value and stops
32//     there.
33//   - No serving-daemon catalogue. Rows come from broker snapshots through
34//     pickerCatalogue, so navigation needs no serving attachment.
35//
36// input ownership reuses the existing pickerConsumer, so the supervisor's one
37// terminal input lifetime feeds exactly one owner and the picker takes
38// precedence: while the controller owns input every decoded batch is consumed
39// and there is no fallback to a session pipeline.
40
41// pickerHost is the supervisor's view of the client-owned picker. The
42// supervisor applies each broker publication and hands it every terminal read
43// from its single input lifetime; the picker decides ownership.
44//
45// The commit seam is what lets the supervisor turn a user
46// commit into one exact broker stream without ever reading the presentation
47// model itself: the picker records the decision and the key it committed
48// atomically, the supervisor wakes on OpsReady, and ResolveCommit revalidates
49// exactly that key. The picker never opens a stream, and the supervisor never
50// reconstructs a selection from a display label.
51type pickerHost interface {
52	pickerInputConsumer
53	// ApplySnapshot folds one broker publication into the catalogue and
54	// refreshes the presentation immediately.
55	ApplySnapshot(ports.BrokerSnapshot)
56	// TakeOp returns and clears the accumulated presentation decision together
57	// with the catalogue key captured with a commit decision.
58	TakeOp() (pickerOp, string)
59	// ResolveCommit revalidates exactly one committed catalogue key into the
60	// exact broker stream request and tab the user committed.
61	ResolveCommit(string, pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error)
62	// SetOwnsInput releases or re-acquires picker input ownership at an attach
63	// boundary, so exactly one owner consumes the shared terminal reader.
64	SetOwnsInput(bool)
65	// OpsReady wakes the supervisor when a presentation decision was recorded.
66	// It is a capacity-one coalescing signal, never closed while the picker
67	// lives, so a driver that ignores it only misses a wakeup, never blocks.
68	OpsReady() <-chan struct{}
69}
70
71// pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead
72// returns true when the picker owned and consumed the read. It returns false for
73// bytes the picker declined, which are then dropped: the supervisor keeps no
74// ordinary terminal handling alongside the picker, so a declined read has no
75// second owner to hand it to.
76type pickerInputConsumer interface {
77	ConsumeTerminalRead(data []byte) bool
78}
79
80const (
81	// pickerGeneration identifies the single client-owned picker generation.
82	// It never changes while the controller lives; the presentation epoch is
83	// the broker epoch, not this value.
84	pickerGeneration = uint64(1)
85	// pickerNoticeLifetime bounds how long one catalogue notice is displayed.
86	pickerNoticeLifetime = 4 * time.Second
87	// pickerNoticeVisible is how many notices stack at once; later ones wait.
88	pickerNoticeVisible = 3
89)
90
91// pickerController owns one client-owned picker: catalogue, model, renderer,
92// bounded notices, and input ownership. It is safe for concurrent use, so the
93// supervisor's input lifetime and its run path may both reach it.
94type pickerController struct {
95	clock     ports.Clock
96	catalogue *pickerCatalogue
97
98	mu       sync.Mutex
99	loop     *pickerLoop
100	consumer pickerConsumer
101	renderer *pickerRenderer
102	notices  *ui.ToastQueue[ui.Toast]
103	// noticeBounds are the toast boxes drawn last. The client cannot restore
104	// the session under them, so boxes that go away are blanked.
105	noticeBounds []domain.Rect
106	interaction  uint64
107	// sort is this client's picker ordering. It survives every picker
108	// reopen for the lifetime of the client process.
109	sort      pickerusecase.SortMode
110	ownsInput bool
111	opsReady  chan struct{}
112	// pending is the decision accumulated since the last TakeOp.
113	pending pickerDecision
114	// hostHealth is the last remote host state seen, so each host transition
115	// is judged once by domain.RemoteHealthNotice.
116	hostHealth map[string]domain.RemoteHealth
117	preview    pickerusecase.Preview
118	// flushStop cancels the armed lone-escape flush. It is non-nil exactly
119	// while one flush is armed for the currently withheld prefix.
120	flushStop chan struct{}
121}
122
123// newPickerController builds a picker over an empty catalogue. The picker owns
124// input from the start: the controller presents it for the whole picker presentation and
125// has no session pipeline to defer to.
126func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController {
127	if supervisorNil(clock) {
128		clock = systemClock{}
129	}
130	controller := &pickerController{
131		clock:     clock,
132		catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
133		renderer:  newPickerRenderer(pickerColorProfile(trueColor)),
134		sort:      defaultPickerSort(),
135		notices:   ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}),
136		ownsInput: true,
137		opsReady:  make(chan struct{}, 1),
138	}
139	controller.consumer.setOwned(pickerGeneration, pickerGeneration)
140	return controller
141}
142
143// Catalogue exposes the projection for a driver that needs to resolve a
144// selection or inspect the applied revision.
145func (p *pickerController) Catalogue() *pickerCatalogue {
146	if p == nil {
147		return nil
148	}
149	return p.catalogue
150}
151
152// SetOwnsInput toggles input ownership. It is the future attach slice's release
153// boundary; the controller keeps it owned for the whole run.
154func (p *pickerController) SetOwnsInput(owns bool) {
155	if p == nil {
156		return
157	}
158	p.mu.Lock()
159	defer p.mu.Unlock()
160	if owns == p.ownsInput {
161		return
162	}
163	p.ownsInput = owns
164	if owns {
165		p.consumer.setOwned(pickerGeneration, pickerGeneration)
166		return
167	}
168	p.consumer.clear()
169}
170
171// ApplySnapshot folds one broker publication into the catalogue and rebuilds
172// the presentation synchronously, so the next Render reflects it immediately.
173func (p *pickerController) ApplySnapshot(snapshot ports.BrokerSnapshot) {
174	if p == nil || p.catalogue == nil {
175		return
176	}
177	if !p.catalogue.Apply(snapshot) {
178		// A publication that fails the broker snapshot contract is rejected as
179		// a whole (the previous projection is kept) and surfaced as a bounded
180		// notice instead of silently vanishing. A merely stale publication
181		// validates and is ignored without a notice.
182		if snapshot.Validate() != nil {
183			p.Notify(domain.Notification{Code: domain.NoticeInternal, Severity: domain.NoticeError, Message: "broker catalogue update rejected"})
184		}
185		return
186	}
187	p.mu.Lock()
188	defer p.mu.Unlock()
189	p.rebuildLocked()
190	p.offerDiagnosticsLocked()
191}
192
193// ConsumeTerminalRead decodes one terminal read while the picker owns input,
194// applies it to the model, and records the presentation decision (with any
195// committed key) atomically under the controller lock. It reports whether the
196// read was consumed: a false result means the supervisor keeps its ordinary
197// handling. It never writes a session.
198func (p *pickerController) ConsumeTerminalRead(data []byte) bool {
199	if p == nil {
200		return false
201	}
202	return p.handleRead(data)
203}
204
205// pickerDecision accumulates the presentation decisions of one or more reads
206// together with the row keys each decision selected when it was recorded, so
207// a key and its decision can never disagree.
208type pickerDecision struct {
209	op        pickerOp
210	commitKey string
211	killKey   string
212}
213
214// record merges op, capturing the cursor row for a commit or a kill.
215func (d *pickerDecision) record(op pickerOp, loop *pickerLoop) {
216	if op.commit {
217		d.commitKey = loop.selectionKey(protocol.PickerActionNavigate)
218	}
219	if op.kill {
220		d.killKey = loop.selectionKey(protocol.PickerActionKill)
221	}
222	d.op = mergePickerOps(d.op, op)
223}
224
225// actionable reports whether the decision asks the driver to act.
226func (d pickerDecision) actionable() bool { return d.op.commit || d.op.close || d.op.kill }
227
228// key is the row the decision acts on. A commit wins over a kill in the same
229// read because it leaves the picker anyway.
230func (d pickerDecision) key() string {
231	if d.op.commit {
232		return d.commitKey
233	}
234	return d.killKey
235}
236
237// recordOpLocked accumulates one read's presentation decision and wakes the
238// driver once when it became actionable. Callers hold p.mu.
239func (p *pickerController) recordOpLocked(op pickerOp) {
240	p.pending.record(op, p.loop)
241	if p.pending.actionable() {
242		p.wakeLocked()
243	}
244}
245
246// wakeLocked coalesces exactly one wakeup: a driver that has not yet run
247// TakeOp re-reads the accumulated decision, so a dropped signal is harmless.
248func (p *pickerController) wakeLocked() {
249	select {
250	case p.opsReady <- struct{}{}:
251	default:
252	}
253}
254
255// applyEventsLocked applies one decoded batch to the open loop and records
256// its decision. Callers hold p.mu.
257func (p *pickerController) applyEventsLocked(events []pickerEvent) {
258	op, changed := p.loop.apply(events)
259	p.recordOpLocked(op)
260	if changed {
261		p.wakeLocked()
262	}
263}
264
265// OpsReady wakes the supervisor when a commit or close was recorded. It is a
266// capacity-one coalescing signal; TakeOp remains the authority on what was
267// decided, so a driver never infers the decision from the wakeup alone.
268func (p *pickerController) OpsReady() <-chan struct{} {
269	if p == nil {
270		return nil
271	}
272	return p.opsReady
273}
274
275// TakeOp returns and clears the accumulated presentation decision since the
276// last call, together with the catalogue key of the row the commit decision
277// selected at the moment it was recorded. Resolving that key with ResolveKey
278// attaches exactly the committed row: TakeOp cannot observe a cursor that a
279// concurrent ApplySnapshot moved afterwards. The key is empty when the
280// decision carries no commit or no committable row.
281func (p *pickerController) TakeOp() (pickerOp, string) {
282	if p == nil {
283		return pickerOp{}, ""
284	}
285	p.mu.Lock()
286	defer p.mu.Unlock()
287	decision := p.pending
288	p.pending = pickerDecision{}
289	return decision.op, decision.key()
290}
291
292// FlushPending resolves a withheld escape or UTF-8 prefix once its
293// disambiguation window expired. The bound belongs to the driver; the picker
294// only decides what the prefix means.
295func (p *pickerController) FlushPending() {
296	if p == nil {
297		return
298	}
299	p.mu.Lock()
300	defer p.mu.Unlock()
301	if !p.ownsInput {
302		return
303	}
304	outcome, consumed := p.consumer.flushPending()
305	if !consumed || p.loop == nil {
306		return
307	}
308	if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
309		return
310	}
311	p.applyEventsLocked(outcome.events)
312}
313
314// armFlushLocked bounds a withheld escape or UTF-8 prefix with the picker's
315// disambiguation window on the controller clock, so a lone Escape keypress
316// resolves without waiting for another key. A later read disarms it first.
317// Callers hold p.mu.
318func (p *pickerController) armFlushLocked() {
319	if p.flushStop != nil || !p.consumer.hasPending() {
320		return
321	}
322	timer := p.clock.NewTimer(pickerEscapeDeadline)
323	if supervisorNil(timer) {
324		return
325	}
326	stop := make(chan struct{})
327	p.flushStop = stop
328	go func() {
329		select {
330		case <-timer.C():
331		case <-stop:
332			stopSupervisorTimer(timer)
333			return
334		}
335		p.mu.Lock()
336		if p.flushStop != stop {
337			p.mu.Unlock()
338			return
339		}
340		p.flushStop = nil
341		p.mu.Unlock()
342		p.FlushPending()
343	}()
344}
345
346// disarmFlushLocked cancels an armed flush: the next read resolves the
347// withheld prefix itself. Callers hold p.mu.
348func (p *pickerController) disarmFlushLocked() {
349	if p.flushStop == nil {
350		return
351	}
352	close(p.flushStop)
353	p.flushStop = nil
354}
355
356// invalidatePresentation makes the next Render redraw the whole box, because
357// another owner wrote the terminal since the last picker frame.
358func (p *pickerController) invalidatePresentation() {
359	if p == nil {
360		return
361	}
362	p.mu.Lock()
363	p.renderer.invalidate()
364	// Another owner repainted the screen: the old toast cells are gone.
365	p.noticeBounds = nil
366	p.mu.Unlock()
367}
368
369// OwnsInput reports whether the picker currently owns terminal input.
370func (p *pickerController) OwnsInput() bool {
371	if p == nil {
372		return false
373	}
374	p.mu.Lock()
375	defer p.mu.Unlock()
376	return p.ownsInput
377}
378
379// Render composes the current picker modal into terminal bytes for one display
380// size. It is a pure read of the current model: it changes no state and writes
381// nothing.
382func (p *pickerController) Render(size domain.Size) []byte {
383	if p == nil {
384		return nil
385	}
386	p.mu.Lock()
387	defer p.mu.Unlock()
388	if p.loop == nil {
389		return nil
390	}
391	// Blank gone notices before the box is drawn, so a blank that overlaps
392	// the box never erases cells the picker renderer believes are on screen.
393	// A blank may also cover part of the box, so the box is then redrawn whole.
394	_, blanks := p.noticeStackLocked(size)
395	if len(blanks) != 0 {
396		p.renderer.invalidate()
397	}
398	return append(blanks, p.renderer.render(p.loop, size, p.preview)...)
399}
400
401// SetPreview displays one preview frame in the given state. A frame is shown
402// only in the fresh and stale states; stale dims it. The other states show a
403// short dim label instead, so the preview pane is never silently blank.
404func (p *pickerController) SetPreview(preview protocol.RemotePreview, state previewState) {
405	if p == nil {
406		return
407	}
408	view := previewView(preview, state)
409	p.mu.Lock()
410	p.preview = view
411	p.mu.Unlock()
412}
413
414// PreviewRequest derives preview authority from the exact catalogue ref behind
415// the selected row. Only live exact sessions are previewable; a tab row
416// previews its own tab and a session row the active one. Create rows and
417// stopped sessions are deliberately refused.
418func (p *pickerController) PreviewRequest(size domain.Size) (ports.BrokerPreviewRoute, protocol.RemotePreviewRequest, bool) {
419	refuse := func() (ports.BrokerPreviewRoute, protocol.RemotePreviewRequest, bool) {
420		return ports.BrokerPreviewRoute{}, protocol.RemotePreviewRequest{}, false
421	}
422	key, ok := p.CursorKey()
423	if !ok || p.catalogue == nil {
424		return refuse()
425	}
426	ref, ok := p.catalogue.Ref(key)
427	if !ok || ref.kind != pickerSelectionExact {
428		return refuse()
429	}
430	// Resolving with a provisional stream reuses every refusal the commit path
431	// applies (epoch, replaced registration, incompatibility, gone session);
432	// the broker allocates the real observation stream itself.
433	resolved, err := p.catalogue.ResolveRef(ref, pickerResolveBase{Connection: ports.BrokerConnectionID{1}, Stream: 1})
434	if err != nil {
435		return refuse()
436	}
437	p.catalogue.mu.Lock()
438	authority := p.catalogue.authorityForRefLocked(ref)
439	p.catalogue.mu.Unlock()
440	if !authority.found {
441		return refuse()
442	}
443	session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
444	if !ok || session.State != catalogue.RemoteCatalogSessionUp {
445		return refuse()
446	}
447	previewTab := domain.TabStableID(session.ActiveTabID)
448	if ref.tab.present {
449		previewTab = ref.tab.id
450	}
451	if previewTab == "" {
452		return refuse()
453	}
454	route := ports.BrokerPreviewRoute{Local: resolved.Local, Policy: resolved.Policy}
455	endpoint := ports.BrokerPreviewLocalEndpoint
456	if !resolved.Local {
457		route.Endpoint, route.Registration = resolved.Endpoint, resolved.Registration
458		endpoint = resolved.Endpoint
459	}
460	viewport := pickerPreviewSize(size)
461	target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: previewTab}
462	if target.DisplayOrigin == "" {
463		target.DisplayOrigin = pickerOriginLabel(authority.observation)
464	}
465	preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)}
466	if route.Validate() != nil || protocol.ValidateRemotePreviewRequest(preview) != nil {
467		return refuse()
468	}
469	return route, preview, true
470}
471
472func (p *pickerController) ClearPreview() {
473	if p == nil {
474		return
475	}
476	p.mu.Lock()
477	p.preview = pickerusecase.Preview{}
478	p.mu.Unlock()
479}
480
481// RenderNotice composes the live notice stack as client-local toasts: the
482// newest at the top-right corner, older ones below it, and any extra notices
483// waiting for a free slot. Boxes drawn by the previous call that are gone now
484// are blanked in the same payload. It returns nil when nothing changes on
485// screen.
486func (p *pickerController) RenderNotice(size domain.Size) []byte {
487	if p == nil {
488		return nil
489	}
490	p.mu.Lock()
491	defer p.mu.Unlock()
492	toasts, blanks := p.noticeStackLocked(size)
493	if len(blanks) != 0 {
494		// A blank may cover picker box cells drawn by the previous Render.
495		p.renderer.invalidate()
496	}
497	buffer := bytes.NewBuffer(blanks)
498	for i, rect := range p.noticeBounds {
499		_ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGR(toasts[i].Severity)))
500	}
501	if buffer.Len() == 0 {
502		return nil
503	}
504	return buffer.Bytes()
505}
506
507// noticeStackLocked lays out the live notices for size and returns them with
508// the bytes that blank boxes drawn before but no longer part of the stack.
509// It records the new layout in noticeBounds.
510func (p *pickerController) noticeStackLocked(size domain.Size) ([]ui.Toast, []byte) {
511	active := p.notices.Visible(p.clock.Now())
512	toasts := make([]ui.Toast, len(active))
513	for i, t := range active {
514		toasts[i] = t.Value
515	}
516	bounds := ui.ToastStackBounds(size, toasts)
517	var buffer bytes.Buffer
518	for _, old := range p.noticeBounds {
519		if slices.Contains(bounds, old) {
520			continue
521		}
522		// After a shrink the old box may overhang the screen; writing past the
523		// edge would wrap onto unrelated cells.
524		if gone := clipRect(old, size); gone.Width > 0 && gone.Height > 0 {
525			_ = writeClientToast(&buffer, gone, blankToastLines(gone))
526		}
527	}
528	p.noticeBounds = bounds
529	return toasts[:len(bounds)], buffer.Bytes()
530}
531
532// clipRect is the part of r inside a size-sized screen.
533func clipRect(r domain.Rect, size domain.Size) domain.Rect {
534	x0, y0 := max(r.X, 0), max(r.Y, 0)
535	x1, y1 := min(r.X+r.Width, size.Cols), min(r.Y+r.Height, size.Rows)
536	if x1 <= x0 || y1 <= y0 {
537		return domain.Rect{}
538	}
539	return domain.Rect{X: x0, Y: y0, Width: x1 - x0, Height: y1 - y0}
540}
541
542// noticeDeadline is when the notice stack next changes on its own.
543func (p *pickerController) noticeDeadline() (time.Time, bool) {
544	if p == nil {
545		return time.Time{}, false
546	}
547	p.mu.Lock()
548	defer p.mu.Unlock()
549	return p.notices.NextDeadline()
550}
551
552// pickerState is a consistent read of the controller's interactive state.
553type pickerState struct {
554	hasModel     bool
555	query        string
556	searchActive bool
557	notices      []string
558}
559
560// state reads the interactive state under one lock.
561func (p *pickerController) state() pickerState {
562	p.mu.Lock()
563	defer p.mu.Unlock()
564	var state pickerState
565	if p.loop != nil && p.loop.model != nil {
566		state.hasModel = true
567		state.query = p.loop.model.Query()
568		state.searchActive = p.loop.model.SearchActive()
569	}
570	for _, toast := range p.notices.Visible(p.clock.Now()) {
571		state.notices = append(state.notices, toast.Value.Message)
572	}
573	return state
574}
575
576// CursorKey reports the opaque key of the row under the cursor, or false when
577// nothing qualifies. It is the selection displayed now; a commit decision is
578// captured with TakeOp instead so a concurrent publication cannot move it.
579func (p *pickerController) CursorKey() (string, bool) {
580	if p == nil {
581		return "", false
582	}
583	p.mu.Lock()
584	defer p.mu.Unlock()
585	key := p.loop.selectionKey(protocol.PickerActionNavigate)
586	return key, key != ""
587}
588
589// Resolve revalidates the row displayed now and returns the exact broker
590// stream request. A row the picker shows but does not admit an action still
591// resolves, so committing an inspectable row yields a typed catalogue refusal
592// (stale, incompatible, unavailable) instead of a generic no-selection. A
593// refusal is bounded and is also surfaced as a notice.
594//
595// Resolve reads the mutable presentation model, so a concurrent ApplySnapshot
596// can move the cursor between the caller's commit decision and this call. A
597// driver that is committing a specific row must capture that row's key with
598// the decision (TakeOp) and resolve it with ResolveCommit instead.
599func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
600	if p == nil {
601		return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
602	}
603	key, ok := p.displayedKey()
604	if !ok {
605		err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"}
606		p.Notify(pickerRefusalNotice(err))
607		return ports.BrokerOpenStreamRequest{}, err
608	}
609	request, _, err := p.ResolveCommit(key, base)
610	return request, err
611}
612
613// ResolveCommit is the single commit path from a published row to an
614// attachment target. It revalidates exactly the supplied catalogue key
615// against the latest applied snapshot, in one catalogue transaction, and
616// returns the exact broker stream request plus the tab the row names. The key
617// captured atomically with the commit decision (see TakeOp) resolves the row
618// the user committed even if the cursor has since moved. A refusal is bounded
619// and is also surfaced as a notice.
620func (p *pickerController) ResolveCommit(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
621	if p == nil || p.catalogue == nil {
622		err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
623		if p != nil {
624			p.Notify(pickerRefusalNotice(err))
625		}
626		return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
627	}
628	request, tab, err := p.catalogue.ResolveTarget(key, base)
629	if err != nil {
630		p.Notify(pickerRefusalNotice(err))
631		return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
632	}
633	return request, tab, nil
634}
635
636// ResolveKill revalidates one kill key; a refusal is surfaced as a notice.
637func (p *pickerController) ResolveKill(key string) (pickerKillTarget, error) {
638	if p == nil || p.catalogue == nil {
639		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
640	}
641	target, err := p.catalogue.ResolveKill(key)
642	if err != nil {
643		p.Notify(pickerRefusalNotice(err))
644		return pickerKillTarget{}, err
645	}
646	return target, nil
647}
648
649// SetCurrent names the attachment the picker is about to be presented over
650// (or none) and restarts the presentation, so a fresh picker opens with the
651// cursor on that session's tab and no leftover search, exactly as each daemon
652// interaction did on main.
653func (p *pickerController) SetCurrent(current pickerCurrent) {
654	if p == nil || p.catalogue == nil {
655		return
656	}
657	p.catalogue.SetCurrent(current)
658	p.mu.Lock()
659	defer p.mu.Unlock()
660	if p.catalogue.Epoch() == 0 {
661		return
662	}
663	p.rememberSortLocked()
664	p.loop = nil
665	p.rebuildLocked()
666	p.renderer.invalidate()
667	p.noticeBounds = nil
668}
669
670// rememberSortLocked keeps the sort the user chose in the closing
671// presentation for the next one.
672func (p *pickerController) rememberSortLocked() {
673	if sort, ok := p.loop.sortMode(); ok {
674		p.sort = sort
675	}
676}
677
678// pickerRefusalNotice is the notice for one resolution refusal.
679func pickerRefusalNotice(err error) domain.Notification {
680	text := err.Error()
681	var typed pickerCatalogueError
682	if errors.As(err, &typed) {
683		text = typed.noticeText()
684	}
685	return domain.Notification{Code: domain.NoticeSessionUnavailable, Severity: domain.NoticeWarn, Message: text}
686}
687
688// displayedKey names the row under the cursor, whether or not it admits an
689// action, and maps its presentation key back to the source identity.
690func (p *pickerController) displayedKey() (string, bool) {
691	p.mu.Lock()
692	defer p.mu.Unlock()
693	return p.loop.displayedKey()
694}
695
696func (p *pickerController) handleRead(data []byte) bool {
697	p.mu.Lock()
698	defer p.mu.Unlock()
699	if !p.ownsInput {
700		return false
701	}
702	if len(data) != 0 {
703		data = append([]byte(nil), data...)
704	}
705	p.disarmFlushLocked()
706	outcome, consumed := p.consumer.consume(terminalReadResult{data: data})
707	if !consumed {
708		return false
709	}
710	defer p.armFlushLocked()
711	if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
712		return true
713	}
714	if p.loop == nil {
715		// Before a catalogue exists only an explicit interrupt is actionable.
716		// Use decoded events so a pasted Ctrl-C is not mistaken for an exit.
717		for _, event := range outcome.events {
718			if event.kind == pickerEventKey && event.key == "Ctrl+C" {
719				p.recordOpLocked(pickerOp{close: true, exit: true})
720			}
721		}
722		return true
723	}
724	p.applyEventsLocked(outcome.events)
725	return true
726}
727
728func (p *pickerController) rebuildLocked() {
729	recent, grouped := p.catalogue.projections()
730	revision := uint64(p.catalogue.Revision())
731	snapshot := func() protocol.PickerSnapshot {
732		return protocol.PickerSnapshot{
733			InteractionID:  p.interaction,
734			SourceID:       pickerCatalogueSourceID,
735			SourceRevision: revision,
736			Lines:          grouped.Lines,
737			Cursor:         grouped.Cursor,
738			Recent:         recent,
739			Grouped:        grouped,
740		}
741	}
742	if p.loop == nil {
743		p.interaction++
744		p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, p.sort)
745		return
746	}
747	p.loop.replaceLines(snapshot())
748}
749
750// offerDiagnosticsLocked notices each remote host transition that
751// domain.RemoteHealthTransition deems worth one toast: a new failure or a
752// recovery. Ordinary observation progress stays on the rows.
753func (p *pickerController) offerDiagnosticsLocked() {
754	current := make(map[string]domain.RemoteHealth)
755	for _, host := range p.catalogue.remoteHealth() {
756		prev, seen := p.hostHealth[host.key]
757		next, event := domain.RemoteHealthTransition(prev, seen, host.health)
758		current[host.key] = next
759		if event != domain.RemoteHealthSilent {
760			p.notifyLocked(pickerHostHealthNotice(host, event))
761		}
762	}
763	p.hostHealth = current
764}
765
766// Notify shows one client-local notice. Notices about the same subject
767// (domain.Notification.Subject) replace each other.
768func (p *pickerController) Notify(n domain.Notification) {
769	if p == nil {
770		return
771	}
772	p.mu.Lock()
773	p.notifyLocked(n)
774	p.mu.Unlock()
775}
776
777func (p *pickerController) notifyLocked(n domain.Notification) {
778	if n.Message == "" {
779		return
780	}
781	p.notices.Push(p.clock.Now(), ui.QueuedToast[ui.Toast]{ID: n.Subject(), Duration: pickerNoticeLifetime, Value: ui.Toast{Message: n.Message, Severity: n.Severity, Anchor: domain.AnchorTopRight}})
782}
783
784// mergePickerOps accumulates two presentation decisions from one read. Close
785// wins because it retires the interaction; commit and kill both remain.
786func mergePickerOps(a, b pickerOp) pickerOp {
787	return pickerOp{
788		commit: a.commit || b.commit,
789		kill:   a.kill || b.kill,
790		close:  a.close || b.close,
791		exit:   a.exit || b.exit,
792	}
793}

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