picker.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"context"
6	"sort"
7	"time"
8
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/protocol"
11	"github.com/bnema/vev/internal/usecase/picker"
12	"github.com/bnema/vev/internal/usecase/ui"
13)
14
15// The picker's floating geometry lives in the picker package: the presenting
16// client draws the same box, and this daemon sizes the preview it captures from
17// it, so both sides always agree.
18var pickerModal = picker.Modal
19
20const remotePickerPreviewDebounce = 80 * time.Millisecond
21
22// pickerLocalViews is the remote-free picker projection. The hybrid
23// projection interleaves foreign rows between the live and stopped rows, so
24// the two groups are returned separately and the stopped rows are returned
25// without a LOCAL section label. The assembler applies that label once it
26// knows whether any row precedes them.
27type pickerLocalViews struct {
28	current        pickerSourceFilter
29	now            time.Time
30	recentLive     []pickerSessionView
31	groupedLive    []pickerSessionView
32	recentStopped  []pickerSessionView
33	groupedStopped []pickerSessionView
34}
35
36// localPickerViews is the prepared local-only picker projection. It captures
37// live and stopped local rows, ordering them as the hybrid projection does
38// when no foreign source is present.
39func (d *Daemon) localPickerViews(cur *session, ac *attachedClient) pickerLocalViews {
40	if cur != nil && ac != nil {
41		cur.repairAttachmentView(ac)
42	}
43	opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
44	inv := d.captureLocalSessionInventory(opts, true)
45	stopped := inv.visibleStopped()
46
47	// One snapshot per live session: sorting and view building read the same
48	// capture, so comparators cannot observe a concurrent touchMRU or
49	// renameSession mid-sort.
50	live := inv.live
51	var current pickerSourceFilter
52	for _, item := range live {
53		snap := item.view
54		if item.sess == cur {
55			if ac != nil {
56				view := ac.viewSnapshot()
57				if view.tabID != "" {
58					current = pickerSourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}
59				}
60			} else if len(snap.tabs) > 0 {
61				current = pickerSourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id}
62			}
63		}
64	}
65	sort.Slice(live, func(i, j int) bool {
66		if live[i].view.mruAt != live[j].view.mruAt {
67			return live[i].view.mruAt > live[j].view.mruAt
68		}
69		if live[i].view.name != live[j].view.name {
70			return live[i].view.name < live[j].view.name
71		}
72		return live[i].view.id < live[j].view.id
73	})
74	sort.Slice(stopped, func(i, j int) bool {
75		if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
76			return stopped[i].lastUsedSeq > stopped[j].lastUsedSeq
77		}
78		return stopped[i].name < stopped[j].name
79	})
80
81	recentLive := make([]pickerSessionView, 0, len(live))
82	groupedLive := make([]pickerSessionView, 0, len(live))
83	for i, item := range live {
84		recentLive = append(recentLive, item.view.pickerView())
85		view := item.view.pickerView()
86		if i == 0 {
87			view.Section = "LOCAL"
88		}
89		groupedLive = append(groupedLive, view)
90	}
91	stoppedRows := stoppedPickerRows(stopped)
92	// The grouped projection labels its first stopped row in place, so the
93	// recent projection keeps its own copy.
94	recentStopped := append([]pickerSessionView(nil), stoppedRows...)
95	groupedStopped := stoppedRows
96	return pickerLocalViews{current: current, now: inv.now, recentLive: recentLive, groupedLive: groupedLive, recentStopped: recentStopped, groupedStopped: groupedStopped}
97}
98
99// stoppedPickerRows projects visible inactive records into picker rows. The
100// local and hybrid projections share this single row shape, and the callers
101// own whether a LOCAL section label is applied.
102func stoppedPickerRows(stopped []inactiveSession) []pickerSessionView {
103	rows := make([]pickerSessionView, 0, len(stopped))
104	for _, s := range stopped {
105		createdAt := s.createdAt
106		rows = append(rows, pickerSessionView{ID: domain.SessionID("stopped:" + s.name), Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Stopped: true, ExpectedCreatedAt: &createdAt})
107	}
108	return rows
109}
110
111// localPickerViewProjections is the complete remote-free picker projection.
112// It appends the local stopped rows directly and applies the LOCAL section
113// label when no live row opened the group.
114func (d *Daemon) localPickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) {
115	local := d.localPickerViews(cur, ac)
116	recent := make([]pickerSessionView, 0, len(local.recentLive)+len(local.recentStopped))
117	recent = append(recent, local.recentLive...)
118	recent = append(recent, local.recentStopped...)
119	grouped := make([]pickerSessionView, 0, len(local.groupedLive)+len(local.groupedStopped))
120	grouped = append(grouped, local.groupedLive...)
121	grouped = append(grouped, withLocalStoppedSection(local.groupedStopped, len(local.groupedLive) == 0)...)
122	return recent, grouped, local.current
123}
124
125func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) {
126	return d.localPickerViewProjections(cur, ac)
127}
128
129// withLocalStoppedSection marks the first stopped row as the LOCAL section
130// when no preceding row already opened a local group.
131func withLocalStoppedSection(stopped []pickerSessionView, local bool) []pickerSessionView {
132	if local && len(stopped) > 0 {
133		stopped[0].Section = "LOCAL"
134	}
135	return stopped
136}
137
138func attentionSuffix(label string) string {
139	return label + " " + string(ui.AttentionGlyph)
140}
141
142func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {
143	preview := picker.PreviewRect(size)
144	width, height := preview.Width, preview.Height
145	if width <= 0 || height <= 0 {
146		return 0, 0
147	}
148	return uint16(min(width, protocol.RemotePreviewMaxWidth)), uint16(min(height, protocol.RemotePreviewMaxHeight))
149}
150
151// pickerRouteTargetsEqual compares the exact route identity of two picker
152// targets, ignoring presentation fields such as UnavailableReason that
153// render around the route on every observation.
154func pickerRouteTargetsEqual(left, right picker.Target) bool {
155	if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
156		left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped ||
157		!remoteKeysEqual(left.RemoteKey, right.RemoteKey) ||
158		!remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) {
159		return false
160	}
161	if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
162		return left.ExpectedCreatedAt == nil && right.ExpectedCreatedAt == nil
163	}
164	return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
165}
166
167func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
168	if left == nil || right == nil {
169		return left == nil && right == nil
170	}
171	return *left == *right
172}
173
174func remoteTargetsEqual(left, right *domain.RemoteSessionTarget) bool {
175	if left == nil || right == nil {
176		return left == nil && right == nil
177	}
178	return *left == *right
179}
180
181func (d *Daemon) sessionByID(id domain.SessionID) *session {
182	d.mu.Lock()
183	defer d.mu.Unlock()
184	return d.sessions[id]
185}
186
187// sessionHandoffGuard carries explicit capabilities and source constraints for
188// one active-session handoff. Its zero value keeps daemon-owned transitions on
189// their established path.
190type sessionHandoffGuard struct {
191	expectedSource *tab
192	allowSamePeer  bool
193}
194
195// switchActiveTargetForAttachment hands a frame-bound navigation request to the
196// centralized transition. The transition releases admission at its freeze seam
197// and revalidates the exact initiating token before changing target focus or
198// attachment ownership.
199func (d *Daemon) switchActiveTargetForAttachment(effect *attachmentEffect, target picker.Target) error {
200	return d.switchActiveTargetForAttachmentGuarded(effect, target, sessionHandoffGuard{}, "jump-attention")
201}
202
203func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
204	fence := &attachmentLifecycleFence{}
205	if target.ExpectedCreatedAt != nil {
206		fence.name = target.Name
207		fence.createdAt = *target.ExpectedCreatedAt
208		fence.checkCreatedAt = true
209	}
210	if target.Incarnation != (domain.IncarnationID{}) {
211		fence.incarnation = target.Incarnation
212		fence.checkIncarnation = true
213	}
214	if target.TabID != "" {
215		fence.tabID = target.TabID
216		fence.tabIndex = target.TabIndex
217		fence.checkTab = true
218	}
219	if !fence.checkCreatedAt && !fence.checkIncarnation && !fence.checkTab {
220		return nil
221	}
222	return fence
223}
224
225func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
226	if !effect.current() || effect.sess == nil || effect.ac == nil {
227		return nil
228	}
229	d.mu.Lock()
230	targetSess := d.sessions[target.Session]
231	d.mu.Unlock()
232	if targetSess == nil {
233		if !effect.current() {
234			return nil
235		}
236		d.invalidateRender(effect.sess, effect.ac, true, "picker.go")
237		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
238	}
239	if targetSess == effect.sess {
240		return nil
241	}
242
243	capability := effect.capability()
244	transition, err := d.transitionAttachment(attachmentTransitionRequest{
245		source: effect.sess, target: targetSess, next: effect.ac,
246
247		expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: action,
248		expectedTargetLifecycle: pickerTargetLifecycleFence(target),
249		activateTargetTab:       true, targetTabIndex: target.TabIndex, ready: true,
250	})
251	if err != nil {
252		// Losing the exact source role is a benign stale action, not a notice for
253		// whichever attachment replaced the initiator.
254		if !capability.current() {
255			return nil
256		}
257		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
258	}
259	d.touchMRU(targetSess)
260	d.deferAttachmentTransitionCleanups(transition)
261	d.firstPaintForTransition(transition.published)
262	return nil
263}
264
265// switchToTarget resolves named lifecycle targets and commits their transition
266// while d.mu is held. A named palette result is allowed to cross an
267// active/stopped transition, but it never follows a same-name replacement.
268func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
269	return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
270}
271
272// switchToTargetForAttachment is the sole client-originated navigation entry. The
273// intent captures the exact initiating capability before admission is released;
274// every active, stopped, and same-session target then uses transitionAttachment
275// for frozen, atomic source-token preflight.
276func (d *Daemon) switchToTargetForAttachment(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
277	if !effect.current() || effect.sess == nil || effect.ac == nil {
278		return nil
279	}
280	if target.RemoteTarget != nil || target.RemoteKey != nil {
281		// Other daemons are client routes: the daemon never
282		// hands out a remote destination.
283		return errAttachmentTransition
284	}
285	return d.sendLocalAttachTargetForAttachment(effect, target, guard, action)
286}
287
288// sendLocalAttachTargetForAttachment offers an endpoint-empty, exact local
289// target on the current authenticated connection. A current client confirms it
290// with SamePeerSwitchRequest; an interrupted or older client retains the
291// existing close-and-redial fallback without any daemon-origin inference.
292func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
293	d.mu.Lock()
294	var targetSess *session
295	var sessionName string
296	var exactTarget *protocol.ExactSessionTarget
297	var matches bool
298	if target.Name != "" {
299		var stopped inactiveSession
300		var stoppedTarget bool
301		targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target)
302		if stoppedTarget && matches {
303			sessionName = stopped.name
304			exactTarget = &protocol.ExactSessionTarget{LifecycleID: stopped.incarnation, SessionName: sessionName}
305		}
306	} else {
307		targetSess = d.sessions[target.Session]
308		matches = targetSess != nil
309	}
310	if matches && targetSess != nil {
311		targetSess.mu.Lock()
312		sessionName = targetSess.name
313		exactTarget = &protocol.ExactSessionTarget{LifecycleID: targetSess.incarnation, SessionName: sessionName}
314		targetSess.mu.Unlock()
315	}
316	d.mu.Unlock()
317	if !matches || !effect.current() {
318		return errAttachmentTransition
319	}
320	if targetSess != nil && target.TabID != "" {
321		// Ordinary local tab navigation keeps the attachment.
322		return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action)
323	}
324
325	// Ordinary live session rows can use the client-confirmed same-peer
326	// path. Stopped targets require a handoff.
327	samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0
328	if exactTarget == nil {
329		return errAttachmentTransition
330	}
331	// A close-and-dial handoff leaves this daemon's Kitty namespace. A
332	// same-peer transition keeps the attachment and its namespace; the target
333	// scene diff deletes and replaces the source session's placements.
334	if samePeerEligible {
335		if err := offerSamePeerAttachTarget(effect, *exactTarget); err != nil {
336			return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err)
337		}
338		return nil
339	}
340	handoff := protocol.AttachTarget{
341		Session:           sessionName,
342		Intent:            protocol.IntentAttach,
343		ExactTarget:       exactTarget,
344		PreferredTabID:    target.TabID,
345		EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
346	}
347	if protocol.ValidateAttachTarget(handoff) != nil {
348		return errAttachmentTransition
349	}
350	if err := d.cleanupAttachmentOutput(effect.ac); err != nil {
351		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err)
352	}
353	if err := effect.sendControl(handoff); err != nil {
354		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err)
355	}
356	// Stopped and explicit non-default index targets retain the close-and-dial
357	// handoff instead of losing their target-specific transition semantics.
358	// Keep the source link open until the client receives the ordered cleanup
359	// and handoff frames, then let the client's close drive ordinary parking.
360	effect.bindActionEnd(d, "detach")
361	effect.End()
362	return nil
363}
364
365// switchToTargetGuarded is retained for daemon-internal and headless callers.
366func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
367	return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
368}
369
370func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceEffect *attachmentEffect, action string) error {
371	var sourceCapability *attachmentCapability
372	if sourceEffect != nil {
373		capability := sourceEffect.capability()
374		sourceCapability = &capability
375	}
376	if target.Name != "" {
377		ctx := d.serveCtx
378		if ctx == nil {
379			ctx = context.Background()
380		}
381		if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
382			if sourceCapability != nil && !sourceCapability.current() {
383				return errAttachmentTransition
384			}
385			d.invalidateRender(from, ac, true, "picker.go")
386			return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
387		}
388	}
389
390	d.mu.Lock()
391	var (
392		targetSess *session
393		transition attachmentTransitionResult
394		switched   bool
395		cause      error
396	)
397	if target.Name != "" {
398		active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
399		if ok {
400			if isStopped {
401				targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceEffect, guard, action)
402			} else {
403				// The snapshot ID may describe the stopped representation. The
404				// locked name/lifecycle resolver chose this active incarnation.
405				resolvedTarget := target
406				resolvedTarget.Session = active.id
407				targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceEffect, action)
408			}
409		}
410	} else if active := d.sessions[target.Session]; active != nil {
411		targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceEffect, action)
412	}
413	d.mu.Unlock()
414
415	if !switched {
416		if sourceCapability != nil && !sourceCapability.current() {
417			return errAttachmentTransition
418		}
419		if guard.expectedSource != nil {
420			return errNoNeighbor
421		}
422		d.invalidateRender(from, ac, true, "picker.go")
423		return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
424	}
425	d.deferAttachmentTransitionCleanups(transition)
426	if targetSess == from {
427		if sourceEffect != nil {
428			fresh, admitted := ac.beginAttachmentEffect(transition.published)
429			if admitted {
430				d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
431				d.invalidateRender(from, ac, true, "picker.go")
432				fresh.End()
433			}
434		} else {
435			d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, nil)
436			d.invalidateRender(from, ac, true, "picker.go")
437		}
438	} else {
439		d.firstPaintForTransition(transition.published)
440	}
441	return nil
442}
443
444// resolveNamedLifecycleTargetLocked chooses exactly one current representation
445// of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle
446// identity here closes the lookup-to-handoff window for palette targets.
447func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, inactiveSession, bool, bool) {
448	if target.Name == "" {
449		return nil, inactiveSession{}, false, false
450	}
451	if active := d.findByNameLocked(target.Name); active != nil {
452		active.mu.Lock()
453		matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation)
454		active.mu.Unlock()
455		return active, inactiveSession{}, false, matches
456	}
457	stopped, ok := d.inactive[target.Name]
458	if !ok || !stopped.canResume() || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
459		return nil, inactiveSession{}, false, false
460	}
461	return nil, stopped, true, true
462}
463
464// switchToActiveTargetLocked commits an active target handoff through the
465// centralized attachment transition. Caller holds d.mu.
466func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceEffect *attachmentEffect, action string) (*session, attachmentTransitionResult, bool) {
467	var sourceCapability *attachmentCapability
468	if sourceEffect != nil {
469		capability := sourceEffect.capability()
470		sourceCapability = &capability
471	}
472	if d.sessions[target.Session] != targetSess {
473		return nil, attachmentTransitionResult{}, false
474	}
475	if targetSess == from {
476		if sourceEffect == nil {
477			targetSess.mu.Lock()
478			defer targetSess.mu.Unlock()
479			_, attached := targetSess.attachments[ac]
480			if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
481				return nil, attachmentTransitionResult{}, false
482			}
483			if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
484				return nil, attachmentTransitionResult{}, false
485			}
486			return targetSess, attachmentTransitionResult{}, true
487		}
488		d.mu.Unlock()
489		transition, err := d.transitionAttachment(attachmentTransitionRequest{
490			source: from, target: targetSess, next: ac,
491
492			expectedTransport:       sourceCapability.transport,
493			sourceCapability:        sourceCapability,
494			sourceEffect:            sourceEffect,
495			action:                  action,
496			expectedTargetLifecycle: pickerTargetLifecycleFence(target),
497			activateTargetTab:       true,
498			targetTabIndex:          target.TabIndex,
499			preserveAttachment:      true,
500		})
501		d.mu.Lock()
502		return targetSess, transition, err == nil
503	}
504
505	unlock := lockAttachmentSessions(from, targetSess)
506	_, sourceAttached := from.attachments[ac]
507	if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) {
508		unlock()
509		return nil, attachmentTransitionResult{}, false
510	}
511	if guard.expectedSource != nil {
512		view := ac.viewSnapshot()
513		if domain.TabStableID(guard.expectedSource.stableID) != view.tabID {
514			unlock()
515			return nil, attachmentTransitionResult{}, false
516		}
517		guard.expectedSource.mu.Lock()
518		sourceHasFloating := guard.expectedSource.floating.state == floatingVisible
519		guard.expectedSource.mu.Unlock()
520		if sourceHasFloating {
521			unlock()
522			return nil, attachmentTransitionResult{}, false
523		}
524	}
525	unlock()
526
527	// Release d.mu before the gate freeze/drain. transitionAttachment reacquires
528	// d.mu and revalidates both registered lifecycles for publication.
529	d.mu.Unlock()
530	expectedTransport := ac.transportSnapshot()
531	if sourceCapability != nil {
532		expectedTransport = sourceCapability.transport
533	}
534	transition, err := d.transitionAttachment(attachmentTransitionRequest{
535		source: from,
536		target: targetSess,
537		next:   ac,
538
539		expectedTransport:       expectedTransport,
540		sourceCapability:        sourceCapability,
541		sourceEffect:            sourceEffect,
542		action:                  action,
543		expectedTargetLifecycle: pickerTargetLifecycleFence(target),
544		expectedSourceTab:       guard.expectedSource,
545		activateTargetTab:       target.TabIndex >= 0,
546		targetTabIndex:          target.TabIndex,
547		ready:                   true,
548	})
549	d.mu.Lock()
550	if err != nil {
551		return nil, attachmentTransitionResult{}, false
552	}
553	d.touchMRU(targetSess)
554	return targetSess, transition, true
555}
556
557// resumeStoppedAndSwitchLocked creates the stopped representation and commits
558// the handoff while d.mu is held. Creation failure leaves the source client
559// and stopped record untouched.
560func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped inactiveSession, sourceEffect *attachmentEffect, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
561	if stopped.broken() {
562		return nil, attachmentTransitionResult{}, false, &protoErr{protocol.ErrInternal, "session durable state is broken: " + target.Name}
563	}
564	if !stopped.canResume() {
565		return nil, attachmentTransitionResult{}, false, errAttachmentTransition
566	}
567
568	if sourceEffect != nil {
569		sourceCapability := sourceEffect.capability()
570		var targetSess *session
571		d.mu.Unlock()
572		transition, err := d.transitionAttachment(attachmentTransitionRequest{
573			source: from, next: ac,
574			expectedTransport: sourceCapability.transport, sourceCapability: &sourceCapability, sourceEffect: sourceEffect, action: action,
575			expectedSourceTab: guard.expectedSource, ready: true,
576			createTargetLocked: func() (*session, error) {
577				if d.purgeAdmissionClosedLocked() {
578					return nil, errPurgeAdmissionClosed
579				}
580				current, ok := d.inactive[target.Name]
581				if !ok || !current.canResume() || !current.sameLifecycle(stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
582					return nil, errAttachmentTransition
583				}
584				from.mu.Lock()
585				env, provisional := from.environmentSeedLocked()
586				from.mu.Unlock()
587				cwd := d.dirOrHome(current.cwd)
588				created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)
589				if createErr == nil && provisional {
590					markEnvironmentProvisional(created)
591				}
592				targetSess = created
593				return created, createErr
594			},
595		})
596		d.mu.Lock()
597		if err != nil {
598			if targetSess != nil {
599				d.mu.Unlock()
600				_ = d.killSession(targetSess, protocol.ReasonSessionKilled, true)
601				d.mu.Lock()
602			}
603			return targetSess, attachmentTransitionResult{}, false, err
604		}
605		d.touchMRU(targetSess)
606		return targetSess, transition, true, nil
607	}
608
609	from.mu.Lock()
610	_, attached := from.attachments[ac]
611	if !attached {
612		from.mu.Unlock()
613		return nil, attachmentTransitionResult{}, false, nil
614	}
615	env, provisional := from.environmentSeedLocked()
616	from.mu.Unlock()
617	if d.purgeAdmissionClosedLocked() {
618		return nil, attachmentTransitionResult{}, false, errPurgeAdmissionClosed
619	}
620	current, ok := d.inactive[target.Name]
621	if !ok || !current.canResume() || !current.sameLifecycle(stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
622		return nil, attachmentTransitionResult{}, false, errAttachmentTransition
623	}
624	cwd := d.dirOrHome(current.cwd)
625	targetSess, err := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)
626	if err != nil {
627		d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
628		return nil, attachmentTransitionResult{}, false, err
629	}
630	if provisional {
631		markEnvironmentProvisional(targetSess)
632	}
633
634	d.mu.Unlock()
635	transition, err := d.transitionAttachment(attachmentTransitionRequest{
636		source: from, target: targetSess, next: ac,
637
638		expectedTransport: ac.transportSnapshot(), ready: true,
639	})
640	d.mu.Lock()
641	if err != nil {
642		return targetSess, attachmentTransitionResult{}, false, err
643	}
644	d.touchMRU(targetSess)
645	return targetSess, transition, true, nil
646}
647
648func targetMatchesLifecycle(target picker.Target, name string, createdAt int64, incarnation domain.IncarnationID) bool {
649	if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != incarnation {
650		return false
651	}
652	return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
653}
654
655// stealClientForTarget is retained for direct-ID callers and tests. Named
656// targets must use switchToTarget so resolution and commit share d.mu.
657func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) {
658	d.mu.Lock()
659	_, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
660	d.mu.Unlock()
661	if !switched {
662		return
663	}
664	d.deferAttachmentTransitionCleanups(transition)
665}
666
667// resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
668// its stopped target and commits creation under one d.mu critical section.
669func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
670	d.mu.Lock()
671	stopped, ok := d.inactive[target.Name]
672	var (
673		transition attachmentTransitionResult
674		switched   bool
675	)
676	if ok && stopped.canResume() && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
677		_, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
678	}
679	d.mu.Unlock()
680	if !switched {
681		d.invalidateRender(from, ac, true, "picker.go")
682		return false
683	}
684	d.deferAttachmentTransitionCleanups(transition)
685	d.firstPaintForTransition(transition.published)
686	return true
687}
688
689func (d *Daemon) killPickerTargetForAttachment(target picker.Target, effect *attachmentEffect) error {
690	if effect == nil || effect.ac == nil {
691		return d.killPickerTarget(target)
692	}
693	if target.RemoteKey != nil {
694		return nil
695	}
696	capability := effect.capability()
697	if target.Stopped {
698		effect.bindActionEnd(d, "picker-delete")
699		effect.End()
700		frozen := freezeAttachmentEffectGates(effect.ac)
701		defer frozen.unfreeze()
702		if !d.sourceAttachmentCapabilityCurrentFrozen(capability) {
703			return nil
704		}
705		return d.killPickerTarget(target)
706	}
707	d.mu.Lock()
708	targetSess := d.sessions[target.Session]
709	d.mu.Unlock()
710	if targetSess == nil {
711		return nil
712	}
713	return d.killSessionForAttachment(targetSess, protocol.ReasonSessionKilled, true, effect, "picker-delete")
714}
715
716func (d *Daemon) killPickerTarget(target picker.Target) error {
717	if target.Stopped {
718		if err := d.retryStoppedPurgeExact(target.Name, target.Incarnation, target.ExpectedCreatedAt); err != nil {
719			d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
720			return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
721		}
722		return nil
723	}
724	d.mu.Lock()
725	targetSess := d.sessions[target.Session]
726	d.mu.Unlock()
727	if targetSess != nil {
728		return d.killSession(targetSess, protocol.ReasonSessionKilled, true)
729	}
730	return nil
731}

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