v0.3.0 99dc266

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	return d.sendLocalAttachTargetForAttachment(token, target, guard, action)
900}
901
902// sendLocalAttachTargetForAttachment makes local picker navigation follow the
903// same client-owned handoff path as remote navigation. The client reconnects
904// to this daemon and restores its per-route tab from the route ledger.
905func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
906	d.mu.Lock()
907	var targetSess *session
908	var sessionName string
909	var exactTarget *ports.ExactSessionTarget
910	var matches bool
911	if target.Name != "" {
912		var stopped stoppedSession
913		var stoppedTarget bool
914		targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target)
915		if stoppedTarget && matches {
916			sessionName = stopped.name
917			exactTarget = &ports.ExactSessionTarget{LifecycleID: stopped.incarnation, SessionName: sessionName}
918		}
919	} else {
920		targetSess = d.sessions[target.Session]
921		matches = targetSess != nil
922	}
923	if matches && targetSess != nil {
924		targetSess.mu.Lock()
925		sessionName = targetSess.name
926		exactTarget = &ports.ExactSessionTarget{LifecycleID: targetSess.incarnation, SessionName: sessionName}
927		targetSess.mu.Unlock()
928	}
929	d.mu.Unlock()
930	if !matches || !token.attachmentCurrent() {
931		return errAttachmentTransition
932	}
933	if target.TabID != "" {
934		// An explicit tab row is a direct user choice, not route memory; retain
935		// the daemon-side transition so it opens exactly that tab.
936		return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
937	}
938
939	payload := ports.MarshalAttachTarget(ports.AttachTarget{
940		Session:           sessionName,
941		Intent:            ports.IntentAttach,
942		ExactTarget:       exactTarget,
943		EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
944	})
945	if payload == nil {
946		return errAttachmentTransition
947	}
948	if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
949		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to local session", err)
950	}
951	if guard.closePicker {
952		d.closePicker(token.ac)
953	}
954	d.clientGoneForAttachment(token, false)
955	return nil
956}
957
958// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
959// endpoint to the thin client. The daemon owns no remote session shadow: after
960// the target is sent, the local attachment is detached and the client opens a
961// fresh connection to the owning daemon.
962func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
963	failUnavailable := func() error {
964		if token.attachmentCurrent() {
965			d.notifyRemotePickerUnavailable(token.sess, target)
966		}
967		return errAttachmentTransition
968	}
969	var handoff ports.AttachTarget
970	if target.RemoteTarget != nil {
971		remoteTarget := *target.RemoteTarget
972		if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
973			return failUnavailable()
974		}
975		if target.RemoteKey != nil {
976			if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
977				return failUnavailable()
978			}
979		}
980		handoff = ports.AttachTarget{
981			Endpoint:          remoteTarget.Endpoint,
982			Session:           remoteTarget.SessionName,
983			Intent:            ports.IntentAttach,
984			RemoteTarget:      &remoteTarget,
985			EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
986		}
987	} else {
988		if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
989			return failUnavailable()
990		}
991		handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
992	}
993	payload := ports.MarshalAttachTarget(handoff)
994	if payload == nil {
995		return failUnavailable()
996	}
997	if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
998		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
999	}
1000	if guard.closePicker {
1001		d.closePicker(token.ac)
1002	}
1003	d.clientGoneForAttachment(token, false)
1004	return nil
1005}
1006
1007func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCacheEntry, bool) {
1008	if d.remoteCatalog.status[host] != remoteHostFresh {
1009		return ports.RemoteCatalogCacheEntry{}, false
1010	}
1011	entry, ok := d.remoteCatalog.cache[host]
1012	if !ok || remoteCatalogExpired(entry.FetchedAt, d.clock.Now()) {
1013		d.remoteCatalog.status[host] = remoteHostStale
1014		return ports.RemoteCatalogCacheEntry{}, false
1015	}
1016	return entry, true
1017}
1018
1019func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
1020	if d == nil {
1021		return false
1022	}
1023	d.remoteCatalog.mu.Lock()
1024	defer d.remoteCatalog.mu.Unlock()
1025	entry, ok := d.remoteCatalogEntryLocked(key.Host)
1026	if !ok {
1027		return false
1028	}
1029	for _, session := range entry.Sessions {
1030		if session.Name == key.Name {
1031			return session.State == "up"
1032		}
1033	}
1034	return false
1035}
1036
1037func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
1038	if d == nil || target.Validate() != nil {
1039		return false
1040	}
1041	d.remoteCatalog.mu.Lock()
1042	defer d.remoteCatalog.mu.Unlock()
1043	entry, ok := d.remoteCatalogEntryLocked(target.Endpoint)
1044	if !ok {
1045		return false
1046	}
1047	for _, session := range entry.Sessions {
1048		if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
1049			continue
1050		}
1051		if target.Stopped != remoteSessionStateStopped(session.State) || session.State == "broken" {
1052			return false
1053		}
1054		tabs := ports.CatalogTabs(session)
1055		metadata := make([]domain.TabSelectorTab, 0, len(tabs))
1056		for _, tab := range tabs {
1057			metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
1058		}
1059		if target.Stopped {
1060			_, ok := target.StoppedTab.Resolve(metadata)
1061			return ok
1062		}
1063		found := false
1064		for _, tab := range tabs {
1065			if tab.ID != string(target.LiveTabID) {
1066				continue
1067			}
1068			if found {
1069				return false
1070			}
1071			found = true
1072		}
1073		return found
1074	}
1075	return false
1076}
1077
1078// switchToTargetGuarded is retained for daemon-internal and headless callers.
1079func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
1080	return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
1081}
1082
1083func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) error {
1084	if target.Name != "" {
1085		ctx := d.serveCtx
1086		if ctx == nil {
1087			ctx = context.Background()
1088		}
1089		if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
1090			if sourceToken != nil && !sourceToken.attachmentCurrent() {
1091				return errAttachmentTransition
1092			}
1093			d.invalidateRender(from, ac, true, "picker.go")
1094			return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
1095		}
1096	}
1097
1098	d.mu.Lock()
1099	var (
1100		targetSess *session
1101		transition attachmentTransitionResult
1102		switched   bool
1103		cause      error
1104	)
1105	if target.Name != "" {
1106		active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
1107		if ok {
1108			if isStopped {
1109				targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceToken, guard, action)
1110			} else {
1111				// The snapshot ID may describe the stopped representation. The
1112				// locked name/lifecycle resolver chose this active incarnation.
1113				resolvedTarget := target
1114				resolvedTarget.Session = active.id
1115				targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
1116			}
1117		}
1118	} else if active := d.sessions[target.Session]; active != nil {
1119		targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
1120	}
1121	d.mu.Unlock()
1122
1123	if !switched {
1124		if sourceToken != nil && !sourceToken.attachmentCurrent() {
1125			return errAttachmentTransition
1126		}
1127		if guard.expectedSource != nil {
1128			return errNoNeighbor
1129		}
1130		d.invalidateRender(from, ac, true, "picker.go")
1131		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
1132	}
1133	if guard.closePicker {
1134		if sourceToken != nil {
1135			fresh, admitted := ac.beginAttachmentEffect(transition.published)
1136			if admitted {
1137				d.closePicker(ac)
1138				fresh.End()
1139			}
1140		} else {
1141			d.closePicker(ac)
1142		}
1143	}
1144	d.deferAttachmentTransitionCleanups(transition)
1145	if targetSess == from {
1146		if sourceToken != nil {
1147			fresh, admitted := ac.beginAttachmentEffect(transition.published)
1148			if admitted {
1149				d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
1150				d.invalidateRender(from, ac, true, "picker.go")
1151				fresh.End()
1152			}
1153		} else {
1154			d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, nil)
1155			d.invalidateRender(from, ac, true, "picker.go")
1156		}
1157	} else {
1158		d.firstPaintForTransition(transition.published)
1159	}
1160	return nil
1161}
1162
1163// resolveNamedLifecycleTargetLocked chooses exactly one current representation
1164// of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle
1165// identity here closes the lookup-to-handoff window for palette targets.
1166func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) {
1167	if target.Name == "" {
1168		return nil, stoppedSession{}, false, false
1169	}
1170	if active := d.findByNameLocked(target.Name); active != nil {
1171		active.mu.Lock()
1172		matches := targetMatchesLifecycle(target, active.name, active.createdAt)
1173		active.mu.Unlock()
1174		return active, stoppedSession{}, false, matches
1175	}
1176	stopped, ok := d.stopped[target.Name]
1177	if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1178		return nil, stoppedSession{}, false, false
1179	}
1180	return nil, stopped, true, true
1181}
1182
1183// switchToActiveTargetLocked commits an active target handoff through the
1184// centralized attachment transition. Caller holds d.mu.
1185func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) (*session, attachmentTransitionResult, bool) {
1186	if d.sessions[target.Session] != targetSess {
1187		return nil, attachmentTransitionResult{}, false
1188	}
1189	if targetSess == from {
1190		if sourceToken == nil {
1191			targetSess.mu.Lock()
1192			defer targetSess.mu.Unlock()
1193			_, attached := targetSess.attachments[ac]
1194			if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
1195				return nil, attachmentTransitionResult{}, false
1196			}
1197			if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
1198				return nil, attachmentTransitionResult{}, false
1199			}
1200			return targetSess, attachmentTransitionResult{}, true
1201		}
1202		d.mu.Unlock()
1203		transition, err := d.transitionAttachment(attachmentTransitionRequest{
1204			source: from, target: targetSess, next: ac,
1205
1206			expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1207			activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true,
1208		})
1209		d.mu.Lock()
1210		return targetSess, transition, err == nil
1211	}
1212
1213	unlock := lockAttachmentSessions(from, targetSess)
1214	_, sourceAttached := from.attachments[ac]
1215	if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
1216		unlock()
1217		return nil, attachmentTransitionResult{}, false
1218	}
1219	if guard.expectedSource != nil {
1220		view := ac.viewSnapshot()
1221		if domain.TabStableID(guard.expectedSource.stableID) != view.tabID {
1222			unlock()
1223			return nil, attachmentTransitionResult{}, false
1224		}
1225		guard.expectedSource.mu.Lock()
1226		sourceHasFloating := guard.expectedSource.floating.state == floatingVisible
1227		guard.expectedSource.mu.Unlock()
1228		if sourceHasFloating {
1229			unlock()
1230			return nil, attachmentTransitionResult{}, false
1231		}
1232	}
1233	unlock()
1234
1235	// Release d.mu before the gate freeze/drain. transitionAttachment reacquires
1236	// d.mu and revalidates both registered lifecycles for publication.
1237	d.mu.Unlock()
1238	expectedTransport := ac.transportSnapshot()
1239	if sourceToken != nil {
1240		expectedTransport = sourceToken.transport
1241	}
1242	transition, err := d.transitionAttachment(attachmentTransitionRequest{
1243		source: from,
1244		target: targetSess,
1245		next:   ac,
1246
1247		expectedTransport:     expectedTransport,
1248		sourceToken:           sourceToken,
1249		action:                action,
1250		expectedSourceTab:     guard.expectedSource,
1251		activateTargetTab:     target.TabIndex >= 0,
1252		targetTabIndex:        target.TabIndex,
1253		copySourceEnvironment: true,
1254		ready:                 true,
1255	})
1256	d.mu.Lock()
1257	if err != nil {
1258		return nil, attachmentTransitionResult{}, false
1259	}
1260	d.touchMRU(targetSess)
1261	return targetSess, transition, true
1262}
1263
1264// resumeStoppedAndSwitchLocked creates the stopped representation and commits
1265// the handoff while d.mu is held. Creation failure leaves the source client
1266// and stopped record untouched.
1267func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
1268	if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
1269		return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
1270	}
1271
1272	if sourceToken != nil {
1273		var targetSess *session
1274		d.mu.Unlock()
1275		transition, err := d.transitionAttachment(attachmentTransitionRequest{
1276			source: from, next: ac,
1277			expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1278			expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
1279			createTargetLocked: func() (*session, error) {
1280				current, ok := d.stopped[target.Name]
1281				if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) {
1282					return nil, errAttachmentTransition
1283				}
1284				from.mu.Lock()
1285				cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)
1286				from.mu.Unlock()
1287				created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, current.tabNames)
1288				targetSess = created
1289				return created, createErr
1290			},
1291		})
1292		d.mu.Lock()
1293		if err != nil {
1294			if targetSess != nil {
1295				d.mu.Unlock()
1296				_ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
1297				d.mu.Lock()
1298			}
1299			return targetSess, attachmentTransitionResult{}, false, err
1300		}
1301		d.touchMRU(targetSess)
1302		return targetSess, transition, true, nil
1303	}
1304
1305	from.mu.Lock()
1306	_, attached := from.attachments[ac]
1307	if !attached {
1308		from.mu.Unlock()
1309		return nil, attachmentTransitionResult{}, false, nil
1310	}
1311	term := from.terminal
1312	env := copyEnvironment(from.env)
1313	from.mu.Unlock()
1314	cwd := d.dirOrHome(stopped.cwd)
1315	targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, stopped.tabNames)
1316	if err != nil {
1317		d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
1318		return nil, attachmentTransitionResult{}, false, err
1319	}
1320
1321	d.mu.Unlock()
1322	transition, err := d.transitionAttachment(attachmentTransitionRequest{
1323		source: from, target: targetSess, next: ac,
1324
1325		expectedTransport: ac.transportSnapshot(), ready: true,
1326	})
1327	d.mu.Lock()
1328	if err != nil {
1329		return targetSess, attachmentTransitionResult{}, false, err
1330	}
1331	d.touchMRU(targetSess)
1332	return targetSess, transition, true, nil
1333}
1334
1335func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
1336	return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
1337}
1338
1339// stealClientForTarget is retained for direct-ID callers and tests. Named
1340// targets must use switchToTarget so resolution and commit share d.mu.
1341func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) {
1342	d.mu.Lock()
1343	_, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
1344	d.mu.Unlock()
1345	if !switched {
1346		return
1347	}
1348	d.deferAttachmentTransitionCleanups(transition)
1349}
1350
1351// resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
1352// its stopped target and commits creation under one d.mu critical section.
1353func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
1354	d.mu.Lock()
1355	stopped, ok := d.stopped[target.Name]
1356	var (
1357		transition attachmentTransitionResult
1358		switched   bool
1359	)
1360	if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1361		_, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
1362	}
1363	d.mu.Unlock()
1364	if !switched {
1365		d.invalidateRender(from, ac, true, "picker.go")
1366		return false
1367	}
1368	d.deferAttachmentTransitionCleanups(transition)
1369	d.firstPaintForTransition(transition.published)
1370	return true
1371}
1372
1373func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error {
1374	if token.ac == nil {
1375		return d.killPickerTarget(target)
1376	}
1377	if target.RemoteKey != nil {
1378		return nil
1379	}
1380	if target.Stopped {
1381		token.effect.bindActionEnd(d, "picker-delete")
1382		token.effect.End()
1383		frozen := freezeAttachmentEffectGates(token.ac)
1384		defer frozen.unfreeze()
1385		if !d.sourceAttachmentTokenCurrentFrozen(token) {
1386			return nil
1387		}
1388		return d.killPickerTarget(target)
1389	}
1390	d.mu.Lock()
1391	targetSess := d.sessions[target.Session]
1392	d.mu.Unlock()
1393	if targetSess == nil {
1394		return nil
1395	}
1396	return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
1397}
1398
1399func (d *Daemon) killPickerTarget(target picker.Target) error {
1400	if target.Stopped {
1401		if err := d.retryStoppedPurge(target.Name); err != nil {
1402			d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
1403			return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
1404		}
1405		return nil
1406	}
1407	d.mu.Lock()
1408	targetSess := d.sessions[target.Session]
1409	d.mu.Unlock()
1410	if targetSess != nil {
1411		return d.killSession(targetSess, ports.ReasonSessionKilled, true)
1412	}
1413	return nil
1414}
1415
1416func snapshotPickerPreview(tb *tab) picker.Preview {
1417	if tb == nil {
1418		return picker.Preview{}
1419	}
1420	tb.mu.Lock()
1421	layoutSnap := solveTabLayoutLocked(tb)
1422	if !layoutSnap.ok {
1423		p := tb.focusedPane()
1424		if p == nil {
1425			tb.mu.Unlock()
1426			return picker.Preview{}
1427		}
1428		p.mu.Lock()
1429		preview := pickerPreviewFromLockedPane(p)
1430		p.mu.Unlock()
1431		tb.mu.Unlock()
1432		return preview
1433	}
1434	state := capturedRenderState{layout: capturedTabLayout{
1435		area: layoutSnap.area, focus: layoutSnap.focus,
1436		placements:  append([]layout.Placement(nil), layoutSnap.placements...),
1437		dividers:    append([]layout.Divider(nil), layoutSnap.dividers...),
1438		fingerprint: layoutSnap.fingerprint, valid: true,
1439	}}
1440	for _, placement := range layoutSnap.placements {
1441		p := tb.panes[placement.ID]
1442		if p == nil {
1443			continue
1444		}
1445		visible := placement.Content
1446		if placement.Collapsed {
1447			visible = domain.Rect{}
1448		}
1449		p.mu.Lock()
1450		captured := capturePaneRenderStateLocked(p, visible)
1451		p.mu.Unlock()
1452		captured.placement = placement
1453		captured.focused = placement.ID == layoutSnap.focus
1454		state.panes = append(state.panes, captured)
1455	}
1456	tb.mu.Unlock()
1457	return pickerPreviewFromCapturedRender(state)
1458}
1459
1460func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
1461	composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame
1462	if composed.Height < 2 {
1463		return pickerPreviewFromFrame(composed)
1464	}
1465	rows := make([][]renderer.Cell, composed.Height-2)
1466	for y := range rows {
1467		rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...)
1468	}
1469	return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)}
1470}
1471
1472func pickerPreviewFromLockedPane(p *pane) picker.Preview {
1473	return pickerPreviewFromFrame(p.screen.Frame)
1474}
1475
1476func pickerPreviewFromFrame(frame renderer.Frame) picker.Preview {
1477	rows := make([][]renderer.Cell, frame.Height)
1478	for y := range rows {
1479		rows[y] = append([]renderer.Cell(nil), frame.Row(y)...)
1480	}
1481	return picker.Preview{Rows: rows, Width: frame.Width, Height: frame.Height}
1482}

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