v0.2.0 80c61e3

picker.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"context"
6	"errors"
7	"sort"
8	"time"
9
10	renderer "github.com/bnema/vev-vt"
11	"github.com/bnema/vev/internal/domain"
12	"github.com/bnema/vev/internal/ports"
13	"github.com/bnema/vev/internal/usecase/layout"
14	"github.com/bnema/vev/internal/usecase/picker"
15	"github.com/bnema/vev/internal/usecase/ui"
16)
17
18var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
19
20// pickerSortMode orders the picker's live sessions. It lives for the daemon's
21// lifetime only and is never persisted.
22type pickerSortMode uint32
23
24const (
25	pickerSortRecent pickerSortMode = iota
26	pickerSortGrouped
27
28	remotePickerPreviewDebounce = 80 * time.Millisecond
29)
30
31func pickerTitle(mode pickerSortMode) string {
32	if mode == pickerSortGrouped {
33		return " Sessions · grouped "
34	}
35	return " Sessions · recent "
36}
37
38// togglePickerSort flips the daemon's picker sort mode between the two known
39// modes with a CompareAndSwap loop, so a concurrent double-press can't lose
40// an update the way a bare Load-then-Store XOR can.
41func (d *Daemon) togglePickerSort() {
42	for {
43		cur := pickerSortMode(d.pickerSort.Load())
44		next := pickerSortRecent
45		if cur == pickerSortRecent {
46			next = pickerSortGrouped
47		}
48		if d.pickerSort.CompareAndSwap(uint32(cur), uint32(next)) {
49			return
50		}
51	}
52}
53
54// enterPicker preserves the existing navigation entry point. Navigation always
55// publishes its model, including an empty one; only move entry can fail for a
56// missing destination.
57func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
58	model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
59	d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
60}
61
62func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
63	rt := ac.overlays
64	rt.pickerMu.Lock()
65	previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
66	rt.pickerPreviewGeneration++
67	if rt.pickerRemotePreviewCancel != nil {
68		rt.pickerRemotePreviewCancel()
69		rt.pickerRemotePreviewCancel = nil
70	}
71	rt.pickerPreviewSession = nil
72	rt.pickerRemotePreview = picker.Preview{}
73	rt.pickerPreview = nil
74	rt.picker = model
75	rt.pickerGeneration++
76	instance := remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: model}
77	rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
78	rt.pickerIntent = intent
79	rt.pickerSource = source
80	rt.pickerPending = nil
81	rt.pickerESC.stop()
82	rt.pickerMu.Unlock()
83	d.teardownPreviewSubscription(ac, previous, previousGeneration)
84	d.registerPreviewForSelection(ac)
85	d.invalidateRender(sess, ac, true, "picker.go")
86	if rt.beforeRemotePickerRegistration != nil {
87		rt.beforeRemotePickerRegistration()
88	}
89	d.remotePickerOpened(instance)
90}
91
92// pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
93// knows nothing about picker intent; picker.New owns all destination policy.
94func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
95	d.mu.Lock()
96	sessions := make([]*session, 0, len(d.sessions))
97	for _, entry := range d.sessions {
98		if entry == nil {
99			continue
100		}
101		sessions = append(sessions, entry)
102	}
103	stopped := make([]stoppedSession, 0, len(d.stopped))
104	for _, s := range d.stopped {
105		if !s.purging {
106			stopped = append(stopped, s)
107		}
108	}
109	d.mu.Unlock()
110
111	for _, entry := range sessions {
112		if entry != nil {
113			d.refreshSessionFocusedTitles(entry)
114		}
115	}
116	opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
117
118	type liveSnapshot struct {
119		entry *session
120		view  sessionView
121	}
122	// One snapshot per live session: sorting and view building read the same
123	// capture, so comparators cannot observe a concurrent touchMRU or
124	// renameSession mid-sort. Remote-catalog locks are released before sorting
125	// and picker-row construction.
126	live := make([]liveSnapshot, 0, len(sessions))
127	var current picker.SourceFilter
128	for _, entry := range sessions {
129		if entry == cur && ac != nil {
130			entry.repairAttachmentView(ac)
131		}
132		snap := entry.snapshotView(opts)
133		if entry == cur {
134			if ac != nil {
135				view := ac.viewSnapshot()
136				if view.tabID != "" {
137					current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}
138				}
139			} else if len(snap.tabs) > 0 {
140				current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id}
141			}
142		}
143		live = append(live, liveSnapshot{entry: entry, view: snap})
144	}
145	sort.Slice(live, func(i, j int) bool {
146		if live[i].view.mruAt != live[j].view.mruAt {
147			return live[i].view.mruAt > live[j].view.mruAt
148		}
149		if live[i].view.name != live[j].view.name {
150			return live[i].view.name < live[j].view.name
151		}
152		return live[i].view.id < live[j].view.id
153	})
154	sort.Slice(stopped, func(i, j int) bool {
155		if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
156			return stopped[i].lastUsedSeq > stopped[j].lastUsedSeq
157		}
158		return stopped[i].name < stopped[j].name
159	})
160	if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
161		// Stable partition: named sessions first, ephemeral after, each keeping
162		// its MRU order from the sort above.
163		sort.SliceStable(live, func(i, j int) bool {
164			return !live[i].view.ephemeral && live[j].view.ephemeral
165		})
166	}
167
168	catalog := d.remotePickerCatalogSnapshot()
169	sortRemotePickerCatalog(catalog, d.remotePickerHostRanks())
170
171	catalogRows := 0
172	for _, host := range catalog {
173		catalogRows += len(host.entry.Sessions)
174		if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
175			catalogRows++
176		}
177	}
178	views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)
179	for _, item := range live {
180		views = append(views, item.view.pickerView())
181	}
182	seenRemoteLifecycles := make(map[domain.SessionLifecycleID]struct{}, catalogRows)
183	for _, host := range catalog {
184		for _, session := range host.entry.Sessions {
185			if session.LifecycleID != (domain.SessionLifecycleID{}) {
186				if _, duplicate := seenRemoteLifecycles[session.LifecycleID]; duplicate {
187					continue
188				}
189				seenRemoteLifecycles[session.LifecycleID] = struct{}{}
190			}
191			key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
192			if key.Validate() != nil {
193				continue
194			}
195			views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
196		}
197		if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
198			views = append(views, remotePickerHostView(host.entry.Host, host.status))
199		}
200	}
201	for _, s := range stopped {
202		createdAt := s.createdAt
203		views = append(views, picker.SessionView{
204			ID:                domain.SessionID("stopped:" + s.name),
205			Incarnation:       s.incarnation,
206			Name:              s.name,
207			TargetName:        s.name,
208			Tabs:              []picker.TabEntry{{}},
209			Stopped:           true,
210			ExpectedCreatedAt: &createdAt,
211		})
212	}
213	return views, current
214}
215
216func (d *Daemon) newPickerModel(cur *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
217	views, attachedCurrent := d.pickerViews(cur, ac)
218	if intent == pickerNavigate && current == (picker.SourceFilter{}) {
219		current = attachedCurrent
220	}
221	return picker.New(views, picker.SelectionConfig{
222		Mode:    pickerSelectionMode(intent),
223		Current: current,
224		Source: picker.SourceFilter{
225			Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID,
226		},
227	})
228}
229
230func attentionSuffix(label string) string {
231	return label + " " + string(ui.AttentionGlyph)
232}
233
234func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Target) {
235	reason := target.UnavailableReason
236	if reason == "" {
237		reason = "identity_changed"
238	}
239	message := "Remote session unavailable"
240	if target.RemoteTarget != nil {
241		message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin
242	} else if target.RemoteHost != "" {
243		message += ": " + target.RemoteHost
244	}
245	message += " — " + remotePickerReasonText(reason)
246	d.notify(sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
247}
248
249func remotePickerReasonText(reason string) string {
250	switch reason {
251	case "catalog_stale":
252		return "catalog stale"
253	case "host_unreachable":
254		return "host unreachable"
255	case "version_mismatch":
256		return "version mismatch"
257	case "session_stopped":
258		return "session stopped"
259	case "session_broken":
260		return "session broken"
261	case "malformed":
262		return "catalog malformed"
263	case "refreshing":
264		return "refreshing"
265	case "identity_changed":
266		return "session identity changed"
267	default:
268		return "unavailable"
269	}
270}
271
272func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
273	rt := ac.overlays
274	var previewGeneration uint64
275	var instance remotePickerInstance
276	return listInputState{
277		pending:  &rt.pickerPending,
278		esc:      &rt.pickerESC,
279		moveUp:   rt.picker.Up,
280		moveDown: rt.picker.Down,
281		lock:     rt.pickerMu.Lock,
282		unlock:   rt.pickerMu.Unlock,
283		active:   func() bool { return rt.picker != nil },
284		closeLocked: func() {
285			instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
286			rt.picker = nil
287			rt.pickerIntent = pickerNavigate
288			rt.pickerSource = moveSourceLocator{}
289			previewGeneration = rt.pickerPreviewGeneration
290		},
291		afterClose: func() {
292			d.clearPreviewGeneration(ac, previewGeneration)
293			d.remotePickerClosed(instance)
294			if sess := ac.currentAttachmentSession(); sess != nil {
295				d.invalidateRender(sess, ac, true, "picker.go")
296			}
297		},
298	}
299}
300
301func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
302	sess := ac.currentAttachmentSession()
303	if sess == nil {
304		return
305	}
306	rt := ac.overlays
307	rt.pickerMu.Lock()
308	if rt.picker == nil {
309		rt.pickerPending = nil
310		rt.pickerESC.stop()
311		rt.pickerMu.Unlock()
312		return
313	}
314	result := handleListInputLocked(d.clock, data, d.pickerListInputState(ac), func(b byte) listInputResult {
315		switch b {
316		case 'x':
317			return listInputResult{action: b, stop: true}
318		case 's':
319			return listInputResult{action: b, stop: true}
320		case '\r', '\n':
321			return listInputResult{action: b, exit: true}
322		default:
323			return listInputResult{}
324		}
325	})
326	var target picker.Target
327	var cursor picker.Target
328	var ok bool
329	var cursorOK bool
330	var intent pickerIntent
331	var source moveSourceLocator
332	// prevIdx is the row the victim occupied; -1 means "no post-delete hint".
333	prevIdx := -1
334	if result.action == 'x' || result.action == '\r' || result.action == '\n' {
335		target, ok = rt.picker.Selected()
336		cursor, cursorOK = rt.picker.Cursor()
337		prevIdx = rt.picker.SelectedIndex()
338	}
339	intent, source = rt.pickerIntent, rt.pickerSource
340	rt.pickerMu.Unlock()
341
342	if result.action == 's' {
343		d.togglePickerSort()
344		d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
345		d.invalidateRender(sess, ac, true, "picker.go")
346		return
347	}
348	if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
349		d.notifyRemotePickerUnavailable(sess, cursor)
350		d.invalidateRender(sess, ac, true, "picker.go")
351		return
352	}
353	if result.action == 'x' {
354		var effect *attachmentEffectTicket
355		if len(effects) != 0 {
356			effect = effects[0]
357		}
358		if ok {
359			var err error
360			if effect != nil {
361				effect.bindActionEnd(d, "picker-delete")
362				err = d.killPickerTargetForAttachment(target, effect.connectionToken())
363			} else {
364				err = d.killPickerTarget(target)
365			}
366			if err != nil {
367				d.reportAttachmentError(sess, err)
368			}
369		}
370		if effect != nil && effect.ended.Load() {
371			fresh, admitted := ac.beginAttachmentEffect(effect.token)
372			if !admitted {
373				return
374			}
375			defer fresh.End()
376		}
377		d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
378		d.invalidateRender(sess, ac, true, "picker.go")
379		return
380	}
381	if result.changed {
382		d.registerPreviewForSelection(ac)
383	}
384	committing := (result.action == '\r' || result.action == '\n') && ok
385	backNavigationSent := false
386	if result.exit && !committing {
387		back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0
388		if back {
389			var token attachmentConnectionToken
390			if len(effects) != 0 && effects[0] != nil {
391				token = effects[0].connectionToken()
392			} else {
393				var effect *attachmentEffectTicket
394				var admitted bool
395				token, effect, admitted = ac.beginCurrentAttachmentEffect(sess, ac.transport())
396				if !admitted {
397					d.invalidateRender(sess, ac, true, "picker.go")
398					return
399				}
400				defer effect.End()
401			}
402			if err := d.sendNavigationActionForAttachment(token, ports.NavigationBack); err != nil {
403				d.reportAttachmentError(sess, err)
404				d.invalidateRender(sess, ac, true, "picker.go")
405				return
406			}
407			backNavigationSent = true
408		}
409		d.closePicker(ac)
410	}
411	if committing {
412		if intent == pickerMovePane || intent == pickerMoveTab {
413			if err := d.movePickerSourceError(source); err != nil {
414				d.reportAttachmentError(sess, err)
415				d.invalidateRender(sess, ac, true, "picker.go")
416				return
417			}
418			if len(effects) != 0 && effects[0] != nil {
419				effects[0].End()
420			}
421			d.closePicker(ac)
422			err := d.commitMovePickerSelection(intent, source, target)
423			if err != nil {
424				d.reportAttachmentError(sess, movePickerUserError(err))
425				d.invalidateRender(sess, ac, true, "picker.go")
426				return
427			}
428			d.invalidateRender(sess, ac, true, "picker.go")
429			return
430		}
431		var err error
432		if len(effects) != 0 && effects[0] != nil {
433			err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
434		} else {
435			d.closePicker(ac)
436			err = d.switchToTarget(sess, ac, target)
437		}
438		if errors.Is(err, errAttachmentTransition) {
439			return
440		}
441		if err != nil {
442			d.reportAttachmentError(sess, err)
443			return
444		}
445		return
446	}
447	if (result.exit || result.changed) && !backNavigationSent {
448		d.invalidateRender(sess, ac, true, "picker.go")
449	}
450}
451
452func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
453	if ac == nil || ac.overlays == nil {
454		return
455	}
456	ac.overlays.pickerMu.Lock()
457	var target picker.Target
458	var ok bool
459	intent := ac.overlays.pickerIntent
460	if ac.overlays.picker != nil {
461		target, ok = ac.overlays.picker.Selected()
462	}
463	previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration
464	ac.overlays.pickerPreviewGeneration++
465	generation := ac.overlays.pickerPreviewGeneration
466	if ac.overlays.pickerRemotePreviewCancel != nil {
467		ac.overlays.pickerRemotePreviewCancel()
468		ac.overlays.pickerRemotePreviewCancel = nil
469	}
470	ac.overlays.pickerPreviewSession = nil
471	ac.overlays.pickerRemotePreview = picker.Preview{}
472	ac.overlays.pickerPreview = nil
473	ac.overlays.pickerMu.Unlock()
474	d.teardownPreviewSubscription(ac, previous, previousGeneration)
475	if !ok {
476		return
477	}
478	if target.RemoteTarget != nil {
479		d.startRemotePickerPreview(ac, target, generation)
480		return
481	}
482	targetSess, next := d.previewTarget(target, intent)
483	if next == nil || targetSess == nil {
484		return
485	}
486	ac.overlays.pickerMu.Lock()
487	// Selection may have changed while the target was resolved.
488	selected, stillSelected := ac.overlays.picker.Selected()
489	valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerTargetsEqual(selected, target)
490	if valid {
491		ac.overlays.pickerPreview = next
492		ac.overlays.pickerPreviewSession = targetSess
493	}
494	ac.overlays.pickerMu.Unlock()
495	if !valid {
496		return
497	}
498	// A same-session preview is already invalidated by its own coordinator.
499	// Cross-session previews subscribe to the target's producer wake instead
500	// of starting a direct paint/timer path.
501	if targetSess == ac.currentAttachmentSession() {
502		return
503	}
504	rc := d.attachCoordinator(targetSess, nil, nil, false)
505	rc.subscribePreviewFor(ac, generation, func(renderWake) {
506		if pickerPreviewCurrent(ac, targetSess, next, generation) {
507			if owner := ac.currentAttachmentSession(); owner != nil {
508				d.invalidateRender(owner, ac, false, "picker preview")
509			}
510		}
511	})
512	// subscribePreviewFor is deliberately outside pickerMu. Revalidate after it
513	// returns and remove only this generation if selection changed meanwhile.
514	d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation)
515}
516
517func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Target, generation uint64) {
518	if d == nil || ac == nil || target.RemoteTarget == nil || (d.remotePreviewClient == nil && !target.RemoteTarget.Stopped) {
519		return
520	}
521	width, height := remotePickerPreviewSize(ac.sizeSnapshot())
522	if width == 0 || height == 0 {
523		return
524	}
525	ctx := d.serveCtx
526	if ctx == nil {
527		ctx = context.Background()
528	}
529	previewCtx, cancel := context.WithCancel(ctx)
530	ac.overlays.pickerMu.Lock()
531	if ac.overlays.picker == nil || ac.overlays.pickerPreviewGeneration != generation {
532		ac.overlays.pickerMu.Unlock()
533		cancel()
534		return
535	}
536	ac.overlays.pickerRemotePreviewCancel = cancel
537	remoteTarget := *target.RemoteTarget
538	message := "loading remote preview…"
539	if remoteTarget.Stopped {
540		message = "stopped session — preview unavailable"
541	}
542	ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)
543	if remoteTarget.Stopped {
544		ac.overlays.pickerRemotePreviewCancel = nil
545		ac.overlays.pickerMu.Unlock()
546		cancel()
547		if sess := ac.currentAttachmentSession(); sess != nil {
548			d.invalidateRender(sess, ac, false, "remote picker preview")
549		}
550		return
551	}
552	ac.overlays.pickerMu.Unlock()
553	go func() {
554		defer cancel()
555		previewClock := d.clock
556		if previewClock == nil {
557			previewClock = systemClock{}
558		}
559		debounce := previewClock.NewTimer(remotePickerPreviewDebounce)
560		defer debounce.Stop()
561		select {
562		case <-debounce.C():
563		case <-previewCtx.Done():
564			return
565		}
566		preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height)
567		if err != nil {
568			ac.overlays.pickerMu.Lock()
569			matching := false
570			if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil {
571				selected, stillSelected := ac.overlays.picker.Selected()
572				matching = stillSelected && pickerTargetsEqual(selected, target)
573				if matching {
574					ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable")
575				}
576			}
577			ac.overlays.pickerMu.Unlock()
578			if matching {
579				if sess := ac.currentAttachmentSession(); sess != nil {
580					d.invalidateRender(sess, ac, false, "remote picker preview")
581				}
582			}
583			return
584		}
585		if refreshed, refreshErr := d.awaitRemotePreviewRefresh(previewCtx, remoteTarget, width, height); refreshErr == nil {
586			preview = refreshed
587		} else if previewCtx.Err() != nil {
588			return
589		}
590		rows := preview.FrameRows()
591		candidate := picker.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
592		ac.overlays.pickerMu.Lock()
593		if ac.overlays.pickerPreviewGeneration != generation || ac.overlays.picker == nil {
594			ac.overlays.pickerMu.Unlock()
595			return
596		}
597		selected, stillSelected := ac.overlays.picker.Selected()
598		if !stillSelected || !pickerTargetsEqual(selected, target) {
599			ac.overlays.pickerMu.Unlock()
600			return
601		}
602		ac.overlays.pickerRemotePreview = candidate
603		ac.overlays.pickerRemotePreviewCancel = nil
604		ac.overlays.pickerMu.Unlock()
605		if sess := ac.currentAttachmentSession(); sess != nil {
606			d.invalidateRender(sess, ac, false, "remote picker preview")
607		}
608	}()
609}
610
611func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {
612	layout := picker.ChooseLayout(size)
613	width, height := layout.Preview.Width, layout.Preview.Height
614	if width <= 0 || height <= 0 {
615		return 0, 0
616	}
617	return uint16(min(width, ports.RemotePreviewMaxWidth)), uint16(min(height, ports.RemotePreviewMaxHeight))
618}
619
620func staticRemotePickerPreview(width, height uint16, message string) picker.Preview {
621	if width == 0 || height == 0 {
622		return picker.Preview{}
623	}
624	rows := [][]renderer.Cell{make([]renderer.Cell, int(width))}
625	style := renderer.DefaultStyle()
626	for x := range rows[0] {
627		rows[0][x] = renderer.Cell{Rune: ' ', Style: style}
628	}
629	for x, r := range []rune(message) {
630		if x >= int(width) {
631			break
632		}
633		rows[0][x] = renderer.Cell{Rune: r, Style: style}
634	}
635	return picker.Preview{Rows: rows, Width: int(width), Height: len(rows)}
636}
637
638func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool {
639	ac.overlays.pickerMu.Lock()
640	defer ac.overlays.pickerMu.Unlock()
641	return ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewSession == targetSess && ac.overlays.pickerPreview == next
642}
643
644// revalidatePreviewSubscription removes a subscription that lost the picker
645// selection race after it was installed.
646func (d *Daemon) revalidatePreviewSubscription(ac *attachedClient, rc *renderCoordinator, targetSess *session, next *tab, generation uint64) {
647	if !pickerPreviewCurrent(ac, targetSess, next, generation) {
648		rc.teardownPreviewFor(ac, generation)
649	}
650}
651
652func (d *Daemon) teardownPreviewSubscription(ac *attachedClient, previous *session, generation uint64) {
653	if previous != nil {
654		if rc := previous.renderCoordinator(); rc != nil {
655			rc.teardownPreviewFor(ac, generation)
656		}
657	}
658}
659
660func (d *Daemon) unregisterPreview(ac *attachedClient) {
661	if ac == nil || ac.overlays == nil {
662		return
663	}
664	ac.overlays.pickerMu.Lock()
665	generation := ac.overlays.pickerPreviewGeneration
666	ac.overlays.pickerMu.Unlock()
667	d.clearPreviewGeneration(ac, generation)
668}
669
670// clearPreviewGeneration removes the preview only if it is still the observed
671// selection. Teardown paths may race a new picker selection.
672func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) bool {
673	ac.overlays.pickerMu.Lock()
674	if ac.overlays.pickerPreviewGeneration != generation {
675		ac.overlays.pickerMu.Unlock()
676		return false
677	}
678	previous := ac.overlays.pickerPreviewSession
679	ac.overlays.pickerPreviewGeneration++
680	if ac.overlays.pickerRemotePreviewCancel != nil {
681		ac.overlays.pickerRemotePreviewCancel()
682		ac.overlays.pickerRemotePreviewCancel = nil
683	}
684	ac.overlays.pickerPreviewSession = nil
685	ac.overlays.pickerRemotePreview = picker.Preview{}
686	ac.overlays.pickerPreview = nil
687	ac.overlays.pickerMu.Unlock()
688	d.teardownPreviewSubscription(ac, previous, generation)
689	return true
690}
691
692// clearDestroyedTabPreview clears coordinator-owned picker state for clients
693// that were previewing a removed tab. Tabs do not route render ownership.
694func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
695	if d == nil || d.sessions == nil {
696		return
697	}
698	d.mu.Lock()
699	var clients []*attachedClient
700	for _, sess := range d.sessions {
701		if sess == nil {
702			continue
703		}
704		sess.mu.Lock()
705		clients = append(clients, sess.snapshotAttachmentsLocked()...)
706		sess.mu.Unlock()
707	}
708	d.mu.Unlock()
709	for _, ac := range clients {
710		if ac == nil || ac.overlays == nil {
711			continue
712		}
713		ac.overlays.pickerMu.Lock()
714		generation := ac.overlays.pickerPreviewGeneration
715		observed := ac.overlays.pickerPreview == tb
716		ac.overlays.pickerMu.Unlock()
717		if observed && d.clearPreviewGeneration(ac, generation) {
718			if sess := ac.currentAttachmentSession(); sess != nil {
719				d.invalidateRender(sess, ac, true, "picker.go")
720			}
721		}
722	}
723}
724
725func (d *Daemon) closePicker(ac *attachedClient) {
726	d.closePickerIfCurrent(ac, nil, 0)
727}
728
729// closePickerIfCurrent closes only the observed picker lifecycle. A nonzero
730// generation guards terminal parked cleanup even when a refresh replaced the
731// model within that generation.
732func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
733	if ac == nil || ac.overlays == nil {
734		return false
735	}
736	ac.overlays.pickerMu.Lock()
737	if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation {
738		ac.overlays.pickerMu.Unlock()
739		return false
740	}
741	instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
742	ac.overlays.picker = nil
743	ac.overlays.pickerTitle = ""
744	ac.overlays.pickerIntent = pickerNavigate
745	ac.overlays.pickerSource = moveSourceLocator{}
746	ac.overlays.pickerPending = nil
747	ac.overlays.pickerESC.stop()
748	previewGeneration := ac.overlays.pickerPreviewGeneration
749	ac.overlays.pickerMu.Unlock()
750	d.clearPreviewGeneration(ac, previewGeneration)
751	if instance.model != nil {
752		d.remotePickerClosed(instance)
753	}
754	return true
755}
756
757func pickerTargetsEqual(left, right picker.Target) bool {
758	if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
759		left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped ||
760		left.UnavailableReason != right.UnavailableReason || !remoteKeysEqual(left.RemoteKey, right.RemoteKey) ||
761		!remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) {
762		return false
763	}
764	if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
765		return left.ExpectedCreatedAt == nil && right.ExpectedCreatedAt == nil
766	}
767	return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
768}
769
770func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
771	if left == nil || right == nil {
772		return left == nil && right == nil
773	}
774	return *left == *right
775}
776
777func remoteTargetsEqual(left, right *domain.RemoteSessionTarget) bool {
778	if left == nil || right == nil {
779		return left == nil && right == nil
780	}
781	return *left == *right
782}
783
784func (d *Daemon) sessionByID(id domain.SessionID) *session {
785	d.mu.Lock()
786	defer d.mu.Unlock()
787	return d.sessions[id]
788}
789
790// sessionHandoffGuard optionally constrains an active-session handoff to the
791// tab that initiated navigation. Its zero value preserves ordinary picker and
792// command switching behavior.
793type sessionHandoffGuard struct {
794	expectedSource *tab
795	closePicker    bool
796}
797
798// switchActiveTargetForAttachment hands a frame-bound navigation request to the
799// centralized transition. The transition releases admission at its freeze seam
800// and revalidates the exact initiating token before changing target focus or
801// attachment ownership.
802func (d *Daemon) switchActiveTargetForAttachment(token attachmentConnectionToken, target picker.Target) error {
803	return d.switchActiveTargetForAttachmentGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
804}
805
806func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
807	fence := &attachmentLifecycleFence{}
808	if target.ExpectedCreatedAt != nil {
809		fence.name = target.Name
810		fence.createdAt = *target.ExpectedCreatedAt
811		fence.checkCreatedAt = true
812	}
813	if target.Incarnation != (domain.IncarnationID{}) {
814		fence.incarnation = target.Incarnation
815		fence.checkIncarnation = true
816	}
817	if target.TabID != "" {
818		fence.tabID = target.TabID
819		fence.tabIndex = target.TabIndex
820		fence.checkTab = true
821	}
822	if !fence.checkCreatedAt && !fence.checkIncarnation && !fence.checkTab {
823		return nil
824	}
825	return fence
826}
827
828func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
829	if token.sess == nil || token.ac == nil {
830		return nil
831	}
832	d.mu.Lock()
833	targetSess := d.sessions[target.Session]
834	d.mu.Unlock()
835	if targetSess == nil {
836		if !token.attachmentCurrent() {
837			return nil
838		}
839		d.invalidateRender(token.sess, token.ac, true, "picker.go")
840		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
841	}
842	if targetSess == token.sess {
843		return nil
844	}
845
846	transition, err := d.transitionAttachment(attachmentTransitionRequest{
847		source: token.sess, target: targetSess, next: token.ac,
848
849		expectedTransport: token.transport, sourceToken: &token, action: action,
850		expectedTargetLifecycle: pickerTargetLifecycleFence(target),
851		activateTargetTab:       true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
852	})
853	if err != nil {
854		// Losing the exact source role is a benign stale action, not a notice for
855		// whichever attachment replaced the initiator.
856		if !token.attachmentCurrent() {
857			return nil
858		}
859		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
860	}
861	if guard.closePicker {
862		fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
863		if admitted {
864			d.closePicker(token.ac)
865			fresh.End()
866		}
867	}
868	d.touchMRU(targetSess)
869	d.deferAttachmentTransitionCleanups(transition)
870	d.firstPaintForTransition(transition.published)
871	return nil
872}
873
874// switchToTarget resolves named lifecycle targets and commits their transition
875// while d.mu is held. A named palette result is allowed to cross an
876// active/stopped transition, but it never follows a same-name replacement.
877func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
878	return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
879}
880
881// switchToTargetForAttachment is the sole client-originated navigation entry. The
882// intent captures the exact initiating capability before admission is released;
883// every active, stopped, and same-session target then uses transitionAttachment
884// for frozen, atomic source-token preflight.
885func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
886	if token.sess == nil || token.ac == nil || token.effect == nil {
887		return nil
888	}
889	if target.RemoteTarget != nil {
890		var key domain.RemoteSessionKey
891		if target.RemoteKey != nil {
892			key = *target.RemoteKey
893		}
894		return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
895	}
896	if target.RemoteKey != nil {
897		return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
898	}
899	token.effect.bindActionEnd(d, action)
900	token.effect.End()
901	if !token.attachmentCurrent() {
902		return errAttachmentTransition
903	}
904	return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
905}
906
907// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
908// endpoint to the thin client. The daemon owns no remote session shadow: after
909// the target is sent, the local attachment is detached and the client opens a
910// fresh connection to the owning daemon.
911func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
912	failUnavailable := func() error {
913		if token.attachmentCurrent() {
914			d.notifyRemotePickerUnavailable(token.sess, target)
915		}
916		return errAttachmentTransition
917	}
918	var handoff ports.AttachTarget
919	if target.RemoteTarget != nil {
920		remoteTarget := *target.RemoteTarget
921		if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
922			return failUnavailable()
923		}
924		if target.RemoteKey != nil {
925			if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
926				return failUnavailable()
927			}
928		}
929		handoff = ports.AttachTarget{
930			Endpoint:          remoteTarget.Endpoint,
931			Session:           remoteTarget.SessionName,
932			Intent:            ports.IntentAttach,
933			RemoteTarget:      &remoteTarget,
934			EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
935		}
936	} else {
937		if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
938			return failUnavailable()
939		}
940		handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
941	}
942	payload := ports.MarshalAttachTarget(handoff)
943	if payload == nil {
944		return failUnavailable()
945	}
946	if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
947		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
948	}
949	if guard.closePicker {
950		d.closePicker(token.ac)
951	}
952	d.clientGoneForAttachment(token, false)
953	return nil
954}
955
956func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCacheEntry, bool) {
957	if d.remoteCatalog.status[host] != remoteHostFresh {
958		return ports.RemoteCatalogCacheEntry{}, false
959	}
960	entry, ok := d.remoteCatalog.cache[host]
961	if !ok || remoteCatalogExpired(entry.FetchedAt, d.clock.Now()) {
962		d.remoteCatalog.status[host] = remoteHostStale
963		return ports.RemoteCatalogCacheEntry{}, false
964	}
965	return entry, true
966}
967
968func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
969	if d == nil {
970		return false
971	}
972	d.remoteCatalog.mu.Lock()
973	defer d.remoteCatalog.mu.Unlock()
974	entry, ok := d.remoteCatalogEntryLocked(key.Host)
975	if !ok {
976		return false
977	}
978	for _, session := range entry.Sessions {
979		if session.Name == key.Name {
980			return session.State == "up"
981		}
982	}
983	return false
984}
985
986func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
987	if d == nil || target.Validate() != nil {
988		return false
989	}
990	d.remoteCatalog.mu.Lock()
991	defer d.remoteCatalog.mu.Unlock()
992	entry, ok := d.remoteCatalogEntryLocked(target.Endpoint)
993	if !ok {
994		return false
995	}
996	for _, session := range entry.Sessions {
997		if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
998			continue
999		}
1000		if target.Stopped != remoteSessionStateStopped(session.State) || session.State == "broken" {
1001			return false
1002		}
1003		tabs := ports.CatalogTabs(session)
1004		metadata := make([]domain.TabSelectorTab, 0, len(tabs))
1005		for _, tab := range tabs {
1006			metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
1007		}
1008		if target.Stopped {
1009			_, ok := target.StoppedTab.Resolve(metadata)
1010			return ok
1011		}
1012		found := false
1013		for _, tab := range tabs {
1014			if tab.ID != string(target.LiveTabID) {
1015				continue
1016			}
1017			if found {
1018				return false
1019			}
1020			found = true
1021		}
1022		return found
1023	}
1024	return false
1025}
1026
1027// switchToTargetGuarded is retained for daemon-internal and headless callers.
1028func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
1029	return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
1030}
1031
1032func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) error {
1033	if target.Name != "" {
1034		ctx := d.serveCtx
1035		if ctx == nil {
1036			ctx = context.Background()
1037		}
1038		if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
1039			if sourceToken != nil && !sourceToken.attachmentCurrent() {
1040				return errAttachmentTransition
1041			}
1042			d.invalidateRender(from, ac, true, "picker.go")
1043			return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
1044		}
1045	}
1046
1047	d.mu.Lock()
1048	var (
1049		targetSess *session
1050		transition attachmentTransitionResult
1051		switched   bool
1052		cause      error
1053	)
1054	if target.Name != "" {
1055		active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
1056		if ok {
1057			if isStopped {
1058				targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceToken, guard, action)
1059			} else {
1060				// The snapshot ID may describe the stopped representation. The
1061				// locked name/lifecycle resolver chose this active incarnation.
1062				resolvedTarget := target
1063				resolvedTarget.Session = active.id
1064				targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
1065			}
1066		}
1067	} else if active := d.sessions[target.Session]; active != nil {
1068		targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
1069	}
1070	d.mu.Unlock()
1071
1072	if !switched {
1073		if sourceToken != nil && !sourceToken.attachmentCurrent() {
1074			return errAttachmentTransition
1075		}
1076		if guard.expectedSource != nil {
1077			return errNoNeighbor
1078		}
1079		d.invalidateRender(from, ac, true, "picker.go")
1080		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
1081	}
1082	if guard.closePicker {
1083		if sourceToken != nil {
1084			fresh, admitted := ac.beginAttachmentEffect(transition.published)
1085			if admitted {
1086				d.closePicker(ac)
1087				fresh.End()
1088			}
1089		} else {
1090			d.closePicker(ac)
1091		}
1092	}
1093	d.deferAttachmentTransitionCleanups(transition)
1094	if targetSess == from {
1095		if sourceToken != nil {
1096			fresh, admitted := ac.beginAttachmentEffect(transition.published)
1097			if admitted {
1098				d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
1099				d.invalidateRender(from, ac, true, "picker.go")
1100				fresh.End()
1101			}
1102		} else {
1103			d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, nil)
1104			d.invalidateRender(from, ac, true, "picker.go")
1105		}
1106	} else {
1107		d.firstPaintForTransition(transition.published)
1108	}
1109	return nil
1110}
1111
1112// resolveNamedLifecycleTargetLocked chooses exactly one current representation
1113// of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle
1114// identity here closes the lookup-to-handoff window for palette targets.
1115func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) {
1116	if target.Name == "" {
1117		return nil, stoppedSession{}, false, false
1118	}
1119	if active := d.findByNameLocked(target.Name); active != nil {
1120		active.mu.Lock()
1121		matches := targetMatchesLifecycle(target, active.name, active.createdAt)
1122		active.mu.Unlock()
1123		return active, stoppedSession{}, false, matches
1124	}
1125	stopped, ok := d.stopped[target.Name]
1126	if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1127		return nil, stoppedSession{}, false, false
1128	}
1129	return nil, stopped, true, true
1130}
1131
1132// switchToActiveTargetLocked commits an active target handoff through the
1133// centralized attachment transition. Caller holds d.mu.
1134func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) (*session, attachmentTransitionResult, bool) {
1135	if d.sessions[target.Session] != targetSess {
1136		return nil, attachmentTransitionResult{}, false
1137	}
1138	if targetSess == from {
1139		if sourceToken == nil {
1140			targetSess.mu.Lock()
1141			defer targetSess.mu.Unlock()
1142			_, attached := targetSess.attachments[ac]
1143			if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
1144				return nil, attachmentTransitionResult{}, false
1145			}
1146			if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
1147				return nil, attachmentTransitionResult{}, false
1148			}
1149			return targetSess, attachmentTransitionResult{}, true
1150		}
1151		d.mu.Unlock()
1152		transition, err := d.transitionAttachment(attachmentTransitionRequest{
1153			source: from, target: targetSess, next: ac,
1154
1155			expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1156			activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true,
1157		})
1158		d.mu.Lock()
1159		return targetSess, transition, err == nil
1160	}
1161
1162	unlock := lockAttachmentSessions(from, targetSess)
1163	_, sourceAttached := from.attachments[ac]
1164	if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
1165		unlock()
1166		return nil, attachmentTransitionResult{}, false
1167	}
1168	if guard.expectedSource != nil {
1169		view := ac.viewSnapshot()
1170		if domain.TabStableID(guard.expectedSource.stableID) != view.tabID {
1171			unlock()
1172			return nil, attachmentTransitionResult{}, false
1173		}
1174		guard.expectedSource.mu.Lock()
1175		sourceHasFloating := guard.expectedSource.floating.state == floatingVisible
1176		guard.expectedSource.mu.Unlock()
1177		if sourceHasFloating {
1178			unlock()
1179			return nil, attachmentTransitionResult{}, false
1180		}
1181	}
1182	unlock()
1183
1184	// Release d.mu before the gate freeze/drain. transitionAttachment reacquires
1185	// d.mu and revalidates both registered lifecycles for publication.
1186	d.mu.Unlock()
1187	expectedTransport := ac.transportSnapshot()
1188	if sourceToken != nil {
1189		expectedTransport = sourceToken.transport
1190	}
1191	transition, err := d.transitionAttachment(attachmentTransitionRequest{
1192		source: from,
1193		target: targetSess,
1194		next:   ac,
1195
1196		expectedTransport:     expectedTransport,
1197		sourceToken:           sourceToken,
1198		action:                action,
1199		expectedSourceTab:     guard.expectedSource,
1200		activateTargetTab:     target.TabIndex >= 0,
1201		targetTabIndex:        target.TabIndex,
1202		copySourceEnvironment: true,
1203		ready:                 true,
1204	})
1205	d.mu.Lock()
1206	if err != nil {
1207		return nil, attachmentTransitionResult{}, false
1208	}
1209	d.touchMRU(targetSess)
1210	return targetSess, transition, true
1211}
1212
1213// resumeStoppedAndSwitchLocked creates the stopped representation and commits
1214// the handoff while d.mu is held. Creation failure leaves the source client
1215// and stopped record untouched.
1216func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
1217	if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
1218		return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
1219	}
1220
1221	if sourceToken != nil {
1222		var targetSess *session
1223		d.mu.Unlock()
1224		transition, err := d.transitionAttachment(attachmentTransitionRequest{
1225			source: from, next: ac,
1226			expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1227			expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
1228			createTargetLocked: func() (*session, error) {
1229				current, ok := d.stopped[target.Name]
1230				if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) {
1231					return nil, errAttachmentTransition
1232				}
1233				from.mu.Lock()
1234				cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)
1235				from.mu.Unlock()
1236				created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, current.tabNames)
1237				targetSess = created
1238				return created, createErr
1239			},
1240		})
1241		d.mu.Lock()
1242		if err != nil {
1243			if targetSess != nil {
1244				d.mu.Unlock()
1245				_ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
1246				d.mu.Lock()
1247			}
1248			return targetSess, attachmentTransitionResult{}, false, err
1249		}
1250		d.touchMRU(targetSess)
1251		return targetSess, transition, true, nil
1252	}
1253
1254	from.mu.Lock()
1255	_, attached := from.attachments[ac]
1256	if !attached {
1257		from.mu.Unlock()
1258		return nil, attachmentTransitionResult{}, false, nil
1259	}
1260	term := from.terminal
1261	env := copyEnvironment(from.env)
1262	from.mu.Unlock()
1263	cwd := d.dirOrHome(stopped.cwd)
1264	targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, stopped.tabNames)
1265	if err != nil {
1266		d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
1267		return nil, attachmentTransitionResult{}, false, err
1268	}
1269
1270	d.mu.Unlock()
1271	transition, err := d.transitionAttachment(attachmentTransitionRequest{
1272		source: from, target: targetSess, next: ac,
1273
1274		expectedTransport: ac.transportSnapshot(), ready: true,
1275	})
1276	d.mu.Lock()
1277	if err != nil {
1278		return targetSess, attachmentTransitionResult{}, false, err
1279	}
1280	d.touchMRU(targetSess)
1281	return targetSess, transition, true, nil
1282}
1283
1284func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
1285	return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
1286}
1287
1288// stealClientForTarget is retained for direct-ID callers and tests. Named
1289// targets must use switchToTarget so resolution and commit share d.mu.
1290func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) {
1291	d.mu.Lock()
1292	_, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
1293	d.mu.Unlock()
1294	if !switched {
1295		return
1296	}
1297	d.deferAttachmentTransitionCleanups(transition)
1298}
1299
1300// resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
1301// its stopped target and commits creation under one d.mu critical section.
1302func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
1303	d.mu.Lock()
1304	stopped, ok := d.stopped[target.Name]
1305	var (
1306		transition attachmentTransitionResult
1307		switched   bool
1308	)
1309	if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1310		_, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
1311	}
1312	d.mu.Unlock()
1313	if !switched {
1314		d.invalidateRender(from, ac, true, "picker.go")
1315		return false
1316	}
1317	d.deferAttachmentTransitionCleanups(transition)
1318	d.firstPaintForTransition(transition.published)
1319	return true
1320}
1321
1322func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error {
1323	if token.ac == nil {
1324		return d.killPickerTarget(target)
1325	}
1326	if target.RemoteKey != nil {
1327		return nil
1328	}
1329	if target.Stopped {
1330		token.effect.bindActionEnd(d, "picker-delete")
1331		token.effect.End()
1332		frozen := freezeAttachmentEffectGates(token.ac)
1333		defer frozen.unfreeze()
1334		if !d.sourceAttachmentTokenCurrentFrozen(token) {
1335			return nil
1336		}
1337		return d.killPickerTarget(target)
1338	}
1339	d.mu.Lock()
1340	targetSess := d.sessions[target.Session]
1341	d.mu.Unlock()
1342	if targetSess == nil {
1343		return nil
1344	}
1345	return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
1346}
1347
1348func (d *Daemon) killPickerTarget(target picker.Target) error {
1349	if target.Stopped {
1350		if err := d.retryStoppedPurge(target.Name); err != nil {
1351			d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
1352			return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
1353		}
1354		return nil
1355	}
1356	d.mu.Lock()
1357	targetSess := d.sessions[target.Session]
1358	d.mu.Unlock()
1359	if targetSess != nil {
1360		return d.killSession(targetSess, ports.ReasonSessionKilled, true)
1361	}
1362	return nil
1363}
1364
1365func snapshotPickerPreview(tb *tab) picker.Preview {
1366	if tb == nil {
1367		return picker.Preview{}
1368	}
1369	tb.mu.Lock()
1370	layoutSnap := solveTabLayoutLocked(tb)
1371	if !layoutSnap.ok {
1372		p := tb.focusedPane()
1373		if p == nil {
1374			tb.mu.Unlock()
1375			return picker.Preview{}
1376		}
1377		p.mu.Lock()
1378		preview := pickerPreviewFromLockedPane(p)
1379		p.mu.Unlock()
1380		tb.mu.Unlock()
1381		return preview
1382	}
1383	state := capturedRenderState{layout: capturedTabLayout{
1384		area: layoutSnap.area, focus: layoutSnap.focus,
1385		placements:  append([]layout.Placement(nil), layoutSnap.placements...),
1386		dividers:    append([]layout.Divider(nil), layoutSnap.dividers...),
1387		fingerprint: layoutSnap.fingerprint, valid: true,
1388	}}
1389	for _, placement := range layoutSnap.placements {
1390		p := tb.panes[placement.ID]
1391		if p == nil {
1392			continue
1393		}
1394		visible := placement.Content
1395		if placement.Collapsed {
1396			visible = domain.Rect{}
1397		}
1398		p.mu.Lock()
1399		captured := capturePaneRenderStateLocked(p, visible)
1400		p.mu.Unlock()
1401		captured.placement = placement
1402		captured.focused = placement.ID == layoutSnap.focus
1403		state.panes = append(state.panes, captured)
1404	}
1405	tb.mu.Unlock()
1406	return pickerPreviewFromCapturedRender(state)
1407}
1408
1409func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
1410	composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame
1411	if composed.Height < 2 {
1412		return pickerPreviewFromFrame(composed)
1413	}
1414	rows := make([][]renderer.Cell, composed.Height-2)
1415	for y := range rows {
1416		rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...)
1417	}
1418	return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)}
1419}
1420
1421func pickerPreviewFromLockedPane(p *pane) picker.Preview {
1422	return pickerPreviewFromFrame(p.screen.Frame)
1423}
1424
1425func pickerPreviewFromFrame(frame renderer.Frame) picker.Preview {
1426	rows := make([][]renderer.Cell, frame.Height)
1427	for y := range rows {
1428		rows[y] = append([]renderer.Cell(nil), frame.Row(y)...)
1429	}
1430	return picker.Preview{Rows: rows, Width: frame.Width, Height: frame.Height}
1431}

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