detached 16ea2ba

fix(remote): support remote view handoffs

commit
16ea2ba414cfa464458c154e7cdc8f706c54adef
author
brice · · 2026-08-07 09:02 UTC
parent
a068955
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +250 -9
M internal/usecase/daemon/notify.go +23 -0
@@ -370,6 +370,29 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
370370 d.notify(nil, sev, code, msg, cause)
371371 }
372372
373+// notifyAttachment publishes a notice to one exact attachment. Remote views do
374+// not belong to the local-session attachment registry, so a remote selection
375+// failure must not fall back to daemon-global delivery.
376+func (d *Daemon) notifyAttachment(ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
377+ if d == nil || ac == nil {
378+ return
379+ }
380+ n := d.notices.record(domain.Notification{
381+ Code: code,
382+ Severity: sev,
383+ Message: msg,
384+ Details: noticeDetails(cause),
385+ Time: d.clock.Now(),
386+ })
387+ d.log.Log(d.serveCtx, slogLevelFor(sev), "attachment notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
388+ d.notices.routingMu.Lock()
389+ published := d.publishToast(ac, n)
390+ d.notices.routingMu.Unlock()
391+ if published {
392+ d.repaintForNotice(ac)
393+ }
394+}
395+
373396 // publishToast durably mutates one client's toast state without repainting.
374397 // Routing paths call it while routingMu excludes detach, then release routingMu
375398 // before rendering so a render failure can safely route another notice.
@@ -370,6 +370,29 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
370 d.notify(nil, sev, code, msg, cause)370 d.notify(nil, sev, code, msg, cause)
371 }371 }
372 372
373+// notifyAttachment publishes a notice to one exact attachment. Remote views do
374+// not belong to the local-session attachment registry, so a remote selection
375+// failure must not fall back to daemon-global delivery.
376+func (d *Daemon) notifyAttachment(ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
377+ if d == nil || ac == nil {
378+ return
379+ }
380+ n := d.notices.record(domain.Notification{
381+ Code: code,
382+ Severity: sev,
383+ Message: msg,
384+ Details: noticeDetails(cause),
385+ Time: d.clock.Now(),
386+ })
387+ d.log.Log(d.serveCtx, slogLevelFor(sev), "attachment notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
388+ d.notices.routingMu.Lock()
389+ published := d.publishToast(ac, n)
390+ d.notices.routingMu.Unlock()
391+ if published {
392+ d.repaintForNotice(ac)
393+ }
394+}
395+
373 // publishToast durably mutates one client's toast state without repainting.396 // publishToast durably mutates one client's toast state without repainting.
374 // Routing paths call it while routingMu excludes detach, then release routingMu397 // Routing paths call it while routingMu excludes detach, then release routingMu
375 // before rendering so a render failure can safely route another notice.398 // before rendering so a render failure can safely route another notice.
M internal/usecase/daemon/recent_sessions.go +20 -2
@@ -66,7 +66,22 @@ func (d *Daemon) recentSessionsForAttachment(current *session, ac *attachedClien
6666 if ac == nil {
6767 return recent
6868 }
69- return trimRecentSessions(append(recent, ac.remoteRecentSessions()...))
69+ return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(remoteViewKey{})...))
70+}
71+
72+// recentSessionsForRemoteAttachment merges daemon-local entries with this
73+// attachment's remote history while omitting the remote view currently rendered
74+// in the status bar.
75+func (d *Daemon) recentSessionsForRemoteAttachment(current *remoteView, ac *attachedClient) []recentSession {
76+ recent := d.recentSessions(nil)
77+ if ac == nil {
78+ return recent
79+ }
80+ var excluded remoteViewKey
81+ if current != nil {
82+ excluded = current.key
83+ }
84+ return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(excluded)...))
7085 }
7186
7287 func trimRecentSessions(recent []recentSession) []recentSession {
@@ -113,7 +128,7 @@ func (d *Daemon) recordRemoteRecent(ac *attachedClient, view *remoteView) {
113128 })
114129 }
115130
116-func (ac *attachedClient) remoteRecentSessions() []recentSession {
131+func (ac *attachedClient) remoteRecentSessionsExcept(excluded remoteViewKey) []recentSession {
117132 if ac == nil {
118133 return nil
119134 }
@@ -121,6 +136,9 @@ func (ac *attachedClient) remoteRecentSessions() []recentSession {
121136 ac.remoteRecent.With(func(entries *[]remoteRecentSession) {
122137 recent = make([]recentSession, 0, len(*entries))
123138 for _, entry := range *entries {
139+ if entry.key == excluded {
140+ continue
141+ }
124142 recent = append(recent, recentSession{name: entry.name(), mruAt: entry.mruAt})
125143 }
126144 })
@@ -66,7 +66,22 @@ func (d *Daemon) recentSessionsForAttachment(current *session, ac *attachedClien
66 if ac == nil {66 if ac == nil {
67 return recent67 return recent
68 }68 }
69- return trimRecentSessions(append(recent, ac.remoteRecentSessions()...))69+ return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(remoteViewKey{})...))
70+}
71+
72+// recentSessionsForRemoteAttachment merges daemon-local entries with this
73+// attachment's remote history while omitting the remote view currently rendered
74+// in the status bar.
75+func (d *Daemon) recentSessionsForRemoteAttachment(current *remoteView, ac *attachedClient) []recentSession {
76+ recent := d.recentSessions(nil)
77+ if ac == nil {
78+ return recent
79+ }
80+ var excluded remoteViewKey
81+ if current != nil {
82+ excluded = current.key
83+ }
84+ return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(excluded)...))
70 }85 }
71 86
72 func trimRecentSessions(recent []recentSession) []recentSession {87 func trimRecentSessions(recent []recentSession) []recentSession {
@@ -113,7 +128,7 @@ func (d *Daemon) recordRemoteRecent(ac *attachedClient, view *remoteView) {
113 })128 })
114 }129 }
115 130
116-func (ac *attachedClient) remoteRecentSessions() []recentSession {131+func (ac *attachedClient) remoteRecentSessionsExcept(excluded remoteViewKey) []recentSession {
117 if ac == nil {132 if ac == nil {
118 return nil133 return nil
119 }134 }
@@ -121,6 +136,9 @@ func (ac *attachedClient) remoteRecentSessions() []recentSession {
121 ac.remoteRecent.With(func(entries *[]remoteRecentSession) {136 ac.remoteRecent.With(func(entries *[]remoteRecentSession) {
122 recent = make([]recentSession, 0, len(*entries))137 recent = make([]recentSession, 0, len(*entries))
123 for _, entry := range *entries {138 for _, entry := range *entries {
139+ if entry.key == excluded {
140+ continue
141+ }
124 recent = append(recent, recentSession{name: entry.name(), mruAt: entry.mruAt})142 recent = append(recent, recentSession{name: entry.name(), mruAt: entry.mruAt})
125 }143 }
126 })144 })
M internal/usecase/daemon/remote_attachment_transition.go +101 -0
@@ -103,6 +103,107 @@ func (d *Daemon) transitionToRemoteViewGuarded(token attachmentConnectionToken,
103103 return published, nil
104104 }
105105
106+// transitionRemoteViewToRemoteViewForPicker moves an attachment between two
107+// daemon-owned remote views after the selected target has completed its
108+// proxied handshake. It keeps the original local predecessor intact so local
109+// navigation and remote-to-local return remain attachment-owned.
110+func (d *Daemon) transitionRemoteViewToRemoteViewForPicker(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection) (attachmentConnectionToken, error) {
111+ source, remote := normalizeAttachmentOwner(token.owner).(*remoteView)
112+ if d == nil || !remote || source == nil || target == nil || token.ac == nil || token.transport.transport == nil {
113+ return attachmentConnectionToken{}, errAttachmentTransition
114+ }
115+ target.mu.Lock()
116+ link, generation := target.link, target.linkGeneration
117+ healthy := remoteViewLinkReusableLocked(target)
118+ target.mu.Unlock()
119+ if !healthy {
120+ return attachmentConnectionToken{}, errAttachmentTransition
121+ }
122+ return d.transitionRemoteViewToRemoteViewGuarded(token, source, target, selection, link, generation)
123+}
124+
125+func (d *Daemon) transitionRemoteViewToRemoteViewGuarded(token attachmentConnectionToken, source, target *remoteView, selection *remotePickerSelection, expectedLink *remoteLink, expectedLinkGeneration uint64) (attachmentConnectionToken, error) {
126+ if d == nil || source == nil || target == nil || token.ac == nil || token.transport.transport == nil {
127+ return attachmentConnectionToken{}, errAttachmentTransition
128+ }
129+ if token.effect != nil {
130+ d.endActionAttachmentEffect(token.effect, "remote-view-transition")
131+ }
132+ frozen := freezeAttachmentEffectGates(token.ac)
133+ defer frozen.unfreeze()
134+ if !frozen.acquired || !frozen.drained || selection == nil || !selection.current() {
135+ return attachmentConnectionToken{}, errAttachmentTransition
136+ }
137+
138+ if source == target {
139+ source.mu.Lock()
140+ current := sameAttachmentOwner(token.owner, source) &&
141+ token.generation == token.ac.connectionGeneration.Load() &&
142+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) &&
143+ token.ac.transportSnapshotCurrent(token.transport) &&
144+ attachmentOwnerRegistered(source, token.ac) && !source.closed &&
145+ expectedLink != nil && source.link == expectedLink &&
146+ source.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(source)
147+ source.mu.Unlock()
148+ if !current {
149+ return attachmentConnectionToken{}, errAttachmentTransition
150+ }
151+ d.recordRemoteRecent(token.ac, target)
152+ return token, nil
153+ }
154+
155+ d.mu.Lock()
156+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(source) || !d.attachmentOwnerRegisteredByDaemonLocked(target) {
157+ d.mu.Unlock()
158+ return attachmentConnectionToken{}, errAttachmentTransition
159+ }
160+ d.notices.routingMu.Lock()
161+ // Remote owners are leaf locks. A total ID order prevents ABBA deadlock
162+ // when separate attachments concurrently move between the same two views.
163+ first, second := source, target
164+ if second.id < first.id {
165+ first, second = second, first
166+ }
167+ first.mu.Lock()
168+ second.mu.Lock()
169+ unlock := func() {
170+ second.mu.Unlock()
171+ first.mu.Unlock()
172+ d.notices.routingMu.Unlock()
173+ d.mu.Unlock()
174+ }
175+ _, sourceAttached := source.attachments[token.ac]
176+ _, targetAttached := target.attachments[token.ac]
177+ current := sameAttachmentOwner(token.owner, source) &&
178+ token.generation == token.ac.connectionGeneration.Load() &&
179+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) &&
180+ token.ac.transportSnapshotCurrent(token.transport) &&
181+ sourceAttached && !source.closed && !target.closed && !targetAttached &&
182+ expectedLink != nil && target.link == expectedLink &&
183+ target.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(target)
184+ if !current || !target.registerAttachmentLocked(token.ac) {
185+ unlock()
186+ return attachmentConnectionToken{}, errAttachmentTransition
187+ }
188+ source.unregisterAttachmentLocked(token.ac)
189+ generation := token.ac.connectionGeneration.Add(1)
190+ token.ac.setAttachmentOwner(target)
191+ published := attachmentConnectionToken{
192+ owner: target,
193+ ac: token.ac,
194+ generation: generation,
195+ transport: token.transport,
196+ rebase: true,
197+ }
198+ token.ac.publishFrozenAttachmentCapability(published)
199+ unlock()
200+
201+ d.activateRemoteView(target)
202+ d.parkRemoteViewWarm(source)
203+ d.recordRemoteRecent(token.ac, target)
204+ return published, nil
205+}
206+
106207 // transitionFromRemoteView moves an attachment from one exact daemon-owned
107208 // remote view back to its previous local session. The remote view has no
108209 // coordinator lease, so only the target session coordinator is attached during
@@ -103,6 +103,107 @@ func (d *Daemon) transitionToRemoteViewGuarded(token attachmentConnectionToken,
103 return published, nil103 return published, nil
104 }104 }
105 105
106+// transitionRemoteViewToRemoteViewForPicker moves an attachment between two
107+// daemon-owned remote views after the selected target has completed its
108+// proxied handshake. It keeps the original local predecessor intact so local
109+// navigation and remote-to-local return remain attachment-owned.
110+func (d *Daemon) transitionRemoteViewToRemoteViewForPicker(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection) (attachmentConnectionToken, error) {
111+ source, remote := normalizeAttachmentOwner(token.owner).(*remoteView)
112+ if d == nil || !remote || source == nil || target == nil || token.ac == nil || token.transport.transport == nil {
113+ return attachmentConnectionToken{}, errAttachmentTransition
114+ }
115+ target.mu.Lock()
116+ link, generation := target.link, target.linkGeneration
117+ healthy := remoteViewLinkReusableLocked(target)
118+ target.mu.Unlock()
119+ if !healthy {
120+ return attachmentConnectionToken{}, errAttachmentTransition
121+ }
122+ return d.transitionRemoteViewToRemoteViewGuarded(token, source, target, selection, link, generation)
123+}
124+
125+func (d *Daemon) transitionRemoteViewToRemoteViewGuarded(token attachmentConnectionToken, source, target *remoteView, selection *remotePickerSelection, expectedLink *remoteLink, expectedLinkGeneration uint64) (attachmentConnectionToken, error) {
126+ if d == nil || source == nil || target == nil || token.ac == nil || token.transport.transport == nil {
127+ return attachmentConnectionToken{}, errAttachmentTransition
128+ }
129+ if token.effect != nil {
130+ d.endActionAttachmentEffect(token.effect, "remote-view-transition")
131+ }
132+ frozen := freezeAttachmentEffectGates(token.ac)
133+ defer frozen.unfreeze()
134+ if !frozen.acquired || !frozen.drained || selection == nil || !selection.current() {
135+ return attachmentConnectionToken{}, errAttachmentTransition
136+ }
137+
138+ if source == target {
139+ source.mu.Lock()
140+ current := sameAttachmentOwner(token.owner, source) &&
141+ token.generation == token.ac.connectionGeneration.Load() &&
142+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) &&
143+ token.ac.transportSnapshotCurrent(token.transport) &&
144+ attachmentOwnerRegistered(source, token.ac) && !source.closed &&
145+ expectedLink != nil && source.link == expectedLink &&
146+ source.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(source)
147+ source.mu.Unlock()
148+ if !current {
149+ return attachmentConnectionToken{}, errAttachmentTransition
150+ }
151+ d.recordRemoteRecent(token.ac, target)
152+ return token, nil
153+ }
154+
155+ d.mu.Lock()
156+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(source) || !d.attachmentOwnerRegisteredByDaemonLocked(target) {
157+ d.mu.Unlock()
158+ return attachmentConnectionToken{}, errAttachmentTransition
159+ }
160+ d.notices.routingMu.Lock()
161+ // Remote owners are leaf locks. A total ID order prevents ABBA deadlock
162+ // when separate attachments concurrently move between the same two views.
163+ first, second := source, target
164+ if second.id < first.id {
165+ first, second = second, first
166+ }
167+ first.mu.Lock()
168+ second.mu.Lock()
169+ unlock := func() {
170+ second.mu.Unlock()
171+ first.mu.Unlock()
172+ d.notices.routingMu.Unlock()
173+ d.mu.Unlock()
174+ }
175+ _, sourceAttached := source.attachments[token.ac]
176+ _, targetAttached := target.attachments[token.ac]
177+ current := sameAttachmentOwner(token.owner, source) &&
178+ token.generation == token.ac.connectionGeneration.Load() &&
179+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) &&
180+ token.ac.transportSnapshotCurrent(token.transport) &&
181+ sourceAttached && !source.closed && !target.closed && !targetAttached &&
182+ expectedLink != nil && target.link == expectedLink &&
183+ target.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(target)
184+ if !current || !target.registerAttachmentLocked(token.ac) {
185+ unlock()
186+ return attachmentConnectionToken{}, errAttachmentTransition
187+ }
188+ source.unregisterAttachmentLocked(token.ac)
189+ generation := token.ac.connectionGeneration.Add(1)
190+ token.ac.setAttachmentOwner(target)
191+ published := attachmentConnectionToken{
192+ owner: target,
193+ ac: token.ac,
194+ generation: generation,
195+ transport: token.transport,
196+ rebase: true,
197+ }
198+ token.ac.publishFrozenAttachmentCapability(published)
199+ unlock()
200+
201+ d.activateRemoteView(target)
202+ d.parkRemoteViewWarm(source)
203+ d.recordRemoteRecent(token.ac, target)
204+ return published, nil
205+}
206+
106 // transitionFromRemoteView moves an attachment from one exact daemon-owned207 // transitionFromRemoteView moves an attachment from one exact daemon-owned
107 // remote view back to its previous local session. The remote view has no208 // remote view back to its previous local session. The remote view has no
108 // coordinator lease, so only the target session coordinator is attached during209 // coordinator lease, so only the target session coordinator is attached during
M internal/usecase/daemon/remote_picker_selection.go +11 -6
@@ -35,7 +35,7 @@ func (selection *remotePickerSelection) current() bool {
3535 }
3636
3737 func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
38- if d == nil || token.ac == nil || token.localSession() == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() {
38+ if d == nil || token.ac == nil || normalizeAttachmentOwner(token.owner) == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() {
3939 return errAttachmentTransition
4040 }
4141 if target.RemoteTarget == nil {
@@ -78,7 +78,7 @@ func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, tar
7878 if token.effect != nil {
7979 token.effect.bindActionEnd(d, action)
8080 }
81- d.invalidateRender(token.localSession(), token.ac, true, "remote_picker_selection.go")
81+ d.invalidateAttachedOwner(token.ac, true, "remote_picker_selection.go")
8282 go d.completeRemotePickerSelection(selection)
8383 return nil
8484 }
@@ -106,7 +106,12 @@ func (d *Daemon) completeRemotePickerSelection(selection *remotePickerSelection)
106106 return
107107 }
108108
109- published, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)
109+ var published attachmentConnectionToken
110+ if _, remote := normalizeAttachmentOwner(selection.token.owner).(*remoteView); remote {
111+ published, err = d.transitionRemoteViewToRemoteViewForPicker(selection.token, view, selection)
112+ } else {
113+ published, err = d.transitionToRemoteViewForPicker(selection.token, view, selection)
114+ }
110115 if err != nil {
111116 d.parkRemoteViewWarm(view)
112117 d.failRemotePickerSelection(selection, err)
@@ -140,10 +145,10 @@ func (d *Daemon) failRemotePickerSelection(selection *remotePickerSelection, err
140145 if !errors.Is(err, context.Canceled) {
141146 d.log.Warn("remote picker selection failed", "endpoint", selection.target.Endpoint, "session", selection.target.SessionName, "err", err)
142147 if selection.token.current() {
143- d.notify(selection.token.localSession(), domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil)
148+ d.notifyAttachment(selection.token.ac, domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil)
144149 }
145150 }
146- if source := selection.token.localSession(); source != nil && selection.token.current() {
147- d.invalidateRender(source, selection.token.ac, true, "remote_picker_selection.go")
151+ if selection.token.current() {
152+ d.invalidateAttachedOwner(selection.token.ac, true, "remote_picker_selection.go")
148153 }
149154 }
@@ -35,7 +35,7 @@ func (selection *remotePickerSelection) current() bool {
35 }35 }
36 36
37 func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {37 func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
38- if d == nil || token.ac == nil || token.localSession() == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() {38+ if d == nil || token.ac == nil || normalizeAttachmentOwner(token.owner) == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() {
39 return errAttachmentTransition39 return errAttachmentTransition
40 }40 }
41 if target.RemoteTarget == nil {41 if target.RemoteTarget == nil {
@@ -78,7 +78,7 @@ func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, tar
78 if token.effect != nil {78 if token.effect != nil {
79 token.effect.bindActionEnd(d, action)79 token.effect.bindActionEnd(d, action)
80 }80 }
81- d.invalidateRender(token.localSession(), token.ac, true, "remote_picker_selection.go")81+ d.invalidateAttachedOwner(token.ac, true, "remote_picker_selection.go")
82 go d.completeRemotePickerSelection(selection)82 go d.completeRemotePickerSelection(selection)
83 return nil83 return nil
84 }84 }
@@ -106,7 +106,12 @@ func (d *Daemon) completeRemotePickerSelection(selection *remotePickerSelection)
106 return106 return
107 }107 }
108 108
109- published, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)109+ var published attachmentConnectionToken
110+ if _, remote := normalizeAttachmentOwner(selection.token.owner).(*remoteView); remote {
111+ published, err = d.transitionRemoteViewToRemoteViewForPicker(selection.token, view, selection)
112+ } else {
113+ published, err = d.transitionToRemoteViewForPicker(selection.token, view, selection)
114+ }
110 if err != nil {115 if err != nil {
111 d.parkRemoteViewWarm(view)116 d.parkRemoteViewWarm(view)
112 d.failRemotePickerSelection(selection, err)117 d.failRemotePickerSelection(selection, err)
@@ -140,10 +145,10 @@ func (d *Daemon) failRemotePickerSelection(selection *remotePickerSelection, err
140 if !errors.Is(err, context.Canceled) {145 if !errors.Is(err, context.Canceled) {
141 d.log.Warn("remote picker selection failed", "endpoint", selection.target.Endpoint, "session", selection.target.SessionName, "err", err)146 d.log.Warn("remote picker selection failed", "endpoint", selection.target.Endpoint, "session", selection.target.SessionName, "err", err)
142 if selection.token.current() {147 if selection.token.current() {
143- d.notify(selection.token.localSession(), domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil)148+ d.notifyAttachment(selection.token.ac, domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil)
144 }149 }
145 }150 }
146- if source := selection.token.localSession(); source != nil && selection.token.current() {151+ if selection.token.current() {
147- d.invalidateRender(source, selection.token.ac, true, "remote_picker_selection.go")152+ d.invalidateAttachedOwner(selection.token.ac, true, "remote_picker_selection.go")
148 }153 }
149 }154 }
M internal/usecase/daemon/remote_picker_test.go +94 -0
@@ -701,6 +701,100 @@ func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *test
701701 d.shutdownAll(ports.ReasonServerShutdown)
702702 }
703703
704+func TestRemotePickerSelectionTransitionsBetweenRemoteViewsAndSharesAttachmentMRU(t *testing.T) {
705+ d := newRemotePickerDaemon(nil)
706+ first := remoteLinkTestTarget()
707+ second := first
708+ second.LifecycleID = domain.SessionLifecycleID{2}
709+ second.SessionName = "other"
710+ second.LiveTabID = "tab-2"
711+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
712+ Host: first.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{
713+ {LifecycleID: first.LifecycleID, Name: first.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(first.LiveTabID), Index: 0, Name: "main"}}},
714+ {LifecycleID: second.LifecycleID, Name: second.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(second.LiveTabID), Index: 0, Name: "main"}}},
715+ },
716+ }})
717+ d.remoteCatalog.mu.Lock()
718+ d.remoteCatalog.status[first.Endpoint] = remoteHostFresh
719+ d.remoteCatalog.mu.Unlock()
720+
721+ firstTransport := newRemoteLinkTestTransport()
722+ secondTransport := newRemoteLinkTestTransport()
723+ d.remoteDialerFactory = &remoteLinkSequenceFactory{dialers: []ports.Dialer{
724+ &remoteLinkTestDialer{transport: firstTransport},
725+ &remoteLinkTestDialer{transport: secondTransport},
726+ }}
727+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
728+ openPicker := func() {
729+ ac.overlays.pickerMu.Lock()
730+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
731+ ac.overlays.pickerGeneration++
732+ ac.overlays.pickerMu.Unlock()
733+ }
734+ selectTarget := func(token attachmentConnectionToken, target domain.RemoteSessionTarget) {
735+ effect, admitted := ac.beginAttachmentEffect(token)
736+ require.True(t, admitted)
737+ token.effect = effect
738+ key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
739+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &target}, sessionHandoffGuard{closePicker: true}, "picker-select"))
740+ effect.End()
741+ }
742+
743+ openPicker()
744+ selectTarget(sess.attachmentToken(ac, ac.transport()), first)
745+ enqueueRemoteLinkHandshake(t, firstTransport, first, ac.sizeSnapshot())
746+ require.Eventually(t, func() bool {
747+ _, remote := ac.currentAttachmentOwner().(*remoteView)
748+ return remote
749+ }, time.Second, 5*time.Millisecond, "the first remote picker selection should publish its view")
750+ firstView, ok := ac.currentAttachmentOwner().(*remoteView)
751+ require.True(t, ok)
752+
753+ openPicker()
754+ selectTarget(attachmentOwnerToken(firstView, ac, ac.transport()), second)
755+ enqueueRemoteLinkHandshake(t, secondTransport, second, ac.sizeSnapshot())
756+ require.Eventually(t, func() bool {
757+ view, remote := ac.currentAttachmentOwner().(*remoteView)
758+ return remote && view != firstView && view.key == remoteViewKey{endpoint: second.Endpoint, lifecycleID: second.LifecycleID, sessionName: second.SessionName}
759+ }, time.Second, 5*time.Millisecond, "the selected remote view should replace the current remote owner")
760+ secondView, ok := ac.currentAttachmentOwner().(*remoteView)
761+ require.True(t, ok)
762+ require.False(t, firstView.attachmentRegistered(ac))
763+ require.True(t, secondView.attachmentRegistered(ac))
764+ require.Same(t, sess, ac.previousOwner.Get(), "remote-to-remote keeps the retained local return owner")
765+ firstView.mu.Lock()
766+ require.NotNil(t, firstView.warm, "the replaced remote view is retained warm")
767+ firstView.mu.Unlock()
768+
769+ remoteRecent := d.recentSessionsForRemoteAttachment(secondView, ac)
770+ remoteNames := make([]string, 0, len(remoteRecent))
771+ for _, entry := range remoteRecent {
772+ remoteNames = append(remoteNames, entry.name)
773+ }
774+ require.Contains(t, remoteNames, "remote@remote.example")
775+ require.NotContains(t, remoteNames, "other@remote.example", "the remote status bar excludes its current view")
776+ select {
777+ case frame := <-sends:
778+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
779+ default:
780+ }
781+
782+ backToken := attachmentOwnerToken(secondView, ac, ac.transport())
783+ backEffect, admitted := ac.beginAttachmentEffect(backToken)
784+ require.True(t, admitted)
785+ backToken.effect = backEffect
786+ require.NoError(t, d.backSessionForAttachment(backToken))
787+ backEffect.End()
788+ require.Same(t, sess, ac.currentAttachmentSession())
789+ localState := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, nil)
790+ localNames := make([]string, 0, len(localState.mru))
791+ for _, entry := range localState.mru {
792+ localNames = append(localNames, entry.name)
793+ }
794+ require.Equal(t, []string{"other@remote.example", "remote@remote.example"}, localNames)
795+ d.shutdownAll(ports.ReasonServerShutdown)
796+}
797+
704798 func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) {
705799 d := newRemotePickerDaemon(nil)
706800 target := remoteLinkTestTarget()
@@ -701,6 +701,100 @@ func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *test
701 d.shutdownAll(ports.ReasonServerShutdown)701 d.shutdownAll(ports.ReasonServerShutdown)
702 }702 }
703 703
704+func TestRemotePickerSelectionTransitionsBetweenRemoteViewsAndSharesAttachmentMRU(t *testing.T) {
705+ d := newRemotePickerDaemon(nil)
706+ first := remoteLinkTestTarget()
707+ second := first
708+ second.LifecycleID = domain.SessionLifecycleID{2}
709+ second.SessionName = "other"
710+ second.LiveTabID = "tab-2"
711+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
712+ Host: first.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{
713+ {LifecycleID: first.LifecycleID, Name: first.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(first.LiveTabID), Index: 0, Name: "main"}}},
714+ {LifecycleID: second.LifecycleID, Name: second.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(second.LiveTabID), Index: 0, Name: "main"}}},
715+ },
716+ }})
717+ d.remoteCatalog.mu.Lock()
718+ d.remoteCatalog.status[first.Endpoint] = remoteHostFresh
719+ d.remoteCatalog.mu.Unlock()
720+
721+ firstTransport := newRemoteLinkTestTransport()
722+ secondTransport := newRemoteLinkTestTransport()
723+ d.remoteDialerFactory = &remoteLinkSequenceFactory{dialers: []ports.Dialer{
724+ &remoteLinkTestDialer{transport: firstTransport},
725+ &remoteLinkTestDialer{transport: secondTransport},
726+ }}
727+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
728+ openPicker := func() {
729+ ac.overlays.pickerMu.Lock()
730+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
731+ ac.overlays.pickerGeneration++
732+ ac.overlays.pickerMu.Unlock()
733+ }
734+ selectTarget := func(token attachmentConnectionToken, target domain.RemoteSessionTarget) {
735+ effect, admitted := ac.beginAttachmentEffect(token)
736+ require.True(t, admitted)
737+ token.effect = effect
738+ key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
739+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &target}, sessionHandoffGuard{closePicker: true}, "picker-select"))
740+ effect.End()
741+ }
742+
743+ openPicker()
744+ selectTarget(sess.attachmentToken(ac, ac.transport()), first)
745+ enqueueRemoteLinkHandshake(t, firstTransport, first, ac.sizeSnapshot())
746+ require.Eventually(t, func() bool {
747+ _, remote := ac.currentAttachmentOwner().(*remoteView)
748+ return remote
749+ }, time.Second, 5*time.Millisecond, "the first remote picker selection should publish its view")
750+ firstView, ok := ac.currentAttachmentOwner().(*remoteView)
751+ require.True(t, ok)
752+
753+ openPicker()
754+ selectTarget(attachmentOwnerToken(firstView, ac, ac.transport()), second)
755+ enqueueRemoteLinkHandshake(t, secondTransport, second, ac.sizeSnapshot())
756+ require.Eventually(t, func() bool {
757+ view, remote := ac.currentAttachmentOwner().(*remoteView)
758+ return remote && view != firstView && view.key == remoteViewKey{endpoint: second.Endpoint, lifecycleID: second.LifecycleID, sessionName: second.SessionName}
759+ }, time.Second, 5*time.Millisecond, "the selected remote view should replace the current remote owner")
760+ secondView, ok := ac.currentAttachmentOwner().(*remoteView)
761+ require.True(t, ok)
762+ require.False(t, firstView.attachmentRegistered(ac))
763+ require.True(t, secondView.attachmentRegistered(ac))
764+ require.Same(t, sess, ac.previousOwner.Get(), "remote-to-remote keeps the retained local return owner")
765+ firstView.mu.Lock()
766+ require.NotNil(t, firstView.warm, "the replaced remote view is retained warm")
767+ firstView.mu.Unlock()
768+
769+ remoteRecent := d.recentSessionsForRemoteAttachment(secondView, ac)
770+ remoteNames := make([]string, 0, len(remoteRecent))
771+ for _, entry := range remoteRecent {
772+ remoteNames = append(remoteNames, entry.name)
773+ }
774+ require.Contains(t, remoteNames, "remote@remote.example")
775+ require.NotContains(t, remoteNames, "other@remote.example", "the remote status bar excludes its current view")
776+ select {
777+ case frame := <-sends:
778+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
779+ default:
780+ }
781+
782+ backToken := attachmentOwnerToken(secondView, ac, ac.transport())
783+ backEffect, admitted := ac.beginAttachmentEffect(backToken)
784+ require.True(t, admitted)
785+ backToken.effect = backEffect
786+ require.NoError(t, d.backSessionForAttachment(backToken))
787+ backEffect.End()
788+ require.Same(t, sess, ac.currentAttachmentSession())
789+ localState := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, nil)
790+ localNames := make([]string, 0, len(localState.mru))
791+ for _, entry := range localState.mru {
792+ localNames = append(localNames, entry.name)
793+ }
794+ require.Equal(t, []string{"other@remote.example", "remote@remote.example"}, localNames)
795+ d.shutdownAll(ports.ReasonServerShutdown)
796+}
797+
704 func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) {798 func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) {
705 d := newRemotePickerDaemon(nil)799 d := newRemotePickerDaemon(nil)
706 target := remoteLinkTestTarget()800 target := remoteLinkTestTarget()
M internal/usecase/daemon/remote_render.go +1 -1
@@ -188,7 +188,7 @@ func (d *Daemon) captureRemoteViewRenderState(view *remoteView, ac *attachedClie
188188 bars.status = remoteStatusSnapshot(view, snapshot)
189189 if d != nil {
190190 bars.attentionFrame = d.attentionFrame()
191- bars.mru = d.recentSessions(nil)
191+ bars.mru = d.recentSessionsForRemoteAttachment(view, ac)
192192 }
193193 state := &capturedRenderState{
194194 attachment: ac,
@@ -188,7 +188,7 @@ func (d *Daemon) captureRemoteViewRenderState(view *remoteView, ac *attachedClie
188 bars.status = remoteStatusSnapshot(view, snapshot)188 bars.status = remoteStatusSnapshot(view, snapshot)
189 if d != nil {189 if d != nil {
190 bars.attentionFrame = d.attentionFrame()190 bars.attentionFrame = d.attentionFrame()
191- bars.mru = d.recentSessions(nil)191+ bars.mru = d.recentSessionsForRemoteAttachment(view, ac)
192 }192 }
193 state := &capturedRenderState{193 state := &capturedRenderState{
194 attachment: ac,194 attachment: ac,

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 2

Tags 13

↑↓ moveenter openesc close