detached cd20560

Merge pull request #211 from bnema/feat/palette-remote-discovery

feat(palette): discover remote sessions
commit
cd20560e6fd530b9d13e48a440cd757d114e2526
author
bnema · · 2026-08-25 12:20 UTC
committer
GitHub · · 2026-08-25 12:20 UTC
merge of
f571e9b + c574469 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
13 files · +854 -191
M docs/remote-resilience.md +14 -6
@@ -27,12 +27,20 @@ directory; direct CLI remote attaches retain their client-request semantics.
2727
2828 Remote attachments remain direct: the selected remote daemon owns the session,
2929 PTYs, rendering, input, resize, effects, and teardown. The client does not
30-proxy remote content through the local daemon. A single client process keeps a
31-bounded, in-memory route history across local, direct remote, and picker-
32-discovered attachments. The daemon renders the latest attachment snapshot;
33-active routes are metadata-only, while `JRS` and `BCK` send typed key/generation
34-actions back to the client. History is process-local and is not persisted or
35-shared between clients.
30+proxy remote content through the local daemon. The session picker and command
31+palette read remote session inventory from the daemon's durable catalog cache
32+and refresh it asynchronously while either overlay is open. Cached names appear
33+immediately, but selection still requires a fresh exact lifecycle and tab
34+identity.
35+
36+A single client process separately keeps a bounded, in-memory route history
37+across local, direct remote, and picker-discovered attachments. Catalog targets
38+retain exact endpoints, while route snapshots keep endpoints private; rows from
39+those sources therefore remain separate when their authority cannot be compared
40+exactly. The daemon renders the latest attachment snapshot; active routes are
41+metadata-only, while `JRS` and `BCK` send typed
42+key/generation actions back to the client. Route history is process-local and
43+is not persisted or shared between clients.
3644
3745 A picker-selected remote target is a direct handoff with one input pump. Exact
3846 lifecycle/name identities are carried separately from display labels, and a
@@ -27,12 +27,20 @@ directory; direct CLI remote attaches retain their client-request semantics.
27 27
28 Remote attachments remain direct: the selected remote daemon owns the session,28 Remote attachments remain direct: the selected remote daemon owns the session,
29 PTYs, rendering, input, resize, effects, and teardown. The client does not29 PTYs, rendering, input, resize, effects, and teardown. The client does not
30-proxy remote content through the local daemon. A single client process keeps a30+proxy remote content through the local daemon. The session picker and command
31-bounded, in-memory route history across local, direct remote, and picker-31+palette read remote session inventory from the daemon's durable catalog cache
32-discovered attachments. The daemon renders the latest attachment snapshot;32+and refresh it asynchronously while either overlay is open. Cached names appear
33-active routes are metadata-only, while `JRS` and `BCK` send typed key/generation33+immediately, but selection still requires a fresh exact lifecycle and tab
34-actions back to the client. History is process-local and is not persisted or34+identity.
35-shared between clients.35+
36+A single client process separately keeps a bounded, in-memory route history
37+across local, direct remote, and picker-discovered attachments. Catalog targets
38+retain exact endpoints, while route snapshots keep endpoints private; rows from
39+those sources therefore remain separate when their authority cannot be compared
40+exactly. The daemon renders the latest attachment snapshot; active routes are
41+metadata-only, while `JRS` and `BCK` send typed
42+key/generation actions back to the client. Route history is process-local and
43+is not persisted or shared between clients.
36 44
37 A picker-selected remote target is a direct handoff with one input pump. Exact45 A picker-selected remote target is a direct handoff with one input pump. Exact
38 lifecycle/name identities are carried separately from display labels, and a46 lifecycle/name identities are carried separately from display labels, and a
M internal/usecase/daemon/client_transport_errors.go +2 -0
@@ -95,6 +95,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
9595 // IntentResume waiters are not stranded on a never-published park.
9696 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
9797 d.closePicker(ac)
98+ d.closePalette(ac)
9899
99100 d.resetScreenDefaultColors(sess)
100101 if explicit && notice {
@@ -183,6 +184,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
183184 }
184185 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
185186 d.closePicker(ac)
187+ d.closePalette(ac)
186188 d.resetScreenDefaultColors(sess)
187189 _ = ac.closeCapturedTransport(failed)
188190 d.log.Warn("detached client after send error", "session", name)
@@ -95,6 +95,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
95 // IntentResume waiters are not stranded on a never-published park.95 // IntentResume waiters are not stranded on a never-published park.
96 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)96 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
97 d.closePicker(ac)97 d.closePicker(ac)
98+ d.closePalette(ac)
98 99
99 d.resetScreenDefaultColors(sess)100 d.resetScreenDefaultColors(sess)
100 if explicit && notice {101 if explicit && notice {
@@ -183,6 +184,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
183 }184 }
184 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)185 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
185 d.closePicker(ac)186 d.closePicker(ac)
187+ d.closePalette(ac)
186 d.resetScreenDefaultColors(sess)188 d.resetScreenDefaultColors(sess)
187 _ = ac.closeCapturedTransport(failed)189 _ = ac.closeCapturedTransport(failed)
188 d.log.Warn("detached client after send error", "session", name)190 d.log.Warn("detached client after send error", "session", name)
M internal/usecase/daemon/daemon.go +9 -8
@@ -322,13 +322,14 @@ type Daemon struct {
322322 }
323323
324324 type parkedAttachment struct {
325- sess *session
326- ac *attachedClient
327- pickerGeneration uint64
328- timer ports.Timer
329- claimed bool
330- done chan struct{}
331- doneOnce sync.Once
325+ sess *session
326+ ac *attachedClient
327+ pickerGeneration uint64
328+ paletteGeneration uint64
329+ timer ports.Timer
330+ claimed bool
331+ done chan struct{}
332+ doneOnce sync.Once
332333 }
333334
334335 // parkingAttachment is the observable detach→park lifecycle for one resume
@@ -816,7 +817,7 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
816817 }
817818
818819 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
819- d.cancelRemotePickerRefresh()
820+ d.cancelRemoteDiscoveryRefresh()
820821 d.closeMoveLifecycles()
821822 d.mu.Lock()
822823 d.closing = true
@@ -322,13 +322,14 @@ type Daemon struct {
322 }322 }
323 323
324 type parkedAttachment struct {324 type parkedAttachment struct {
325- sess *session325+ sess *session
326- ac *attachedClient326+ ac *attachedClient
327- pickerGeneration uint64327+ pickerGeneration uint64
328- timer ports.Timer328+ paletteGeneration uint64
329- claimed bool329+ timer ports.Timer
330- done chan struct{}330+ claimed bool
331- doneOnce sync.Once331+ done chan struct{}
332+ doneOnce sync.Once
332 }333 }
333 334
334 // parkingAttachment is the observable detach→park lifecycle for one resume335 // parkingAttachment is the observable detach→park lifecycle for one resume
@@ -816,7 +817,7 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
816 }817 }
817 818
818 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {819 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
819- d.cancelRemotePickerRefresh()820+ d.cancelRemoteDiscoveryRefresh()
820 d.closeMoveLifecycles()821 d.closeMoveLifecycles()
821 d.mu.Lock()822 d.mu.Lock()
822 d.closing = true823 d.closing = true
M internal/usecase/daemon/palette.go +176 -18
@@ -43,16 +43,42 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
4343 routeSnapshot := ac.routeSnapshotCopy()
4444 commands := d.paletteCommands()
4545 results := d.paletteResults(sess, commands, routeSnapshot)
46+ model := palette.New(results)
4647 ac.overlays.paletteMu.Lock()
4748 ac.overlays.paletteGeneration++
48- ac.overlays.palette = palette.New(results)
49+ ac.overlays.palette = model
4950 ac.overlays.paletteRouteSnapshot = routeSnapshot
5051 ac.overlays.paletteHints = palette.ContextualHints{}
5152 ac.overlays.palettePreview = ""
5253 ac.overlays.paletteFeedback = ""
5354 ac.overlays.palettePending = nil
55+ generation := ac.overlays.paletteGeneration
5456 ac.overlays.paletteMu.Unlock()
5557 d.invalidateRender(sess, ac, true, "palette.go")
58+ d.remoteDiscoveryOpened(remoteDiscoveryInstance{
59+ ac: ac, kind: remoteDiscoveryPalette, generation: generation, palette: model,
60+ })
61+}
62+
63+type paletteSessionIdentity struct {
64+ kind ports.RouteKind
65+ endpoint string
66+ lifecycle domain.SessionLifecycleID
67+}
68+
69+func localPaletteSessionIdentity(lifecycle domain.SessionLifecycleID) paletteSessionIdentity {
70+ return paletteSessionIdentity{kind: ports.RouteKindLocal, endpoint: "local", lifecycle: lifecycle}
71+}
72+
73+func remotePaletteSessionIdentity(endpoint string, lifecycle domain.SessionLifecycleID) paletteSessionIdentity {
74+ return paletteSessionIdentity{kind: ports.RouteKindRemote, endpoint: endpoint, lifecycle: lifecycle}
75+}
76+
77+func remotePaletteUnavailableReason(status remoteHostStatus, session ports.RemoteCatalogSession) string {
78+ if session.State == ports.RemoteCatalogSessionBroken {
79+ return "session_broken"
80+ }
81+ return remoteReasonForStatus(status)
5682 }
5783
5884 // paletteResults captures eligible named sessions before paletteMu so the
@@ -61,16 +87,23 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
6187 d.mu.Lock()
6288 sessions := d.sessionsSnapshotLocked()
6389 stopped := make([]inactiveSession, 0, len(d.inactive))
64- localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(d.inactive))
90+ represented := make(map[paletteSessionIdentity]struct{}, len(sessions)+len(d.inactive)+len(routeSnapshot.Entries))
6591 for _, entry := range d.inactive {
66- localLifecycles[entry.incarnation] = struct{}{}
92+ represented[localPaletteSessionIdentity(entry.incarnation)] = struct{}{}
6793 if entry.canResume() {
6894 stopped = append(stopped, entry)
6995 }
7096 }
7197 d.mu.Unlock()
7298
73- results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+len(routeSnapshot.Entries))
99+ remoteCatalog := d.remoteCatalogSnapshot()
100+ sortRemoteCatalog(remoteCatalog, d.remoteHostRanks())
101+ remoteSessionCount := 0
102+ for _, host := range remoteCatalog {
103+ remoteSessionCount += len(host.entry.Sessions)
104+ }
105+
106+ results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))
74107 for _, cmd := range commands {
75108 results = append(results, palette.NewCommandResult(cmd))
76109 }
@@ -80,7 +113,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
80113 if snap.name == "" || snap.ephemeral {
81114 continue
82115 }
83- localLifecycles[snap.incarnation] = struct{}{}
116+ represented[localPaletteSessionIdentity(snap.incarnation)] = struct{}{}
84117 if candidate == current {
85118 continue
86119 }
@@ -97,7 +130,29 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
97130 for _, candidate := range stopped {
98131 target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}
99132 results = append(results, palette.NewStoppedSessionResult(target, time.Unix(0, candidate.createdAt)))
100- localLifecycles[target.LifecycleID] = struct{}{}
133+ represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{}
134+ }
135+ type discoveredRemote struct {
136+ identity paletteSessionIdentity
137+ result palette.Result
138+ }
139+ discovered := make([]discoveredRemote, 0, remoteSessionCount)
140+ for _, host := range remoteCatalog {
141+ for _, session := range host.entry.Sessions {
142+ if session.Ephemeral {
143+ continue
144+ }
145+ key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{
146+ Host: host.entry.Host, Name: session.Name,
147+ }, session)
148+ if key.Validate() != nil || target.Validate() != nil {
149+ continue
150+ }
151+ discovered = append(discovered, discoveredRemote{
152+ identity: remotePaletteSessionIdentity(key.Host, session.LifecycleID),
153+ result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host.status, session)),
154+ })
155+ }
101156 }
102157 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
103158 for i, entry := range routeSnapshot.Entries {
@@ -108,7 +163,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
108163 if label == "" {
109164 continue
110165 }
111- if _, represented := localLifecycles[entry.Target.LifecycleID]; represented {
166+ identity := localPaletteSessionIdentity(entry.Target.LifecycleID)
167+ if _, exists := represented[identity]; entry.Kind == ports.RouteKindLocal && exists {
112168 continue
113169 }
114170 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
@@ -116,10 +172,48 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
116172 Key: entry.Key,
117173 Generation: entry.Generation,
118174 }))
175+ if entry.Kind == ports.RouteKindLocal {
176+ represented[identity] = struct{}{}
177+ }
178+ }
179+ for _, remote := range discovered {
180+ if _, exists := represented[remote.identity]; exists {
181+ continue
182+ }
183+ results = append(results, remote.result)
184+ represented[remote.identity] = struct{}{}
119185 }
120186 return results
121187 }
122188
189+func (d *Daemon) refreshPalette(ac *attachedClient) {
190+ if d == nil || ac == nil || ac.overlays == nil {
191+ return
192+ }
193+ sess := ac.currentAttachmentSession()
194+ if sess == nil {
195+ return
196+ }
197+ rt := ac.overlays
198+ rt.paletteMu.Lock()
199+ model := rt.palette
200+ generation := rt.paletteGeneration
201+ routeSnapshot := rt.paletteRouteSnapshot
202+ routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
203+ rt.paletteMu.Unlock()
204+ if model == nil {
205+ return
206+ }
207+
208+ results := d.paletteResults(sess, d.paletteCommands(), routeSnapshot)
209+ rt.paletteMu.Lock()
210+ defer rt.paletteMu.Unlock()
211+ if rt.paletteGeneration != generation || rt.palette != model {
212+ return
213+ }
214+ model.ReplaceResults(results)
215+}
216+
123217 func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints {
124218 formatted := formatRecentRouteSnapshot(snapshot)
125219 names := make([]string, len(formatted))
@@ -192,6 +286,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
192286 var cmd command.Command
193287 var sessionTarget palette.Result
194288 var hasSessionTarget bool
289+ var remoteTarget domain.RemoteSessionTarget
290+ var remoteKey domain.RemoteSessionKey
291+ var remoteUnavailableReason string
292+ var hasRemoteTarget bool
195293 var routeTarget ports.RouteNavigationAction
196294 var hasRouteTarget bool
197295 var args []string
@@ -199,6 +297,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
199297 var generation uint64
200298 var rawQuery string
201299 changed, cancel, execute := false, false, false
300+ var closedDiscovery remoteDiscoveryInstance
202301 var effect *attachmentEffectTicket
203302 if len(effects) != 0 {
204303 effect = effects[0]
@@ -270,6 +369,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
270369 routeSnapshot = ac.overlays.paletteRouteSnapshot
271370 routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
272371 }
372+ } else if target, ok := selected.RemoteSessionTarget(); ok {
373+ key, keyOK := selected.RemoteSessionKey()
374+ if !keyOK {
375+ changed = true
376+ return
377+ }
378+ remoteTarget = target
379+ remoteKey = key
380+ remoteUnavailableReason, _ = selected.RemoteSessionUnavailableReason()
381+ hasRemoteTarget = true
273382 } else if action, ok := selected.RouteNavigationAction(); ok {
274383 routeTarget = action
275384 hasRouteTarget = true
@@ -303,10 +412,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
303412 }
304413 }
305414 if cancel {
306- ac.clearPaletteLocked()
415+ closedDiscovery = ac.clearPaletteLocked()
307416 }
308417 ac.overlays.paletteMu.Unlock()
309418 if cancel {
419+ d.remoteDiscoveryClosed(closedDiscovery)
310420 d.invalidateRender(entry, ac, true, "palette.go")
311421 return
312422 }
@@ -317,6 +427,29 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
317427 return
318428 }
319429
430+ if hasRemoteTarget {
431+ if effect == nil {
432+ ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")
433+ d.invalidateRender(entry, ac, true, "palette.go")
434+ return
435+ }
436+ target := picker.Target{
437+ Session: remoteKey.ID(), Incarnation: remoteKey.LifecycleID, Name: remoteKey.Display(),
438+ RemoteKey: &remoteKey, RemoteTarget: &remoteTarget, RemoteHost: remoteKey.Host,
439+ UnavailableReason: remoteUnavailableReason, TabIndex: -1,
440+ }
441+ err := d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-remote-session")
442+ if err == nil {
443+ d.closeExecutedPalette(ac, generation, rawQuery)
444+ return
445+ }
446+ if !errors.Is(err, errAttachmentTransition) {
447+ ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")
448+ d.invalidateRender(entry, ac, true, "palette.go")
449+ }
450+ return
451+ }
452+
320453 if hasRouteTarget {
321454 exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
322455 if err := exec.NavigateRecentRoute(routeTarget); err != nil {
@@ -327,7 +460,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
327460 d.invalidateRender(entry, ac, true, "palette.go")
328461 return
329462 }
330- if ac.closeExecutedPalette(generation, rawQuery) {
463+ if d.closeExecutedPalette(ac, generation, rawQuery) {
331464 d.invalidateRender(entry, ac, true, "palette.go")
332465 }
333466 return
@@ -356,7 +489,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
356489 d.invalidateRender(entry, ac, true, "palette.go")
357490 return
358491 }
359- if ac.closeExecutedPalette(generation, rawQuery) {
492+ if d.closeExecutedPalette(ac, generation, rawQuery) {
360493 if current := ac.currentAttachmentSession(); current != nil {
361494 d.invalidateRender(current, ac, true, "palette.go")
362495 }
@@ -380,7 +513,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
380513 d.invalidateRender(entry, ac, true, "palette.go")
381514 return
382515 }
383- if ac.closeExecutedPalette(generation, rawQuery) {
516+ if d.closeExecutedPalette(ac, generation, rawQuery) {
384517 d.recordPaletteUse(cmd.Code)
385518 if current := ac.currentAttachmentSession(); current != nil {
386519 d.invalidateRender(current, ac, true, "palette.go")
@@ -389,7 +522,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
389522 return
390523 }
391524 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
392- if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
525+ if !attachmentHandoff && !d.closeExecutedPalette(ac, generation, rawQuery) {
393526 return
394527 }
395528 sess.dispatchMu.Lock()
@@ -399,14 +532,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
399532 if current := ac.currentSession(); current != nil {
400533 currentToken := current.attachmentToken(ac, ac.transport())
401534 fresh, admitted := ac.beginAttachmentEffect(currentToken)
402- if ac.closeExecutedPalette(generation, rawQuery) {
535+ if d.closeExecutedPalette(ac, generation, rawQuery) {
403536 d.invalidateRender(current, ac, true, "palette.go")
404537 }
405538 if admitted {
406539 fresh.End()
407540 }
408541 } else {
409- ac.closeExecutedPalette(generation, rawQuery)
542+ d.closeExecutedPalette(ac, generation, rawQuery)
410543 }
411544 }
412545 if errors.Is(err, errAttachmentTransition) {
@@ -435,7 +568,8 @@ func paletteArgs(query string, cmd command.Command) []string {
435568 return action.Args
436569 }
437570
438-func (ac *attachedClient) clearPaletteLocked() {
571+func (ac *attachedClient) clearPaletteLocked() remoteDiscoveryInstance {
572+ model := ac.overlays.palette
439573 ac.overlays.paletteGeneration++
440574 ac.overlays.palette = nil
441575 ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}
@@ -443,6 +577,9 @@ func (ac *attachedClient) clearPaletteLocked() {
443577 ac.overlays.palettePreview = ""
444578 ac.overlays.paletteFeedback = ""
445579 ac.overlays.palettePending = nil
580+ return remoteDiscoveryInstance{
581+ ac: ac, kind: remoteDiscoveryPalette, generation: ac.overlays.paletteGeneration, palette: model,
582+ }
446583 }
447584
448585 func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
@@ -454,13 +591,34 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s
454591 ac.overlays.paletteFeedback = feedback
455592 }
456593
457-func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {
594+func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool {
458595 ac.overlays.paletteMu.Lock()
459- defer ac.overlays.paletteMu.Unlock()
460596 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
597+ ac.overlays.paletteMu.Unlock()
461598 return false
462599 }
463- ac.clearPaletteLocked()
600+ instance := ac.clearPaletteLocked()
601+ ac.overlays.paletteMu.Unlock()
602+ d.remoteDiscoveryClosed(instance)
603+ return true
604+}
605+
606+func (d *Daemon) closePalette(ac *attachedClient) {
607+ d.closePaletteIfCurrent(ac, 0)
608+}
609+
610+func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bool {
611+ if d == nil || ac == nil || ac.overlays == nil {
612+ return false
613+ }
614+ ac.overlays.paletteMu.Lock()
615+ if ac.overlays.palette == nil || generation != 0 && ac.overlays.paletteGeneration != generation {
616+ ac.overlays.paletteMu.Unlock()
617+ return false
618+ }
619+ instance := ac.clearPaletteLocked()
620+ ac.overlays.paletteMu.Unlock()
621+ d.remoteDiscoveryClosed(instance)
464622 return true
465623 }
466624
@@ -43,16 +43,42 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
43 routeSnapshot := ac.routeSnapshotCopy()43 routeSnapshot := ac.routeSnapshotCopy()
44 commands := d.paletteCommands()44 commands := d.paletteCommands()
45 results := d.paletteResults(sess, commands, routeSnapshot)45 results := d.paletteResults(sess, commands, routeSnapshot)
46+ model := palette.New(results)
46 ac.overlays.paletteMu.Lock()47 ac.overlays.paletteMu.Lock()
47 ac.overlays.paletteGeneration++48 ac.overlays.paletteGeneration++
48- ac.overlays.palette = palette.New(results)49+ ac.overlays.palette = model
49 ac.overlays.paletteRouteSnapshot = routeSnapshot50 ac.overlays.paletteRouteSnapshot = routeSnapshot
50 ac.overlays.paletteHints = palette.ContextualHints{}51 ac.overlays.paletteHints = palette.ContextualHints{}
51 ac.overlays.palettePreview = ""52 ac.overlays.palettePreview = ""
52 ac.overlays.paletteFeedback = ""53 ac.overlays.paletteFeedback = ""
53 ac.overlays.palettePending = nil54 ac.overlays.palettePending = nil
55+ generation := ac.overlays.paletteGeneration
54 ac.overlays.paletteMu.Unlock()56 ac.overlays.paletteMu.Unlock()
55 d.invalidateRender(sess, ac, true, "palette.go")57 d.invalidateRender(sess, ac, true, "palette.go")
58+ d.remoteDiscoveryOpened(remoteDiscoveryInstance{
59+ ac: ac, kind: remoteDiscoveryPalette, generation: generation, palette: model,
60+ })
61+}
62+
63+type paletteSessionIdentity struct {
64+ kind ports.RouteKind
65+ endpoint string
66+ lifecycle domain.SessionLifecycleID
67+}
68+
69+func localPaletteSessionIdentity(lifecycle domain.SessionLifecycleID) paletteSessionIdentity {
70+ return paletteSessionIdentity{kind: ports.RouteKindLocal, endpoint: "local", lifecycle: lifecycle}
71+}
72+
73+func remotePaletteSessionIdentity(endpoint string, lifecycle domain.SessionLifecycleID) paletteSessionIdentity {
74+ return paletteSessionIdentity{kind: ports.RouteKindRemote, endpoint: endpoint, lifecycle: lifecycle}
75+}
76+
77+func remotePaletteUnavailableReason(status remoteHostStatus, session ports.RemoteCatalogSession) string {
78+ if session.State == ports.RemoteCatalogSessionBroken {
79+ return "session_broken"
80+ }
81+ return remoteReasonForStatus(status)
56 }82 }
57 83
58 // paletteResults captures eligible named sessions before paletteMu so the84 // paletteResults captures eligible named sessions before paletteMu so the
@@ -61,16 +87,23 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
61 d.mu.Lock()87 d.mu.Lock()
62 sessions := d.sessionsSnapshotLocked()88 sessions := d.sessionsSnapshotLocked()
63 stopped := make([]inactiveSession, 0, len(d.inactive))89 stopped := make([]inactiveSession, 0, len(d.inactive))
64- localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(d.inactive))90+ represented := make(map[paletteSessionIdentity]struct{}, len(sessions)+len(d.inactive)+len(routeSnapshot.Entries))
65 for _, entry := range d.inactive {91 for _, entry := range d.inactive {
66- localLifecycles[entry.incarnation] = struct{}{}92+ represented[localPaletteSessionIdentity(entry.incarnation)] = struct{}{}
67 if entry.canResume() {93 if entry.canResume() {
68 stopped = append(stopped, entry)94 stopped = append(stopped, entry)
69 }95 }
70 }96 }
71 d.mu.Unlock()97 d.mu.Unlock()
72 98
73- results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+len(routeSnapshot.Entries))99+ remoteCatalog := d.remoteCatalogSnapshot()
100+ sortRemoteCatalog(remoteCatalog, d.remoteHostRanks())
101+ remoteSessionCount := 0
102+ for _, host := range remoteCatalog {
103+ remoteSessionCount += len(host.entry.Sessions)
104+ }
105+
106+ results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))
74 for _, cmd := range commands {107 for _, cmd := range commands {
75 results = append(results, palette.NewCommandResult(cmd))108 results = append(results, palette.NewCommandResult(cmd))
76 }109 }
@@ -80,7 +113,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
80 if snap.name == "" || snap.ephemeral {113 if snap.name == "" || snap.ephemeral {
81 continue114 continue
82 }115 }
83- localLifecycles[snap.incarnation] = struct{}{}116+ represented[localPaletteSessionIdentity(snap.incarnation)] = struct{}{}
84 if candidate == current {117 if candidate == current {
85 continue118 continue
86 }119 }
@@ -97,7 +130,29 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
97 for _, candidate := range stopped {130 for _, candidate := range stopped {
98 target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}131 target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}
99 results = append(results, palette.NewStoppedSessionResult(target, time.Unix(0, candidate.createdAt)))132 results = append(results, palette.NewStoppedSessionResult(target, time.Unix(0, candidate.createdAt)))
100- localLifecycles[target.LifecycleID] = struct{}{}133+ represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{}
134+ }
135+ type discoveredRemote struct {
136+ identity paletteSessionIdentity
137+ result palette.Result
138+ }
139+ discovered := make([]discoveredRemote, 0, remoteSessionCount)
140+ for _, host := range remoteCatalog {
141+ for _, session := range host.entry.Sessions {
142+ if session.Ephemeral {
143+ continue
144+ }
145+ key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{
146+ Host: host.entry.Host, Name: session.Name,
147+ }, session)
148+ if key.Validate() != nil || target.Validate() != nil {
149+ continue
150+ }
151+ discovered = append(discovered, discoveredRemote{
152+ identity: remotePaletteSessionIdentity(key.Host, session.LifecycleID),
153+ result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host.status, session)),
154+ })
155+ }
101 }156 }
102 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)157 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
103 for i, entry := range routeSnapshot.Entries {158 for i, entry := range routeSnapshot.Entries {
@@ -108,7 +163,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
108 if label == "" {163 if label == "" {
109 continue164 continue
110 }165 }
111- if _, represented := localLifecycles[entry.Target.LifecycleID]; represented {166+ identity := localPaletteSessionIdentity(entry.Target.LifecycleID)
167+ if _, exists := represented[identity]; entry.Kind == ports.RouteKindLocal && exists {
112 continue168 continue
113 }169 }
114 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{170 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
@@ -116,10 +172,48 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
116 Key: entry.Key,172 Key: entry.Key,
117 Generation: entry.Generation,173 Generation: entry.Generation,
118 }))174 }))
175+ if entry.Kind == ports.RouteKindLocal {
176+ represented[identity] = struct{}{}
177+ }
178+ }
179+ for _, remote := range discovered {
180+ if _, exists := represented[remote.identity]; exists {
181+ continue
182+ }
183+ results = append(results, remote.result)
184+ represented[remote.identity] = struct{}{}
119 }185 }
120 return results186 return results
121 }187 }
122 188
189+func (d *Daemon) refreshPalette(ac *attachedClient) {
190+ if d == nil || ac == nil || ac.overlays == nil {
191+ return
192+ }
193+ sess := ac.currentAttachmentSession()
194+ if sess == nil {
195+ return
196+ }
197+ rt := ac.overlays
198+ rt.paletteMu.Lock()
199+ model := rt.palette
200+ generation := rt.paletteGeneration
201+ routeSnapshot := rt.paletteRouteSnapshot
202+ routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
203+ rt.paletteMu.Unlock()
204+ if model == nil {
205+ return
206+ }
207+
208+ results := d.paletteResults(sess, d.paletteCommands(), routeSnapshot)
209+ rt.paletteMu.Lock()
210+ defer rt.paletteMu.Unlock()
211+ if rt.paletteGeneration != generation || rt.palette != model {
212+ return
213+ }
214+ model.ReplaceResults(results)
215+}
216+
123 func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints {217 func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints {
124 formatted := formatRecentRouteSnapshot(snapshot)218 formatted := formatRecentRouteSnapshot(snapshot)
125 names := make([]string, len(formatted))219 names := make([]string, len(formatted))
@@ -192,6 +286,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
192 var cmd command.Command286 var cmd command.Command
193 var sessionTarget palette.Result287 var sessionTarget palette.Result
194 var hasSessionTarget bool288 var hasSessionTarget bool
289+ var remoteTarget domain.RemoteSessionTarget
290+ var remoteKey domain.RemoteSessionKey
291+ var remoteUnavailableReason string
292+ var hasRemoteTarget bool
195 var routeTarget ports.RouteNavigationAction293 var routeTarget ports.RouteNavigationAction
196 var hasRouteTarget bool294 var hasRouteTarget bool
197 var args []string295 var args []string
@@ -199,6 +297,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
199 var generation uint64297 var generation uint64
200 var rawQuery string298 var rawQuery string
201 changed, cancel, execute := false, false, false299 changed, cancel, execute := false, false, false
300+ var closedDiscovery remoteDiscoveryInstance
202 var effect *attachmentEffectTicket301 var effect *attachmentEffectTicket
203 if len(effects) != 0 {302 if len(effects) != 0 {
204 effect = effects[0]303 effect = effects[0]
@@ -270,6 +369,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
270 routeSnapshot = ac.overlays.paletteRouteSnapshot369 routeSnapshot = ac.overlays.paletteRouteSnapshot
271 routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)370 routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
272 }371 }
372+ } else if target, ok := selected.RemoteSessionTarget(); ok {
373+ key, keyOK := selected.RemoteSessionKey()
374+ if !keyOK {
375+ changed = true
376+ return
377+ }
378+ remoteTarget = target
379+ remoteKey = key
380+ remoteUnavailableReason, _ = selected.RemoteSessionUnavailableReason()
381+ hasRemoteTarget = true
273 } else if action, ok := selected.RouteNavigationAction(); ok {382 } else if action, ok := selected.RouteNavigationAction(); ok {
274 routeTarget = action383 routeTarget = action
275 hasRouteTarget = true384 hasRouteTarget = true
@@ -303,10 +412,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
303 }412 }
304 }413 }
305 if cancel {414 if cancel {
306- ac.clearPaletteLocked()415+ closedDiscovery = ac.clearPaletteLocked()
307 }416 }
308 ac.overlays.paletteMu.Unlock()417 ac.overlays.paletteMu.Unlock()
309 if cancel {418 if cancel {
419+ d.remoteDiscoveryClosed(closedDiscovery)
310 d.invalidateRender(entry, ac, true, "palette.go")420 d.invalidateRender(entry, ac, true, "palette.go")
311 return421 return
312 }422 }
@@ -317,6 +427,29 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
317 return427 return
318 }428 }
319 429
430+ if hasRemoteTarget {
431+ if effect == nil {
432+ ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")
433+ d.invalidateRender(entry, ac, true, "palette.go")
434+ return
435+ }
436+ target := picker.Target{
437+ Session: remoteKey.ID(), Incarnation: remoteKey.LifecycleID, Name: remoteKey.Display(),
438+ RemoteKey: &remoteKey, RemoteTarget: &remoteTarget, RemoteHost: remoteKey.Host,
439+ UnavailableReason: remoteUnavailableReason, TabIndex: -1,
440+ }
441+ err := d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-remote-session")
442+ if err == nil {
443+ d.closeExecutedPalette(ac, generation, rawQuery)
444+ return
445+ }
446+ if !errors.Is(err, errAttachmentTransition) {
447+ ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")
448+ d.invalidateRender(entry, ac, true, "palette.go")
449+ }
450+ return
451+ }
452+
320 if hasRouteTarget {453 if hasRouteTarget {
321 exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}454 exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
322 if err := exec.NavigateRecentRoute(routeTarget); err != nil {455 if err := exec.NavigateRecentRoute(routeTarget); err != nil {
@@ -327,7 +460,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
327 d.invalidateRender(entry, ac, true, "palette.go")460 d.invalidateRender(entry, ac, true, "palette.go")
328 return461 return
329 }462 }
330- if ac.closeExecutedPalette(generation, rawQuery) {463+ if d.closeExecutedPalette(ac, generation, rawQuery) {
331 d.invalidateRender(entry, ac, true, "palette.go")464 d.invalidateRender(entry, ac, true, "palette.go")
332 }465 }
333 return466 return
@@ -356,7 +489,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
356 d.invalidateRender(entry, ac, true, "palette.go")489 d.invalidateRender(entry, ac, true, "palette.go")
357 return490 return
358 }491 }
359- if ac.closeExecutedPalette(generation, rawQuery) {492+ if d.closeExecutedPalette(ac, generation, rawQuery) {
360 if current := ac.currentAttachmentSession(); current != nil {493 if current := ac.currentAttachmentSession(); current != nil {
361 d.invalidateRender(current, ac, true, "palette.go")494 d.invalidateRender(current, ac, true, "palette.go")
362 }495 }
@@ -380,7 +513,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
380 d.invalidateRender(entry, ac, true, "palette.go")513 d.invalidateRender(entry, ac, true, "palette.go")
381 return514 return
382 }515 }
383- if ac.closeExecutedPalette(generation, rawQuery) {516+ if d.closeExecutedPalette(ac, generation, rawQuery) {
384 d.recordPaletteUse(cmd.Code)517 d.recordPaletteUse(cmd.Code)
385 if current := ac.currentAttachmentSession(); current != nil {518 if current := ac.currentAttachmentSession(); current != nil {
386 d.invalidateRender(current, ac, true, "palette.go")519 d.invalidateRender(current, ac, true, "palette.go")
@@ -389,7 +522,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
389 return522 return
390 }523 }
391 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"524 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
392- if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {525+ if !attachmentHandoff && !d.closeExecutedPalette(ac, generation, rawQuery) {
393 return526 return
394 }527 }
395 sess.dispatchMu.Lock()528 sess.dispatchMu.Lock()
@@ -399,14 +532,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
399 if current := ac.currentSession(); current != nil {532 if current := ac.currentSession(); current != nil {
400 currentToken := current.attachmentToken(ac, ac.transport())533 currentToken := current.attachmentToken(ac, ac.transport())
401 fresh, admitted := ac.beginAttachmentEffect(currentToken)534 fresh, admitted := ac.beginAttachmentEffect(currentToken)
402- if ac.closeExecutedPalette(generation, rawQuery) {535+ if d.closeExecutedPalette(ac, generation, rawQuery) {
403 d.invalidateRender(current, ac, true, "palette.go")536 d.invalidateRender(current, ac, true, "palette.go")
404 }537 }
405 if admitted {538 if admitted {
406 fresh.End()539 fresh.End()
407 }540 }
408 } else {541 } else {
409- ac.closeExecutedPalette(generation, rawQuery)542+ d.closeExecutedPalette(ac, generation, rawQuery)
410 }543 }
411 }544 }
412 if errors.Is(err, errAttachmentTransition) {545 if errors.Is(err, errAttachmentTransition) {
@@ -435,7 +568,8 @@ func paletteArgs(query string, cmd command.Command) []string {
435 return action.Args568 return action.Args
436 }569 }
437 570
438-func (ac *attachedClient) clearPaletteLocked() {571+func (ac *attachedClient) clearPaletteLocked() remoteDiscoveryInstance {
572+ model := ac.overlays.palette
439 ac.overlays.paletteGeneration++573 ac.overlays.paletteGeneration++
440 ac.overlays.palette = nil574 ac.overlays.palette = nil
441 ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}575 ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}
@@ -443,6 +577,9 @@ func (ac *attachedClient) clearPaletteLocked() {
443 ac.overlays.palettePreview = ""577 ac.overlays.palettePreview = ""
444 ac.overlays.paletteFeedback = ""578 ac.overlays.paletteFeedback = ""
445 ac.overlays.palettePending = nil579 ac.overlays.palettePending = nil
580+ return remoteDiscoveryInstance{
581+ ac: ac, kind: remoteDiscoveryPalette, generation: ac.overlays.paletteGeneration, palette: model,
582+ }
446 }583 }
447 584
448 func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {585 func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
@@ -454,13 +591,34 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s
454 ac.overlays.paletteFeedback = feedback591 ac.overlays.paletteFeedback = feedback
455 }592 }
456 593
457-func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {594+func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool {
458 ac.overlays.paletteMu.Lock()595 ac.overlays.paletteMu.Lock()
459- defer ac.overlays.paletteMu.Unlock()
460 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {596 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
597+ ac.overlays.paletteMu.Unlock()
461 return false598 return false
462 }599 }
463- ac.clearPaletteLocked()600+ instance := ac.clearPaletteLocked()
601+ ac.overlays.paletteMu.Unlock()
602+ d.remoteDiscoveryClosed(instance)
603+ return true
604+}
605+
606+func (d *Daemon) closePalette(ac *attachedClient) {
607+ d.closePaletteIfCurrent(ac, 0)
608+}
609+
610+func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bool {
611+ if d == nil || ac == nil || ac.overlays == nil {
612+ return false
613+ }
614+ ac.overlays.paletteMu.Lock()
615+ if ac.overlays.palette == nil || generation != 0 && ac.overlays.paletteGeneration != generation {
616+ ac.overlays.paletteMu.Unlock()
617+ return false
618+ }
619+ instance := ac.clearPaletteLocked()
620+ ac.overlays.paletteMu.Unlock()
621+ d.remoteDiscoveryClosed(instance)
464 return true622 return true
465 }623 }
466 624
M internal/usecase/daemon/palette_test.go +253 -0
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "sync"
66 "testing"
7+ "time"
78
89 "github.com/stretchr/testify/mock"
910 "github.com/stretchr/testify/require"
@@ -428,6 +429,258 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
428429 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
429430 }
430431
432+func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *testing.T) {
433+ now := time.Unix(1_000, 0)
434+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
435+ current := addControlSession(d, "current", "tab-1", "pane-1")
436+ current.ephemeral = false
437+ local := addControlSession(d, "vev", "tab-2", "pane-2")
438+ local.ephemeral = false
439+ remoteLifecycle := domain.SessionLifecycleID{21}
440+ d.remoteCatalog.mu.Lock()
441+ d.remoteCatalog.cache["user@arch"] = ports.RemoteCatalogCacheEntry{
442+ Host: "user@arch", FetchedAt: now,
443+ Sessions: []ports.RemoteCatalogSession{{
444+ LifecycleID: remoteLifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
445+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, ActiveTabID: "tab-remote",
446+ }},
447+ }
448+ d.remoteCatalog.status["user@arch"] = remoteHostFresh
449+ d.remoteCatalog.mu.Unlock()
450+
451+ results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{})
452+ var matching []palette.Result
453+ for _, result := range results {
454+ if result.DisplayText() == "Switch to session vev" || result.DisplayText() == "Switch to session vev@arch" {
455+ matching = append(matching, result)
456+ }
457+ }
458+ require.Len(t, matching, 2)
459+ remoteTarget, ok := matching[1].RemoteSessionTarget()
460+ require.True(t, ok)
461+ require.Equal(t, domain.RemoteSessionTarget{
462+ Endpoint: "user@arch", DisplayOrigin: "arch", LifecycleID: remoteLifecycle,
463+ SessionName: "vev", LiveTabID: "tab-remote",
464+ }, remoteTarget)
465+}
466+
467+func TestPaletteKeepsExactCatalogEndpointsWithEqualDisplayAndLifecycle(t *testing.T) {
468+ now := time.Unix(1_000, 0)
469+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
470+ current := addControlSession(d, "current", "tab-1", "pane-1")
471+ current.ephemeral = false
472+ lifecycle := domain.SessionLifecycleID{22}
473+ d.remoteCatalog.mu.Lock()
474+ for _, endpoint := range []string{"user@arch", "admin@arch"} {
475+ d.remoteCatalog.cache[endpoint] = ports.RemoteCatalogCacheEntry{
476+ Host: endpoint, FetchedAt: now,
477+ Sessions: []ports.RemoteCatalogSession{{
478+ LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
479+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
480+ }},
481+ }
482+ d.remoteCatalog.status[endpoint] = remoteHostFresh
483+ }
484+ d.remoteCatalog.mu.Unlock()
485+
486+ results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{})
487+ var endpoints []string
488+ for _, result := range results {
489+ if result.DisplayText() != "Switch to session vev@arch" {
490+ continue
491+ }
492+ target, ok := result.RemoteSessionTarget()
493+ require.True(t, ok)
494+ endpoints = append(endpoints, target.Endpoint)
495+ }
496+ require.ElementsMatch(t, []string{"user@arch", "admin@arch"}, endpoints)
497+}
498+
499+func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) {
500+ p, release := newBlockingPTY(t)
501+ defer release()
502+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
503+ ac.resumeCapable = true
504+ now := time.Unix(1_000, 0)
505+ d.clock = fixedRemoteRefreshClock{now: now}
506+ remoteLifecycle := domain.SessionLifecycleID{31}
507+ d.remoteCatalog.mu.Lock()
508+ d.remoteCatalog.cache["user@arch"] = ports.RemoteCatalogCacheEntry{
509+ Host: "user@arch", FetchedAt: now,
510+ Sessions: []ports.RemoteCatalogSession{{
511+ LifecycleID: remoteLifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,
512+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work",
513+ }},
514+ }
515+ d.remoteCatalog.status["user@arch"] = remoteHostFresh
516+ d.remoteCatalog.mu.Unlock()
517+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
518+
519+ d.handleInputForAttachment(token, []byte("\x1b "))
520+ awaitFrame(t, sends, ports.MsgOutput)
521+ d.handleInputForAttachment(token, []byte("work@arch\r"))
522+
523+ frame := awaitFrame(t, sends, ports.MsgAttachTarget)
524+ target, err := ports.UnmarshalAttachTarget(frame.Payload)
525+ require.NoError(t, err)
526+ remoteTarget := domain.RemoteSessionTarget{
527+ Endpoint: "user@arch", DisplayOrigin: "arch", LifecycleID: remoteLifecycle,
528+ SessionName: "work", LiveTabID: "tab-work",
529+ }
530+ require.Equal(t, ports.AttachTarget{
531+ Endpoint: "user@arch", Session: "work", Intent: ports.IntentAttach,
532+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
533+ }, target)
534+ require.False(t, ac.overlays.paletteActive())
535+ d.remoteCatalog.mu.Lock()
536+ require.Zero(t, d.remoteCatalog.consumers[ac]&remoteDiscoveryPalette)
537+ d.remoteCatalog.mu.Unlock()
538+}
539+
540+func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) {
541+ p, release := newBlockingPTY(t)
542+ defer release()
543+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
544+ now := time.Unix(1_000, 0)
545+ d.clock = fixedRemoteRefreshClock{now: now}
546+ d.remoteCatalog.mu.Lock()
547+ d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{
548+ Host: "arch", FetchedAt: now,
549+ Sessions: []ports.RemoteCatalogSession{{
550+ LifecycleID: domain.SessionLifecycleID{32}, Name: "cached", State: ports.RemoteCatalogSessionUp,
551+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-cached", Index: 0}}, ActiveTabID: "tab-cached",
552+ }},
553+ }
554+ d.remoteCatalog.status["arch"] = remoteHostCached
555+ d.remoteCatalog.mu.Unlock()
556+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
557+
558+ d.handleInputForAttachment(token, []byte("\x1b "))
559+ awaitFrame(t, sends, ports.MsgOutput)
560+ d.handleInputForAttachment(token, []byte("cached@arch\r"))
561+
562+ require.Same(t, current, ac.currentAttachmentSession())
563+ require.True(t, ac.overlays.paletteActive())
564+ history := d.notices.history()
565+ require.NotEmpty(t, history)
566+ require.Contains(t, history[len(history)-1].Message, "refreshing")
567+ require.NotContains(t, history[len(history)-1].Message, "identity changed")
568+ for {
569+ select {
570+ case frame := <-sends:
571+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
572+ default:
573+ return
574+ }
575+ }
576+}
577+
578+func TestRemoteRefreshUpdatesOpenPaletteAndPreservesQuery(t *testing.T) {
579+ hosts := &remoteRefreshHostStore{hosts: []string{"user@arch"}}
580+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(1_000, 0))
581+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "current")
582+
583+ d.enterPalette(sess, ac)
584+ request := receiveRemotePicker(t, catalog.requests, "palette catalog request")
585+ ac.overlays.paletteMu.Lock()
586+ model := ac.overlays.palette
587+ for _, r := range "vev" {
588+ model.Insert(r)
589+ }
590+ ac.overlays.paletteMu.Unlock()
591+ request.result <- remoteRefreshResult{catalog: remoteCatalogForTest(ports.RemoteCatalogSession{
592+ LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: ports.RemoteCatalogSessionUp,
593+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
594+ })}
595+ receiveRemotePicker(t, cache.stores, "palette catalog cache store")
596+ d.sessWg.Wait()
597+
598+ ac.overlays.paletteMu.Lock()
599+ require.Same(t, model, ac.overlays.palette)
600+ require.Equal(t, "vev", model.Query())
601+ matches := model.Matches()
602+ ac.overlays.paletteMu.Unlock()
603+ displayTexts := make([]string, len(matches))
604+ for i, match := range matches {
605+ displayTexts[i] = match.Result.DisplayText()
606+ }
607+ require.Contains(t, displayTexts, "Switch to session vev@arch")
608+
609+ d.closePalette(ac)
610+ d.remoteCatalog.mu.Lock()
611+ require.NotContains(t, d.remoteCatalog.consumers, ac)
612+ require.Nil(t, d.remoteCatalog.cancel)
613+ d.remoteCatalog.mu.Unlock()
614+}
615+
616+func TestRemoteRefreshTracksPickerAndPaletteOnSameAttachment(t *testing.T) {
617+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
618+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(1_100, 0))
619+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
620+
621+ d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
622+ request := receiveRemotePicker(t, catalog.requests, "picker catalog request")
623+ d.enterPalette(sess, ac)
624+ receiveRemotePickerClose(t, request.ctx.Done(), "superseded picker catalog request")
625+ request = receiveRemotePicker(t, catalog.requests, "shared catalog request")
626+
627+ d.closePalette(ac)
628+ select {
629+ case <-request.ctx.Done():
630+ t.Fatal("refresh canceled while the picker remained open")
631+ default:
632+ }
633+ d.remoteCatalog.mu.Lock()
634+ require.Equal(t, remoteDiscoveryPicker, d.remoteCatalog.consumers[ac])
635+ d.remoteCatalog.mu.Unlock()
636+
637+ d.closePicker(ac)
638+ receiveRemotePickerClose(t, request.ctx.Done(), "last discovery consumer cancellation")
639+ d.remoteCatalog.mu.Lock()
640+ require.Empty(t, d.remoteCatalog.consumers)
641+ require.Nil(t, d.remoteCatalog.cancel)
642+ d.remoteCatalog.mu.Unlock()
643+}
644+
645+func TestPaletteKeepsCatalogTargetWhenRecentRouteHasOnlyDisplayOrigin(t *testing.T) {
646+ now := time.Unix(1_200, 0)
647+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
648+ current := addControlSession(d, "current", "tab-1", "pane-1")
649+ current.ephemeral = false
650+ lifecycle := domain.SessionLifecycleID{51}
651+ d.remoteCatalog.mu.Lock()
652+ d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{
653+ Host: "arch", FetchedAt: now,
654+ Sessions: []ports.RemoteCatalogSession{{
655+ LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
656+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
657+ }},
658+ }
659+ d.remoteCatalog.status["arch"] = remoteHostFresh
660+ d.remoteCatalog.mu.Unlock()
661+
662+ results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{
663+ Generation: 2,
664+ Entries: []ports.RecentRouteEntry{{
665+ Key: 3, Generation: 1,
666+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "vev"},
667+ Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote,
668+ }},
669+ })
670+ var matching []palette.Result
671+ for _, result := range results {
672+ if result.DisplayText() == "Switch to session vev@arch" {
673+ matching = append(matching, result)
674+ }
675+ }
676+ require.Len(t, matching, 2, "presentation-only route origin cannot deduplicate an exact catalog endpoint")
677+ action, routeOK := matching[0].RouteNavigationAction()
678+ _, catalogOK := matching[1].RemoteSessionTarget()
679+ require.True(t, routeOK)
680+ require.True(t, catalogOK)
681+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 3, Generation: 1}, action)
682+}
683+
431684 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
432685 d := newTestDaemon(t, nil, stubClock{})
433686 current := addControlSession(d, "hi", "tab-1", "pane-1")
@@ -4,6 +4,7 @@ import (
4 "errors"4 "errors"
5 "sync"5 "sync"
6 "testing"6 "testing"
7+ "time"
7 8
8 "github.com/stretchr/testify/mock"9 "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -428,6 +429,258 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
428 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")429 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
429 }430 }
430 431
432+func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *testing.T) {
433+ now := time.Unix(1_000, 0)
434+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
435+ current := addControlSession(d, "current", "tab-1", "pane-1")
436+ current.ephemeral = false
437+ local := addControlSession(d, "vev", "tab-2", "pane-2")
438+ local.ephemeral = false
439+ remoteLifecycle := domain.SessionLifecycleID{21}
440+ d.remoteCatalog.mu.Lock()
441+ d.remoteCatalog.cache["user@arch"] = ports.RemoteCatalogCacheEntry{
442+ Host: "user@arch", FetchedAt: now,
443+ Sessions: []ports.RemoteCatalogSession{{
444+ LifecycleID: remoteLifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
445+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, ActiveTabID: "tab-remote",
446+ }},
447+ }
448+ d.remoteCatalog.status["user@arch"] = remoteHostFresh
449+ d.remoteCatalog.mu.Unlock()
450+
451+ results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{})
452+ var matching []palette.Result
453+ for _, result := range results {
454+ if result.DisplayText() == "Switch to session vev" || result.DisplayText() == "Switch to session vev@arch" {
455+ matching = append(matching, result)
456+ }
457+ }
458+ require.Len(t, matching, 2)
459+ remoteTarget, ok := matching[1].RemoteSessionTarget()
460+ require.True(t, ok)
461+ require.Equal(t, domain.RemoteSessionTarget{
462+ Endpoint: "user@arch", DisplayOrigin: "arch", LifecycleID: remoteLifecycle,
463+ SessionName: "vev", LiveTabID: "tab-remote",
464+ }, remoteTarget)
465+}
466+
467+func TestPaletteKeepsExactCatalogEndpointsWithEqualDisplayAndLifecycle(t *testing.T) {
468+ now := time.Unix(1_000, 0)
469+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
470+ current := addControlSession(d, "current", "tab-1", "pane-1")
471+ current.ephemeral = false
472+ lifecycle := domain.SessionLifecycleID{22}
473+ d.remoteCatalog.mu.Lock()
474+ for _, endpoint := range []string{"user@arch", "admin@arch"} {
475+ d.remoteCatalog.cache[endpoint] = ports.RemoteCatalogCacheEntry{
476+ Host: endpoint, FetchedAt: now,
477+ Sessions: []ports.RemoteCatalogSession{{
478+ LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
479+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
480+ }},
481+ }
482+ d.remoteCatalog.status[endpoint] = remoteHostFresh
483+ }
484+ d.remoteCatalog.mu.Unlock()
485+
486+ results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{})
487+ var endpoints []string
488+ for _, result := range results {
489+ if result.DisplayText() != "Switch to session vev@arch" {
490+ continue
491+ }
492+ target, ok := result.RemoteSessionTarget()
493+ require.True(t, ok)
494+ endpoints = append(endpoints, target.Endpoint)
495+ }
496+ require.ElementsMatch(t, []string{"user@arch", "admin@arch"}, endpoints)
497+}
498+
499+func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) {
500+ p, release := newBlockingPTY(t)
501+ defer release()
502+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
503+ ac.resumeCapable = true
504+ now := time.Unix(1_000, 0)
505+ d.clock = fixedRemoteRefreshClock{now: now}
506+ remoteLifecycle := domain.SessionLifecycleID{31}
507+ d.remoteCatalog.mu.Lock()
508+ d.remoteCatalog.cache["user@arch"] = ports.RemoteCatalogCacheEntry{
509+ Host: "user@arch", FetchedAt: now,
510+ Sessions: []ports.RemoteCatalogSession{{
511+ LifecycleID: remoteLifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,
512+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work",
513+ }},
514+ }
515+ d.remoteCatalog.status["user@arch"] = remoteHostFresh
516+ d.remoteCatalog.mu.Unlock()
517+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
518+
519+ d.handleInputForAttachment(token, []byte("\x1b "))
520+ awaitFrame(t, sends, ports.MsgOutput)
521+ d.handleInputForAttachment(token, []byte("work@arch\r"))
522+
523+ frame := awaitFrame(t, sends, ports.MsgAttachTarget)
524+ target, err := ports.UnmarshalAttachTarget(frame.Payload)
525+ require.NoError(t, err)
526+ remoteTarget := domain.RemoteSessionTarget{
527+ Endpoint: "user@arch", DisplayOrigin: "arch", LifecycleID: remoteLifecycle,
528+ SessionName: "work", LiveTabID: "tab-work",
529+ }
530+ require.Equal(t, ports.AttachTarget{
531+ Endpoint: "user@arch", Session: "work", Intent: ports.IntentAttach,
532+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
533+ }, target)
534+ require.False(t, ac.overlays.paletteActive())
535+ d.remoteCatalog.mu.Lock()
536+ require.Zero(t, d.remoteCatalog.consumers[ac]&remoteDiscoveryPalette)
537+ d.remoteCatalog.mu.Unlock()
538+}
539+
540+func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) {
541+ p, release := newBlockingPTY(t)
542+ defer release()
543+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
544+ now := time.Unix(1_000, 0)
545+ d.clock = fixedRemoteRefreshClock{now: now}
546+ d.remoteCatalog.mu.Lock()
547+ d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{
548+ Host: "arch", FetchedAt: now,
549+ Sessions: []ports.RemoteCatalogSession{{
550+ LifecycleID: domain.SessionLifecycleID{32}, Name: "cached", State: ports.RemoteCatalogSessionUp,
551+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-cached", Index: 0}}, ActiveTabID: "tab-cached",
552+ }},
553+ }
554+ d.remoteCatalog.status["arch"] = remoteHostCached
555+ d.remoteCatalog.mu.Unlock()
556+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
557+
558+ d.handleInputForAttachment(token, []byte("\x1b "))
559+ awaitFrame(t, sends, ports.MsgOutput)
560+ d.handleInputForAttachment(token, []byte("cached@arch\r"))
561+
562+ require.Same(t, current, ac.currentAttachmentSession())
563+ require.True(t, ac.overlays.paletteActive())
564+ history := d.notices.history()
565+ require.NotEmpty(t, history)
566+ require.Contains(t, history[len(history)-1].Message, "refreshing")
567+ require.NotContains(t, history[len(history)-1].Message, "identity changed")
568+ for {
569+ select {
570+ case frame := <-sends:
571+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
572+ default:
573+ return
574+ }
575+ }
576+}
577+
578+func TestRemoteRefreshUpdatesOpenPaletteAndPreservesQuery(t *testing.T) {
579+ hosts := &remoteRefreshHostStore{hosts: []string{"user@arch"}}
580+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(1_000, 0))
581+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "current")
582+
583+ d.enterPalette(sess, ac)
584+ request := receiveRemotePicker(t, catalog.requests, "palette catalog request")
585+ ac.overlays.paletteMu.Lock()
586+ model := ac.overlays.palette
587+ for _, r := range "vev" {
588+ model.Insert(r)
589+ }
590+ ac.overlays.paletteMu.Unlock()
591+ request.result <- remoteRefreshResult{catalog: remoteCatalogForTest(ports.RemoteCatalogSession{
592+ LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: ports.RemoteCatalogSessionUp,
593+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
594+ })}
595+ receiveRemotePicker(t, cache.stores, "palette catalog cache store")
596+ d.sessWg.Wait()
597+
598+ ac.overlays.paletteMu.Lock()
599+ require.Same(t, model, ac.overlays.palette)
600+ require.Equal(t, "vev", model.Query())
601+ matches := model.Matches()
602+ ac.overlays.paletteMu.Unlock()
603+ displayTexts := make([]string, len(matches))
604+ for i, match := range matches {
605+ displayTexts[i] = match.Result.DisplayText()
606+ }
607+ require.Contains(t, displayTexts, "Switch to session vev@arch")
608+
609+ d.closePalette(ac)
610+ d.remoteCatalog.mu.Lock()
611+ require.NotContains(t, d.remoteCatalog.consumers, ac)
612+ require.Nil(t, d.remoteCatalog.cancel)
613+ d.remoteCatalog.mu.Unlock()
614+}
615+
616+func TestRemoteRefreshTracksPickerAndPaletteOnSameAttachment(t *testing.T) {
617+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
618+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(1_100, 0))
619+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
620+
621+ d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
622+ request := receiveRemotePicker(t, catalog.requests, "picker catalog request")
623+ d.enterPalette(sess, ac)
624+ receiveRemotePickerClose(t, request.ctx.Done(), "superseded picker catalog request")
625+ request = receiveRemotePicker(t, catalog.requests, "shared catalog request")
626+
627+ d.closePalette(ac)
628+ select {
629+ case <-request.ctx.Done():
630+ t.Fatal("refresh canceled while the picker remained open")
631+ default:
632+ }
633+ d.remoteCatalog.mu.Lock()
634+ require.Equal(t, remoteDiscoveryPicker, d.remoteCatalog.consumers[ac])
635+ d.remoteCatalog.mu.Unlock()
636+
637+ d.closePicker(ac)
638+ receiveRemotePickerClose(t, request.ctx.Done(), "last discovery consumer cancellation")
639+ d.remoteCatalog.mu.Lock()
640+ require.Empty(t, d.remoteCatalog.consumers)
641+ require.Nil(t, d.remoteCatalog.cancel)
642+ d.remoteCatalog.mu.Unlock()
643+}
644+
645+func TestPaletteKeepsCatalogTargetWhenRecentRouteHasOnlyDisplayOrigin(t *testing.T) {
646+ now := time.Unix(1_200, 0)
647+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
648+ current := addControlSession(d, "current", "tab-1", "pane-1")
649+ current.ephemeral = false
650+ lifecycle := domain.SessionLifecycleID{51}
651+ d.remoteCatalog.mu.Lock()
652+ d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{
653+ Host: "arch", FetchedAt: now,
654+ Sessions: []ports.RemoteCatalogSession{{
655+ LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
656+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
657+ }},
658+ }
659+ d.remoteCatalog.status["arch"] = remoteHostFresh
660+ d.remoteCatalog.mu.Unlock()
661+
662+ results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{
663+ Generation: 2,
664+ Entries: []ports.RecentRouteEntry{{
665+ Key: 3, Generation: 1,
666+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "vev"},
667+ Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote,
668+ }},
669+ })
670+ var matching []palette.Result
671+ for _, result := range results {
672+ if result.DisplayText() == "Switch to session vev@arch" {
673+ matching = append(matching, result)
674+ }
675+ }
676+ require.Len(t, matching, 2, "presentation-only route origin cannot deduplicate an exact catalog endpoint")
677+ action, routeOK := matching[0].RouteNavigationAction()
678+ _, catalogOK := matching[1].RemoteSessionTarget()
679+ require.True(t, routeOK)
680+ require.True(t, catalogOK)
681+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 3, Generation: 1}, action)
682+}
683+
431 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {684 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
432 d := newTestDaemon(t, nil, stubClock{})685 d := newTestDaemon(t, nil, stubClock{})
433 current := addControlSession(d, "hi", "tab-1", "pane-1")686 current := addControlSession(d, "hi", "tab-1", "pane-1")
M internal/usecase/daemon/picker.go +7 -7
@@ -86,7 +86,7 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
8686 if rt.beforeRemotePickerRegistration != nil {
8787 rt.beforeRemotePickerRegistration()
8888 }
89- d.remotePickerOpened(instance)
89+ d.remoteDiscoveryOpened(instance.discoveryInstance())
9090 }
9191
9292 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
@@ -165,8 +165,8 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
165165 })
166166 }
167167
168- catalog := d.remotePickerCatalogSnapshot()
169- sortRemotePickerCatalog(catalog, d.remotePickerHostRanks())
168+ catalog := d.remoteCatalogSnapshot()
169+ sortRemoteCatalog(catalog, d.remoteHostRanks())
170170
171171 catalogRows := 0
172172 for _, host := range catalog {
@@ -283,7 +283,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
283283 },
284284 afterClose: func() {
285285 d.clearPreviewGeneration(ac, previewGeneration)
286- d.remotePickerClosed(instance)
286+ d.remoteDiscoveryClosed(instance.discoveryInstance())
287287 if sess := ac.currentAttachmentSession(); sess != nil {
288288 d.invalidateRender(sess, ac, true, "picker.go")
289289 }
@@ -742,7 +742,7 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
742742 ac.overlays.pickerMu.Unlock()
743743 d.clearPreviewGeneration(ac, previewGeneration)
744744 if instance.model != nil {
745- d.remotePickerClosed(instance)
745+ d.remoteDiscoveryClosed(instance.discoveryInstance())
746746 }
747747 return true
748748 }
@@ -969,7 +969,7 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT
969969 }
970970 remoteTarget := *target.RemoteTarget
971971 key := *target.RemoteKey
972- if err := remoteTarget.Validate(); err != nil || key.Validate() != nil || target.Session != key.ID() || key.Host != remoteTarget.Endpoint || key.Name != remoteTarget.SessionName || key.LifecycleID != remoteTarget.LifecycleID || !d.remotePickerTargetReadyTarget(remoteTarget) {
972+ if err := remoteTarget.Validate(); err != nil || key.Validate() != nil || target.Session != key.ID() || key.Host != remoteTarget.Endpoint || key.Name != remoteTarget.SessionName || key.LifecycleID != remoteTarget.LifecycleID || !d.remoteCatalogTargetReady(remoteTarget) {
973973 return failUnavailable()
974974 }
975975 handoff := ports.AttachTarget{
@@ -1005,7 +1005,7 @@ func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCache
10051005 return entry, true
10061006 }
10071007
1008-func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
1008+func (d *Daemon) remoteCatalogTargetReady(target domain.RemoteSessionTarget) bool {
10091009 if d == nil || target.Validate() != nil {
10101010 return false
10111011 }
@@ -86,7 +86,7 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
86 if rt.beforeRemotePickerRegistration != nil {86 if rt.beforeRemotePickerRegistration != nil {
87 rt.beforeRemotePickerRegistration()87 rt.beforeRemotePickerRegistration()
88 }88 }
89- d.remotePickerOpened(instance)89+ d.remoteDiscoveryOpened(instance.discoveryInstance())
90 }90 }
91 91
92 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally92 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
@@ -165,8 +165,8 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
165 })165 })
166 }166 }
167 167
168- catalog := d.remotePickerCatalogSnapshot()168+ catalog := d.remoteCatalogSnapshot()
169- sortRemotePickerCatalog(catalog, d.remotePickerHostRanks())169+ sortRemoteCatalog(catalog, d.remoteHostRanks())
170 170
171 catalogRows := 0171 catalogRows := 0
172 for _, host := range catalog {172 for _, host := range catalog {
@@ -283,7 +283,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
283 },283 },
284 afterClose: func() {284 afterClose: func() {
285 d.clearPreviewGeneration(ac, previewGeneration)285 d.clearPreviewGeneration(ac, previewGeneration)
286- d.remotePickerClosed(instance)286+ d.remoteDiscoveryClosed(instance.discoveryInstance())
287 if sess := ac.currentAttachmentSession(); sess != nil {287 if sess := ac.currentAttachmentSession(); sess != nil {
288 d.invalidateRender(sess, ac, true, "picker.go")288 d.invalidateRender(sess, ac, true, "picker.go")
289 }289 }
@@ -742,7 +742,7 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
742 ac.overlays.pickerMu.Unlock()742 ac.overlays.pickerMu.Unlock()
743 d.clearPreviewGeneration(ac, previewGeneration)743 d.clearPreviewGeneration(ac, previewGeneration)
744 if instance.model != nil {744 if instance.model != nil {
745- d.remotePickerClosed(instance)745+ d.remoteDiscoveryClosed(instance.discoveryInstance())
746 }746 }
747 return true747 return true
748 }748 }
@@ -969,7 +969,7 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT
969 }969 }
970 remoteTarget := *target.RemoteTarget970 remoteTarget := *target.RemoteTarget
971 key := *target.RemoteKey971 key := *target.RemoteKey
972- if err := remoteTarget.Validate(); err != nil || key.Validate() != nil || target.Session != key.ID() || key.Host != remoteTarget.Endpoint || key.Name != remoteTarget.SessionName || key.LifecycleID != remoteTarget.LifecycleID || !d.remotePickerTargetReadyTarget(remoteTarget) {972+ if err := remoteTarget.Validate(); err != nil || key.Validate() != nil || target.Session != key.ID() || key.Host != remoteTarget.Endpoint || key.Name != remoteTarget.SessionName || key.LifecycleID != remoteTarget.LifecycleID || !d.remoteCatalogTargetReady(remoteTarget) {
973 return failUnavailable()973 return failUnavailable()
974 }974 }
975 handoff := ports.AttachTarget{975 handoff := ports.AttachTarget{
@@ -1005,7 +1005,7 @@ func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCache
1005 return entry, true1005 return entry, true
1006 }1006 }
1007 1007
1008-func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {1008+func (d *Daemon) remoteCatalogTargetReady(target domain.RemoteSessionTarget) bool {
1009 if d == nil || target.Validate() != nil {1009 if d == nil || target.Validate() != nil {
1010 return false1010 return false
1011 }1011 }
M internal/usecase/daemon/remote_catalog_state.go +19 -12
@@ -24,23 +24,30 @@ const (
2424
2525 // remoteCatalogState owns the cache-derived discovery state. Its mutex never
2626 // covers cache or transport I/O; writeMu only serializes complete cache writes.
27+type remoteDiscoveryConsumerKind uint8
28+
29+const (
30+ remoteDiscoveryPicker remoteDiscoveryConsumerKind = 1 << iota
31+ remoteDiscoveryPalette
32+)
33+
2734 type remoteCatalogState struct {
28- mu sync.Mutex
29- cache map[string]ports.RemoteCatalogCacheEntry
30- status map[string]remoteHostStatus
31- failure map[string]time.Time
32- refresh uint64
33- cancel context.CancelFunc
34- pickers map[*attachedClient]struct{}
35- writeMu sync.Mutex
35+ mu sync.Mutex
36+ cache map[string]ports.RemoteCatalogCacheEntry
37+ status map[string]remoteHostStatus
38+ failure map[string]time.Time
39+ refresh uint64
40+ cancel context.CancelFunc
41+ consumers map[*attachedClient]remoteDiscoveryConsumerKind
42+ writeMu sync.Mutex
3643 }
3744
3845 func newRemoteCatalogState() remoteCatalogState {
3946 return remoteCatalogState{
40- cache: make(map[string]ports.RemoteCatalogCacheEntry),
41- status: make(map[string]remoteHostStatus),
42- failure: make(map[string]time.Time),
43- pickers: make(map[*attachedClient]struct{}),
47+ cache: make(map[string]ports.RemoteCatalogCacheEntry),
48+ status: make(map[string]remoteHostStatus),
49+ failure: make(map[string]time.Time),
50+ consumers: make(map[*attachedClient]remoteDiscoveryConsumerKind),
4451 }
4552 }
4653
@@ -24,23 +24,30 @@ const (
24 24
25 // remoteCatalogState owns the cache-derived discovery state. Its mutex never25 // remoteCatalogState owns the cache-derived discovery state. Its mutex never
26 // covers cache or transport I/O; writeMu only serializes complete cache writes.26 // covers cache or transport I/O; writeMu only serializes complete cache writes.
27+type remoteDiscoveryConsumerKind uint8
28+
29+const (
30+ remoteDiscoveryPicker remoteDiscoveryConsumerKind = 1 << iota
31+ remoteDiscoveryPalette
32+)
33+
27 type remoteCatalogState struct {34 type remoteCatalogState struct {
28- mu sync.Mutex35+ mu sync.Mutex
29- cache map[string]ports.RemoteCatalogCacheEntry36+ cache map[string]ports.RemoteCatalogCacheEntry
30- status map[string]remoteHostStatus37+ status map[string]remoteHostStatus
31- failure map[string]time.Time38+ failure map[string]time.Time
32- refresh uint6439+ refresh uint64
33- cancel context.CancelFunc40+ cancel context.CancelFunc
34- pickers map[*attachedClient]struct{}41+ consumers map[*attachedClient]remoteDiscoveryConsumerKind
35- writeMu sync.Mutex42+ writeMu sync.Mutex
36 }43 }
37 44
38 func newRemoteCatalogState() remoteCatalogState {45 func newRemoteCatalogState() remoteCatalogState {
39 return remoteCatalogState{46 return remoteCatalogState{
40- cache: make(map[string]ports.RemoteCatalogCacheEntry),47+ cache: make(map[string]ports.RemoteCatalogCacheEntry),
41- status: make(map[string]remoteHostStatus),48+ status: make(map[string]remoteHostStatus),
42- failure: make(map[string]time.Time),49+ failure: make(map[string]time.Time),
43- pickers: make(map[*attachedClient]struct{}),50+ consumers: make(map[*attachedClient]remoteDiscoveryConsumerKind),
44 }51 }
45 }52 }
46 53
M internal/usecase/daemon/remote_picker.go +204 -92
@@ -11,10 +11,11 @@ import (
1111
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/palette"
1415 "github.com/bnema/vev/internal/usecase/picker"
1516 )
1617
17-type remotePickerCatalogEntry struct {
18+type remoteCatalogPresentationEntry struct {
1819 entry ports.RemoteCatalogCacheEntry
1920 status remoteHostStatus
2021 }
@@ -28,12 +29,25 @@ type remotePickerInstance struct {
2829 model *picker.Model
2930 }
3031
31-// remotePickerCatalogSnapshot copies the cache-derived state needed for a
32-// picker before any row sorting begins. No remote-state lock survives this
33-// snapshot.
34-const remotePickerAttachTTL = 30 * time.Second
32+func (i remotePickerInstance) discoveryInstance() remoteDiscoveryInstance {
33+ return remoteDiscoveryInstance{
34+ ac: i.ac, kind: remoteDiscoveryPicker, generation: i.generation, picker: i.model,
35+ }
36+}
37+
38+type remoteDiscoveryInstance struct {
39+ ac *attachedClient
40+ kind remoteDiscoveryConsumerKind
41+ generation uint64
42+ picker *picker.Model
43+ palette *palette.Model
44+}
45+
46+const remoteCatalogAttachTTL = 30 * time.Second
3547
36-func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
48+// remoteCatalogSnapshot copies the cache-derived discovery state before any
49+// overlay sorting begins. No remote-state lock survives this snapshot.
50+func (d *Daemon) remoteCatalogSnapshot() []remoteCatalogPresentationEntry {
3751 if d == nil {
3852 return nil
3953 }
@@ -44,10 +58,10 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
4458 d.remoteCatalog.status[host] = remoteHostStale
4559 }
4660 }
47- entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
61+ entries := make([]remoteCatalogPresentationEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
4862 seen := make(map[string]struct{}, len(d.remoteCatalog.cache))
4963 for host, entry := range d.remoteCatalog.cache {
50- entries = append(entries, remotePickerCatalogEntry{
64+ entries = append(entries, remoteCatalogPresentationEntry{
5165 entry: cloneRemoteCatalogEntry(entry),
5266 status: d.remoteCatalog.status[host],
5367 })
@@ -57,7 +71,7 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
5771 if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch && status != remoteHostMalformed) {
5872 continue
5973 }
60- entries = append(entries, remotePickerCatalogEntry{
74+ entries = append(entries, remoteCatalogPresentationEntry{
6175 entry: ports.RemoteCatalogCacheEntry{Host: host},
6276 status: status,
6377 })
@@ -66,14 +80,14 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
6680 return entries
6781 }
6882
69-func (d *Daemon) remotePickerHostRanks() map[string]int {
83+func (d *Daemon) remoteHostRanks() map[string]int {
7084 ranks := make(map[string]int)
7185 if d == nil || d.remoteHostStore == nil {
7286 return ranks
7387 }
7488 pinned, learned, err := d.remoteHostStore.Hosts()
7589 if err != nil {
76- d.log.Warn("listing remote hosts for picker failed", "err", err)
90+ d.log.Warn("listing remote hosts for discovery failed", "err", err)
7791 return ranks
7892 }
7993 for _, host := range append(pinned, learned...) {
@@ -85,7 +99,7 @@ func (d *Daemon) remotePickerHostRanks() map[string]int {
8599 }
86100
87101 func remoteCatalogExpired(fetchedAt, now time.Time) bool {
88- return fetchedAt.IsZero() || (!now.Before(fetchedAt) && now.Sub(fetchedAt) >= remotePickerAttachTTL)
102+ return fetchedAt.IsZero() || (!now.Before(fetchedAt) && now.Sub(fetchedAt) >= remoteCatalogAttachTTL)
89103 }
90104
91105 func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {
@@ -128,9 +142,43 @@ func remoteSessionStateStopped(state ports.RemoteCatalogSessionState) bool {
128142 return state == ports.RemoteCatalogSessionDown
129143 }
130144
131-func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
145+func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session ports.RemoteCatalogSession) (domain.RemoteSessionKey, domain.RemoteSessionTarget) {
132146 key.LifecycleID = session.LifecycleID
133147 key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
148+ target := domain.RemoteSessionTarget{
149+ Endpoint: key.Host,
150+ DisplayOrigin: key.DisplayOrigin,
151+ LifecycleID: session.LifecycleID,
152+ SessionName: session.Name,
153+ Stopped: remoteSessionStateStopped(session.State),
154+ }
155+ tabs := ports.CatalogTabs(session)
156+ if len(tabs) == 0 {
157+ return key, target
158+ }
159+ if target.Stopped {
160+ first := tabs[0]
161+ if first.ID != "" {
162+ target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
163+ } else {
164+ tabCount := min(len(tabs), math.MaxUint16)
165+ target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(tabCount))
166+ }
167+ return key, target
168+ }
169+ active := 0
170+ for i, tab := range tabs {
171+ if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
172+ active = i
173+ break
174+ }
175+ }
176+ target.LiveTabID = domain.TabStableID(tabs[active].ID)
177+ return key, target
178+}
179+
180+func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
181+ key, target := remoteCatalogSessionTarget(key, session)
134182 availability := remotePickerAvailability(status)
135183 stopped := remoteSessionStateStopped(session.State)
136184 broken := session.State == ports.RemoteCatalogSessionBroken
@@ -153,26 +201,6 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
153201 Attention: tab.Attention,
154202 })
155203 }
156- target := domain.RemoteSessionTarget{
157- Endpoint: key.Host,
158- DisplayOrigin: key.DisplayOrigin,
159- LifecycleID: session.LifecycleID,
160- SessionName: session.Name,
161- Stopped: stopped,
162- }
163- if len(tabs) != 0 {
164- first := tabs[0]
165- if stopped {
166- if first.ID != "" {
167- target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
168- } else {
169- tabCount := min(len(tabs), math.MaxUint16)
170- target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(tabCount))
171- }
172- } else {
173- target.LiveTabID = domain.TabStableID(tabs[active].ID)
174- }
175- }
176204 // Keep the structured session identity on rows even when one tab ID is
177205 // malformed or a broken session cannot be activated. Cursor navigation
178206 // must remain possible, while sendRemoteAttachTargetForAttachment will
@@ -265,7 +293,7 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi
265293 }
266294 }
267295
268-func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
296+func sortRemoteCatalog(entries []remoteCatalogPresentationEntry, ranks map[string]int) {
269297 for i := range entries {
270298 sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
271299 l, r := entries[i].entry.Sessions[left], entries[i].entry.Sessions[right]
@@ -289,63 +317,106 @@ func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[strin
289317 })
290318 }
291319
292-func (d *Daemon) remotePickerOpened(instance remotePickerInstance) {
293- if d.registerRemotePicker(instance) {
294- d.startRemotePickerRefresh(instance)
320+func (d *Daemon) remoteDiscoveryOpened(instance remoteDiscoveryInstance) {
321+ if d.registerRemoteDiscoveryConsumer(instance) {
322+ d.startRemoteDiscoveryRefresh(instance)
295323 }
296324 }
297325
298-// registerRemotePicker installs one exact picker generation as a refresh owner.
299-// Keeping this step separate gives refresh startup a second atomic validation
300-// point: a close can win between registration and startup without being undone
301-// by a delayed opener.
302-func (d *Daemon) registerRemotePicker(instance remotePickerInstance) bool {
303- if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
326+// registerRemoteDiscoveryConsumer installs one exact overlay generation as a
327+// refresh owner. Overlay ownership always nests its model lock before the
328+// catalog lock; no path may acquire an overlay lock while retaining catalog.mu.
329+func (d *Daemon) registerRemoteDiscoveryConsumer(instance remoteDiscoveryInstance) bool {
330+ if d == nil || instance.ac == nil || instance.ac.overlays == nil {
304331 return false
305332 }
306333 rt := instance.ac.overlays
307- // Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may
308- // acquire pickerMu while retaining the catalog lock.
309- rt.pickerMu.Lock()
310- defer rt.pickerMu.Unlock()
311- if rt.pickerGeneration != instance.generation || rt.picker != instance.model {
334+ switch instance.kind {
335+ case remoteDiscoveryPicker:
336+ if instance.picker == nil {
337+ return false
338+ }
339+ rt.pickerMu.Lock()
340+ defer rt.pickerMu.Unlock()
341+ if rt.pickerGeneration != instance.generation || rt.picker != instance.picker {
342+ return false
343+ }
344+ case remoteDiscoveryPalette:
345+ if instance.palette == nil {
346+ return false
347+ }
348+ rt.paletteMu.Lock()
349+ defer rt.paletteMu.Unlock()
350+ if rt.paletteGeneration != instance.generation || rt.palette != instance.palette {
351+ return false
352+ }
353+ default:
312354 return false
313355 }
314356 d.remoteCatalog.mu.Lock()
315- d.remoteCatalog.pickers[instance.ac] = struct{}{}
357+ d.remoteCatalog.consumers[instance.ac] |= instance.kind
316358 d.remoteCatalog.mu.Unlock()
317359 return true
318360 }
319361
320-func (d *Daemon) remotePickerClosed(instance remotePickerInstance) {
321- if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
362+func (d *Daemon) remoteDiscoveryClosed(instance remoteDiscoveryInstance) {
363+ if d == nil || instance.ac == nil || instance.ac.overlays == nil {
322364 return
323365 }
324366 rt := instance.ac.overlays
325- rt.pickerMu.Lock()
326- current := rt.pickerGeneration == instance.generation && rt.picker == nil
367+ var current bool
368+ var unlock func()
369+ switch instance.kind {
370+ case remoteDiscoveryPicker:
371+ if instance.picker == nil {
372+ return
373+ }
374+ rt.pickerMu.Lock()
375+ unlock = rt.pickerMu.Unlock
376+ current = rt.pickerGeneration == instance.generation && rt.picker == nil
377+ case remoteDiscoveryPalette:
378+ if instance.palette == nil {
379+ return
380+ }
381+ rt.paletteMu.Lock()
382+ unlock = rt.paletteMu.Unlock
383+ current = rt.paletteGeneration == instance.generation && rt.palette == nil
384+ default:
385+ return
386+ }
327387 if !current {
328- rt.pickerMu.Unlock()
388+ unlock()
329389 return
330390 }
331391 d.remoteCatalog.mu.Lock()
332- delete(d.remoteCatalog.pickers, instance.ac)
333- if len(d.remoteCatalog.pickers) != 0 {
392+ owners := d.remoteCatalog.consumers[instance.ac]
393+ if owners&instance.kind == 0 {
334394 d.remoteCatalog.mu.Unlock()
335- rt.pickerMu.Unlock()
395+ unlock()
396+ return
397+ }
398+ owners &^= instance.kind
399+ if owners == 0 {
400+ delete(d.remoteCatalog.consumers, instance.ac)
401+ } else {
402+ d.remoteCatalog.consumers[instance.ac] = owners
403+ }
404+ if len(d.remoteCatalog.consumers) != 0 {
405+ d.remoteCatalog.mu.Unlock()
406+ unlock()
336407 return
337408 }
338409 d.remoteCatalog.refresh++
339410 cancel := d.remoteCatalog.cancel
340411 d.remoteCatalog.cancel = nil
341412 d.remoteCatalog.mu.Unlock()
342- rt.pickerMu.Unlock()
413+ unlock()
343414 if cancel != nil {
344415 cancel()
345416 }
346417 }
347418
348-func (d *Daemon) cancelRemotePickerRefresh() {
419+func (d *Daemon) cancelRemoteDiscoveryRefresh() {
349420 if d == nil {
350421 return
351422 }
@@ -359,7 +430,7 @@ func (d *Daemon) cancelRemotePickerRefresh() {
359430 }
360431 }
361432
362-func (d *Daemon) remotePickerHosts() ([]string, error) {
433+func (d *Daemon) remoteDiscoveryHosts() ([]string, error) {
363434 if d == nil || d.remoteHostStore == nil {
364435 return nil, nil
365436 }
@@ -383,12 +454,12 @@ func (d *Daemon) remotePickerHosts() ([]string, error) {
383454 return hosts, nil
384455 }
385456
386-// startRemotePickerRefresh replaces the current refresh generation and launches
387-// one bounded catalog call per known host. Installing the generation is gated
388-// by the exact picker generation that registered ownership. Host-store and
389-// remote I/O happen after every architecture lock has been released.
390-func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 {
391- if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
457+// startRemoteDiscoveryRefresh replaces the current refresh generation and
458+// launches one bounded catalog call per known host. Installing the generation
459+// is gated by the exact overlay generation that registered ownership. Host
460+// store and remote I/O happen after every architecture lock is released.
461+func (d *Daemon) startRemoteDiscoveryRefresh(instance remoteDiscoveryInstance) uint64 {
462+ if d == nil || instance.ac == nil || instance.ac.overlays == nil {
392463 return 0
393464 }
394465 root := context.Background()
@@ -398,22 +469,47 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
398469 ctx, cancel := context.WithCancel(root)
399470
400471 rt := instance.ac.overlays
401- rt.pickerMu.Lock()
402- d.remoteCatalog.mu.Lock()
403- _, owner := d.remoteCatalog.pickers[instance.ac]
404- current := rt.pickerGeneration == instance.generation && rt.picker == instance.model
405- if !current || !owner || len(d.remoteCatalog.pickers) == 0 {
406- d.remoteCatalog.mu.Unlock()
472+ install := func(current bool) (context.CancelFunc, uint64, bool) {
473+ d.remoteCatalog.mu.Lock()
474+ defer d.remoteCatalog.mu.Unlock()
475+ owner := d.remoteCatalog.consumers[instance.ac]&instance.kind != 0
476+ if !current || !owner || len(d.remoteCatalog.consumers) == 0 {
477+ return nil, 0, false
478+ }
479+ previous := d.remoteCatalog.cancel
480+ d.remoteCatalog.refresh++
481+ generation := d.remoteCatalog.refresh
482+ d.remoteCatalog.cancel = cancel
483+ return previous, generation, true
484+ }
485+ var previous context.CancelFunc
486+ var generation uint64
487+ var installed bool
488+ switch instance.kind {
489+ case remoteDiscoveryPicker:
490+ if instance.picker == nil {
491+ cancel()
492+ return 0
493+ }
494+ rt.pickerMu.Lock()
495+ previous, generation, installed = install(rt.pickerGeneration == instance.generation && rt.picker == instance.picker)
407496 rt.pickerMu.Unlock()
497+ case remoteDiscoveryPalette:
498+ if instance.palette == nil {
499+ cancel()
500+ return 0
501+ }
502+ rt.paletteMu.Lock()
503+ previous, generation, installed = install(rt.paletteGeneration == instance.generation && rt.palette == instance.palette)
504+ rt.paletteMu.Unlock()
505+ default:
506+ cancel()
507+ return 0
508+ }
509+ if !installed {
408510 cancel()
409511 return 0
410512 }
411- previous := d.remoteCatalog.cancel
412- d.remoteCatalog.refresh++
413- generation := d.remoteCatalog.refresh
414- d.remoteCatalog.cancel = cancel
415- d.remoteCatalog.mu.Unlock()
416- rt.pickerMu.Unlock()
417513 if previous != nil {
418514 previous()
419515 }
@@ -421,7 +517,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
421517 return generation
422518 }
423519
424- hosts, err := d.remotePickerHosts()
520+ hosts, err := d.remoteDiscoveryHosts()
425521 if err != nil {
426522 d.log.Warn("listing remote hosts for refresh failed", "err", err)
427523 return generation
@@ -454,7 +550,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
454550 if cacheChanged {
455551 d.persistRemoteCatalogCache()
456552 }
457- d.refreshRemoteOpenPickers()
553+ d.refreshRemoteDiscoveryConsumers()
458554
459555 for _, host := range hosts {
460556 d.sessWg.Go(func() {
@@ -465,7 +561,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
465561 return generation
466562 }
467563
468-func containsRemotePickerHost(hosts []string, target string) bool {
564+func containsRemoteDiscoveryHost(hosts []string, target string) bool {
469565 for _, host := range hosts {
470566 if host == target {
471567 return true
@@ -488,12 +584,12 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
488584 return false
489585 }
490586
491- hosts, hostsErr := d.remotePickerHosts()
587+ hosts, hostsErr := d.remoteDiscoveryHosts()
492588 if hostsErr != nil {
493589 d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
494590 return false
495591 }
496- stillKnown := containsRemotePickerHost(hosts, host)
592+ stillKnown := containsRemoteDiscoveryHost(hosts, host)
497593 now := d.clock.Now()
498594 if listErr == nil {
499595 if err := ports.ValidateRemoteCatalog(catalog); err != nil {
@@ -514,7 +610,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
514610 d.remoteCatalog.mu.Unlock()
515611 if cacheChanged {
516612 d.persistRemoteCatalogCache()
517- d.refreshRemoteOpenPickers()
613+ d.refreshRemoteDiscoveryConsumers()
518614 }
519615 return cacheChanged
520616 }
@@ -532,7 +628,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
532628 d.remoteCatalog.status[host] = status
533629 d.remoteCatalog.failure[host] = now
534630 d.remoteCatalog.mu.Unlock()
535- d.refreshRemoteOpenPickers()
631+ d.refreshRemoteDiscoveryConsumers()
536632 return false
537633 }
538634 d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{
@@ -545,26 +641,42 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
545641 d.remoteCatalog.mu.Unlock()
546642
547643 d.persistRemoteCatalogCache()
548- d.refreshRemoteOpenPickers()
644+ d.refreshRemoteDiscoveryConsumers()
549645 return true
550646 }
551647
552-func (d *Daemon) refreshRemoteOpenPickers() {
648+func (d *Daemon) refreshRemoteDiscoveryConsumers() {
553649 if d == nil {
554650 return
555651 }
652+ type consumer struct {
653+ ac *attachedClient
654+ kind remoteDiscoveryConsumerKind
655+ }
556656 d.remoteCatalog.mu.Lock()
557- clients := make([]*attachedClient, 0, len(d.remoteCatalog.pickers))
558- for ac := range d.remoteCatalog.pickers {
559- clients = append(clients, ac)
657+ consumers := make([]consumer, 0, len(d.remoteCatalog.consumers))
658+ for ac, kind := range d.remoteCatalog.consumers {
659+ consumers = append(consumers, consumer{ac: ac, kind: kind})
560660 }
561661 d.remoteCatalog.mu.Unlock()
562662
563- for _, ac := range clients {
663+ for _, consumer := range consumers {
664+ ac := consumer.ac
564665 if ac == nil || ac.overlays == nil {
565666 continue
566667 }
567- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
668+ refreshed := false
669+ if consumer.kind&remoteDiscoveryPicker != 0 {
670+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
671+ refreshed = true
672+ }
673+ if consumer.kind&remoteDiscoveryPalette != 0 {
674+ d.refreshPalette(ac)
675+ refreshed = true
676+ }
677+ if !refreshed {
678+ continue
679+ }
568680 if entry := ac.currentAttachmentSession(); entry != nil {
569681 d.invalidateRender(entry, ac, true, "remote_picker.go")
570682 }
@@ -11,10 +11,11 @@ import (
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/palette"
14 "github.com/bnema/vev/internal/usecase/picker"15 "github.com/bnema/vev/internal/usecase/picker"
15 )16 )
16 17
17-type remotePickerCatalogEntry struct {18+type remoteCatalogPresentationEntry struct {
18 entry ports.RemoteCatalogCacheEntry19 entry ports.RemoteCatalogCacheEntry
19 status remoteHostStatus20 status remoteHostStatus
20 }21 }
@@ -28,12 +29,25 @@ type remotePickerInstance struct {
28 model *picker.Model29 model *picker.Model
29 }30 }
30 31
31-// remotePickerCatalogSnapshot copies the cache-derived state needed for a32+func (i remotePickerInstance) discoveryInstance() remoteDiscoveryInstance {
32-// picker before any row sorting begins. No remote-state lock survives this33+ return remoteDiscoveryInstance{
33-// snapshot.34+ ac: i.ac, kind: remoteDiscoveryPicker, generation: i.generation, picker: i.model,
34-const remotePickerAttachTTL = 30 * time.Second35+ }
36+}
37+
38+type remoteDiscoveryInstance struct {
39+ ac *attachedClient
40+ kind remoteDiscoveryConsumerKind
41+ generation uint64
42+ picker *picker.Model
43+ palette *palette.Model
44+}
45+
46+const remoteCatalogAttachTTL = 30 * time.Second
35 47
36-func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {48+// remoteCatalogSnapshot copies the cache-derived discovery state before any
49+// overlay sorting begins. No remote-state lock survives this snapshot.
50+func (d *Daemon) remoteCatalogSnapshot() []remoteCatalogPresentationEntry {
37 if d == nil {51 if d == nil {
38 return nil52 return nil
39 }53 }
@@ -44,10 +58,10 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
44 d.remoteCatalog.status[host] = remoteHostStale58 d.remoteCatalog.status[host] = remoteHostStale
45 }59 }
46 }60 }
47- entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))61+ entries := make([]remoteCatalogPresentationEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
48 seen := make(map[string]struct{}, len(d.remoteCatalog.cache))62 seen := make(map[string]struct{}, len(d.remoteCatalog.cache))
49 for host, entry := range d.remoteCatalog.cache {63 for host, entry := range d.remoteCatalog.cache {
50- entries = append(entries, remotePickerCatalogEntry{64+ entries = append(entries, remoteCatalogPresentationEntry{
51 entry: cloneRemoteCatalogEntry(entry),65 entry: cloneRemoteCatalogEntry(entry),
52 status: d.remoteCatalog.status[host],66 status: d.remoteCatalog.status[host],
53 })67 })
@@ -57,7 +71,7 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
57 if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch && status != remoteHostMalformed) {71 if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch && status != remoteHostMalformed) {
58 continue72 continue
59 }73 }
60- entries = append(entries, remotePickerCatalogEntry{74+ entries = append(entries, remoteCatalogPresentationEntry{
61 entry: ports.RemoteCatalogCacheEntry{Host: host},75 entry: ports.RemoteCatalogCacheEntry{Host: host},
62 status: status,76 status: status,
63 })77 })
@@ -66,14 +80,14 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
66 return entries80 return entries
67 }81 }
68 82
69-func (d *Daemon) remotePickerHostRanks() map[string]int {83+func (d *Daemon) remoteHostRanks() map[string]int {
70 ranks := make(map[string]int)84 ranks := make(map[string]int)
71 if d == nil || d.remoteHostStore == nil {85 if d == nil || d.remoteHostStore == nil {
72 return ranks86 return ranks
73 }87 }
74 pinned, learned, err := d.remoteHostStore.Hosts()88 pinned, learned, err := d.remoteHostStore.Hosts()
75 if err != nil {89 if err != nil {
76- d.log.Warn("listing remote hosts for picker failed", "err", err)90+ d.log.Warn("listing remote hosts for discovery failed", "err", err)
77 return ranks91 return ranks
78 }92 }
79 for _, host := range append(pinned, learned...) {93 for _, host := range append(pinned, learned...) {
@@ -85,7 +99,7 @@ func (d *Daemon) remotePickerHostRanks() map[string]int {
85 }99 }
86 100
87 func remoteCatalogExpired(fetchedAt, now time.Time) bool {101 func remoteCatalogExpired(fetchedAt, now time.Time) bool {
88- return fetchedAt.IsZero() || (!now.Before(fetchedAt) && now.Sub(fetchedAt) >= remotePickerAttachTTL)102+ return fetchedAt.IsZero() || (!now.Before(fetchedAt) && now.Sub(fetchedAt) >= remoteCatalogAttachTTL)
89 }103 }
90 104
91 func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {105 func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {
@@ -128,9 +142,43 @@ func remoteSessionStateStopped(state ports.RemoteCatalogSessionState) bool {
128 return state == ports.RemoteCatalogSessionDown142 return state == ports.RemoteCatalogSessionDown
129 }143 }
130 144
131-func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {145+func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session ports.RemoteCatalogSession) (domain.RemoteSessionKey, domain.RemoteSessionTarget) {
132 key.LifecycleID = session.LifecycleID146 key.LifecycleID = session.LifecycleID
133 key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)147 key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
148+ target := domain.RemoteSessionTarget{
149+ Endpoint: key.Host,
150+ DisplayOrigin: key.DisplayOrigin,
151+ LifecycleID: session.LifecycleID,
152+ SessionName: session.Name,
153+ Stopped: remoteSessionStateStopped(session.State),
154+ }
155+ tabs := ports.CatalogTabs(session)
156+ if len(tabs) == 0 {
157+ return key, target
158+ }
159+ if target.Stopped {
160+ first := tabs[0]
161+ if first.ID != "" {
162+ target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
163+ } else {
164+ tabCount := min(len(tabs), math.MaxUint16)
165+ target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(tabCount))
166+ }
167+ return key, target
168+ }
169+ active := 0
170+ for i, tab := range tabs {
171+ if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
172+ active = i
173+ break
174+ }
175+ }
176+ target.LiveTabID = domain.TabStableID(tabs[active].ID)
177+ return key, target
178+}
179+
180+func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
181+ key, target := remoteCatalogSessionTarget(key, session)
134 availability := remotePickerAvailability(status)182 availability := remotePickerAvailability(status)
135 stopped := remoteSessionStateStopped(session.State)183 stopped := remoteSessionStateStopped(session.State)
136 broken := session.State == ports.RemoteCatalogSessionBroken184 broken := session.State == ports.RemoteCatalogSessionBroken
@@ -153,26 +201,6 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
153 Attention: tab.Attention,201 Attention: tab.Attention,
154 })202 })
155 }203 }
156- target := domain.RemoteSessionTarget{
157- Endpoint: key.Host,
158- DisplayOrigin: key.DisplayOrigin,
159- LifecycleID: session.LifecycleID,
160- SessionName: session.Name,
161- Stopped: stopped,
162- }
163- if len(tabs) != 0 {
164- first := tabs[0]
165- if stopped {
166- if first.ID != "" {
167- target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
168- } else {
169- tabCount := min(len(tabs), math.MaxUint16)
170- target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(tabCount))
171- }
172- } else {
173- target.LiveTabID = domain.TabStableID(tabs[active].ID)
174- }
175- }
176 // Keep the structured session identity on rows even when one tab ID is204 // Keep the structured session identity on rows even when one tab ID is
177 // malformed or a broken session cannot be activated. Cursor navigation205 // malformed or a broken session cannot be activated. Cursor navigation
178 // must remain possible, while sendRemoteAttachTargetForAttachment will206 // must remain possible, while sendRemoteAttachTargetForAttachment will
@@ -265,7 +293,7 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi
265 }293 }
266 }294 }
267 295
268-func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {296+func sortRemoteCatalog(entries []remoteCatalogPresentationEntry, ranks map[string]int) {
269 for i := range entries {297 for i := range entries {
270 sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {298 sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
271 l, r := entries[i].entry.Sessions[left], entries[i].entry.Sessions[right]299 l, r := entries[i].entry.Sessions[left], entries[i].entry.Sessions[right]
@@ -289,63 +317,106 @@ func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[strin
289 })317 })
290 }318 }
291 319
292-func (d *Daemon) remotePickerOpened(instance remotePickerInstance) {320+func (d *Daemon) remoteDiscoveryOpened(instance remoteDiscoveryInstance) {
293- if d.registerRemotePicker(instance) {321+ if d.registerRemoteDiscoveryConsumer(instance) {
294- d.startRemotePickerRefresh(instance)322+ d.startRemoteDiscoveryRefresh(instance)
295 }323 }
296 }324 }
297 325
298-// registerRemotePicker installs one exact picker generation as a refresh owner.326+// registerRemoteDiscoveryConsumer installs one exact overlay generation as a
299-// Keeping this step separate gives refresh startup a second atomic validation327+// refresh owner. Overlay ownership always nests its model lock before the
300-// point: a close can win between registration and startup without being undone328+// catalog lock; no path may acquire an overlay lock while retaining catalog.mu.
301-// by a delayed opener.329+func (d *Daemon) registerRemoteDiscoveryConsumer(instance remoteDiscoveryInstance) bool {
302-func (d *Daemon) registerRemotePicker(instance remotePickerInstance) bool {330+ if d == nil || instance.ac == nil || instance.ac.overlays == nil {
303- if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
304 return false331 return false
305 }332 }
306 rt := instance.ac.overlays333 rt := instance.ac.overlays
307- // Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may334+ switch instance.kind {
308- // acquire pickerMu while retaining the catalog lock.335+ case remoteDiscoveryPicker:
309- rt.pickerMu.Lock()336+ if instance.picker == nil {
310- defer rt.pickerMu.Unlock()337+ return false
311- if rt.pickerGeneration != instance.generation || rt.picker != instance.model {338+ }
339+ rt.pickerMu.Lock()
340+ defer rt.pickerMu.Unlock()
341+ if rt.pickerGeneration != instance.generation || rt.picker != instance.picker {
342+ return false
343+ }
344+ case remoteDiscoveryPalette:
345+ if instance.palette == nil {
346+ return false
347+ }
348+ rt.paletteMu.Lock()
349+ defer rt.paletteMu.Unlock()
350+ if rt.paletteGeneration != instance.generation || rt.palette != instance.palette {
351+ return false
352+ }
353+ default:
312 return false354 return false
313 }355 }
314 d.remoteCatalog.mu.Lock()356 d.remoteCatalog.mu.Lock()
315- d.remoteCatalog.pickers[instance.ac] = struct{}{}357+ d.remoteCatalog.consumers[instance.ac] |= instance.kind
316 d.remoteCatalog.mu.Unlock()358 d.remoteCatalog.mu.Unlock()
317 return true359 return true
318 }360 }
319 361
320-func (d *Daemon) remotePickerClosed(instance remotePickerInstance) {362+func (d *Daemon) remoteDiscoveryClosed(instance remoteDiscoveryInstance) {
321- if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {363+ if d == nil || instance.ac == nil || instance.ac.overlays == nil {
322 return364 return
323 }365 }
324 rt := instance.ac.overlays366 rt := instance.ac.overlays
325- rt.pickerMu.Lock()367+ var current bool
326- current := rt.pickerGeneration == instance.generation && rt.picker == nil368+ var unlock func()
369+ switch instance.kind {
370+ case remoteDiscoveryPicker:
371+ if instance.picker == nil {
372+ return
373+ }
374+ rt.pickerMu.Lock()
375+ unlock = rt.pickerMu.Unlock
376+ current = rt.pickerGeneration == instance.generation && rt.picker == nil
377+ case remoteDiscoveryPalette:
378+ if instance.palette == nil {
379+ return
380+ }
381+ rt.paletteMu.Lock()
382+ unlock = rt.paletteMu.Unlock
383+ current = rt.paletteGeneration == instance.generation && rt.palette == nil
384+ default:
385+ return
386+ }
327 if !current {387 if !current {
328- rt.pickerMu.Unlock()388+ unlock()
329 return389 return
330 }390 }
331 d.remoteCatalog.mu.Lock()391 d.remoteCatalog.mu.Lock()
332- delete(d.remoteCatalog.pickers, instance.ac)392+ owners := d.remoteCatalog.consumers[instance.ac]
333- if len(d.remoteCatalog.pickers) != 0 {393+ if owners&instance.kind == 0 {
334 d.remoteCatalog.mu.Unlock()394 d.remoteCatalog.mu.Unlock()
335- rt.pickerMu.Unlock()395+ unlock()
396+ return
397+ }
398+ owners &^= instance.kind
399+ if owners == 0 {
400+ delete(d.remoteCatalog.consumers, instance.ac)
401+ } else {
402+ d.remoteCatalog.consumers[instance.ac] = owners
403+ }
404+ if len(d.remoteCatalog.consumers) != 0 {
405+ d.remoteCatalog.mu.Unlock()
406+ unlock()
336 return407 return
337 }408 }
338 d.remoteCatalog.refresh++409 d.remoteCatalog.refresh++
339 cancel := d.remoteCatalog.cancel410 cancel := d.remoteCatalog.cancel
340 d.remoteCatalog.cancel = nil411 d.remoteCatalog.cancel = nil
341 d.remoteCatalog.mu.Unlock()412 d.remoteCatalog.mu.Unlock()
342- rt.pickerMu.Unlock()413+ unlock()
343 if cancel != nil {414 if cancel != nil {
344 cancel()415 cancel()
345 }416 }
346 }417 }
347 418
348-func (d *Daemon) cancelRemotePickerRefresh() {419+func (d *Daemon) cancelRemoteDiscoveryRefresh() {
349 if d == nil {420 if d == nil {
350 return421 return
351 }422 }
@@ -359,7 +430,7 @@ func (d *Daemon) cancelRemotePickerRefresh() {
359 }430 }
360 }431 }
361 432
362-func (d *Daemon) remotePickerHosts() ([]string, error) {433+func (d *Daemon) remoteDiscoveryHosts() ([]string, error) {
363 if d == nil || d.remoteHostStore == nil {434 if d == nil || d.remoteHostStore == nil {
364 return nil, nil435 return nil, nil
365 }436 }
@@ -383,12 +454,12 @@ func (d *Daemon) remotePickerHosts() ([]string, error) {
383 return hosts, nil454 return hosts, nil
384 }455 }
385 456
386-// startRemotePickerRefresh replaces the current refresh generation and launches457+// startRemoteDiscoveryRefresh replaces the current refresh generation and
387-// one bounded catalog call per known host. Installing the generation is gated458+// launches one bounded catalog call per known host. Installing the generation
388-// by the exact picker generation that registered ownership. Host-store and459+// is gated by the exact overlay generation that registered ownership. Host
389-// remote I/O happen after every architecture lock has been released.460+// store and remote I/O happen after every architecture lock is released.
390-func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 {461+func (d *Daemon) startRemoteDiscoveryRefresh(instance remoteDiscoveryInstance) uint64 {
391- if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {462+ if d == nil || instance.ac == nil || instance.ac.overlays == nil {
392 return 0463 return 0
393 }464 }
394 root := context.Background()465 root := context.Background()
@@ -398,22 +469,47 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
398 ctx, cancel := context.WithCancel(root)469 ctx, cancel := context.WithCancel(root)
399 470
400 rt := instance.ac.overlays471 rt := instance.ac.overlays
401- rt.pickerMu.Lock()472+ install := func(current bool) (context.CancelFunc, uint64, bool) {
402- d.remoteCatalog.mu.Lock()473+ d.remoteCatalog.mu.Lock()
403- _, owner := d.remoteCatalog.pickers[instance.ac]474+ defer d.remoteCatalog.mu.Unlock()
404- current := rt.pickerGeneration == instance.generation && rt.picker == instance.model475+ owner := d.remoteCatalog.consumers[instance.ac]&instance.kind != 0
405- if !current || !owner || len(d.remoteCatalog.pickers) == 0 {476+ if !current || !owner || len(d.remoteCatalog.consumers) == 0 {
406- d.remoteCatalog.mu.Unlock()477+ return nil, 0, false
478+ }
479+ previous := d.remoteCatalog.cancel
480+ d.remoteCatalog.refresh++
481+ generation := d.remoteCatalog.refresh
482+ d.remoteCatalog.cancel = cancel
483+ return previous, generation, true
484+ }
485+ var previous context.CancelFunc
486+ var generation uint64
487+ var installed bool
488+ switch instance.kind {
489+ case remoteDiscoveryPicker:
490+ if instance.picker == nil {
491+ cancel()
492+ return 0
493+ }
494+ rt.pickerMu.Lock()
495+ previous, generation, installed = install(rt.pickerGeneration == instance.generation && rt.picker == instance.picker)
407 rt.pickerMu.Unlock()496 rt.pickerMu.Unlock()
497+ case remoteDiscoveryPalette:
498+ if instance.palette == nil {
499+ cancel()
500+ return 0
501+ }
502+ rt.paletteMu.Lock()
503+ previous, generation, installed = install(rt.paletteGeneration == instance.generation && rt.palette == instance.palette)
504+ rt.paletteMu.Unlock()
505+ default:
506+ cancel()
507+ return 0
508+ }
509+ if !installed {
408 cancel()510 cancel()
409 return 0511 return 0
410 }512 }
411- previous := d.remoteCatalog.cancel
412- d.remoteCatalog.refresh++
413- generation := d.remoteCatalog.refresh
414- d.remoteCatalog.cancel = cancel
415- d.remoteCatalog.mu.Unlock()
416- rt.pickerMu.Unlock()
417 if previous != nil {513 if previous != nil {
418 previous()514 previous()
419 }515 }
@@ -421,7 +517,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
421 return generation517 return generation
422 }518 }
423 519
424- hosts, err := d.remotePickerHosts()520+ hosts, err := d.remoteDiscoveryHosts()
425 if err != nil {521 if err != nil {
426 d.log.Warn("listing remote hosts for refresh failed", "err", err)522 d.log.Warn("listing remote hosts for refresh failed", "err", err)
427 return generation523 return generation
@@ -454,7 +550,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
454 if cacheChanged {550 if cacheChanged {
455 d.persistRemoteCatalogCache()551 d.persistRemoteCatalogCache()
456 }552 }
457- d.refreshRemoteOpenPickers()553+ d.refreshRemoteDiscoveryConsumers()
458 554
459 for _, host := range hosts {555 for _, host := range hosts {
460 d.sessWg.Go(func() {556 d.sessWg.Go(func() {
@@ -465,7 +561,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
465 return generation561 return generation
466 }562 }
467 563
468-func containsRemotePickerHost(hosts []string, target string) bool {564+func containsRemoteDiscoveryHost(hosts []string, target string) bool {
469 for _, host := range hosts {565 for _, host := range hosts {
470 if host == target {566 if host == target {
471 return true567 return true
@@ -488,12 +584,12 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
488 return false584 return false
489 }585 }
490 586
491- hosts, hostsErr := d.remotePickerHosts()587+ hosts, hostsErr := d.remoteDiscoveryHosts()
492 if hostsErr != nil {588 if hostsErr != nil {
493 d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)589 d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
494 return false590 return false
495 }591 }
496- stillKnown := containsRemotePickerHost(hosts, host)592+ stillKnown := containsRemoteDiscoveryHost(hosts, host)
497 now := d.clock.Now()593 now := d.clock.Now()
498 if listErr == nil {594 if listErr == nil {
499 if err := ports.ValidateRemoteCatalog(catalog); err != nil {595 if err := ports.ValidateRemoteCatalog(catalog); err != nil {
@@ -514,7 +610,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
514 d.remoteCatalog.mu.Unlock()610 d.remoteCatalog.mu.Unlock()
515 if cacheChanged {611 if cacheChanged {
516 d.persistRemoteCatalogCache()612 d.persistRemoteCatalogCache()
517- d.refreshRemoteOpenPickers()613+ d.refreshRemoteDiscoveryConsumers()
518 }614 }
519 return cacheChanged615 return cacheChanged
520 }616 }
@@ -532,7 +628,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
532 d.remoteCatalog.status[host] = status628 d.remoteCatalog.status[host] = status
533 d.remoteCatalog.failure[host] = now629 d.remoteCatalog.failure[host] = now
534 d.remoteCatalog.mu.Unlock()630 d.remoteCatalog.mu.Unlock()
535- d.refreshRemoteOpenPickers()631+ d.refreshRemoteDiscoveryConsumers()
536 return false632 return false
537 }633 }
538 d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{634 d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{
@@ -545,26 +641,42 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
545 d.remoteCatalog.mu.Unlock()641 d.remoteCatalog.mu.Unlock()
546 642
547 d.persistRemoteCatalogCache()643 d.persistRemoteCatalogCache()
548- d.refreshRemoteOpenPickers()644+ d.refreshRemoteDiscoveryConsumers()
549 return true645 return true
550 }646 }
551 647
552-func (d *Daemon) refreshRemoteOpenPickers() {648+func (d *Daemon) refreshRemoteDiscoveryConsumers() {
553 if d == nil {649 if d == nil {
554 return650 return
555 }651 }
652+ type consumer struct {
653+ ac *attachedClient
654+ kind remoteDiscoveryConsumerKind
655+ }
556 d.remoteCatalog.mu.Lock()656 d.remoteCatalog.mu.Lock()
557- clients := make([]*attachedClient, 0, len(d.remoteCatalog.pickers))657+ consumers := make([]consumer, 0, len(d.remoteCatalog.consumers))
558- for ac := range d.remoteCatalog.pickers {658+ for ac, kind := range d.remoteCatalog.consumers {
559- clients = append(clients, ac)659+ consumers = append(consumers, consumer{ac: ac, kind: kind})
560 }660 }
561 d.remoteCatalog.mu.Unlock()661 d.remoteCatalog.mu.Unlock()
562 662
563- for _, ac := range clients {663+ for _, consumer := range consumers {
664+ ac := consumer.ac
564 if ac == nil || ac.overlays == nil {665 if ac == nil || ac.overlays == nil {
565 continue666 continue
566 }667 }
567- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})668+ refreshed := false
669+ if consumer.kind&remoteDiscoveryPicker != 0 {
670+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
671+ refreshed = true
672+ }
673+ if consumer.kind&remoteDiscoveryPalette != 0 {
674+ d.refreshPalette(ac)
675+ refreshed = true
676+ }
677+ if !refreshed {
678+ continue
679+ }
568 if entry := ac.currentAttachmentSession(); entry != nil {680 if entry := ac.currentAttachmentSession(); entry != nil {
569 d.invalidateRender(entry, ac, true, "remote_picker.go")681 d.invalidateRender(entry, ac, true, "remote_picker.go")
570 }682 }
M internal/usecase/daemon/remote_picker_test.go +34 -28
@@ -130,7 +130,7 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
130130 d := newTestDaemon(t, nil, clock)
131131 lifecycle := remoteLifecycleForTest()
132132 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
133- Host: "arch", FetchedAt: now.Add(-remotePickerAttachTTL), Sessions: []ports.RemoteCatalogSession{{
133+ Host: "arch", FetchedAt: now.Add(-remoteCatalogAttachTTL), Sessions: []ports.RemoteCatalogSession{{
134134 LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
135135 }},
136136 }})
@@ -138,11 +138,11 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
138138 d.remoteCatalog.status["arch"] = remoteHostFresh
139139 d.remoteCatalog.mu.Unlock()
140140
141- entries := d.remotePickerCatalogSnapshot()
141+ entries := d.remoteCatalogSnapshot()
142142 require.Len(t, entries, 1)
143143 require.Equal(t, remoteHostStale, entries[0].status)
144144 target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
145- require.False(t, d.remotePickerTargetReadyTarget(target), "attachment must reject a catalogue exactly at the TTL boundary")
145+ require.False(t, d.remoteCatalogTargetReady(target), "attachment must reject a catalogue exactly at the TTL boundary")
146146 }
147147
148148 func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) {
@@ -163,10 +163,10 @@ func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testin
163163 target, ok := model.Selected()
164164 require.True(t, ok)
165165 require.NotNil(t, target.RemoteTarget)
166- require.True(t, d.remotePickerTargetReadyTarget(*target.RemoteTarget), "fresh picker target must be ready before the attach TTL")
166+ require.True(t, d.remoteCatalogTargetReady(*target.RemoteTarget), "fresh picker target must be ready before the attach TTL")
167167
168- clock.Advance(remotePickerAttachTTL)
169- require.False(t, d.remotePickerTargetReadyTarget(*target.RemoteTarget), "handoff must reject a picker target whose catalog reached the attach TTL")
168+ clock.Advance(remoteCatalogAttachTTL)
169+ require.False(t, d.remoteCatalogTargetReady(*target.RemoteTarget), "handoff must reject a picker target whose catalog reached the attach TTL")
170170 d.remoteCatalog.mu.Lock()
171171 require.Equal(t, remoteHostStale, d.remoteCatalog.status["arch"])
172172 d.remoteCatalog.mu.Unlock()
@@ -483,7 +483,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
483483 ac.overlays.pickerGeneration++
484484 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
485485 ac.overlays.pickerMu.Unlock()
486- require.True(t, d.registerRemotePicker(instance))
486+ require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
487487 lifecycle := remoteLifecycleForTest()
488488 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
489489 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
@@ -492,7 +492,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
492492 }},
493493 }})
494494
495- firstGeneration := d.startRemotePickerRefresh(instance)
495+ firstGeneration := d.startRemoteDiscoveryRefresh(instance.discoveryInstance())
496496 first := map[string]remoteRefreshRequest{}
497497 for range 2 {
498498 request := receiveRemotePicker(t, catalog.requests, "catalog request")
@@ -514,7 +514,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
514514 _, selectable := refreshedModel.Selected()
515515 require.False(t, selectable, "refreshing status must gate picker activation")
516516
517- secondGeneration := d.startRemotePickerRefresh(instance)
517+ secondGeneration := d.startRemoteDiscoveryRefresh(instance.discoveryInstance())
518518 require.Greater(t, secondGeneration, firstGeneration)
519519 for _, request := range first {
520520 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
@@ -547,9 +547,9 @@ func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
547547 ac.overlays.pickerGeneration++
548548 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
549549 ac.overlays.pickerMu.Unlock()
550- require.True(t, d.registerRemotePicker(instance))
550+ require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
551551
552- d.startRemotePickerRefresh(instance)
552+ d.startRemoteDiscoveryRefresh(instance.discoveryInstance())
553553 request := receiveRemotePicker(t, catalog.requests, "catalog request")
554554 request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()}
555555 receiveRemotePicker(t, cache.stores, "remote cache store")
@@ -567,9 +567,9 @@ func TestRemoteRefreshWithoutCatalogClientDoesNotMarkHostsRefreshing(t *testing.
567567 ac.overlays.pickerGeneration++
568568 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
569569 ac.overlays.pickerMu.Unlock()
570- require.True(t, d.registerRemotePicker(instance))
570+ require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
571571
572- require.NotZero(t, d.startRemotePickerRefresh(instance))
572+ require.NotZero(t, d.startRemoteDiscoveryRefresh(instance.discoveryInstance()))
573573 d.remoteCatalog.mu.Lock()
574574 require.NotEqual(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
575575 d.remoteCatalog.mu.Unlock()
@@ -950,7 +950,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
950950 instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker}
951951 before[owner] = owner.overlays.picker
952952 owner.overlays.pickerMu.Unlock()
953- require.True(t, d.registerRemotePicker(instance))
953+ require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
954954 }
955955 d.remoteCatalog.mu.Lock()
956956 d.remoteCatalog.refresh = 4
@@ -1002,7 +1002,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
10021002 test.teardown(d, sess, ac)
10031003
10041004 d.remoteCatalog.mu.Lock()
1005- _, registered := d.remoteCatalog.pickers[ac]
1005+ _, registered := d.remoteCatalog.consumers[ac]
10061006 d.remoteCatalog.mu.Unlock()
10071007 ac.overlays.pickerMu.Lock()
10081008 current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration
@@ -1025,7 +1025,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
10251025 }
10261026 }
10271027
1028-func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.T) {
1028+func TestParkedOverlayExpiryClosesCapturedDiscoveryGenerations(t *testing.T) {
10291029 clock := &signalClock{timers: make(chan *signalTimer, 1)}
10301030 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
10311031 d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(475, 0))
@@ -1033,7 +1033,10 @@ func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.
10331033 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
10341034 ac.resumeCapable = true
10351035 d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
1036- request := receiveRemotePicker(t, catalog.requests, "catalog request")
1036+ request := receiveRemotePicker(t, catalog.requests, "picker catalog request")
1037+ d.enterPalette(sess, ac)
1038+ receiveRemotePickerClose(t, request.ctx.Done(), "superseded picker catalog request")
1039+ request = receiveRemotePicker(t, catalog.requests, "shared catalog request")
10371040
10381041 require.True(t, d.parkAttachment(sess, ac))
10391042 timer := receiveRemotePicker(t, clock.timers, "park expiry timer")
@@ -1043,8 +1046,11 @@ func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.
10431046 ac.overlays.pickerMu.Lock()
10441047 require.Nil(t, ac.overlays.picker)
10451048 ac.overlays.pickerMu.Unlock()
1049+ ac.overlays.paletteMu.Lock()
1050+ require.Nil(t, ac.overlays.palette)
1051+ ac.overlays.paletteMu.Unlock()
10461052 d.remoteCatalog.mu.Lock()
1047- require.NotContains(t, d.remoteCatalog.pickers, ac)
1053+ require.NotContains(t, d.remoteCatalog.consumers, ac)
10481054 d.remoteCatalog.mu.Unlock()
10491055 }
10501056
@@ -1155,12 +1161,12 @@ func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
11551161 instance.ac.overlays.pickerMu.Lock()
11561162 instance.ac.overlays.picker = nil
11571163 instance.ac.overlays.pickerMu.Unlock()
1158- d.remotePickerClosed(instance)
1164+ d.remoteDiscoveryClosed(instance.discoveryInstance())
11591165 }
11601166 first, second := newInstance(), newInstance()
1161- d.remotePickerOpened(first)
1167+ d.remoteDiscoveryOpened(first.discoveryInstance())
11621168 request := receiveRemotePicker(t, catalog.requests, "catalog request")
1163- d.remotePickerOpened(second)
1169+ d.remoteDiscoveryOpened(second.discoveryInstance())
11641170 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
11651171 request = receiveRemotePicker(t, catalog.requests, "catalog request")
11661172
@@ -1174,7 +1180,7 @@ func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
11741180 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
11751181
11761182 d.remoteCatalog.mu.Lock()
1177- require.Empty(t, d.remoteCatalog.pickers)
1183+ require.Empty(t, d.remoteCatalog.consumers)
11781184 require.Nil(t, d.remoteCatalog.cancel)
11791185 d.remoteCatalog.mu.Unlock()
11801186 }
@@ -1193,19 +1199,19 @@ func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
11931199
11941200 // Stop at the production seam after exact picker ownership registration but
11951201 // before refresh startup, then let the last-picker close linearize first.
1196- require.True(t, d.registerRemotePicker(instance))
1202+ require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
11971203 ac.overlays.pickerMu.Lock()
11981204 ac.overlays.picker = nil
11991205 ac.overlays.pickerMu.Unlock()
1200- d.remotePickerClosed(instance)
1206+ d.remoteDiscoveryClosed(instance.discoveryInstance())
12011207
12021208 d.remoteCatalog.mu.Lock()
12031209 refreshBefore := d.remoteCatalog.refresh
12041210 d.remoteCatalog.mu.Unlock()
1205- require.Zero(t, d.startRemotePickerRefresh(instance))
1211+ require.Zero(t, d.startRemoteDiscoveryRefresh(instance.discoveryInstance()))
12061212
12071213 d.remoteCatalog.mu.Lock()
1208- require.Empty(t, d.remoteCatalog.pickers, "the closing picker must remain unregistered")
1214+ require.Empty(t, d.remoteCatalog.consumers, "the closing picker must remain unregistered")
12091215 require.Equal(t, refreshBefore, d.remoteCatalog.refresh, "a close that wins before startup must prevent a later refresh generation")
12101216 require.Nil(t, d.remoteCatalog.cancel, "a close that wins before startup must prevent a later cancel installation")
12111217 d.remoteCatalog.mu.Unlock()
@@ -1232,7 +1238,7 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
12321238 staleClose.afterClose()
12331239
12341240 d.remoteCatalog.mu.Lock()
1235- _, registered := d.remoteCatalog.pickers[ac]
1241+ _, registered := d.remoteCatalog.consumers[ac]
12361242 d.remoteCatalog.mu.Unlock()
12371243 require.True(t, registered, "stale close cleanup must retain the reopened picker registration")
12381244 ac.overlays.pickerMu.Lock()
@@ -1255,7 +1261,7 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
12551261 }
12561262 refreshed := make(chan struct{})
12571263 go func() {
1258- d.refreshRemoteOpenPickers()
1264+ d.refreshRemoteDiscoveryConsumers()
12591265 close(refreshed)
12601266 }()
12611267 receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
@@ -130,7 +130,7 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
130 d := newTestDaemon(t, nil, clock)130 d := newTestDaemon(t, nil, clock)
131 lifecycle := remoteLifecycleForTest()131 lifecycle := remoteLifecycleForTest()
132 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{132 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
133- Host: "arch", FetchedAt: now.Add(-remotePickerAttachTTL), Sessions: []ports.RemoteCatalogSession{{133+ Host: "arch", FetchedAt: now.Add(-remoteCatalogAttachTTL), Sessions: []ports.RemoteCatalogSession{{
134 LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},134 LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
135 }},135 }},
136 }})136 }})
@@ -138,11 +138,11 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
138 d.remoteCatalog.status["arch"] = remoteHostFresh138 d.remoteCatalog.status["arch"] = remoteHostFresh
139 d.remoteCatalog.mu.Unlock()139 d.remoteCatalog.mu.Unlock()
140 140
141- entries := d.remotePickerCatalogSnapshot()141+ entries := d.remoteCatalogSnapshot()
142 require.Len(t, entries, 1)142 require.Len(t, entries, 1)
143 require.Equal(t, remoteHostStale, entries[0].status)143 require.Equal(t, remoteHostStale, entries[0].status)
144 target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}144 target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
145- require.False(t, d.remotePickerTargetReadyTarget(target), "attachment must reject a catalogue exactly at the TTL boundary")145+ require.False(t, d.remoteCatalogTargetReady(target), "attachment must reject a catalogue exactly at the TTL boundary")
146 }146 }
147 147
148 func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) {148 func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) {
@@ -163,10 +163,10 @@ func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testin
163 target, ok := model.Selected()163 target, ok := model.Selected()
164 require.True(t, ok)164 require.True(t, ok)
165 require.NotNil(t, target.RemoteTarget)165 require.NotNil(t, target.RemoteTarget)
166- require.True(t, d.remotePickerTargetReadyTarget(*target.RemoteTarget), "fresh picker target must be ready before the attach TTL")166+ require.True(t, d.remoteCatalogTargetReady(*target.RemoteTarget), "fresh picker target must be ready before the attach TTL")
167 167
168- clock.Advance(remotePickerAttachTTL)168+ clock.Advance(remoteCatalogAttachTTL)
169- require.False(t, d.remotePickerTargetReadyTarget(*target.RemoteTarget), "handoff must reject a picker target whose catalog reached the attach TTL")169+ require.False(t, d.remoteCatalogTargetReady(*target.RemoteTarget), "handoff must reject a picker target whose catalog reached the attach TTL")
170 d.remoteCatalog.mu.Lock()170 d.remoteCatalog.mu.Lock()
171 require.Equal(t, remoteHostStale, d.remoteCatalog.status["arch"])171 require.Equal(t, remoteHostStale, d.remoteCatalog.status["arch"])
172 d.remoteCatalog.mu.Unlock()172 d.remoteCatalog.mu.Unlock()
@@ -483,7 +483,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
483 ac.overlays.pickerGeneration++483 ac.overlays.pickerGeneration++
484 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}484 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
485 ac.overlays.pickerMu.Unlock()485 ac.overlays.pickerMu.Unlock()
486- require.True(t, d.registerRemotePicker(instance))486+ require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
487 lifecycle := remoteLifecycleForTest()487 lifecycle := remoteLifecycleForTest()
488 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{488 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
489 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{489 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
@@ -492,7 +492,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
492 }},492 }},
493 }})493 }})
494 494
495- firstGeneration := d.startRemotePickerRefresh(instance)495+ firstGeneration := d.startRemoteDiscoveryRefresh(instance.discoveryInstance())
496 first := map[string]remoteRefreshRequest{}496 first := map[string]remoteRefreshRequest{}
497 for range 2 {497 for range 2 {
498 request := receiveRemotePicker(t, catalog.requests, "catalog request")498 request := receiveRemotePicker(t, catalog.requests, "catalog request")
@@ -514,7 +514,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
514 _, selectable := refreshedModel.Selected()514 _, selectable := refreshedModel.Selected()
515 require.False(t, selectable, "refreshing status must gate picker activation")515 require.False(t, selectable, "refreshing status must gate picker activation")
516 516
517- secondGeneration := d.startRemotePickerRefresh(instance)517+ secondGeneration := d.startRemoteDiscoveryRefresh(instance.discoveryInstance())
518 require.Greater(t, secondGeneration, firstGeneration)518 require.Greater(t, secondGeneration, firstGeneration)
519 for _, request := range first {519 for _, request := range first {
520 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")520 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
@@ -547,9 +547,9 @@ func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
547 ac.overlays.pickerGeneration++547 ac.overlays.pickerGeneration++
548 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}548 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
549 ac.overlays.pickerMu.Unlock()549 ac.overlays.pickerMu.Unlock()
550- require.True(t, d.registerRemotePicker(instance))550+ require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
551 551
552- d.startRemotePickerRefresh(instance)552+ d.startRemoteDiscoveryRefresh(instance.discoveryInstance())
553 request := receiveRemotePicker(t, catalog.requests, "catalog request")553 request := receiveRemotePicker(t, catalog.requests, "catalog request")
554 request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()}554 request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()}
555 receiveRemotePicker(t, cache.stores, "remote cache store")555 receiveRemotePicker(t, cache.stores, "remote cache store")
@@ -567,9 +567,9 @@ func TestRemoteRefreshWithoutCatalogClientDoesNotMarkHostsRefreshing(t *testing.
567 ac.overlays.pickerGeneration++567 ac.overlays.pickerGeneration++
568 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}568 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
569 ac.overlays.pickerMu.Unlock()569 ac.overlays.pickerMu.Unlock()
570- require.True(t, d.registerRemotePicker(instance))570+ require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
571 571
572- require.NotZero(t, d.startRemotePickerRefresh(instance))572+ require.NotZero(t, d.startRemoteDiscoveryRefresh(instance.discoveryInstance()))
573 d.remoteCatalog.mu.Lock()573 d.remoteCatalog.mu.Lock()
574 require.NotEqual(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])574 require.NotEqual(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
575 d.remoteCatalog.mu.Unlock()575 d.remoteCatalog.mu.Unlock()
@@ -950,7 +950,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
950 instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker}950 instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker}
951 before[owner] = owner.overlays.picker951 before[owner] = owner.overlays.picker
952 owner.overlays.pickerMu.Unlock()952 owner.overlays.pickerMu.Unlock()
953- require.True(t, d.registerRemotePicker(instance))953+ require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
954 }954 }
955 d.remoteCatalog.mu.Lock()955 d.remoteCatalog.mu.Lock()
956 d.remoteCatalog.refresh = 4956 d.remoteCatalog.refresh = 4
@@ -1002,7 +1002,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
1002 test.teardown(d, sess, ac)1002 test.teardown(d, sess, ac)
1003 1003
1004 d.remoteCatalog.mu.Lock()1004 d.remoteCatalog.mu.Lock()
1005- _, registered := d.remoteCatalog.pickers[ac]1005+ _, registered := d.remoteCatalog.consumers[ac]
1006 d.remoteCatalog.mu.Unlock()1006 d.remoteCatalog.mu.Unlock()
1007 ac.overlays.pickerMu.Lock()1007 ac.overlays.pickerMu.Lock()
1008 current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration1008 current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration
@@ -1025,7 +1025,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
1025 }1025 }
1026 }1026 }
1027 1027
1028-func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.T) {1028+func TestParkedOverlayExpiryClosesCapturedDiscoveryGenerations(t *testing.T) {
1029 clock := &signalClock{timers: make(chan *signalTimer, 1)}1029 clock := &signalClock{timers: make(chan *signalTimer, 1)}
1030 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}1030 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
1031 d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(475, 0))1031 d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(475, 0))
@@ -1033,7 +1033,10 @@ func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.
1033 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")1033 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1034 ac.resumeCapable = true1034 ac.resumeCapable = true
1035 d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})1035 d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
1036- request := receiveRemotePicker(t, catalog.requests, "catalog request")1036+ request := receiveRemotePicker(t, catalog.requests, "picker catalog request")
1037+ d.enterPalette(sess, ac)
1038+ receiveRemotePickerClose(t, request.ctx.Done(), "superseded picker catalog request")
1039+ request = receiveRemotePicker(t, catalog.requests, "shared catalog request")
1037 1040
1038 require.True(t, d.parkAttachment(sess, ac))1041 require.True(t, d.parkAttachment(sess, ac))
1039 timer := receiveRemotePicker(t, clock.timers, "park expiry timer")1042 timer := receiveRemotePicker(t, clock.timers, "park expiry timer")
@@ -1043,8 +1046,11 @@ func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.
1043 ac.overlays.pickerMu.Lock()1046 ac.overlays.pickerMu.Lock()
1044 require.Nil(t, ac.overlays.picker)1047 require.Nil(t, ac.overlays.picker)
1045 ac.overlays.pickerMu.Unlock()1048 ac.overlays.pickerMu.Unlock()
1049+ ac.overlays.paletteMu.Lock()
1050+ require.Nil(t, ac.overlays.palette)
1051+ ac.overlays.paletteMu.Unlock()
1046 d.remoteCatalog.mu.Lock()1052 d.remoteCatalog.mu.Lock()
1047- require.NotContains(t, d.remoteCatalog.pickers, ac)1053+ require.NotContains(t, d.remoteCatalog.consumers, ac)
1048 d.remoteCatalog.mu.Unlock()1054 d.remoteCatalog.mu.Unlock()
1049 }1055 }
1050 1056
@@ -1155,12 +1161,12 @@ func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
1155 instance.ac.overlays.pickerMu.Lock()1161 instance.ac.overlays.pickerMu.Lock()
1156 instance.ac.overlays.picker = nil1162 instance.ac.overlays.picker = nil
1157 instance.ac.overlays.pickerMu.Unlock()1163 instance.ac.overlays.pickerMu.Unlock()
1158- d.remotePickerClosed(instance)1164+ d.remoteDiscoveryClosed(instance.discoveryInstance())
1159 }1165 }
1160 first, second := newInstance(), newInstance()1166 first, second := newInstance(), newInstance()
1161- d.remotePickerOpened(first)1167+ d.remoteDiscoveryOpened(first.discoveryInstance())
1162 request := receiveRemotePicker(t, catalog.requests, "catalog request")1168 request := receiveRemotePicker(t, catalog.requests, "catalog request")
1163- d.remotePickerOpened(second)1169+ d.remoteDiscoveryOpened(second.discoveryInstance())
1164 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")1170 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
1165 request = receiveRemotePicker(t, catalog.requests, "catalog request")1171 request = receiveRemotePicker(t, catalog.requests, "catalog request")
1166 1172
@@ -1174,7 +1180,7 @@ func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
1174 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")1180 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
1175 1181
1176 d.remoteCatalog.mu.Lock()1182 d.remoteCatalog.mu.Lock()
1177- require.Empty(t, d.remoteCatalog.pickers)1183+ require.Empty(t, d.remoteCatalog.consumers)
1178 require.Nil(t, d.remoteCatalog.cancel)1184 require.Nil(t, d.remoteCatalog.cancel)
1179 d.remoteCatalog.mu.Unlock()1185 d.remoteCatalog.mu.Unlock()
1180 }1186 }
@@ -1193,19 +1199,19 @@ func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
1193 1199
1194 // Stop at the production seam after exact picker ownership registration but1200 // Stop at the production seam after exact picker ownership registration but
1195 // before refresh startup, then let the last-picker close linearize first.1201 // before refresh startup, then let the last-picker close linearize first.
1196- require.True(t, d.registerRemotePicker(instance))1202+ require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
1197 ac.overlays.pickerMu.Lock()1203 ac.overlays.pickerMu.Lock()
1198 ac.overlays.picker = nil1204 ac.overlays.picker = nil
1199 ac.overlays.pickerMu.Unlock()1205 ac.overlays.pickerMu.Unlock()
1200- d.remotePickerClosed(instance)1206+ d.remoteDiscoveryClosed(instance.discoveryInstance())
1201 1207
1202 d.remoteCatalog.mu.Lock()1208 d.remoteCatalog.mu.Lock()
1203 refreshBefore := d.remoteCatalog.refresh1209 refreshBefore := d.remoteCatalog.refresh
1204 d.remoteCatalog.mu.Unlock()1210 d.remoteCatalog.mu.Unlock()
1205- require.Zero(t, d.startRemotePickerRefresh(instance))1211+ require.Zero(t, d.startRemoteDiscoveryRefresh(instance.discoveryInstance()))
1206 1212
1207 d.remoteCatalog.mu.Lock()1213 d.remoteCatalog.mu.Lock()
1208- require.Empty(t, d.remoteCatalog.pickers, "the closing picker must remain unregistered")1214+ require.Empty(t, d.remoteCatalog.consumers, "the closing picker must remain unregistered")
1209 require.Equal(t, refreshBefore, d.remoteCatalog.refresh, "a close that wins before startup must prevent a later refresh generation")1215 require.Equal(t, refreshBefore, d.remoteCatalog.refresh, "a close that wins before startup must prevent a later refresh generation")
1210 require.Nil(t, d.remoteCatalog.cancel, "a close that wins before startup must prevent a later cancel installation")1216 require.Nil(t, d.remoteCatalog.cancel, "a close that wins before startup must prevent a later cancel installation")
1211 d.remoteCatalog.mu.Unlock()1217 d.remoteCatalog.mu.Unlock()
@@ -1232,7 +1238,7 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
1232 staleClose.afterClose()1238 staleClose.afterClose()
1233 1239
1234 d.remoteCatalog.mu.Lock()1240 d.remoteCatalog.mu.Lock()
1235- _, registered := d.remoteCatalog.pickers[ac]1241+ _, registered := d.remoteCatalog.consumers[ac]
1236 d.remoteCatalog.mu.Unlock()1242 d.remoteCatalog.mu.Unlock()
1237 require.True(t, registered, "stale close cleanup must retain the reopened picker registration")1243 require.True(t, registered, "stale close cleanup must retain the reopened picker registration")
1238 ac.overlays.pickerMu.Lock()1244 ac.overlays.pickerMu.Lock()
@@ -1255,7 +1261,7 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
1255 }1261 }
1256 refreshed := make(chan struct{})1262 refreshed := make(chan struct{})
1257 go func() {1263 go func() {
1258- d.refreshRemoteOpenPickers()1264+ d.refreshRemoteDiscoveryConsumers()
1259 close(refreshed)1265 close(refreshed)
1260 }()1266 }()
1261 receiveRemotePickerClose(t, rebuildReached, "picker rebuild")1267 receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
M internal/usecase/daemon/resume.go +26 -15
@@ -221,11 +221,14 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
221221 // cannot leave closed panes retained through its capture cache. This must
222222 // remain outside d.mu: sendMu is ordered before the daemon lock.
223223 ac.clearCaptureFrames()
224- var pickerGeneration uint64
224+ var pickerGeneration, paletteGeneration uint64
225225 if ac.overlays != nil {
226226 ac.overlays.pickerMu.Lock()
227227 pickerGeneration = ac.overlays.pickerGeneration
228228 ac.overlays.pickerMu.Unlock()
229+ ac.overlays.paletteMu.Lock()
230+ paletteGeneration = ac.overlays.paletteGeneration
231+ ac.overlays.paletteMu.Unlock()
229232 }
230233 d.mu.Lock()
231234 if d.closing || d.sessions[sess.id] != sess {
@@ -248,7 +251,10 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
248251 }
249252 grace := d.resumeParkGrace
250253 timer := d.clock.NewTimer(grace)
251- parked := &parkedAttachment{sess: sess, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
254+ parked := &parkedAttachment{
255+ sess: sess, ac: ac, pickerGeneration: pickerGeneration, paletteGeneration: paletteGeneration,
256+ timer: timer, done: make(chan struct{}),
257+ }
252258 ac.parked = true
253259 d.parked[token] = parked
254260 d.clearParkingInFlightLocked(token, ac)
@@ -291,6 +297,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
291297 d.removeParkedLocked(token, parked)
292298 d.mu.Unlock()
293299 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
300+ d.closePaletteIfCurrent(parked.ac, parked.paletteGeneration)
294301 d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot())
295302 return
296303 }
@@ -373,11 +380,12 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
373380 }
374381 parked.closeDone()
375382 rearmed := &parkedAttachment{
376- sess: parked.sess,
377- ac: ac,
378- pickerGeneration: parked.pickerGeneration,
379- timer: d.clock.NewTimer(d.resumeParkGrace),
380- done: make(chan struct{}),
383+ sess: parked.sess,
384+ ac: ac,
385+ pickerGeneration: parked.pickerGeneration,
386+ paletteGeneration: parked.paletteGeneration,
387+ timer: d.clock.NewTimer(d.resumeParkGrace),
388+ done: make(chan struct{}),
381389 }
382390 d.parked[token] = rearmed
383391 captured := ac.transportSnapshot().transport
@@ -394,9 +402,10 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
394402 }
395403
396404 type parkedAttachmentRetirement struct {
397- parked *parkedAttachment
398- pickerGeneration uint64
399- transport transportSnapshot
405+ parked *parkedAttachment
406+ pickerGeneration uint64
407+ paletteGeneration uint64
408+ transport transportSnapshot
400409 }
401410
402411 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
@@ -406,9 +415,10 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
406415 parked.ac.connectionGeneration.Add(1)
407416 parked.ac.setSession(nil)
408417 return parkedAttachmentRetirement{
409- parked: parked,
410- pickerGeneration: parked.pickerGeneration,
411- transport: parked.ac.transportSnapshot(),
418+ parked: parked,
419+ pickerGeneration: parked.pickerGeneration,
420+ paletteGeneration: parked.paletteGeneration,
421+ transport: parked.ac.transportSnapshot(),
412422 }
413423 }
414424
@@ -433,8 +443,8 @@ func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
433443 return retirements
434444 }
435445
436-// finishParkedAttachmentRetirements stops timers and retires picker ownership
437-// and transports without holding daemon, session, coordinator, or pane locks.
446+// finishParkedAttachmentRetirements stops timers and retires discovery-overlay
447+// ownership and transports without holding daemon, session, coordinator, or pane locks.
438448 func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {
439449 for _, retirement := range retirements {
440450 if retirement.parked.timer != nil {
@@ -443,6 +453,7 @@ func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmen
443453 retirement.parked.closeDone()
444454 ac := retirement.parked.ac
445455 d.closePickerIfCurrent(ac, nil, retirement.pickerGeneration)
456+ d.closePaletteIfCurrent(ac, retirement.paletteGeneration)
446457 _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
447458 }
448459 }
@@ -221,11 +221,14 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
221 // cannot leave closed panes retained through its capture cache. This must221 // cannot leave closed panes retained through its capture cache. This must
222 // remain outside d.mu: sendMu is ordered before the daemon lock.222 // remain outside d.mu: sendMu is ordered before the daemon lock.
223 ac.clearCaptureFrames()223 ac.clearCaptureFrames()
224- var pickerGeneration uint64224+ var pickerGeneration, paletteGeneration uint64
225 if ac.overlays != nil {225 if ac.overlays != nil {
226 ac.overlays.pickerMu.Lock()226 ac.overlays.pickerMu.Lock()
227 pickerGeneration = ac.overlays.pickerGeneration227 pickerGeneration = ac.overlays.pickerGeneration
228 ac.overlays.pickerMu.Unlock()228 ac.overlays.pickerMu.Unlock()
229+ ac.overlays.paletteMu.Lock()
230+ paletteGeneration = ac.overlays.paletteGeneration
231+ ac.overlays.paletteMu.Unlock()
229 }232 }
230 d.mu.Lock()233 d.mu.Lock()
231 if d.closing || d.sessions[sess.id] != sess {234 if d.closing || d.sessions[sess.id] != sess {
@@ -248,7 +251,10 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
248 }251 }
249 grace := d.resumeParkGrace252 grace := d.resumeParkGrace
250 timer := d.clock.NewTimer(grace)253 timer := d.clock.NewTimer(grace)
251- parked := &parkedAttachment{sess: sess, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}254+ parked := &parkedAttachment{
255+ sess: sess, ac: ac, pickerGeneration: pickerGeneration, paletteGeneration: paletteGeneration,
256+ timer: timer, done: make(chan struct{}),
257+ }
252 ac.parked = true258 ac.parked = true
253 d.parked[token] = parked259 d.parked[token] = parked
254 d.clearParkingInFlightLocked(token, ac)260 d.clearParkingInFlightLocked(token, ac)
@@ -291,6 +297,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
291 d.removeParkedLocked(token, parked)297 d.removeParkedLocked(token, parked)
292 d.mu.Unlock()298 d.mu.Unlock()
293 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)299 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
300+ d.closePaletteIfCurrent(parked.ac, parked.paletteGeneration)
294 d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot())301 d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot())
295 return302 return
296 }303 }
@@ -373,11 +380,12 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
373 }380 }
374 parked.closeDone()381 parked.closeDone()
375 rearmed := &parkedAttachment{382 rearmed := &parkedAttachment{
376- sess: parked.sess,383+ sess: parked.sess,
377- ac: ac,384+ ac: ac,
378- pickerGeneration: parked.pickerGeneration,385+ pickerGeneration: parked.pickerGeneration,
379- timer: d.clock.NewTimer(d.resumeParkGrace),386+ paletteGeneration: parked.paletteGeneration,
380- done: make(chan struct{}),387+ timer: d.clock.NewTimer(d.resumeParkGrace),
388+ done: make(chan struct{}),
381 }389 }
382 d.parked[token] = rearmed390 d.parked[token] = rearmed
383 captured := ac.transportSnapshot().transport391 captured := ac.transportSnapshot().transport
@@ -394,9 +402,10 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
394 }402 }
395 403
396 type parkedAttachmentRetirement struct {404 type parkedAttachmentRetirement struct {
397- parked *parkedAttachment405+ parked *parkedAttachment
398- pickerGeneration uint64406+ pickerGeneration uint64
399- transport transportSnapshot407+ paletteGeneration uint64
408+ transport transportSnapshot
400 }409 }
401 410
402 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {411 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
@@ -406,9 +415,10 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
406 parked.ac.connectionGeneration.Add(1)415 parked.ac.connectionGeneration.Add(1)
407 parked.ac.setSession(nil)416 parked.ac.setSession(nil)
408 return parkedAttachmentRetirement{417 return parkedAttachmentRetirement{
409- parked: parked,418+ parked: parked,
410- pickerGeneration: parked.pickerGeneration,419+ pickerGeneration: parked.pickerGeneration,
411- transport: parked.ac.transportSnapshot(),420+ paletteGeneration: parked.paletteGeneration,
421+ transport: parked.ac.transportSnapshot(),
412 }422 }
413 }423 }
414 424
@@ -433,8 +443,8 @@ func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
433 return retirements443 return retirements
434 }444 }
435 445
436-// finishParkedAttachmentRetirements stops timers and retires picker ownership446+// finishParkedAttachmentRetirements stops timers and retires discovery-overlay
437-// and transports without holding daemon, session, coordinator, or pane locks.447+// ownership and transports without holding daemon, session, coordinator, or pane locks.
438 func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {448 func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {
439 for _, retirement := range retirements {449 for _, retirement := range retirements {
440 if retirement.parked.timer != nil {450 if retirement.parked.timer != nil {
@@ -443,6 +453,7 @@ func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmen
443 retirement.parked.closeDone()453 retirement.parked.closeDone()
444 ac := retirement.parked.ac454 ac := retirement.parked.ac
445 d.closePickerIfCurrent(ac, nil, retirement.pickerGeneration)455 d.closePickerIfCurrent(ac, nil, retirement.pickerGeneration)
456+ d.closePaletteIfCurrent(ac, retirement.paletteGeneration)
446 _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))457 _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
447 }458 }
448 }459 }
M internal/usecase/palette/model.go +22 -0
@@ -57,6 +57,28 @@ func New(results []Result) *Model {
5757
5858 func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) }
5959
60+// ReplaceResults replaces the model's immutable result source while preserving
61+// the current query and exact selected target when it remains available.
62+func (m *Model) ReplaceResults(results []Result) {
63+ if m == nil {
64+ return
65+ }
66+ selected, hadSelection := m.Selected()
67+ m.results = append([]Result(nil), results...)
68+ m.argumentResults = requiredArgumentResults(m.results)
69+ m.refresh()
70+ if !hadSelection {
71+ return
72+ }
73+ for i, match := range m.matches {
74+ if match.Result.sameTarget(selected) {
75+ m.selected = i
76+ m.clamp()
77+ return
78+ }
79+ }
80+}
81+
6082 func DefaultRenderStyles() RenderStyles {
6183 selection := renderer.DefaultStyle()
6284 selection.Inverse = true
@@ -57,6 +57,28 @@ func New(results []Result) *Model {
57 57
58 func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) }58 func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) }
59 59
60+// ReplaceResults replaces the model's immutable result source while preserving
61+// the current query and exact selected target when it remains available.
62+func (m *Model) ReplaceResults(results []Result) {
63+ if m == nil {
64+ return
65+ }
66+ selected, hadSelection := m.Selected()
67+ m.results = append([]Result(nil), results...)
68+ m.argumentResults = requiredArgumentResults(m.results)
69+ m.refresh()
70+ if !hadSelection {
71+ return
72+ }
73+ for i, match := range m.matches {
74+ if match.Result.sameTarget(selected) {
75+ m.selected = i
76+ m.clamp()
77+ return
78+ }
79+ }
80+}
81+
60 func DefaultRenderStyles() RenderStyles {82 func DefaultRenderStyles() RenderStyles {
61 selection := renderer.DefaultStyle()83 selection := renderer.DefaultStyle()
62 selection.Inverse = true84 selection.Inverse = true
M internal/usecase/palette/model_test.go +25 -0
@@ -12,6 +12,31 @@ import (
1212 "github.com/stretchr/testify/require"
1313 )
1414
15+func TestReplaceResultsPreservesExactRemoteSelection(t *testing.T) {
16+ result := func(endpoint string, lifecycle byte) Result {
17+ key := domain.RemoteSessionKey{
18+ Host: endpoint, Name: "vev", LifecycleID: domain.SessionLifecycleID{lifecycle}, DisplayOrigin: "arch",
19+ }
20+ target := domain.RemoteSessionTarget{
21+ Endpoint: endpoint, DisplayOrigin: "arch", LifecycleID: key.LifecycleID,
22+ SessionName: "vev", LiveTabID: "tab-vev",
23+ }
24+ return NewRemoteSessionResult(key, target, "")
25+ }
26+ first := result("user@arch", 1)
27+ selected := result("admin@arch", 2)
28+ m := New([]Result{first, selected})
29+ m.Down()
30+
31+ m.ReplaceResults([]Result{selected, first})
32+
33+ got, ok := m.Selected()
34+ require.True(t, ok)
35+ target, ok := got.RemoteSessionTarget()
36+ require.True(t, ok)
37+ require.Equal(t, "admin@arch", target.Endpoint)
38+}
39+
1540 func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
1641 m := New(CommandResults([]command.Command{
1742 cmd("ABC", "Alpha", "first"),
@@ -12,6 +12,31 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
15+func TestReplaceResultsPreservesExactRemoteSelection(t *testing.T) {
16+ result := func(endpoint string, lifecycle byte) Result {
17+ key := domain.RemoteSessionKey{
18+ Host: endpoint, Name: "vev", LifecycleID: domain.SessionLifecycleID{lifecycle}, DisplayOrigin: "arch",
19+ }
20+ target := domain.RemoteSessionTarget{
21+ Endpoint: endpoint, DisplayOrigin: "arch", LifecycleID: key.LifecycleID,
22+ SessionName: "vev", LiveTabID: "tab-vev",
23+ }
24+ return NewRemoteSessionResult(key, target, "")
25+ }
26+ first := result("user@arch", 1)
27+ selected := result("admin@arch", 2)
28+ m := New([]Result{first, selected})
29+ m.Down()
30+
31+ m.ReplaceResults([]Result{selected, first})
32+
33+ got, ok := m.Selected()
34+ require.True(t, ok)
35+ target, ok := got.RemoteSessionTarget()
36+ require.True(t, ok)
37+ require.Equal(t, "admin@arch", target.Endpoint)
38+}
39+
15 func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {40 func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
16 m := New(CommandResults([]command.Command{41 m := New(CommandResults([]command.Command{
17 cmd("ABC", "Alpha", "first"),42 cmd("ABC", "Alpha", "first"),
M internal/usecase/palette/result.go +63 -5
@@ -3,6 +3,7 @@ package palette
33 import (
44 "time"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/ports"
78 "github.com/bnema/vev/internal/usecase/command"
89 )
@@ -14,16 +15,18 @@ const (
1415 ResultKindCommand ResultKind = iota
1516 ResultKindActiveSession
1617 ResultKindStoppedSession
18+ ResultKindRemoteSession
1719 ResultKindRecentRoute
1820 )
1921
2022 // Result is an immutable palette target. Its kind is the sole discriminator
21-// for its private command or session payload.
23+// for its private command, local-session, remote-session, or route payload.
2224 type Result struct {
23- kind ResultKind
24- command command.Command
25- session sessionPayload
26- route routePayload
25+ kind ResultKind
26+ command command.Command
27+ session sessionPayload
28+ remoteSession remoteSessionPayload
29+ route routePayload
2730 }
2831
2932 type sessionPayload struct {
@@ -32,6 +35,12 @@ type sessionPayload struct {
3235 target ports.ExactSessionTarget
3336 }
3437
38+type remoteSessionPayload struct {
39+ key domain.RemoteSessionKey
40+ target domain.RemoteSessionTarget
41+ unavailableReason string
42+}
43+
3544 type routePayload struct {
3645 label string
3746 action ports.RouteNavigationAction
@@ -58,6 +67,16 @@ func NewStoppedSessionResult(target ports.ExactSessionTarget, createdAt time.Tim
5867 }
5968 }
6069
70+// NewRemoteSessionResult creates an immutable catalog-backed remote target.
71+func NewRemoteSessionResult(key domain.RemoteSessionKey, target domain.RemoteSessionTarget, unavailableReason string) Result {
72+ return Result{
73+ kind: ResultKindRemoteSession,
74+ remoteSession: remoteSessionPayload{
75+ key: key, target: target, unavailableReason: unavailableReason,
76+ },
77+ }
78+}
79+
6180 // NewRecentRouteResult creates an immutable client-ledger route target.
6281 func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Result {
6382 return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}}
@@ -65,10 +84,31 @@ func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Resu
6584
6685 func (r Result) Kind() ResultKind { return r.kind }
6786
87+func (r Result) sameTarget(other Result) bool {
88+ if r.kind != other.kind {
89+ return false
90+ }
91+ switch r.kind {
92+ case ResultKindCommand:
93+ return r.command.Slug == other.command.Slug
94+ case ResultKindActiveSession, ResultKindStoppedSession:
95+ return r.session.target == other.session.target
96+ case ResultKindRemoteSession:
97+ return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target
98+ case ResultKindRecentRoute:
99+ return r.route.action == other.route.action
100+ default:
101+ return false
102+ }
103+}
104+
68105 func (r Result) DisplayText() string {
69106 if r.kind == ResultKindCommand {
70107 return r.command.Code
71108 }
109+ if r.kind == ResultKindRemoteSession {
110+ return activeSessionDisplayPrefix + r.remoteSession.key.Display()
111+ }
72112 if r.kind == ResultKindRecentRoute {
73113 return activeSessionDisplayPrefix + r.route.label
74114 }
@@ -107,6 +147,24 @@ func (r Result) SessionTarget() (ports.ExactSessionTarget, bool) {
107147 return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
108148 }
109149
150+// RemoteSessionTarget returns the structured catalog target only for a remote
151+// session result.
152+func (r Result) RemoteSessionTarget() (domain.RemoteSessionTarget, bool) {
153+ return r.remoteSession.target, r.kind == ResultKindRemoteSession
154+}
155+
156+// RemoteSessionKey returns the structured catalog identity only for a remote
157+// session result.
158+func (r Result) RemoteSessionKey() (domain.RemoteSessionKey, bool) {
159+ return r.remoteSession.key, r.kind == ResultKindRemoteSession
160+}
161+
162+// RemoteSessionUnavailableReason returns the catalog availability reason only
163+// for a remote session result.
164+func (r Result) RemoteSessionUnavailableReason() (string, bool) {
165+ return r.remoteSession.unavailableReason, r.kind == ResultKindRemoteSession
166+}
167+
110168 // RouteNavigationAction returns the exact client-ledger target only for a
111169 // recent-route result.
112170 func (r Result) RouteNavigationAction() (ports.RouteNavigationAction, bool) {
@@ -3,6 +3,7 @@ package palette
3 import (3 import (
4 "time"4 "time"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
8 )9 )
@@ -14,16 +15,18 @@ const (
14 ResultKindCommand ResultKind = iota15 ResultKindCommand ResultKind = iota
15 ResultKindActiveSession16 ResultKindActiveSession
16 ResultKindStoppedSession17 ResultKindStoppedSession
18+ ResultKindRemoteSession
17 ResultKindRecentRoute19 ResultKindRecentRoute
18 )20 )
19 21
20 // Result is an immutable palette target. Its kind is the sole discriminator22 // Result is an immutable palette target. Its kind is the sole discriminator
21-// for its private command or session payload.23+// for its private command, local-session, remote-session, or route payload.
22 type Result struct {24 type Result struct {
23- kind ResultKind25+ kind ResultKind
24- command command.Command26+ command command.Command
25- session sessionPayload27+ session sessionPayload
26- route routePayload28+ remoteSession remoteSessionPayload
29+ route routePayload
27 }30 }
28 31
29 type sessionPayload struct {32 type sessionPayload struct {
@@ -32,6 +35,12 @@ type sessionPayload struct {
32 target ports.ExactSessionTarget35 target ports.ExactSessionTarget
33 }36 }
34 37
38+type remoteSessionPayload struct {
39+ key domain.RemoteSessionKey
40+ target domain.RemoteSessionTarget
41+ unavailableReason string
42+}
43+
35 type routePayload struct {44 type routePayload struct {
36 label string45 label string
37 action ports.RouteNavigationAction46 action ports.RouteNavigationAction
@@ -58,6 +67,16 @@ func NewStoppedSessionResult(target ports.ExactSessionTarget, createdAt time.Tim
58 }67 }
59 }68 }
60 69
70+// NewRemoteSessionResult creates an immutable catalog-backed remote target.
71+func NewRemoteSessionResult(key domain.RemoteSessionKey, target domain.RemoteSessionTarget, unavailableReason string) Result {
72+ return Result{
73+ kind: ResultKindRemoteSession,
74+ remoteSession: remoteSessionPayload{
75+ key: key, target: target, unavailableReason: unavailableReason,
76+ },
77+ }
78+}
79+
61 // NewRecentRouteResult creates an immutable client-ledger route target.80 // NewRecentRouteResult creates an immutable client-ledger route target.
62 func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Result {81 func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Result {
63 return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}}82 return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}}
@@ -65,10 +84,31 @@ func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Resu
65 84
66 func (r Result) Kind() ResultKind { return r.kind }85 func (r Result) Kind() ResultKind { return r.kind }
67 86
87+func (r Result) sameTarget(other Result) bool {
88+ if r.kind != other.kind {
89+ return false
90+ }
91+ switch r.kind {
92+ case ResultKindCommand:
93+ return r.command.Slug == other.command.Slug
94+ case ResultKindActiveSession, ResultKindStoppedSession:
95+ return r.session.target == other.session.target
96+ case ResultKindRemoteSession:
97+ return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target
98+ case ResultKindRecentRoute:
99+ return r.route.action == other.route.action
100+ default:
101+ return false
102+ }
103+}
104+
68 func (r Result) DisplayText() string {105 func (r Result) DisplayText() string {
69 if r.kind == ResultKindCommand {106 if r.kind == ResultKindCommand {
70 return r.command.Code107 return r.command.Code
71 }108 }
109+ if r.kind == ResultKindRemoteSession {
110+ return activeSessionDisplayPrefix + r.remoteSession.key.Display()
111+ }
72 if r.kind == ResultKindRecentRoute {112 if r.kind == ResultKindRecentRoute {
73 return activeSessionDisplayPrefix + r.route.label113 return activeSessionDisplayPrefix + r.route.label
74 }114 }
@@ -107,6 +147,24 @@ func (r Result) SessionTarget() (ports.ExactSessionTarget, bool) {
107 return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession147 return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
108 }148 }
109 149
150+// RemoteSessionTarget returns the structured catalog target only for a remote
151+// session result.
152+func (r Result) RemoteSessionTarget() (domain.RemoteSessionTarget, bool) {
153+ return r.remoteSession.target, r.kind == ResultKindRemoteSession
154+}
155+
156+// RemoteSessionKey returns the structured catalog identity only for a remote
157+// session result.
158+func (r Result) RemoteSessionKey() (domain.RemoteSessionKey, bool) {
159+ return r.remoteSession.key, r.kind == ResultKindRemoteSession
160+}
161+
162+// RemoteSessionUnavailableReason returns the catalog availability reason only
163+// for a remote session result.
164+func (r Result) RemoteSessionUnavailableReason() (string, bool) {
165+ return r.remoteSession.unavailableReason, r.kind == ResultKindRemoteSession
166+}
167+
110 // RouteNavigationAction returns the exact client-ledger target only for a168 // RouteNavigationAction returns the exact client-ledger target only for a
111 // recent-route result.169 // recent-route result.
112 func (r Result) RouteNavigationAction() (ports.RouteNavigationAction, bool) {170 func (r Result) RouteNavigationAction() (ports.RouteNavigationAction, bool) {

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