v0.8.1 1f54f61

client.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2//
3// Lock ordering: never acquire sendMu or session/daemon locks while holding a
4// pane lock. Coordinator callbacks and timer methods run without coordinator.mu;
5// see the lock-specific comments on attachedClient and renderCoordinator.
6// Attachment lifecycle gates order after daemon, session, routing, coordinator,
7// and pane locks: a daemon or session owner may briefly take one
8// lifecycle.mu to observe committed activity (see
9// sessionInteractivelyAttachedLocked), but must never freeze, drain, or wait on
10// a gate while holding any architecture lock. Freeze/drain always runs with
11// every architecture lock released.
12//
13// Daemon-level gate order is moveLifecycleMu before d.mu: reserveMoveLifecycles
14// holds moveLifecycleMu while taking d.mu to reserve the KillAll purge-gate
15// counter, and releaseMoveAdmissionLocked does the same while releasing it.
16// closeMoveLifecycles deliberately drops moveLifecycleMu before taking d.mu, so
17// no path holds d.mu and then waits on moveLifecycleMu. Consequently
18// moveLifecycleReservation.Release must never be called with d.mu held: it takes
19// moveLifecycleMu first, releases both the move-gate and purge-gate reservations
20// in that order, and only then drops moveLifecycleMu.
21//
22// Snapshot worker locks (snapshotWorker in snapshot_worker_state.go):
23// snapshotWorker.mu (d.snapshots.mu) is a leaf with respect to d.mu and the
24// session (mu), tab, and pane locks: no path acquires any of them while holding
25// it, and the capture, queue, and worker code never takes d.mu. The only nested
26// acquisition is a session's snapshotMu inside it (enqueueFinalSnapshotCapture
27// finishes a replaced capture), so snapshotMu holders must release snapshotMu
28// before taking snapshotWorker.mu, as finishSnapshotCapture does. That nested
29// finishSnapshotCapture(replaced, false) cannot re-enter snapshotWorker.mu:
30// final-queue captures are never normalWorkerAdmitted, and succeeded=false
31// suppresses the forced-successor scheduling path.
32// snapshotWorker.noticeMu guards the active persistence-failure signature; it
33// is a leaf and is never held together with snapshotWorker.mu. Restoration
34// takes d.mu only outside snapshotWorker.mu.
35//
36// paletteHistory.mu guards the daemon-wide recent-command list. It is a leaf:
37// it is only held to copy or rewrite that slice, never while calling out.
38//
39// purgeAllMu serializes whole KillAll purges (purgeAllSessions). It is the
40// outermost daemon-level lock on that path: the owner takes d.mu (briefly, to
41// open and close the purge admission gate) and later per-session teardown locks
42// while holding it. No path acquires purgeAllMu while holding d.mu,
43// moveLifecycleMu, a session lock, or a pane lock. It is not an admission gate;
44// admission is the d.mu-guarded purgeAdmission* state.
45//
46// attnMu guards only the attention animation frame counter (animFrame). It is
47// a leaf: attentionFrame, advanceAttentionFrame, and setAttentionFrame take it
48// for the read or write and call nothing while holding it.
49//
50// Naming rule for the Locked suffix: on a Daemon method (or a free function
51// taking a Daemon-owned structure) a ...Locked suffix means the caller holds
52// d.mu. On a method of any other type, it means the caller holds that
53// receiver's own mutex (session.mu, tab.mu, pane.mu, and so on). Whenever a
54// ...Locked function instead requires a different lock (for example
55// overlayRuntime.copyMu, snapshotWorker.mu, moveLifecycleMu, or
56// barScriptState.mu), its doc comment names that lock explicitly.
57package daemon
58
59import (
60	"errors"
61	"sync"
62	"sync/atomic"
63	"time"
64
65	"github.com/bnema/vev/internal/domain"
66	"github.com/bnema/vev/internal/domain/terminalcap"
67	"github.com/bnema/vev/internal/ports"
68	"github.com/bnema/vev/internal/protocol"
69	"github.com/bnema/vev/internal/usecase/keys"
70	"github.com/bnema/vev/internal/usecase/mouse"
71	themeui "github.com/bnema/vev/internal/usecase/theme"
72)
73
74type transportSnapshot struct {
75	transport   ports.ServerConnection
76	incarnation uint64
77}
78
79// appliedTheme is the single immutable chrome snapshot consumed by rendering.
80// Raw retains terminal defaults for panes; Resolved belongs exclusively to vev
81// chrome. Generation changes whenever either terminal input or policy changes.
82type appliedTheme struct {
83	Raw        themeui.Theme
84	Resolved   themeui.ResolvedTheme
85	Generation uint64
86}
87
88type attachedClient struct {
89	tr                     ports.ServerConnection
90	transportIncarnation   uint64
91	output                 *attachmentOutput
92	overlays               *overlayRuntime
93	overlayOnce            sync.Once
94	clientID               [16]byte
95	terminalCapabilities   terminalcap.Capabilities
96	navigationCapabilities protocol.NavigationCapabilities
97	// lifecycle is the sole authority for attachment capability publication,
98	// effect admission, transition freeze/drain, and connection generation.
99	lifecycle     attachmentLifecycle
100	resumeCapable bool
101	resumeToken   uint64
102	// resumeClaimToken is non-zero only between a parked resume claim and its
103	// successful Welcome. It lets a failed pre-claim handshake restore the old
104	// credential instead of consuming it.
105	resumeClaimToken uint64
106	parked           bool
107	// echoAck is the newest client InputSeq applied at least echoAckDelay ago;
108	// echo owns its updates.
109	echoAck atomic.Uint64
110	echo    echoAckTracker
111	// focus holds the domain.TerminalFocus the client last reported. It is an
112	// atomic so any daemon path reads it without taking a lock; see
113	// terminalFocus.
114	focus atomic.Uint32
115	// clientPID is the local client process from Hello, 0 when remote. It is
116	// atomic because a resume replaces it while bar scripts read it.
117	clientPID atomic.Uint32
118	// clientEnv is the immutable client-owned environment from the latest
119	// Hello, or nil when the daemon owns the environment (remote). A resume
120	// replaces it, so a remote resume of a local attachment clears it. Session
121	// handoffs refresh the target from it. Atomic because a resume replaces it
122	// while handoffs read it.
123	clientEnv atomic.Pointer[[]string]
124	// prepareFailureFallback prevents a direct fallback paint from recursively
125	// reporting the same failed prepare through its notice repaint. It is only
126	// needed while no render coordinator is installed.
127	prepareFailureFallback atomic.Bool
128	// render is the attachment's composition state. Only touched under sendMu.
129	render attachmentRenderState
130	// sizeMu lets shared-geometry snapshots read attachment windows without
131	// waiting behind a blocked transport send.
132	sizeMu   sync.RWMutex
133	geometry domain.Geometry // controlling-terminal geometry for this Attachment
134	keys     *keys.Router
135	// view is attachment-local navigation state. It is never inferred from a
136	// session-wide active tab, so multiple attachments can observe different
137	// tabs and panes without changing shared session ownership.
138	viewMu                     sync.Mutex
139	view                       attachmentView
140	sess                       Guarded[*session]
141	mouseScan                  mouse.Scanner
142	themeMu                    sync.Mutex
143	clientTheme                themeui.Theme
144	appliedTheme               appliedTheme
145	renderStages               renderStageHooks // optional render and handoff observability hooks
146	linkMu                     sync.Mutex
147	sendMu                     sync.Mutex
148	afterFrame                 afterFrameQueue
149	routeMu                    sync.RWMutex
150	routeSnapshot              protocol.RecentRouteSnapshot
151	pendingRouteIdentity       bool
152	samePeerOfferMu            sync.Mutex
153	samePeerOffer              *protocol.ExactSessionTarget
154	routeAttentionSubscription protocol.RouteAttentionSubscription
155	routeSubscriptionTransport transportSnapshot
156	routeObservationAfter      map[protocol.RouteAttentionTarget]time.Time
157	// routeCreatedSession marks a session created by this attachment's route.
158	// A handshake that never commits Welcome must tear down that exact empty
159	// session, while an attachment routed to an existing session must not.
160	routeCreatedSession bool
161	// routeSessionPurge distinguishes a brand-new session from a restored
162	// stopped session when failed-handshake cleanup removes the live instance.
163	routeSessionPurge bool
164	// beforeAttachmentCapabilityValidation is a deterministic lifecycle-race Seam
165	// used only by package tests.
166	beforeAttachmentCapabilityValidation func()
167}
168
169type cursorOut struct {
170	valid    bool
171	hidden   bool
172	row      int
173	col      int
174	style    int
175	hasStyle bool
176}
177
178// pendingByteTimer is retained for overlay input timers. Resize timing belongs
179// exclusively to renderCoordinator.
180type pendingByteTimer struct {
181	timer ports.Timer
182	done  chan struct{}
183}
184
185func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
186	p.timer = clock.NewTimer(delay)
187	p.done = make(chan struct{})
188	go func(timer ports.Timer, done <-chan struct{}) {
189		select {
190		case <-timer.C():
191		case <-done:
192			return
193		}
194		onFire(timer)
195	}(p.timer, p.done)
196}
197
198func (p *pendingByteTimer) stop() {
199	if p.timer != nil {
200		p.timer.Stop()
201		p.timer = nil
202	}
203	if p.done != nil {
204		close(p.done)
205		p.done = nil
206	}
207}
208
209func (ac *attachedClient) initOverlays() {
210	ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
211}
212
213func (ac *attachedClient) sizeSnapshot() domain.Size {
214	if ac == nil {
215		return domain.Size{}
216	}
217	ac.sizeMu.RLock()
218	defer ac.sizeMu.RUnlock()
219	return ac.geometry.Size
220}
221
222func (ac *attachedClient) setSize(size domain.Size) {
223	if ac == nil {
224		return
225	}
226	ac.sizeMu.Lock()
227	ac.geometry.Size = size
228	ac.geometry = ac.geometry.NormalizePixels()
229	ac.sizeMu.Unlock()
230}
231
232func (ac *attachedClient) geometrySnapshot() domain.Geometry {
233	if ac == nil {
234		return domain.Geometry{}
235	}
236	ac.sizeMu.RLock()
237	defer ac.sizeMu.RUnlock()
238	if ac.geometry.Valid() {
239		return ac.geometry
240	}
241	// Pixels only describe a valid cell size; never hand them out without one.
242	return domain.Geometry{Size: ac.geometry.Size}
243}
244
245func (ac *attachedClient) setGeometry(geometry domain.Geometry) {
246	if ac == nil {
247		return
248	}
249	geometry = geometry.NormalizePixels()
250	ac.sizeMu.Lock()
251	ac.geometry = geometry
252	ac.sizeMu.Unlock()
253}
254
255// currentSession returns the attachment's owning local session.
256func (ac *attachedClient) currentSession() *session {
257	if ac == nil {
258		return nil
259	}
260	return ac.sess.Get()
261}
262
263// currentAttachmentSession is the explicit ownership-boundary alias used by
264// transition and rendering paths.
265func (ac *attachedClient) currentAttachmentSession() *session {
266	return ac.currentSession()
267}
268
269func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
270
271// pruneCaptureFrames releases snapshots for panes that have left their owner.
272// Callers must not hold daemon, session, tab, or pane locks.
273func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {
274	if ac == nil || len(panes) == 0 {
275		return
276	}
277	ac.sendMu.Lock()
278	ac.render.forgetPanes(panes...)
279	ac.sendMu.Unlock()
280}
281
282// clearCaptureFrames releases all pane snapshots when the attachment no longer
283// owns a session.
284func (ac *attachedClient) clearCaptureFrames() {
285	if ac == nil {
286		return
287	}
288	ac.sendMu.Lock()
289	ac.render.forgetAllPanes()
290	ac.sendMu.Unlock()
291}
292
293func (ac *attachedClient) getAppliedTheme() appliedTheme {
294	ac.themeMu.Lock()
295	defer ac.themeMu.Unlock()
296	applied := ac.appliedTheme
297	if applied.Generation == 0 {
298		// An unattached client has no terminal report yet. Reuse the static
299		// neutral cache rather than resolving from a render path.
300		applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackStylesFor(ac.terminalCapabilities.Color)}
301	}
302	return applied
303}
304
305func (ac *attachedClient) getClientTheme() themeui.Theme {
306	ac.themeMu.Lock()
307	defer ac.themeMu.Unlock()
308	return ac.clientTheme
309}
310
311func (ac *attachedClient) setAppliedTheme(next appliedTheme) {
312	ac.themeMu.Lock()
313	next.Generation = ac.appliedTheme.Generation + 1
314	ac.appliedTheme = next
315	ac.themeMu.Unlock()
316}
317
318func (ac *attachedClient) setClientTheme(t themeui.Theme) {
319	ac.themeMu.Lock()
320	ac.clientTheme = t
321	ac.themeMu.Unlock()
322}
323
324func (ac *attachedClient) transport() ports.ServerConnection {
325	ac.linkMu.Lock()
326	defer ac.linkMu.Unlock()
327	return ac.tr
328}
329
330func (ac *attachedClient) replaceTransport(tr ports.ServerConnection) {
331	ac.linkMu.Lock()
332	ac.tr = tr
333	ac.transportIncarnation++
334	ac.linkMu.Unlock()
335}
336
337// revokeTransport removes tr only if it is still this attachment's current
338// link. It returns the revoked transport, so asynchronous retirement can
339// close exactly that link without touching a later connection incarnation.
340func (ac *attachedClient) revokeTransport(tr ports.ServerConnection) ports.ServerConnection {
341	if tr == nil {
342		return nil
343	}
344	ac.linkMu.Lock()
345	if ac.tr != tr {
346		ac.linkMu.Unlock()
347		return nil
348	}
349	ac.tr = nil
350	ac.transportIncarnation++
351	ac.linkMu.Unlock()
352	return tr
353}
354
355// transportSnapshot binds a send to one concrete link lifetime. Callers must
356// revalidate it after acquiring sendMu before writing to that link.
357func (ac *attachedClient) transportSnapshot() transportSnapshot {
358	ac.linkMu.Lock()
359	defer ac.linkMu.Unlock()
360	return transportSnapshot{transport: ac.tr, incarnation: ac.transportIncarnation}
361}
362
363func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) bool {
364	ac.linkMu.Lock()
365	defer ac.linkMu.Unlock()
366	return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation
367}
368
369func (ac *attachedClient) closeCapturedTransport(tr ports.ServerConnection) error {
370	if tr == nil {
371		return nil
372	}
373	return tr.Close()
374}
375
376func (ac *attachedClient) transportIs(tr ports.ServerConnection) bool {
377	ac.linkMu.Lock()
378	defer ac.linkMu.Unlock()
379	return ac.tr == tr
380}
381
382func (ac *attachedClient) currentTransportIs(tr ports.ServerConnection) bool {
383	return tr != nil && ac.transportIs(tr)
384}
385
386// setRouteSnapshot records a client-owned route view and reports whether a
387// rename identity was deferred until that first usable publication.
388func (ac *attachedClient) setRouteSnapshot(snapshot protocol.RecentRouteSnapshot) bool {
389	snapshot.Entries = append([]protocol.RecentRouteEntry(nil), snapshot.Entries...)
390	ac.routeMu.Lock()
391	ac.routeSnapshot = snapshot
392	replayPendingIdentity := snapshot.Generation != 0 && ac.pendingRouteIdentity
393	if replayPendingIdentity {
394		ac.pendingRouteIdentity = false
395	}
396	ac.routeMu.Unlock()
397	return replayPendingIdentity
398}
399
400// deferRouteIdentityUntilSnapshot records a rename that happens before the
401// client has published its route ledger. It returns false once a snapshot is
402// already available and the identity may be sent immediately.
403func (ac *attachedClient) deferRouteIdentityUntilSnapshot() bool {
404	ac.routeMu.Lock()
405	defer ac.routeMu.Unlock()
406	if ac.routeSnapshot.Generation != 0 {
407		return false
408	}
409	ac.pendingRouteIdentity = true
410	return true
411}
412
413func (ac *attachedClient) routeSnapshotCopy() protocol.RecentRouteSnapshot {
414	ac.routeMu.RLock()
415	defer ac.routeMu.RUnlock()
416	snapshot := ac.routeSnapshot
417	snapshot.Entries = append([]protocol.RecentRouteEntry(nil), snapshot.Entries...)
418	return snapshot
419}
420
421func (ac *attachedClient) setRouteAttentionSubscription(subscription protocol.RouteAttentionSubscription, expected transportSnapshot, now time.Time) {
422	subscription.Targets = append([]protocol.RouteAttentionTarget(nil), subscription.Targets...)
423	ac.routeMu.Lock()
424	defer ac.routeMu.Unlock()
425	after := make(map[protocol.RouteAttentionTarget]time.Time, len(subscription.Targets))
426	for _, target := range subscription.Targets {
427		admitted := now
428		// Preserve the causal observation fence across unchanged republishes;
429		// a new connection must establish a fresh post-Welcome subscription.
430		if prior, ok := ac.routeObservationAfter[target]; ok && ac.routeSubscriptionTransport == expected {
431			admitted = prior
432		}
433		after[target] = admitted
434	}
435	ac.routeObservationAfter = after
436	ac.routeSubscriptionTransport = expected
437	ac.routeAttentionSubscription = subscription
438}
439
440func (ac *attachedClient) routeAttentionTarget(ref protocol.RouteRef) (protocol.RouteAttentionTarget, bool) {
441	ac.routeMu.RLock()
442	defer ac.routeMu.RUnlock()
443	for _, target := range ac.routeAttentionSubscription.Targets {
444		if target.Ref == ref {
445			return target, true
446		}
447	}
448	return protocol.RouteAttentionTarget{}, false
449}
450
451// ackOutputState applies one cumulative output ACK under the send boundary and
452// reports whether it advanced the paint window.
453func (ac *attachedClient) ackOutputState(epoch, state uint64) bool {
454	ac.sendMu.Lock()
455	defer ac.sendMu.Unlock()
456	return ac.output != nil && ac.output.ack(epoch, state)
457}
458
459// rebaseOutput resets only this attachment's output representations. Callers
460// hold sendMu (or the activation barrier).
461func (ac *attachedClient) rebaseOutput() {
462	if ac == nil || ac.output == nil {
463		return
464	}
465	ac.output.rebaseAttachment()
466}
467
468// restartOutputLocked starts a new output epoch and drops the composition it
469// would otherwise diff against, so the next paint is a complete frame.
470// dropPanes also drops per-pane capture snapshots, which is required when the
471// panes may belong to another session or a different window size. Caller
472// holds sendMu.
473func (ac *attachedClient) restartOutputLocked(dropPanes bool) {
474	ac.rebaseOutput()
475	ac.render.forgetComposition()
476	if dropPanes {
477		ac.render.forgetAllPanes()
478	}
479}
480
481// publishWindowLocked publishes a new window height to the attachment view and
482// restarts output for it. Caller holds sendMu.
483func (ac *attachedClient) publishWindowLocked(rows int, dropPanes bool) {
484	view := ac.viewSnapshot()
485	view.windowRows = rows
486	view.windowSet = true
487	view.windowTop = 0
488	view.revision++
489	ac.publishView(view)
490	ac.restartOutputLocked(dropPanes)
491}
492
493var errTransportReplaced = errors.New("client transport was replaced")
494
495// sendExpectedTransport writes only when expected is still the attachment's
496// current transport incarnation. It preserves sendMu -> linkMu lock ordering.
497func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, message protocol.ServerMessage) error {
498	ac.sendMu.Lock()
499	defer ac.sendMu.Unlock()
500	if !ac.transportSnapshotCurrent(expected) {
501		return errTransportReplaced
502	}
503	if expected.transport == nil {
504		return errors.New("client transport is nil")
505	}
506	return expected.transport.SendServer(message)
507}
508
509// beginExpectedTransportSendLocked validates that expected is still the
510// attachment's current transport incarnation and admits ticket's interruptible
511// transport send. The caller must already hold ac.sendMu and, on success, owns
512// both the send and its matching ticket.endTransportSend.
513func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *attachmentEffect) error {
514	if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
515		return errTransportReplaced
516	}
517	if ticket != nil && (ticket.ended.Load() || !ticket.beginTransportSend(expected)) {
518		return errAttachmentTransition
519	}
520	return nil
521}
522
523func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportSnapshot, message protocol.ServerMessage, ticket *attachmentEffect) error {
524	ac.sendMu.Lock()
525	defer ac.sendMu.Unlock()
526	// A attachment-bound send requires a live ticket, and reports every rejection,
527	// including a replaced transport, as a lost attachment transition.
528	if ticket == nil {
529		return errAttachmentTransition
530	}
531	if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {
532		return errAttachmentTransition
533	}
534	err := expected.transport.SendServer(message)
535	if err != nil {
536		ticket.reportTransportFailure(expected)
537	}
538	ticket.endTransportSend()
539	return err
540}
541
542// boundedSend sends f to ac with a deadline watchdog: if the send (including
543// waiting on sendMu behind a wedged paint) does not complete within
544// detachNotifyTimeout, the transport is force-closed, failing the in-flight
545// write. Detach/kill/shutdown paths use this so they are never gated on a
546// client that has stopped draining its socket.
547func (d *Daemon) boundedSend(ac *attachedClient, message protocol.ServerMessage) {
548	_ = d.boundedSendErr(ac, message)
549}
550
551func (d *Daemon) boundedSendErr(ac *attachedClient, message protocol.ServerMessage) error {
552	expected := ac.transportSnapshot()
553	if expected.transport == nil {
554		return errors.New("client transport is nil")
555	}
556	tr, err := d.boundedSendWith(expected.transport, func() error {
557		return ac.sendExpectedTransport(expected, message)
558	})
559	if err != nil && errors.Is(err, errSendTimedOut) {
560		_ = ac.closeCapturedTransport(tr)
561	}
562	return err
563}
564
565func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
566	_, err := d.boundedSendOutputErrTransport(ac, b)
567	return err
568}
569
570func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.ServerConnection, error) {
571	expected := ac.transportSnapshot()
572	if expected.transport == nil {
573		return nil, errors.New("client transport is nil")
574	}
575	if expected.transport.Capabilities().OwnedSynchronousSend {
576		ac.sendMu.Lock()
577		defer ac.sendMu.Unlock()
578		if !ac.transportSnapshotCurrent(expected) {
579			return expected.transport, errTransportReplaced
580		}
581		ac.output.lockView()
582		defer ac.output.unlockView()
583		output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
584		if err == nil {
585			err = expected.transport.SendOutputSynchronous(output)
586		}
587		if err == nil {
588			ac.output.sideEffectSentLocked(output)
589		}
590		return expected.transport, err
591	}
592	return d.boundedSendWith(expected.transport, func() error {
593		ac.sendMu.Lock()
594		defer ac.sendMu.Unlock()
595		if !ac.transportSnapshotCurrent(expected) {
596			return errTransportReplaced
597		}
598		ac.output.lockView()
599		defer ac.output.unlockView()
600		output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
601		if err != nil {
602			return err
603		}
604		if err := expected.transport.SendOutput(output); err != nil {
605			return err
606		}
607		ac.output.sideEffectSentLocked(output)
608		return nil
609	})
610}
611
612var errSendTimedOut = errors.New("send timed out")
613
614func (d *Daemon) boundedSendWith(tr ports.ServerConnection, send func() error) (ports.ServerConnection, error) {
615	return d.boundedSendWithTimeout(detachNotifyTimeout, tr, send)
616}
617
618func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.ServerConnection, send func() error) (ports.ServerConnection, error) {
619	timer := d.clock.NewTimer(timeout)
620	result := make(chan error, 1)
621	go func() {
622		result <- send()
623	}()
624	select {
625	case err := <-result:
626		timer.Stop()
627		return tr, err
628	case <-timer.C():
629		select {
630		case err := <-result:
631			return tr, err
632		default:
633		}
634		d.log.Warn("bounded send timed out; force closing client transport")
635		return tr, errSendTimedOut
636	}
637}
638
639type detachedAttachmentSnapshot struct {
640	ac        *attachedClient
641	transport transportSnapshot
642}
643
644// notifyDetachedSnapshotAsync sends and closes only the transport incarnation
645// captured by atomic session teardown. It cannot touch a later link installed
646// on the same attachedClient object.
647func (d *Daemon) notifyDetachedSnapshotAsync(snapshot detachedAttachmentSnapshot, reason uint8) {
648	if snapshot.ac == nil || snapshot.transport.transport == nil {
649		return
650	}
651	done := make(chan struct{})
652	d.mu.Lock()
653	// Prune completed entries so the slice stays bounded by the number of
654	// notifications actually in flight.
655	kept := d.notifies[:0]
656	for _, c := range d.notifies {
657		select {
658		case <-c:
659		default:
660			kept = append(kept, c)
661		}
662	}
663	d.notifies = append(kept, done)
664	d.mu.Unlock()
665
666	go func() {
667		defer close(done)
668		_, _ = d.boundedSendWith(snapshot.transport.transport, func() error {
669			return snapshot.ac.sendExpectedTransport(snapshot.transport, serverDetached(reason))
670		})
671		revoked := snapshot.ac.revokeTransport(snapshot.transport.transport)
672		if revoked == nil {
673			revoked = snapshot.transport.transport
674		}
675		_ = snapshot.ac.closeCapturedTransport(revoked)
676		d.log.Info("client detached", "reason", reason)
677	}()
678}
679
680// waitNotifies blocks until every Detached notification in flight at the time
681// of the call has completed. Each is deadline-bounded (boundedSend), so this
682// wait is bounded too.
683func (d *Daemon) waitNotifies() {
684	for _, c := range d.notifiesSnapshot() {
685		<-c
686	}
687}
688
689func (d *Daemon) notifiesSnapshot() []chan struct{} {
690	d.mu.Lock()
691	defer d.mu.Unlock()
692	snapshot := make([]chan struct{}, len(d.notifies))
693	copy(snapshot, d.notifies)
694	return snapshot
695}
696
697type attachClientOptions struct {
698	clientID               [16]byte
699	kittyKeyboard          bool
700	resumeCapable          bool
701	maxOutputInFlight      uint8
702	terminalCapabilities   terminalcap.Capabilities
703	capabilitiesSet        bool
704	navigationCapabilities protocol.NavigationCapabilities
705	terminalFocus          domain.TerminalFocus
706	clientPID              uint32
707	clientEnv              []string
708}
709
710func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
711	d.mu.Lock()
712	ac := d.prepareAttachedClientLocked(sess, tr, domain.Geometry{Size: sz}, opts)
713	d.mu.Unlock()
714	result, err := d.transitionAttachment(attachmentTransitionRequest{
715		target: sess,
716		next:   ac,
717
718		expectedTransport: ac.transportSnapshot(),
719		ready:             false,
720	})
721	if err != nil {
722		d.discardAttachmentOutput(ac)
723		if tr != nil {
724			_ = tr.Close()
725		}
726		return nil, err
727	}
728	d.finishAttachedClient(sess, ac, opts)
729	d.deferAttachmentTransitionCleanups(result)
730	return ac, nil
731}
732
733// colorDowngradeNotice reports the toast shown when a declared (not forced)
734// terminal renders with fewer than TrueColor colors. A forced mode is a user
735// choice and never warrants a notice.
736func colorDowngradeNotice(color terminalcap.ColorCapabilities) (string, bool) {
737	if color.Source != terminalcap.SourceDeclared {
738		return "", false
739	}
740	switch color.Mode {
741	case terminalcap.ANSI256:
742		return "Terminal supports 256 colors; vev UI colors are reduced.", true
743	case terminalcap.ANSI16:
744		return "Terminal supports 16 colors; vev UI colors are reduced.", true
745	case terminalcap.Monochrome:
746		return "Terminal has no color support; vev UI uses bold and reverse.", true
747	default:
748		return "", false
749	}
750}
751
752// publishColorDowngradeNotice toasts ac when its terminal declared a reduced
753// color mode (see colorDowngradeNotice).
754func (d *Daemon) publishColorDowngradeNotice(sess *session, ac *attachedClient) {
755	message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color)
756	if !ok {
757		return
758	}
759	d.publishToast(ac, domain.Notification{
760		Code:      domain.NoticeUser,
761		Severity:  domain.NoticeWarn,
762		Message:   message,
763		Time:      d.clock.Now(),
764		Count:     1,
765		SessionID: sess.id,
766	})
767}
768
769func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
770	d.touchMRU(sess)
771	d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
772	d.publishColorDowngradeNotice(sess, ac)
773	d.applyHostTheme(sess, ac, themeui.Theme{}, true)
774	if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) {
775		d.warnUnsupportedGraphics(ac)
776	}
777}
778
779// prepareAttachedClientLocked allocates one detached attachment. Caller holds
780// d.mu only to allocate its resume token; attachment publication happens later after
781// the caller releases every architecture lock.
782func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConnection, geometry domain.Geometry, opts attachClientOptions) *attachedClient {
783	resumeToken := uint64(0)
784	if opts.resumeCapable {
785		resumeToken = d.nextResumeTokenLocked()
786	}
787	if !opts.capabilitiesSet {
788		// The zero value is TrueColor: manually constructed attachments keep
789		// the historical renderer behavior.
790		opts.terminalCapabilities = terminalcap.Capabilities{}
791	}
792	output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
793	geometry = geometry.NormalizePixels()
794	var graphicsOutput *graphicsOutputState
795	if opts.terminalCapabilities.SupportsKittyGraphics() {
796		if namespace, fence := d.reserveGraphicsNamespaceLeaseLocked(graphicsNamespaceKey(sess, opts.clientID)); namespace != 0 {
797			graphicsOutput = newGraphicsOutputStateWithLease(namespace, fence)
798		} else {
799			// Namespace leases that may have emitted are never reused without a
800			// terminal ACK. The bounded pool therefore fails closed to the normal
801			// text renderer instead of risking another attachment's objects.
802			opts.terminalCapabilities.KittyGraphics = false
803		}
804	}
805	output.graphicsOutput = graphicsOutput
806	ac := &attachedClient{
807		tr:                     tr,
808		output:                 output,
809		geometry:               geometry,
810		view:                   attachmentView{windowRows: geometry.Rows, windowSet: true},
811		clientID:               opts.clientID,
812		terminalCapabilities:   opts.terminalCapabilities,
813		navigationCapabilities: opts.navigationCapabilities,
814		resumeCapable:          opts.resumeCapable,
815		resumeToken:            resumeToken,
816	}
817	ac.setTerminalFocus(opts.terminalFocus)
818	ac.clientPID.Store(opts.clientPID)
819	ac.setClientEnvironment(opts.clientEnv)
820	output.attachment = ac
821	ac.initOverlays()
822	ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
823	ac.keys.SetKittyKeyboard(opts.kittyKeyboard)
824	return ac
825}
826
827// attachCoordinator installs the coordinator lease for an attachment. Session
828// membership remains independent, so installing one attachment never displaces
829// another.
830func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
831	rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
832	cleanup.finish()
833	return rc
834}
835
836func (d *Daemon) attachCoordinatorDeferred(sess *session, _ *attachedClient, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
837	rc := d.ensureRenderCoordinator(sess)
838	if current != nil {
839		rc.attachWithReadiness(current, ready)
840	}
841	return rc, renderLifecycleCleanup{}
842}
843
844// ensureRenderCoordinator publishes the session's single coordinator without
845// binding an attachment lease. Centralized attachment transitions bind the
846// lease later while their ordered session locks are still held.
847func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator {
848	sess.mu.Lock()
849	defer sess.mu.Unlock()
850	return d.ensureRenderCoordinatorPrelocked(sess)
851}
852
853// ensureRenderCoordinatorPrelocked is the non-reentrant coordinator setup seam
854// for transactions that already hold sess.mu.
855func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordinator {
856	return d.ensureAttachmentRenderCoordinatorPrelocked(sess)
857}
858
859// ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
860// exact session. The caller holds entry.core().mu.
861func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *renderCoordinator {
862	if rc := attachmentRenderCoordinator(entry); rc != nil {
863		return rc
864	}
865	var rc *renderCoordinator
866	rc = newRenderCoordinator(renderCoordinatorOptions{
867		clock:    d.clock,
868		observer: d.runtimeObserver,
869		wake: func(w renderWake) {
870			if !rc.wakeCurrent(w) {
871				return
872			}
873			// The session snapshot is deterministic. Each paint revalidates the
874			// attachment's membership and transport fence after that snapshot, so
875			// a detached peer cannot stop remaining attachments receiving output.
876			attachments := entry.snapshotAttachments()
877			for _, attachment := range attachments {
878				if !rc.wakeCurrent(w) {
879					return
880				}
881				lease := w.attachmentLeases[attachment]
882				if lease == nil || !rc.leaseCurrent(lease, true) {
883					continue
884				}
885				// Pane damage is session-shared; every fan-out attachment needs a
886				// fresh capture because any peer may acknowledge the damage first.
887				// Each attachment owns its send lock, so a blocked peer cannot stall
888				// this fan-out or the PTY reader. A handshake-targeted wake is the
889				// one synchronous exception: its first paint must finish before the
890				// handshake deadline is released.
891				fanoutReset := w.reset || len(attachments) > 1
892				paint := func() {
893					if d.paint(entry, attachment, fanoutReset, lease) == paintBlockedCapacity {
894						rc.retryCapacity(w, lease)
895					}
896				}
897				if w.lease != nil && w.lease.attachment == attachment {
898					paint()
899				} else if len(attachments) > 1 {
900					go paint()
901				} else {
902					paint()
903				}
904			}
905		},
906		ackReadyFor: func(attached *attachedClient) bool {
907			// attachmentOutput publishes capacity atomically. Do not take
908			// attached.sendMu here: a slow transport may be holding it for an
909			// in-flight Send, and that peer must not gate healthy attachments.
910			return attached == nil || attached.output == nil || !attached.output.atCapacity()
911		},
912	})
913	installAttachmentRenderCoordinator(entry, rc)
914	return rc
915}
916
917// resetScreenDefaultColors clears the known default foreground/background
918// colors on every tab in sess. Called once a client has been detached, this
919// makes child OSC 10/11 queries go back to being swallowed (Known=false)
920// instead of being answered with the departed client's colors, which the
921// next client (with a different terminal theme) may never have reported.
922// Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
923// release it, then take each tb.mu in turn — never holding sess.mu and
924// tb.mu together. Guarded against a race with a newer attachment: if the
925// session has gained an attachment by the time sess.mu is taken, that
926// attachment has already run its attach-time reset, so this call must leave
927// the tabs alone rather than clobbering freshly applied colors.
928func (d *Daemon) resetScreenDefaultColors(sess *session) {
929	d.applyHostTheme(sess, nil, themeui.Theme{}, true)
930}
931
932// firstPaintForTransition is the only active post-transition paint entry
933// point. It rejects a stale attachment capability before rebasing output and carries
934// the exact coordinator lease through every resize and render effect.
935func (d *Daemon) firstPaintForTransition(capability attachmentCapability) bool {
936	if !capability.current() {
937		return false
938	}
939	effect, admitted := capability.ac.beginAttachmentEffect(capability)
940	if !admitted {
941		return false
942	}
943	defer effect.End()
944	if d.afterAttachmentEffectAdmitted != nil {
945		d.afterAttachmentEffectAdmitted(effect.capability())
946	}
947	if effect.rebase {
948		if d.beforeFirstPaintSendWait != nil {
949			d.beforeFirstPaintSendWait(effect.capability())
950		}
951		effect.ac.sendMu.Lock()
952		if effect.ac.renderStages.handoffRebase != nil {
953			effect.ac.renderStages.handoffRebase()
954		}
955		effect.ac.rebaseOutput()
956		// Captures belong to the old session even when pane-local IDs happen
957		// to be reused by the destination.
958		effect.ac.render.forgetAllPanes()
959		effect.ac.sendMu.Unlock()
960	}
961	if effect.sess == nil {
962		return false
963	}
964	d.firstPaintWithLease(effect.sess, effect.ac, effect.lease, true)
965	return true
966}
967
968// firstPaint guarantees the freshly attached client sees the full screen. The
969// shared session geometry is reconciled to the latest attachment claim before
970// each attachment gets its own outer-window paint.
971func (d *Daemon) firstPaint(sess *session, ac *attachedClient) {
972	d.firstPaintWithLease(sess, ac, nil, false)
973}
974
975func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, lease *attachmentLease, asyncGeometry bool) {
976	// Global notices raised while nothing was attached surface on this client.
977	// Drained before the early return below so a session without an active tab
978	// cannot swallow the queue.
979	d.drainPendingForFirstPaint(sess, ac)
980	tb := sess.tabForAttachment(ac)
981	if tb == nil {
982		return
983	}
984	if asyncGeometry {
985		// PTY.Resize is an external, non-cancelable operation. Do not hold the
986		// handshake on it; the first frame can use retained geometry and the
987		// shared invalidation will repaint every attachment after the claim wins.
988		sess.geometry.reconcileAndInvalidateAsync(d, sess, "client.go")
989	} else {
990		sess.geometry.reconcileAndInvalidate(d, sess, nil, "client.go")
991	}
992	d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
993	// Floating activation may perform its own pane-local warmup after the shared
994	// session geometry has been reconciled above.
995	d.ensureFloatingWarm(sess, tb)
996	if lease == nil {
997		d.invalidateRenderNow(sess, ac, true, "client.go")
998	} else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
999		rc.fireCurrentForLease(lease)
1000	}
1001}
1002
1003// detachIfCurrent clears the client iff ac is the current one, reporting
1004
1005func themeFromMessage(msg protocol.Theme) themeui.Theme {
1006	return themeui.Theme{
1007		Foreground:   msg.Foreground,
1008		Background:   msg.Background,
1009		Palette:      msg.Palette,
1010		PaletteKnown: msg.PaletteKnown,
1011		HasFG:        msg.HasForeground,
1012		HasBG:        msg.HasBackground,
1013		Known:        msg.HasForeground && msg.HasBackground,
1014		SchemeKnown:  msg.SchemeKnown,
1015		Light:        msg.Light,
1016	}
1017}
1018
1019func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg protocol.Theme) {
1020	clientTheme := themeFromMessage(msg)
1021	ac.setClientTheme(clientTheme)
1022
1023	if !d.applyHostTheme(sess, ac, clientTheme, false) {
1024		return
1025	}
1026	d.invalidateRender(sess, ac, true, "client.go")
1027}
1028
1029func (d *Daemon) applyThemeForAttachment(effect *attachmentEffect, msg protocol.Theme) {
1030	if !effect.current() {
1031		return
1032	}
1033	clientTheme := themeFromMessage(msg)
1034	effect.ac.setClientTheme(clientTheme)
1035	sess := effect.sess
1036	if sess == nil || !effect.current() || !d.applyHostTheme(sess, effect.ac, clientTheme, false) {
1037		return
1038	}
1039	if !effect.current() {
1040		return
1041	}
1042	rc := sess.renderCoordinator()
1043	if effect.lease == nil || rc == nil {
1044		d.invalidateRender(sess, effect.ac, true, "client.go")
1045		return
1046	}
1047	rc.invalidateForLease(effect.ac, effect.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
1048}
1049
1050func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
1051	sess.geometry.requestResize(d, sess, ac, sz, false)
1052}
1053
1054// resizeAttachmentForLease updates the sender's window/view, then makes that
1055// attachment the latest shared tab/PTY geometry claimant. Callers hold the
1056// attachment effect admission; output state is rebased before any redraw so
1057// every subsequent frame starts a new epoch.
1058func (d *Daemon) resizeAttachmentForLease(effect *attachmentEffect, size domain.Size) bool {
1059	geometry := domain.Geometry{Size: size}
1060	if effect != nil && effect.ac != nil {
1061		// Direct size-only callers do not have a fresh pixel pair. Preserve the
1062		// attachment's last complete pair instead of downgrading its claim to
1063		// cell-only geometry.
1064		geometry = effect.ac.geometrySnapshot()
1065		geometry.Size = size
1066	}
1067	return d.resizeAttachmentGeometryForLease(effect, geometry)
1068}
1069
1070func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geometry domain.Geometry) bool {
1071	geometry = geometry.NormalizePixels()
1072	if !effect.current() || effect.ac == nil || !geometry.Valid() {
1073		return false
1074	}
1075	ac := effect.ac
1076	ac.sendMu.Lock()
1077	if !effect.current() || !ac.transportSnapshotCurrent(effect.transport) {
1078		ac.sendMu.Unlock()
1079		return false
1080	}
1081	previous := ac.geometrySnapshot()
1082	sameSize := previous.Size == geometry.Size
1083	sameGeometry := previous == geometry
1084	if !sameGeometry {
1085		ac.setGeometry(geometry)
1086	}
1087	if !sameSize {
1088		ac.publishWindowLocked(geometry.Rows, false)
1089	}
1090	ac.sendMu.Unlock()
1091
1092	if !sameSize && ac.overlays != nil && ac.overlays.pickerClientActive() {
1093		// A client-owned interaction renders from its own size: a resize
1094		// bumps the snapshot revision so the client never repaints against a
1095		// stale size epoch.
1096		d.refreshPickerSnapshot(ac)
1097	}
1098
1099	sess := effect.sess
1100	if sess == nil {
1101		return false
1102	}
1103	// Shared PTY/layout geometry follows the latest accepted attachment claim.
1104	// When this resize changes canonical geometry, the shared invalidation fans
1105	// out a fresh frame to every attachment; otherwise only this attachment
1106	// needs a viewport redraw.
1107	if sess.geometry.reconcileAndInvalidate(d, sess, ac, "client.go") {
1108		return true
1109	}
1110	if sameGeometry {
1111		return false
1112	}
1113	rc := sess.renderCoordinator()
1114	if rc == nil {
1115		return false
1116	}
1117	// Rendering is attachment-scoped. Schedule it through the coordinator so
1118	// rapid resize frames coalesce under the attachment lease and deadline.
1119	return rc.scheduleResizeForLease(geometry.Size, ac, effect.lease, func(uint64) {
1120		go d.paint(sess, ac, true, effect.lease)
1121	}) != 0
1122}
1123
1124// handleClientNotice maps the closed client-event enum to daemon-owned notice
1125// content. routingMu makes ownership validation and toast mutation one atomic
1126// attachment-routing operation: attachment publication also takes routingMu
1127// before changing membership. Never retain sess.mu while touching notice or overlay
1128// state, and retain the routingMu -> sess.mu order used by attachment paths.
1129func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice protocol.ClientNotice) {
1130	if sess == nil || ac == nil {
1131		return
1132	}
1133	tr := ac.transport()
1134	token := sess.captureAttachmentCapability(ac, tr)
1135	if token.lease == nil {
1136		// Direct test/headless callers retain pointer-based routing. Production
1137		// client frames always carry the exact lease through the attachment path below.
1138		d.handleClientNoticeDirect(sess, ac, notice)
1139		return
1140	}
1141	effect, admitted := ac.beginAttachmentEffect(token)
1142	if !admitted {
1143		return
1144	}
1145	defer effect.End()
1146	d.handleClientNoticeForAttachment(effect, notice)
1147}
1148
1149func (d *Daemon) handleClientNoticeForAttachment(effect *attachmentEffect, notice protocol.ClientNotice) {
1150	d.notices.routingMu.Lock()
1151	if !effect.current() {
1152		d.notices.routingMu.Unlock()
1153		return
1154	}
1155	if d.notices.beforeClientNoticeMutation != nil {
1156		d.notices.beforeClientNoticeMutation()
1157	}
1158	if !effect.current() {
1159		d.notices.routingMu.Unlock()
1160		return
1161	}
1162	if effect.sess == nil {
1163		d.notices.routingMu.Unlock()
1164		return
1165	}
1166	repaint := d.mutateClientNotice(effect.sess, effect.ac, notice)
1167	d.notices.routingMu.Unlock()
1168	if repaint && effect.current() {
1169		d.repaintForNotice(effect.ac)
1170	}
1171}
1172
1173func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice protocol.ClientNotice) {
1174	d.notices.routingMu.Lock()
1175	sess.mu.Lock()
1176	_, current := sess.attachments[ac]
1177	sess.mu.Unlock()
1178	if !current {
1179		d.notices.routingMu.Unlock()
1180		return
1181	}
1182	if d.notices.beforeClientNoticeMutation != nil {
1183		d.notices.beforeClientNoticeMutation()
1184	}
1185	repaint := d.mutateClientNotice(sess, ac, notice)
1186	d.notices.routingMu.Unlock()
1187	if repaint {
1188		d.repaintForNotice(ac)
1189	}
1190}
1191
1192func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice protocol.ClientNotice) bool {
1193	switch notice.Action {
1194	case protocol.ClientNoticeClipboardFallback:
1195		return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
1196	case protocol.ClientNoticeClipboardTooLarge:
1197		return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
1198	case protocol.ClientNoticeLinkDegraded:
1199		return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
1200	case protocol.ClientNoticeLinkConnected:
1201		return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
1202	default:
1203		return false
1204	}
1205}
1206
1207// recordClientNotice mutates notice history and the selected attachment while
1208// routingMu keeps another connection from changing that selection. Rendering is left
1209// to the caller after it releases routingMu.
1210func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
1211	n := d.notices.record(domain.Notification{
1212		Code:      code,
1213		Severity:  sev,
1214		Message:   message,
1215		Time:      d.clock.Now(),
1216		SessionID: sess.id,
1217	})
1218	return d.publishToast(ac, n)
1219}
1220
1221// resizeForFirstPaint retains attach's synchronous geometry guarantee. The
1222// returned value reports whether the synchronous request was accepted.
1223func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
1224	return sess.geometry.requestResize(d, sess, ac, sz, true)
1225}

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