feat(palette): discover remote sessions
- commit
- cc86ead93b694229e26a8865d41cdb97be93959d
- author
- brice · · 2026-08-25 10:42 UTC
- parent
- f571e9b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +789 -190
M docs/remote-resilience.md +14 -6
| @@ -27,12 +27,20 @@ directory; direct CLI remote attaches retain their client-request semantics. | |||
| 27 | 27 | ||
| 28 | 28 | Remote attachments remain direct: the selected remote daemon owns the session, | |
| 29 | 29 | 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. | |
| 36 | 44 | ||
| 37 | 45 | A picker-selected remote target is a direct handoff with one input pump. Exact | |
| 38 | 46 | 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 not | 29 | 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 | 30 | + | 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/generation | 33 | + | 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 or | 34 | + | 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. Exact | 45 | 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 a | 46 | 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 | |||
| 95 | 95 | // IntentResume waiters are not stranded on a never-published park. | |
| 96 | 96 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | |
| 97 | 97 | d.closePicker(ac) | |
| 98 | + | d.closePalette(ac) | |
| 98 | 99 | ||
| 99 | 100 | d.resetScreenDefaultColors(sess) | |
| 100 | 101 | if explicit && notice { | |
| @@ -183,6 +184,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed | |||
| 183 | 184 | } | |
| 184 | 185 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | |
| 185 | 186 | d.closePicker(ac) | |
| 187 | + | d.closePalette(ac) | |
| 186 | 188 | d.resetScreenDefaultColors(sess) | |
| 187 | 189 | _ = ac.closeCapturedTransport(failed) | |
| 188 | 190 | 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 { | |||
| 322 | 322 | } | |
| 323 | 323 | ||
| 324 | 324 | 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 | |
| 332 | 333 | } | |
| 333 | 334 | ||
| 334 | 335 | // 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 | 819 | func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) { | |
| 819 | - | d.cancelRemotePickerRefresh() | |
| 820 | + | d.cancelRemoteDiscoveryRefresh() | |
| 820 | 821 | d.closeMoveLifecycles() | |
| 821 | 822 | d.mu.Lock() | |
| 822 | 823 | d.closing = true | |
| @@ -322,13 +322,14 @@ type Daemon struct { | |||||
| 322 | } | 322 | } | ||
| 323 | 323 | ||||
| 324 | type parkedAttachment struct { | 324 | type parkedAttachment struct { | ||
| 325 | - | sess *session | 325 | + | sess *session |
| 326 | - | ac *attachedClient | 326 | + | ac *attachedClient |
| 327 | - | pickerGeneration uint64 | 327 | + | pickerGeneration uint64 |
| 328 | - | timer ports.Timer | 328 | + | paletteGeneration uint64 |
| 329 | - | claimed bool | 329 | + | timer ports.Timer |
| 330 | - | done chan struct{} | 330 | + | claimed bool |
| 331 | - | doneOnce sync.Once | 331 | + | done chan struct{} |
| 332 | + | doneOnce sync.Once | |||
| 332 | } | 333 | } | ||
| 333 | 334 | ||||
| 334 | // parkingAttachment is the observable detach→park lifecycle for one resume | 335 | // 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 = true | 823 | d.closing = true | ||
M internal/usecase/daemon/palette.go +171 -18
| @@ -43,16 +43,42 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 43 | 43 | routeSnapshot := ac.routeSnapshotCopy() | |
| 44 | 44 | commands := d.paletteCommands() | |
| 45 | 45 | results := d.paletteResults(sess, commands, routeSnapshot) | |
| 46 | + | model := palette.New(results) | |
| 46 | 47 | ac.overlays.paletteMu.Lock() | |
| 47 | 48 | ac.overlays.paletteGeneration++ | |
| 48 | - | ac.overlays.palette = palette.New(results) | |
| 49 | + | ac.overlays.palette = model | |
| 49 | 50 | ac.overlays.paletteRouteSnapshot = routeSnapshot | |
| 50 | 51 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 51 | 52 | ac.overlays.palettePreview = "" | |
| 52 | 53 | ac.overlays.paletteFeedback = "" | |
| 53 | 54 | ac.overlays.palettePending = nil | |
| 55 | + | generation := ac.overlays.paletteGeneration | |
| 54 | 56 | ac.overlays.paletteMu.Unlock() | |
| 55 | 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 | 84 | // paletteResults captures eligible named sessions before paletteMu so the | |
| @@ -61,16 +87,23 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 61 | 87 | d.mu.Lock() | |
| 62 | 88 | sessions := d.sessionsSnapshotLocked() | |
| 63 | 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 | 91 | for _, entry := range d.inactive { | |
| 66 | - | localLifecycles[entry.incarnation] = struct{}{} | |
| 92 | + | represented[localPaletteSessionIdentity(entry.incarnation)] = struct{}{} | |
| 67 | 93 | if entry.canResume() { | |
| 68 | 94 | stopped = append(stopped, entry) | |
| 69 | 95 | } | |
| 70 | 96 | } | |
| 71 | 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 | 107 | for _, cmd := range commands { | |
| 75 | 108 | results = append(results, palette.NewCommandResult(cmd)) | |
| 76 | 109 | } | |
| @@ -80,7 +113,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 80 | 113 | if snap.name == "" || snap.ephemeral { | |
| 81 | 114 | continue | |
| 82 | 115 | } | |
| 83 | - | localLifecycles[snap.incarnation] = struct{}{} | |
| 116 | + | represented[localPaletteSessionIdentity(snap.incarnation)] = struct{}{} | |
| 84 | 117 | if candidate == current { | |
| 85 | 118 | continue | |
| 86 | 119 | } | |
| @@ -97,7 +130,29 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 97 | 130 | for _, candidate := range stopped { | |
| 98 | 131 | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} | |
| 99 | 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 | 157 | formattedRoutes := formatRecentRouteSnapshot(routeSnapshot) | |
| 103 | 158 | for i, entry := range routeSnapshot.Entries { | |
| @@ -108,7 +163,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 108 | 163 | if label == "" { | |
| 109 | 164 | 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 | 168 | continue | |
| 113 | 169 | } | |
| 114 | 170 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | |
| @@ -116,10 +172,48 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 116 | 172 | Key: entry.Key, | |
| 117 | 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 | 186 | 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 | 217 | func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints { | |
| 124 | 218 | formatted := formatRecentRouteSnapshot(snapshot) | |
| 125 | 219 | names := make([]string, len(formatted)) | |
| @@ -192,6 +286,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 192 | 286 | var cmd command.Command | |
| 193 | 287 | var sessionTarget palette.Result | |
| 194 | 288 | var hasSessionTarget bool | |
| 289 | + | var remoteTarget domain.RemoteSessionTarget | |
| 290 | + | var remoteKey domain.RemoteSessionKey | |
| 291 | + | var remoteUnavailableReason string | |
| 292 | + | var hasRemoteTarget bool | |
| 195 | 293 | var routeTarget ports.RouteNavigationAction | |
| 196 | 294 | var hasRouteTarget bool | |
| 197 | 295 | var args []string | |
| @@ -199,6 +297,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 199 | 297 | var generation uint64 | |
| 200 | 298 | var rawQuery string | |
| 201 | 299 | changed, cancel, execute := false, false, false | |
| 300 | + | var closedDiscovery remoteDiscoveryInstance | |
| 202 | 301 | var effect *attachmentEffectTicket | |
| 203 | 302 | if len(effects) != 0 { | |
| 204 | 303 | effect = effects[0] | |
| @@ -270,6 +369,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 270 | 369 | routeSnapshot = ac.overlays.paletteRouteSnapshot | |
| 271 | 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 | 382 | } else if action, ok := selected.RouteNavigationAction(); ok { | |
| 274 | 383 | routeTarget = action | |
| 275 | 384 | hasRouteTarget = true | |
| @@ -303,10 +412,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 303 | 412 | } | |
| 304 | 413 | } | |
| 305 | 414 | if cancel { | |
| 306 | - | ac.clearPaletteLocked() | |
| 415 | + | closedDiscovery = ac.clearPaletteLocked() | |
| 307 | 416 | } | |
| 308 | 417 | ac.overlays.paletteMu.Unlock() | |
| 309 | 418 | if cancel { | |
| 419 | + | d.remoteDiscoveryClosed(closedDiscovery) | |
| 310 | 420 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 311 | 421 | return | |
| 312 | 422 | } | |
| @@ -317,6 +427,24 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 317 | 427 | 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 | + | if err := d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-remote-session"); err != nil && !errors.Is(err, errAttachmentTransition) { | |
| 442 | + | ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable") | |
| 443 | + | d.invalidateRender(entry, ac, true, "palette.go") | |
| 444 | + | } | |
| 445 | + | return | |
| 446 | + | } | |
| 447 | + | ||
| 320 | 448 | if hasRouteTarget { | |
| 321 | 449 | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect} | |
| 322 | 450 | if err := exec.NavigateRecentRoute(routeTarget); err != nil { | |
| @@ -327,7 +455,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 327 | 455 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 328 | 456 | return | |
| 329 | 457 | } | |
| 330 | - | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 458 | + | if d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 331 | 459 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 332 | 460 | } | |
| 333 | 461 | return | |
| @@ -356,7 +484,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 356 | 484 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 357 | 485 | return | |
| 358 | 486 | } | |
| 359 | - | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 487 | + | if d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 360 | 488 | if current := ac.currentAttachmentSession(); current != nil { | |
| 361 | 489 | d.invalidateRender(current, ac, true, "palette.go") | |
| 362 | 490 | } | |
| @@ -380,7 +508,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 380 | 508 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 381 | 509 | return | |
| 382 | 510 | } | |
| 383 | - | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 511 | + | if d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 384 | 512 | d.recordPaletteUse(cmd.Code) | |
| 385 | 513 | if current := ac.currentAttachmentSession(); current != nil { | |
| 386 | 514 | d.invalidateRender(current, ac, true, "palette.go") | |
| @@ -389,7 +517,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 389 | 517 | return | |
| 390 | 518 | } | |
| 391 | 519 | attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach" | |
| 392 | - | if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) { | |
| 520 | + | if !attachmentHandoff && !d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 393 | 521 | return | |
| 394 | 522 | } | |
| 395 | 523 | sess.dispatchMu.Lock() | |
| @@ -399,14 +527,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 399 | 527 | if current := ac.currentSession(); current != nil { | |
| 400 | 528 | currentToken := current.attachmentToken(ac, ac.transport()) | |
| 401 | 529 | fresh, admitted := ac.beginAttachmentEffect(currentToken) | |
| 402 | - | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 530 | + | if d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 403 | 531 | d.invalidateRender(current, ac, true, "palette.go") | |
| 404 | 532 | } | |
| 405 | 533 | if admitted { | |
| 406 | 534 | fresh.End() | |
| 407 | 535 | } | |
| 408 | 536 | } else { | |
| 409 | - | ac.closeExecutedPalette(generation, rawQuery) | |
| 537 | + | d.closeExecutedPalette(ac, generation, rawQuery) | |
| 410 | 538 | } | |
| 411 | 539 | } | |
| 412 | 540 | if errors.Is(err, errAttachmentTransition) { | |
| @@ -435,7 +563,8 @@ func paletteArgs(query string, cmd command.Command) []string { | |||
| 435 | 563 | return action.Args | |
| 436 | 564 | } | |
| 437 | 565 | ||
| 438 | - | func (ac *attachedClient) clearPaletteLocked() { | |
| 566 | + | func (ac *attachedClient) clearPaletteLocked() remoteDiscoveryInstance { | |
| 567 | + | model := ac.overlays.palette | |
| 439 | 568 | ac.overlays.paletteGeneration++ | |
| 440 | 569 | ac.overlays.palette = nil | |
| 441 | 570 | ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{} | |
| @@ -443,6 +572,9 @@ func (ac *attachedClient) clearPaletteLocked() { | |||
| 443 | 572 | ac.overlays.palettePreview = "" | |
| 444 | 573 | ac.overlays.paletteFeedback = "" | |
| 445 | 574 | ac.overlays.palettePending = nil | |
| 575 | + | return remoteDiscoveryInstance{ | |
| 576 | + | ac: ac, kind: remoteDiscoveryPalette, generation: ac.overlays.paletteGeneration, palette: model, | |
| 577 | + | } | |
| 446 | 578 | } | |
| 447 | 579 | ||
| 448 | 580 | func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) { | |
| @@ -454,13 +586,34 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s | |||
| 454 | 586 | ac.overlays.paletteFeedback = feedback | |
| 455 | 587 | } | |
| 456 | 588 | ||
| 457 | - | func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool { | |
| 589 | + | func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool { | |
| 458 | 590 | ac.overlays.paletteMu.Lock() | |
| 459 | - | defer ac.overlays.paletteMu.Unlock() | |
| 460 | 591 | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | |
| 592 | + | ac.overlays.paletteMu.Unlock() | |
| 593 | + | return false | |
| 594 | + | } | |
| 595 | + | instance := ac.clearPaletteLocked() | |
| 596 | + | ac.overlays.paletteMu.Unlock() | |
| 597 | + | d.remoteDiscoveryClosed(instance) | |
| 598 | + | return true | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | func (d *Daemon) closePalette(ac *attachedClient) { | |
| 602 | + | d.closePaletteIfCurrent(ac, 0) | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bool { | |
| 606 | + | if d == nil || ac == nil || ac.overlays == nil { | |
| 461 | 607 | return false | |
| 462 | 608 | } | |
| 463 | - | ac.clearPaletteLocked() | |
| 609 | + | ac.overlays.paletteMu.Lock() | |
| 610 | + | if ac.overlays.palette == nil || generation != 0 && ac.overlays.paletteGeneration != generation { | |
| 611 | + | ac.overlays.paletteMu.Unlock() | |
| 612 | + | return false | |
| 613 | + | } | |
| 614 | + | instance := ac.clearPaletteLocked() | |
| 615 | + | ac.overlays.paletteMu.Unlock() | |
| 616 | + | d.remoteDiscoveryClosed(instance) | |
| 464 | 617 | return true | |
| 465 | 618 | } | |
| 466 | 619 | ||
| @@ -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 = routeSnapshot | 50 | 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 = nil | 54 | 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 the | 84 | // 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 | continue | 114 | continue | ||
| 82 | } | 115 | } | ||
| 83 | - | localLifecycles[snap.incarnation] = struct{}{} | 116 | + | represented[localPaletteSessionIdentity(snap.incarnation)] = struct{}{} |
| 84 | if candidate == current { | 117 | if candidate == current { | ||
| 85 | continue | 118 | 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 | continue | 164 | 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 | continue | 168 | 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 results | 186 | 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.Command | 286 | var cmd command.Command | ||
| 193 | var sessionTarget palette.Result | 287 | var sessionTarget palette.Result | ||
| 194 | var hasSessionTarget bool | 288 | 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.RouteNavigationAction | 293 | var routeTarget ports.RouteNavigationAction | ||
| 196 | var hasRouteTarget bool | 294 | var hasRouteTarget bool | ||
| 197 | var args []string | 295 | var args []string | ||
| @@ -199,6 +297,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 199 | var generation uint64 | 297 | var generation uint64 | ||
| 200 | var rawQuery string | 298 | var rawQuery string | ||
| 201 | changed, cancel, execute := false, false, false | 299 | changed, cancel, execute := false, false, false | ||
| 300 | + | var closedDiscovery remoteDiscoveryInstance | |||
| 202 | var effect *attachmentEffectTicket | 301 | 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.paletteRouteSnapshot | 369 | 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 = action | 383 | routeTarget = action | ||
| 275 | hasRouteTarget = true | 384 | 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 | return | 421 | return | ||
| 312 | } | 422 | } | ||
| @@ -317,6 +427,24 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 317 | return | 427 | 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 | + | if err := d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-remote-session"); err != nil && !errors.Is(err, errAttachmentTransition) { | |||
| 442 | + | ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable") | |||
| 443 | + | d.invalidateRender(entry, ac, true, "palette.go") | |||
| 444 | + | } | |||
| 445 | + | return | |||
| 446 | + | } | |||
| 447 | + | ||||
| 320 | if hasRouteTarget { | 448 | if hasRouteTarget { | ||
| 321 | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect} | 449 | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect} | ||
| 322 | if err := exec.NavigateRecentRoute(routeTarget); err != nil { | 450 | if err := exec.NavigateRecentRoute(routeTarget); err != nil { | ||
| @@ -327,7 +455,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 327 | d.invalidateRender(entry, ac, true, "palette.go") | 455 | d.invalidateRender(entry, ac, true, "palette.go") | ||
| 328 | return | 456 | return | ||
| 329 | } | 457 | } | ||
| 330 | - | if ac.closeExecutedPalette(generation, rawQuery) { | 458 | + | if d.closeExecutedPalette(ac, generation, rawQuery) { |
| 331 | d.invalidateRender(entry, ac, true, "palette.go") | 459 | d.invalidateRender(entry, ac, true, "palette.go") | ||
| 332 | } | 460 | } | ||
| 333 | return | 461 | return | ||
| @@ -356,7 +484,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 356 | d.invalidateRender(entry, ac, true, "palette.go") | 484 | d.invalidateRender(entry, ac, true, "palette.go") | ||
| 357 | return | 485 | return | ||
| 358 | } | 486 | } | ||
| 359 | - | if ac.closeExecutedPalette(generation, rawQuery) { | 487 | + | if d.closeExecutedPalette(ac, generation, rawQuery) { |
| 360 | if current := ac.currentAttachmentSession(); current != nil { | 488 | if current := ac.currentAttachmentSession(); current != nil { | ||
| 361 | d.invalidateRender(current, ac, true, "palette.go") | 489 | d.invalidateRender(current, ac, true, "palette.go") | ||
| 362 | } | 490 | } | ||
| @@ -380,7 +508,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 380 | d.invalidateRender(entry, ac, true, "palette.go") | 508 | d.invalidateRender(entry, ac, true, "palette.go") | ||
| 381 | return | 509 | return | ||
| 382 | } | 510 | } | ||
| 383 | - | if ac.closeExecutedPalette(generation, rawQuery) { | 511 | + | if d.closeExecutedPalette(ac, generation, rawQuery) { |
| 384 | d.recordPaletteUse(cmd.Code) | 512 | d.recordPaletteUse(cmd.Code) | ||
| 385 | if current := ac.currentAttachmentSession(); current != nil { | 513 | if current := ac.currentAttachmentSession(); current != nil { | ||
| 386 | d.invalidateRender(current, ac, true, "palette.go") | 514 | d.invalidateRender(current, ac, true, "palette.go") | ||
| @@ -389,7 +517,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 389 | return | 517 | return | ||
| 390 | } | 518 | } | ||
| 391 | attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach" | 519 | attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach" | ||
| 392 | - | if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) { | 520 | + | if !attachmentHandoff && !d.closeExecutedPalette(ac, generation, rawQuery) { |
| 393 | return | 521 | return | ||
| 394 | } | 522 | } | ||
| 395 | sess.dispatchMu.Lock() | 523 | sess.dispatchMu.Lock() | ||
| @@ -399,14 +527,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 399 | if current := ac.currentSession(); current != nil { | 527 | if current := ac.currentSession(); current != nil { | ||
| 400 | currentToken := current.attachmentToken(ac, ac.transport()) | 528 | currentToken := current.attachmentToken(ac, ac.transport()) | ||
| 401 | fresh, admitted := ac.beginAttachmentEffect(currentToken) | 529 | fresh, admitted := ac.beginAttachmentEffect(currentToken) | ||
| 402 | - | if ac.closeExecutedPalette(generation, rawQuery) { | 530 | + | if d.closeExecutedPalette(ac, generation, rawQuery) { |
| 403 | d.invalidateRender(current, ac, true, "palette.go") | 531 | d.invalidateRender(current, ac, true, "palette.go") | ||
| 404 | } | 532 | } | ||
| 405 | if admitted { | 533 | if admitted { | ||
| 406 | fresh.End() | 534 | fresh.End() | ||
| 407 | } | 535 | } | ||
| 408 | } else { | 536 | } else { | ||
| 409 | - | ac.closeExecutedPalette(generation, rawQuery) | 537 | + | d.closeExecutedPalette(ac, generation, rawQuery) |
| 410 | } | 538 | } | ||
| 411 | } | 539 | } | ||
| 412 | if errors.Is(err, errAttachmentTransition) { | 540 | if errors.Is(err, errAttachmentTransition) { | ||
| @@ -435,7 +563,8 @@ func paletteArgs(query string, cmd command.Command) []string { | |||||
| 435 | return action.Args | 563 | return action.Args | ||
| 436 | } | 564 | } | ||
| 437 | 565 | ||||
| 438 | - | func (ac *attachedClient) clearPaletteLocked() { | 566 | + | func (ac *attachedClient) clearPaletteLocked() remoteDiscoveryInstance { |
| 567 | + | model := ac.overlays.palette | |||
| 439 | ac.overlays.paletteGeneration++ | 568 | ac.overlays.paletteGeneration++ | ||
| 440 | ac.overlays.palette = nil | 569 | ac.overlays.palette = nil | ||
| 441 | ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{} | 570 | ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{} | ||
| @@ -443,6 +572,9 @@ func (ac *attachedClient) clearPaletteLocked() { | |||||
| 443 | ac.overlays.palettePreview = "" | 572 | ac.overlays.palettePreview = "" | ||
| 444 | ac.overlays.paletteFeedback = "" | 573 | ac.overlays.paletteFeedback = "" | ||
| 445 | ac.overlays.palettePending = nil | 574 | ac.overlays.palettePending = nil | ||
| 575 | + | return remoteDiscoveryInstance{ | |||
| 576 | + | ac: ac, kind: remoteDiscoveryPalette, generation: ac.overlays.paletteGeneration, palette: model, | |||
| 577 | + | } | |||
| 446 | } | 578 | } | ||
| 447 | 579 | ||||
| 448 | func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) { | 580 | func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) { | ||
| @@ -454,13 +586,34 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s | |||||
| 454 | ac.overlays.paletteFeedback = feedback | 586 | ac.overlays.paletteFeedback = feedback | ||
| 455 | } | 587 | } | ||
| 456 | 588 | ||||
| 457 | - | func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool { | 589 | + | func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool { |
| 458 | ac.overlays.paletteMu.Lock() | 590 | 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 { | 591 | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | ||
| 592 | + | ac.overlays.paletteMu.Unlock() | |||
| 593 | + | return false | |||
| 594 | + | } | |||
| 595 | + | instance := ac.clearPaletteLocked() | |||
| 596 | + | ac.overlays.paletteMu.Unlock() | |||
| 597 | + | d.remoteDiscoveryClosed(instance) | |||
| 598 | + | return true | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | func (d *Daemon) closePalette(ac *attachedClient) { | |||
| 602 | + | d.closePaletteIfCurrent(ac, 0) | |||
| 603 | + | } | |||
| 604 | + | ||||
| 605 | + | func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bool { | |||
| 606 | + | if d == nil || ac == nil || ac.overlays == nil { | |||
| 461 | return false | 607 | return false | ||
| 462 | } | 608 | } | ||
| 463 | - | ac.clearPaletteLocked() | 609 | + | ac.overlays.paletteMu.Lock() |
| 610 | + | if ac.overlays.palette == nil || generation != 0 && ac.overlays.paletteGeneration != generation { | |||
| 611 | + | ac.overlays.paletteMu.Unlock() | |||
| 612 | + | return false | |||
| 613 | + | } | |||
| 614 | + | instance := ac.clearPaletteLocked() | |||
| 615 | + | ac.overlays.paletteMu.Unlock() | |||
| 616 | + | d.remoteDiscoveryClosed(instance) | |||
| 464 | return true | 617 | return true | ||
| 465 | } | 618 | } | ||
| 466 | 619 | ||||
M internal/usecase/daemon/palette_test.go +248 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "sync" | |
| 6 | 6 | "testing" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/stretchr/testify/mock" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| @@ -428,6 +429,253 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | |||
| 428 | 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 | + | now := time.Unix(1_000, 0) | |
| 504 | + | d.clock = fixedRemoteRefreshClock{now: now} | |
| 505 | + | remoteLifecycle := domain.SessionLifecycleID{31} | |
| 506 | + | d.remoteCatalog.mu.Lock() | |
| 507 | + | d.remoteCatalog.cache["user@arch"] = ports.RemoteCatalogCacheEntry{ | |
| 508 | + | Host: "user@arch", FetchedAt: now, | |
| 509 | + | Sessions: []ports.RemoteCatalogSession{{ | |
| 510 | + | LifecycleID: remoteLifecycle, Name: "work", State: ports.RemoteCatalogSessionUp, | |
| 511 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work", | |
| 512 | + | }}, | |
| 513 | + | } | |
| 514 | + | d.remoteCatalog.status["user@arch"] = remoteHostFresh | |
| 515 | + | d.remoteCatalog.mu.Unlock() | |
| 516 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 517 | + | ||
| 518 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| 519 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 520 | + | d.handleInputForAttachment(token, []byte("work@arch\r")) | |
| 521 | + | ||
| 522 | + | frame := awaitFrame(t, sends, ports.MsgAttachTarget) | |
| 523 | + | target, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| 524 | + | require.NoError(t, err) | |
| 525 | + | remoteTarget := domain.RemoteSessionTarget{ | |
| 526 | + | Endpoint: "user@arch", DisplayOrigin: "arch", LifecycleID: remoteLifecycle, | |
| 527 | + | SessionName: "work", LiveTabID: "tab-work", | |
| 528 | + | } | |
| 529 | + | require.Equal(t, ports.AttachTarget{ | |
| 530 | + | Endpoint: "user@arch", Session: "work", Intent: ports.IntentAttach, | |
| 531 | + | RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 532 | + | }, target) | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) { | |
| 536 | + | p, release := newBlockingPTY(t) | |
| 537 | + | defer release() | |
| 538 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |
| 539 | + | now := time.Unix(1_000, 0) | |
| 540 | + | d.clock = fixedRemoteRefreshClock{now: now} | |
| 541 | + | d.remoteCatalog.mu.Lock() | |
| 542 | + | d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{ | |
| 543 | + | Host: "arch", FetchedAt: now, | |
| 544 | + | Sessions: []ports.RemoteCatalogSession{{ | |
| 545 | + | LifecycleID: domain.SessionLifecycleID{32}, Name: "cached", State: ports.RemoteCatalogSessionUp, | |
| 546 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-cached", Index: 0}}, ActiveTabID: "tab-cached", | |
| 547 | + | }}, | |
| 548 | + | } | |
| 549 | + | d.remoteCatalog.status["arch"] = remoteHostCached | |
| 550 | + | d.remoteCatalog.mu.Unlock() | |
| 551 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 552 | + | ||
| 553 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| 554 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 555 | + | d.handleInputForAttachment(token, []byte("cached@arch\r")) | |
| 556 | + | ||
| 557 | + | require.Same(t, current, ac.currentAttachmentSession()) | |
| 558 | + | require.True(t, ac.overlays.paletteActive()) | |
| 559 | + | history := d.notices.history() | |
| 560 | + | require.NotEmpty(t, history) | |
| 561 | + | require.Contains(t, history[len(history)-1].Message, "refreshing") | |
| 562 | + | require.NotContains(t, history[len(history)-1].Message, "identity changed") | |
| 563 | + | for { | |
| 564 | + | select { | |
| 565 | + | case frame := <-sends: | |
| 566 | + | require.NotEqual(t, ports.MsgAttachTarget, frame.Type) | |
| 567 | + | default: | |
| 568 | + | return | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | func TestRemoteRefreshUpdatesOpenPaletteAndPreservesQuery(t *testing.T) { | |
| 574 | + | hosts := &remoteRefreshHostStore{hosts: []string{"user@arch"}} | |
| 575 | + | d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(1_000, 0)) | |
| 576 | + | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "current") | |
| 577 | + | ||
| 578 | + | d.enterPalette(sess, ac) | |
| 579 | + | request := receiveRemotePicker(t, catalog.requests, "palette catalog request") | |
| 580 | + | ac.overlays.paletteMu.Lock() | |
| 581 | + | model := ac.overlays.palette | |
| 582 | + | for _, r := range "vev" { | |
| 583 | + | model.Insert(r) | |
| 584 | + | } | |
| 585 | + | ac.overlays.paletteMu.Unlock() | |
| 586 | + | request.result <- remoteRefreshResult{catalog: remoteCatalogForTest(ports.RemoteCatalogSession{ | |
| 587 | + | LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: ports.RemoteCatalogSessionUp, | |
| 588 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev", | |
| 589 | + | })} | |
| 590 | + | receiveRemotePicker(t, cache.stores, "palette catalog cache store") | |
| 591 | + | d.sessWg.Wait() | |
| 592 | + | ||
| 593 | + | ac.overlays.paletteMu.Lock() | |
| 594 | + | require.Same(t, model, ac.overlays.palette) | |
| 595 | + | require.Equal(t, "vev", model.Query()) | |
| 596 | + | matches := model.Matches() | |
| 597 | + | ac.overlays.paletteMu.Unlock() | |
| 598 | + | displayTexts := make([]string, len(matches)) | |
| 599 | + | for i, match := range matches { | |
| 600 | + | displayTexts[i] = match.Result.DisplayText() | |
| 601 | + | } | |
| 602 | + | require.Contains(t, displayTexts, "Switch to session vev@arch") | |
| 603 | + | ||
| 604 | + | d.closePalette(ac) | |
| 605 | + | d.remoteCatalog.mu.Lock() | |
| 606 | + | require.NotContains(t, d.remoteCatalog.consumers, ac) | |
| 607 | + | require.Nil(t, d.remoteCatalog.cancel) | |
| 608 | + | d.remoteCatalog.mu.Unlock() | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | func TestRemoteRefreshTracksPickerAndPaletteOnSameAttachment(t *testing.T) { | |
| 612 | + | hosts := &remoteRefreshHostStore{hosts: []string{"arch"}} | |
| 613 | + | d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(1_100, 0)) | |
| 614 | + | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 615 | + | ||
| 616 | + | d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 617 | + | request := receiveRemotePicker(t, catalog.requests, "picker catalog request") | |
| 618 | + | d.enterPalette(sess, ac) | |
| 619 | + | receiveRemotePickerClose(t, request.ctx.Done(), "superseded picker catalog request") | |
| 620 | + | request = receiveRemotePicker(t, catalog.requests, "shared catalog request") | |
| 621 | + | ||
| 622 | + | d.closePalette(ac) | |
| 623 | + | select { | |
| 624 | + | case <-request.ctx.Done(): | |
| 625 | + | t.Fatal("refresh canceled while the picker remained open") | |
| 626 | + | default: | |
| 627 | + | } | |
| 628 | + | d.remoteCatalog.mu.Lock() | |
| 629 | + | require.Equal(t, remoteDiscoveryPicker, d.remoteCatalog.consumers[ac]) | |
| 630 | + | d.remoteCatalog.mu.Unlock() | |
| 631 | + | ||
| 632 | + | d.closePicker(ac) | |
| 633 | + | receiveRemotePickerClose(t, request.ctx.Done(), "last discovery consumer cancellation") | |
| 634 | + | d.remoteCatalog.mu.Lock() | |
| 635 | + | require.Empty(t, d.remoteCatalog.consumers) | |
| 636 | + | require.Nil(t, d.remoteCatalog.cancel) | |
| 637 | + | d.remoteCatalog.mu.Unlock() | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | func TestPaletteKeepsCatalogTargetWhenRecentRouteHasOnlyDisplayOrigin(t *testing.T) { | |
| 641 | + | now := time.Unix(1_200, 0) | |
| 642 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 643 | + | current := addControlSession(d, "current", "tab-1", "pane-1") | |
| 644 | + | current.ephemeral = false | |
| 645 | + | lifecycle := domain.SessionLifecycleID{51} | |
| 646 | + | d.remoteCatalog.mu.Lock() | |
| 647 | + | d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{ | |
| 648 | + | Host: "arch", FetchedAt: now, | |
| 649 | + | Sessions: []ports.RemoteCatalogSession{{ | |
| 650 | + | LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp, | |
| 651 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev", | |
| 652 | + | }}, | |
| 653 | + | } | |
| 654 | + | d.remoteCatalog.status["arch"] = remoteHostFresh | |
| 655 | + | d.remoteCatalog.mu.Unlock() | |
| 656 | + | ||
| 657 | + | results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{ | |
| 658 | + | Generation: 2, | |
| 659 | + | Entries: []ports.RecentRouteEntry{{ | |
| 660 | + | Key: 3, Generation: 1, | |
| 661 | + | Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "vev"}, | |
| 662 | + | Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote, | |
| 663 | + | }}, | |
| 664 | + | }) | |
| 665 | + | var matching []palette.Result | |
| 666 | + | for _, result := range results { | |
| 667 | + | if result.DisplayText() == "Switch to session vev@arch" { | |
| 668 | + | matching = append(matching, result) | |
| 669 | + | } | |
| 670 | + | } | |
| 671 | + | require.Len(t, matching, 2, "presentation-only route origin cannot deduplicate an exact catalog endpoint") | |
| 672 | + | action, routeOK := matching[0].RouteNavigationAction() | |
| 673 | + | _, catalogOK := matching[1].RemoteSessionTarget() | |
| 674 | + | require.True(t, routeOK) | |
| 675 | + | require.True(t, catalogOK) | |
| 676 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 3, Generation: 1}, action) | |
| 677 | + | } | |
| 678 | + | ||
| 431 | 679 | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | |
| 432 | 680 | d := newTestDaemon(t, nil, stubClock{}) | |
| 433 | 681 | 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,253 @@ 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 | + | now := time.Unix(1_000, 0) | |||
| 504 | + | d.clock = fixedRemoteRefreshClock{now: now} | |||
| 505 | + | remoteLifecycle := domain.SessionLifecycleID{31} | |||
| 506 | + | d.remoteCatalog.mu.Lock() | |||
| 507 | + | d.remoteCatalog.cache["user@arch"] = ports.RemoteCatalogCacheEntry{ | |||
| 508 | + | Host: "user@arch", FetchedAt: now, | |||
| 509 | + | Sessions: []ports.RemoteCatalogSession{{ | |||
| 510 | + | LifecycleID: remoteLifecycle, Name: "work", State: ports.RemoteCatalogSessionUp, | |||
| 511 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work", | |||
| 512 | + | }}, | |||
| 513 | + | } | |||
| 514 | + | d.remoteCatalog.status["user@arch"] = remoteHostFresh | |||
| 515 | + | d.remoteCatalog.mu.Unlock() | |||
| 516 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |||
| 517 | + | ||||
| 518 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |||
| 519 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 520 | + | d.handleInputForAttachment(token, []byte("work@arch\r")) | |||
| 521 | + | ||||
| 522 | + | frame := awaitFrame(t, sends, ports.MsgAttachTarget) | |||
| 523 | + | target, err := ports.UnmarshalAttachTarget(frame.Payload) | |||
| 524 | + | require.NoError(t, err) | |||
| 525 | + | remoteTarget := domain.RemoteSessionTarget{ | |||
| 526 | + | Endpoint: "user@arch", DisplayOrigin: "arch", LifecycleID: remoteLifecycle, | |||
| 527 | + | SessionName: "work", LiveTabID: "tab-work", | |||
| 528 | + | } | |||
| 529 | + | require.Equal(t, ports.AttachTarget{ | |||
| 530 | + | Endpoint: "user@arch", Session: "work", Intent: ports.IntentAttach, | |||
| 531 | + | RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 532 | + | }, target) | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) { | |||
| 536 | + | p, release := newBlockingPTY(t) | |||
| 537 | + | defer release() | |||
| 538 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 539 | + | now := time.Unix(1_000, 0) | |||
| 540 | + | d.clock = fixedRemoteRefreshClock{now: now} | |||
| 541 | + | d.remoteCatalog.mu.Lock() | |||
| 542 | + | d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{ | |||
| 543 | + | Host: "arch", FetchedAt: now, | |||
| 544 | + | Sessions: []ports.RemoteCatalogSession{{ | |||
| 545 | + | LifecycleID: domain.SessionLifecycleID{32}, Name: "cached", State: ports.RemoteCatalogSessionUp, | |||
| 546 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-cached", Index: 0}}, ActiveTabID: "tab-cached", | |||
| 547 | + | }}, | |||
| 548 | + | } | |||
| 549 | + | d.remoteCatalog.status["arch"] = remoteHostCached | |||
| 550 | + | d.remoteCatalog.mu.Unlock() | |||
| 551 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |||
| 552 | + | ||||
| 553 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |||
| 554 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 555 | + | d.handleInputForAttachment(token, []byte("cached@arch\r")) | |||
| 556 | + | ||||
| 557 | + | require.Same(t, current, ac.currentAttachmentSession()) | |||
| 558 | + | require.True(t, ac.overlays.paletteActive()) | |||
| 559 | + | history := d.notices.history() | |||
| 560 | + | require.NotEmpty(t, history) | |||
| 561 | + | require.Contains(t, history[len(history)-1].Message, "refreshing") | |||
| 562 | + | require.NotContains(t, history[len(history)-1].Message, "identity changed") | |||
| 563 | + | for { | |||
| 564 | + | select { | |||
| 565 | + | case frame := <-sends: | |||
| 566 | + | require.NotEqual(t, ports.MsgAttachTarget, frame.Type) | |||
| 567 | + | default: | |||
| 568 | + | return | |||
| 569 | + | } | |||
| 570 | + | } | |||
| 571 | + | } | |||
| 572 | + | ||||
| 573 | + | func TestRemoteRefreshUpdatesOpenPaletteAndPreservesQuery(t *testing.T) { | |||
| 574 | + | hosts := &remoteRefreshHostStore{hosts: []string{"user@arch"}} | |||
| 575 | + | d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(1_000, 0)) | |||
| 576 | + | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "current") | |||
| 577 | + | ||||
| 578 | + | d.enterPalette(sess, ac) | |||
| 579 | + | request := receiveRemotePicker(t, catalog.requests, "palette catalog request") | |||
| 580 | + | ac.overlays.paletteMu.Lock() | |||
| 581 | + | model := ac.overlays.palette | |||
| 582 | + | for _, r := range "vev" { | |||
| 583 | + | model.Insert(r) | |||
| 584 | + | } | |||
| 585 | + | ac.overlays.paletteMu.Unlock() | |||
| 586 | + | request.result <- remoteRefreshResult{catalog: remoteCatalogForTest(ports.RemoteCatalogSession{ | |||
| 587 | + | LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: ports.RemoteCatalogSessionUp, | |||
| 588 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev", | |||
| 589 | + | })} | |||
| 590 | + | receiveRemotePicker(t, cache.stores, "palette catalog cache store") | |||
| 591 | + | d.sessWg.Wait() | |||
| 592 | + | ||||
| 593 | + | ac.overlays.paletteMu.Lock() | |||
| 594 | + | require.Same(t, model, ac.overlays.palette) | |||
| 595 | + | require.Equal(t, "vev", model.Query()) | |||
| 596 | + | matches := model.Matches() | |||
| 597 | + | ac.overlays.paletteMu.Unlock() | |||
| 598 | + | displayTexts := make([]string, len(matches)) | |||
| 599 | + | for i, match := range matches { | |||
| 600 | + | displayTexts[i] = match.Result.DisplayText() | |||
| 601 | + | } | |||
| 602 | + | require.Contains(t, displayTexts, "Switch to session vev@arch") | |||
| 603 | + | ||||
| 604 | + | d.closePalette(ac) | |||
| 605 | + | d.remoteCatalog.mu.Lock() | |||
| 606 | + | require.NotContains(t, d.remoteCatalog.consumers, ac) | |||
| 607 | + | require.Nil(t, d.remoteCatalog.cancel) | |||
| 608 | + | d.remoteCatalog.mu.Unlock() | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | func TestRemoteRefreshTracksPickerAndPaletteOnSameAttachment(t *testing.T) { | |||
| 612 | + | hosts := &remoteRefreshHostStore{hosts: []string{"arch"}} | |||
| 613 | + | d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(1_100, 0)) | |||
| 614 | + | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |||
| 615 | + | ||||
| 616 | + | d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |||
| 617 | + | request := receiveRemotePicker(t, catalog.requests, "picker catalog request") | |||
| 618 | + | d.enterPalette(sess, ac) | |||
| 619 | + | receiveRemotePickerClose(t, request.ctx.Done(), "superseded picker catalog request") | |||
| 620 | + | request = receiveRemotePicker(t, catalog.requests, "shared catalog request") | |||
| 621 | + | ||||
| 622 | + | d.closePalette(ac) | |||
| 623 | + | select { | |||
| 624 | + | case <-request.ctx.Done(): | |||
| 625 | + | t.Fatal("refresh canceled while the picker remained open") | |||
| 626 | + | default: | |||
| 627 | + | } | |||
| 628 | + | d.remoteCatalog.mu.Lock() | |||
| 629 | + | require.Equal(t, remoteDiscoveryPicker, d.remoteCatalog.consumers[ac]) | |||
| 630 | + | d.remoteCatalog.mu.Unlock() | |||
| 631 | + | ||||
| 632 | + | d.closePicker(ac) | |||
| 633 | + | receiveRemotePickerClose(t, request.ctx.Done(), "last discovery consumer cancellation") | |||
| 634 | + | d.remoteCatalog.mu.Lock() | |||
| 635 | + | require.Empty(t, d.remoteCatalog.consumers) | |||
| 636 | + | require.Nil(t, d.remoteCatalog.cancel) | |||
| 637 | + | d.remoteCatalog.mu.Unlock() | |||
| 638 | + | } | |||
| 639 | + | ||||
| 640 | + | func TestPaletteKeepsCatalogTargetWhenRecentRouteHasOnlyDisplayOrigin(t *testing.T) { | |||
| 641 | + | now := time.Unix(1_200, 0) | |||
| 642 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |||
| 643 | + | current := addControlSession(d, "current", "tab-1", "pane-1") | |||
| 644 | + | current.ephemeral = false | |||
| 645 | + | lifecycle := domain.SessionLifecycleID{51} | |||
| 646 | + | d.remoteCatalog.mu.Lock() | |||
| 647 | + | d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{ | |||
| 648 | + | Host: "arch", FetchedAt: now, | |||
| 649 | + | Sessions: []ports.RemoteCatalogSession{{ | |||
| 650 | + | LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp, | |||
| 651 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev", | |||
| 652 | + | }}, | |||
| 653 | + | } | |||
| 654 | + | d.remoteCatalog.status["arch"] = remoteHostFresh | |||
| 655 | + | d.remoteCatalog.mu.Unlock() | |||
| 656 | + | ||||
| 657 | + | results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{ | |||
| 658 | + | Generation: 2, | |||
| 659 | + | Entries: []ports.RecentRouteEntry{{ | |||
| 660 | + | Key: 3, Generation: 1, | |||
| 661 | + | Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "vev"}, | |||
| 662 | + | Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote, | |||
| 663 | + | }}, | |||
| 664 | + | }) | |||
| 665 | + | var matching []palette.Result | |||
| 666 | + | for _, result := range results { | |||
| 667 | + | if result.DisplayText() == "Switch to session vev@arch" { | |||
| 668 | + | matching = append(matching, result) | |||
| 669 | + | } | |||
| 670 | + | } | |||
| 671 | + | require.Len(t, matching, 2, "presentation-only route origin cannot deduplicate an exact catalog endpoint") | |||
| 672 | + | action, routeOK := matching[0].RouteNavigationAction() | |||
| 673 | + | _, catalogOK := matching[1].RemoteSessionTarget() | |||
| 674 | + | require.True(t, routeOK) | |||
| 675 | + | require.True(t, catalogOK) | |||
| 676 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 3, Generation: 1}, action) | |||
| 677 | + | } | |||
| 678 | + | ||||
| 431 | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | 679 | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | ||
| 432 | d := newTestDaemon(t, nil, stubClock{}) | 680 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 433 | current := addControlSession(d, "hi", "tab-1", "pane-1") | 681 | 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. | |||
| 86 | 86 | if rt.beforeRemotePickerRegistration != nil { | |
| 87 | 87 | rt.beforeRemotePickerRegistration() | |
| 88 | 88 | } | |
| 89 | - | d.remotePickerOpened(instance) | |
| 89 | + | d.remoteDiscoveryOpened(instance.discoveryInstance()) | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 92 | 92 | // 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() | |
| 169 | - | sortRemotePickerCatalog(catalog, d.remotePickerHostRanks()) | |
| 168 | + | catalog := d.remoteCatalogSnapshot() | |
| 169 | + | sortRemoteCatalog(catalog, d.remoteHostRanks()) | |
| 170 | 170 | ||
| 171 | 171 | catalogRows := 0 | |
| 172 | 172 | for _, host := range catalog { | |
| @@ -283,7 +283,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 283 | 283 | }, | |
| 284 | 284 | afterClose: func() { | |
| 285 | 285 | d.clearPreviewGeneration(ac, previewGeneration) | |
| 286 | - | d.remotePickerClosed(instance) | |
| 286 | + | d.remoteDiscoveryClosed(instance.discoveryInstance()) | |
| 287 | 287 | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 288 | 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 | 742 | ac.overlays.pickerMu.Unlock() | |
| 743 | 743 | d.clearPreviewGeneration(ac, previewGeneration) | |
| 744 | 744 | if instance.model != nil { | |
| 745 | - | d.remotePickerClosed(instance) | |
| 745 | + | d.remoteDiscoveryClosed(instance.discoveryInstance()) | |
| 746 | 746 | } | |
| 747 | 747 | return true | |
| 748 | 748 | } | |
| @@ -969,7 +969,7 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT | |||
| 969 | 969 | } | |
| 970 | 970 | remoteTarget := *target.RemoteTarget | |
| 971 | 971 | 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 | 973 | return failUnavailable() | |
| 974 | 974 | } | |
| 975 | 975 | handoff := ports.AttachTarget{ | |
| @@ -1005,7 +1005,7 @@ func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCache | |||
| 1005 | 1005 | 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 | 1009 | if d == nil || target.Validate() != nil { | |
| 1010 | 1010 | return false | |
| 1011 | 1011 | } | |
| @@ -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 intentionally | 92 | // 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 := 0 | 171 | 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 true | 747 | return true | ||
| 748 | } | 748 | } | ||
| @@ -969,7 +969,7 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT | |||||
| 969 | } | 969 | } | ||
| 970 | remoteTarget := *target.RemoteTarget | 970 | remoteTarget := *target.RemoteTarget | ||
| 971 | key := *target.RemoteKey | 971 | 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, true | 1005 | 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 false | 1010 | return false | ||
| 1011 | } | 1011 | } | ||
M internal/usecase/daemon/remote_catalog_state.go +19 -12
| @@ -24,23 +24,30 @@ const ( | |||
| 24 | 24 | ||
| 25 | 25 | // remoteCatalogState owns the cache-derived discovery state. Its mutex never | |
| 26 | 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 | 34 | 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 | |
| 36 | 43 | } | |
| 37 | 44 | ||
| 38 | 45 | func newRemoteCatalogState() remoteCatalogState { | |
| 39 | 46 | 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), | |
| 44 | 51 | } | |
| 45 | 52 | } | |
| 46 | 53 | ||
| @@ -24,23 +24,30 @@ const ( | |||||
| 24 | 24 | ||||
| 25 | // remoteCatalogState owns the cache-derived discovery state. Its mutex never | 25 | // 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.Mutex | 35 | + | mu sync.Mutex |
| 29 | - | cache map[string]ports.RemoteCatalogCacheEntry | 36 | + | cache map[string]ports.RemoteCatalogCacheEntry |
| 30 | - | status map[string]remoteHostStatus | 37 | + | status map[string]remoteHostStatus |
| 31 | - | failure map[string]time.Time | 38 | + | failure map[string]time.Time |
| 32 | - | refresh uint64 | 39 | + | refresh uint64 |
| 33 | - | cancel context.CancelFunc | 40 | + | cancel context.CancelFunc |
| 34 | - | pickers map[*attachedClient]struct{} | 41 | + | consumers map[*attachedClient]remoteDiscoveryConsumerKind |
| 35 | - | writeMu sync.Mutex | 42 | + | 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 ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/picker" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | - | type remotePickerCatalogEntry struct { | |
| 18 | + | type remoteCatalogPresentationEntry struct { | |
| 18 | 19 | entry ports.RemoteCatalogCacheEntry | |
| 19 | 20 | status remoteHostStatus | |
| 20 | 21 | } | |
| @@ -28,12 +29,25 @@ type remotePickerInstance struct { | |||
| 28 | 29 | model *picker.Model | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 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 | + | // remoteCatalogSnapshot copies the cache-derived discovery state before any | |
| 47 | + | // overlay sorting begins. No remote-state lock survives this snapshot. | |
| 48 | + | const remoteCatalogAttachTTL = 30 * time.Second | |
| 35 | 49 | ||
| 36 | - | func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | |
| 50 | + | func (d *Daemon) remoteCatalogSnapshot() []remoteCatalogPresentationEntry { | |
| 37 | 51 | if d == nil { | |
| 38 | 52 | return nil | |
| 39 | 53 | } | |
| @@ -44,10 +58,10 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | |||
| 44 | 58 | 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 | 62 | seen := make(map[string]struct{}, len(d.remoteCatalog.cache)) | |
| 49 | 63 | for host, entry := range d.remoteCatalog.cache { | |
| 50 | - | entries = append(entries, remotePickerCatalogEntry{ | |
| 64 | + | entries = append(entries, remoteCatalogPresentationEntry{ | |
| 51 | 65 | entry: cloneRemoteCatalogEntry(entry), | |
| 52 | 66 | status: d.remoteCatalog.status[host], | |
| 53 | 67 | }) | |
| @@ -57,7 +71,7 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | |||
| 57 | 71 | if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch && status != remoteHostMalformed) { | |
| 58 | 72 | continue | |
| 59 | 73 | } | |
| 60 | - | entries = append(entries, remotePickerCatalogEntry{ | |
| 74 | + | entries = append(entries, remoteCatalogPresentationEntry{ | |
| 61 | 75 | entry: ports.RemoteCatalogCacheEntry{Host: host}, | |
| 62 | 76 | status: status, | |
| 63 | 77 | }) | |
| @@ -66,14 +80,14 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | |||
| 66 | 80 | return entries | |
| 67 | 81 | } | |
| 68 | 82 | ||
| 69 | - | func (d *Daemon) remotePickerHostRanks() map[string]int { | |
| 83 | + | func (d *Daemon) remoteHostRanks() map[string]int { | |
| 70 | 84 | ranks := make(map[string]int) | |
| 71 | 85 | if d == nil || d.remoteHostStore == nil { | |
| 72 | 86 | return ranks | |
| 73 | 87 | } | |
| 74 | 88 | pinned, learned, err := d.remoteHostStore.Hosts() | |
| 75 | 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 | 91 | return ranks | |
| 78 | 92 | } | |
| 79 | 93 | for _, host := range append(pinned, learned...) { | |
| @@ -85,7 +99,7 @@ func (d *Daemon) remotePickerHostRanks() map[string]int { | |||
| 85 | 99 | } | |
| 86 | 100 | ||
| 87 | 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 | 105 | func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability { | |
| @@ -128,9 +142,43 @@ func remoteSessionStateStopped(state ports.RemoteCatalogSessionState) bool { | |||
| 128 | 142 | 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 | 146 | key.LifecycleID = session.LifecycleID | |
| 133 | 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 | 182 | availability := remotePickerAvailability(status) | |
| 135 | 183 | stopped := remoteSessionStateStopped(session.State) | |
| 136 | 184 | broken := session.State == ports.RemoteCatalogSessionBroken | |
| @@ -153,26 +201,6 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||
| 153 | 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 | 204 | // Keep the structured session identity on rows even when one tab ID is | |
| 177 | 205 | // malformed or a broken session cannot be activated. Cursor navigation | |
| 178 | 206 | // 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 | 297 | for i := range entries { | |
| 270 | 298 | sort.Slice(entries[i].entry.Sessions, func(left, right int) bool { | |
| 271 | 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) { | |
| 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) | |
| 295 | 323 | } | |
| 296 | 324 | } | |
| 297 | 325 | ||
| 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 { | |
| 304 | 331 | return false | |
| 305 | 332 | } | |
| 306 | 333 | 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: | |
| 312 | 354 | return false | |
| 313 | 355 | } | |
| 314 | 356 | d.remoteCatalog.mu.Lock() | |
| 315 | - | d.remoteCatalog.pickers[instance.ac] = struct{}{} | |
| 357 | + | d.remoteCatalog.consumers[instance.ac] |= instance.kind | |
| 316 | 358 | d.remoteCatalog.mu.Unlock() | |
| 317 | 359 | return true | |
| 318 | 360 | } | |
| 319 | 361 | ||
| 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 { | |
| 322 | 364 | return | |
| 323 | 365 | } | |
| 324 | 366 | 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 | + | } | |
| 327 | 387 | if !current { | |
| 328 | - | rt.pickerMu.Unlock() | |
| 388 | + | unlock() | |
| 329 | 389 | return | |
| 330 | 390 | } | |
| 331 | 391 | 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 { | |
| 334 | 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 | 407 | return | |
| 337 | 408 | } | |
| 338 | 409 | d.remoteCatalog.refresh++ | |
| 339 | 410 | cancel := d.remoteCatalog.cancel | |
| 340 | 411 | d.remoteCatalog.cancel = nil | |
| 341 | 412 | d.remoteCatalog.mu.Unlock() | |
| 342 | - | rt.pickerMu.Unlock() | |
| 413 | + | unlock() | |
| 343 | 414 | if cancel != nil { | |
| 344 | 415 | cancel() | |
| 345 | 416 | } | |
| 346 | 417 | } | |
| 347 | 418 | ||
| 348 | - | func (d *Daemon) cancelRemotePickerRefresh() { | |
| 419 | + | func (d *Daemon) cancelRemoteDiscoveryRefresh() { | |
| 349 | 420 | if d == nil { | |
| 350 | 421 | 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 | 434 | if d == nil || d.remoteHostStore == nil { | |
| 364 | 435 | return nil, nil | |
| 365 | 436 | } | |
| @@ -383,12 +454,12 @@ func (d *Daemon) remotePickerHosts() ([]string, error) { | |||
| 383 | 454 | return hosts, nil | |
| 384 | 455 | } | |
| 385 | 456 | ||
| 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 { | |
| 392 | 463 | return 0 | |
| 393 | 464 | } | |
| 394 | 465 | root := context.Background() | |
| @@ -398,22 +469,47 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 | |||
| 398 | 469 | ctx, cancel := context.WithCancel(root) | |
| 399 | 470 | ||
| 400 | 471 | 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) | |
| 407 | 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 | 510 | cancel() | |
| 409 | 511 | 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 | 513 | if previous != nil { | |
| 418 | 514 | previous() | |
| 419 | 515 | } | |
| @@ -421,7 +517,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 | |||
| 421 | 517 | return generation | |
| 422 | 518 | } | |
| 423 | 519 | ||
| 424 | - | hosts, err := d.remotePickerHosts() | |
| 520 | + | hosts, err := d.remoteDiscoveryHosts() | |
| 425 | 521 | if err != nil { | |
| 426 | 522 | d.log.Warn("listing remote hosts for refresh failed", "err", err) | |
| 427 | 523 | return generation | |
| @@ -454,7 +550,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 | |||
| 454 | 550 | if cacheChanged { | |
| 455 | 551 | d.persistRemoteCatalogCache() | |
| 456 | 552 | } | |
| 457 | - | d.refreshRemoteOpenPickers() | |
| 553 | + | d.refreshRemoteDiscoveryConsumers() | |
| 458 | 554 | ||
| 459 | 555 | for _, host := range hosts { | |
| 460 | 556 | d.sessWg.Go(func() { | |
| @@ -465,7 +561,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 | |||
| 465 | 561 | return generation | |
| 466 | 562 | } | |
| 467 | 563 | ||
| 468 | - | func containsRemotePickerHost(hosts []string, target string) bool { | |
| 564 | + | func containsRemoteDiscoveryHost(hosts []string, target string) bool { | |
| 469 | 565 | for _, host := range hosts { | |
| 470 | 566 | if host == target { | |
| 471 | 567 | return true | |
| @@ -488,12 +584,12 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||
| 488 | 584 | return false | |
| 489 | 585 | } | |
| 490 | 586 | ||
| 491 | - | hosts, hostsErr := d.remotePickerHosts() | |
| 587 | + | hosts, hostsErr := d.remoteDiscoveryHosts() | |
| 492 | 588 | if hostsErr != nil { | |
| 493 | 589 | d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr) | |
| 494 | 590 | return false | |
| 495 | 591 | } | |
| 496 | - | stillKnown := containsRemotePickerHost(hosts, host) | |
| 592 | + | stillKnown := containsRemoteDiscoveryHost(hosts, host) | |
| 497 | 593 | now := d.clock.Now() | |
| 498 | 594 | if listErr == nil { | |
| 499 | 595 | if err := ports.ValidateRemoteCatalog(catalog); err != nil { | |
| @@ -514,7 +610,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||
| 514 | 610 | d.remoteCatalog.mu.Unlock() | |
| 515 | 611 | if cacheChanged { | |
| 516 | 612 | d.persistRemoteCatalogCache() | |
| 517 | - | d.refreshRemoteOpenPickers() | |
| 613 | + | d.refreshRemoteDiscoveryConsumers() | |
| 518 | 614 | } | |
| 519 | 615 | return cacheChanged | |
| 520 | 616 | } | |
| @@ -532,7 +628,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||
| 532 | 628 | d.remoteCatalog.status[host] = status | |
| 533 | 629 | d.remoteCatalog.failure[host] = now | |
| 534 | 630 | d.remoteCatalog.mu.Unlock() | |
| 535 | - | d.refreshRemoteOpenPickers() | |
| 631 | + | d.refreshRemoteDiscoveryConsumers() | |
| 536 | 632 | return false | |
| 537 | 633 | } | |
| 538 | 634 | d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{ | |
| @@ -545,26 +641,42 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||
| 545 | 641 | d.remoteCatalog.mu.Unlock() | |
| 546 | 642 | ||
| 547 | 643 | d.persistRemoteCatalogCache() | |
| 548 | - | d.refreshRemoteOpenPickers() | |
| 644 | + | d.refreshRemoteDiscoveryConsumers() | |
| 549 | 645 | return true | |
| 550 | 646 | } | |
| 551 | 647 | ||
| 552 | - | func (d *Daemon) refreshRemoteOpenPickers() { | |
| 648 | + | func (d *Daemon) refreshRemoteDiscoveryConsumers() { | |
| 553 | 649 | if d == nil { | |
| 554 | 650 | return | |
| 555 | 651 | } | |
| 652 | + | type consumer struct { | |
| 653 | + | ac *attachedClient | |
| 654 | + | kind remoteDiscoveryConsumerKind | |
| 655 | + | } | |
| 556 | 656 | 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}) | |
| 560 | 660 | } | |
| 561 | 661 | d.remoteCatalog.mu.Unlock() | |
| 562 | 662 | ||
| 563 | - | for _, ac := range clients { | |
| 663 | + | for _, consumer := range consumers { | |
| 664 | + | ac := consumer.ac | |
| 564 | 665 | if ac == nil || ac.overlays == nil { | |
| 565 | 666 | 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 | 680 | if entry := ac.currentAttachmentSession(); entry != nil { | |
| 569 | 681 | d.invalidateRender(entry, ac, true, "remote_picker.go") | |
| 570 | 682 | } | |
| @@ -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.RemoteCatalogCacheEntry | 19 | entry ports.RemoteCatalogCacheEntry | ||
| 19 | status remoteHostStatus | 20 | status remoteHostStatus | ||
| 20 | } | 21 | } | ||
| @@ -28,12 +29,25 @@ type remotePickerInstance struct { | |||||
| 28 | model *picker.Model | 29 | model *picker.Model | ||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | - | // remotePickerCatalogSnapshot copies the cache-derived state needed for a | 32 | + | func (i remotePickerInstance) discoveryInstance() remoteDiscoveryInstance { |
| 32 | - | // picker before any row sorting begins. No remote-state lock survives this | 33 | + | return remoteDiscoveryInstance{ |
| 33 | - | // snapshot. | 34 | + | ac: i.ac, kind: remoteDiscoveryPicker, generation: i.generation, picker: i.model, |
| 34 | - | const remotePickerAttachTTL = 30 * time.Second | 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 | + | // remoteCatalogSnapshot copies the cache-derived discovery state before any | |||
| 47 | + | // overlay sorting begins. No remote-state lock survives this snapshot. | |||
| 48 | + | const remoteCatalogAttachTTL = 30 * time.Second | |||
| 35 | 49 | ||||
| 36 | - | func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | 50 | + | func (d *Daemon) remoteCatalogSnapshot() []remoteCatalogPresentationEntry { |
| 37 | if d == nil { | 51 | if d == nil { | ||
| 38 | return nil | 52 | return nil | ||
| 39 | } | 53 | } | ||
| @@ -44,10 +58,10 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | |||||
| 44 | d.remoteCatalog.status[host] = remoteHostStale | 58 | 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 | continue | 72 | 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 entries | 80 | 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 ranks | 86 | 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 ranks | 91 | 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.RemoteCatalogSessionDown | 142 | 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.LifecycleID | 146 | 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.RemoteCatalogSessionBroken | 184 | 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 is | 204 | // Keep the structured session identity on rows even when one tab ID is | ||
| 177 | // malformed or a broken session cannot be activated. Cursor navigation | 205 | // malformed or a broken session cannot be activated. Cursor navigation | ||
| 178 | // must remain possible, while sendRemoteAttachTargetForAttachment will | 206 | // 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 validation | 327 | + | // refresh owner. Overlay ownership always nests its model lock before the |
| 300 | - | // point: a close can win between registration and startup without being undone | 328 | + | // 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 false | 331 | return false | ||
| 305 | } | 332 | } | ||
| 306 | rt := instance.ac.overlays | 333 | rt := instance.ac.overlays | ||
| 307 | - | // Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may | 334 | + | 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 false | 354 | 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 true | 359 | 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 | return | 364 | return | ||
| 323 | } | 365 | } | ||
| 324 | rt := instance.ac.overlays | 366 | rt := instance.ac.overlays | ||
| 325 | - | rt.pickerMu.Lock() | 367 | + | var current bool |
| 326 | - | current := rt.pickerGeneration == instance.generation && rt.picker == nil | 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 | + | } | |||
| 327 | if !current { | 387 | if !current { | ||
| 328 | - | rt.pickerMu.Unlock() | 388 | + | unlock() |
| 329 | return | 389 | 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 | return | 407 | return | ||
| 337 | } | 408 | } | ||
| 338 | d.remoteCatalog.refresh++ | 409 | d.remoteCatalog.refresh++ | ||
| 339 | cancel := d.remoteCatalog.cancel | 410 | cancel := d.remoteCatalog.cancel | ||
| 340 | d.remoteCatalog.cancel = nil | 411 | 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 | return | 421 | 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, nil | 435 | return nil, nil | ||
| 365 | } | 436 | } | ||
| @@ -383,12 +454,12 @@ func (d *Daemon) remotePickerHosts() ([]string, error) { | |||||
| 383 | return hosts, nil | 454 | return hosts, nil | ||
| 384 | } | 455 | } | ||
| 385 | 456 | ||||
| 386 | - | // startRemotePickerRefresh replaces the current refresh generation and launches | 457 | + | // startRemoteDiscoveryRefresh replaces the current refresh generation and |
| 387 | - | // one bounded catalog call per known host. Installing the generation is gated | 458 | + | // launches one bounded catalog call per known host. Installing the generation |
| 388 | - | // by the exact picker generation that registered ownership. Host-store and | 459 | + | // 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 0 | 463 | 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.overlays | 471 | 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.model | 475 | + | 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 0 | 511 | 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 generation | 517 | 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 generation | 523 | 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 generation | 561 | 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 true | 567 | return true | ||
| @@ -488,12 +584,12 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||||
| 488 | return false | 584 | 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 false | 590 | 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 cacheChanged | 615 | return cacheChanged | ||
| 520 | } | 616 | } | ||
| @@ -532,7 +628,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||||
| 532 | d.remoteCatalog.status[host] = status | 628 | d.remoteCatalog.status[host] = status | ||
| 533 | d.remoteCatalog.failure[host] = now | 629 | d.remoteCatalog.failure[host] = now | ||
| 534 | d.remoteCatalog.mu.Unlock() | 630 | d.remoteCatalog.mu.Unlock() | ||
| 535 | - | d.refreshRemoteOpenPickers() | 631 | + | d.refreshRemoteDiscoveryConsumers() |
| 536 | return false | 632 | 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 true | 645 | 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 | return | 650 | 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 | continue | 666 | 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) { | |||
| 130 | 130 | d := newTestDaemon(t, nil, clock) | |
| 131 | 131 | lifecycle := remoteLifecycleForTest() | |
| 132 | 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 | 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 | 138 | d.remoteCatalog.status["arch"] = remoteHostFresh | |
| 139 | 139 | d.remoteCatalog.mu.Unlock() | |
| 140 | 140 | ||
| 141 | - | entries := d.remotePickerCatalogSnapshot() | |
| 141 | + | entries := d.remoteCatalogSnapshot() | |
| 142 | 142 | require.Len(t, entries, 1) | |
| 143 | 143 | require.Equal(t, remoteHostStale, entries[0].status) | |
| 144 | 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 | 148 | func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) { | |
| @@ -163,10 +163,10 @@ func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testin | |||
| 163 | 163 | target, ok := model.Selected() | |
| 164 | 164 | require.True(t, ok) | |
| 165 | 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) | |
| 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") | |
| 170 | 170 | d.remoteCatalog.mu.Lock() | |
| 171 | 171 | require.Equal(t, remoteHostStale, d.remoteCatalog.status["arch"]) | |
| 172 | 172 | d.remoteCatalog.mu.Unlock() | |
| @@ -483,7 +483,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T) | |||
| 483 | 483 | ac.overlays.pickerGeneration++ | |
| 484 | 484 | instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model} | |
| 485 | 485 | ac.overlays.pickerMu.Unlock() | |
| 486 | - | require.True(t, d.registerRemotePicker(instance)) | |
| 486 | + | require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance())) | |
| 487 | 487 | lifecycle := remoteLifecycleForTest() | |
| 488 | 488 | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |
| 489 | 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 | 496 | first := map[string]remoteRefreshRequest{} | |
| 497 | 497 | for range 2 { | |
| 498 | 498 | request := receiveRemotePicker(t, catalog.requests, "catalog request") | |
| @@ -514,7 +514,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T) | |||
| 514 | 514 | _, selectable := refreshedModel.Selected() | |
| 515 | 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 | 518 | require.Greater(t, secondGeneration, firstGeneration) | |
| 519 | 519 | for _, request := range first { | |
| 520 | 520 | receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation") | |
| @@ -547,9 +547,9 @@ func TestRemoteRefreshLockProbesSerialized(t *testing.T) { | |||
| 547 | 547 | ac.overlays.pickerGeneration++ | |
| 548 | 548 | instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model} | |
| 549 | 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 | 553 | request := receiveRemotePicker(t, catalog.requests, "catalog request") | |
| 554 | 554 | request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()} | |
| 555 | 555 | receiveRemotePicker(t, cache.stores, "remote cache store") | |
| @@ -567,9 +567,9 @@ func TestRemoteRefreshWithoutCatalogClientDoesNotMarkHostsRefreshing(t *testing. | |||
| 567 | 567 | ac.overlays.pickerGeneration++ | |
| 568 | 568 | instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model} | |
| 569 | 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 | 573 | d.remoteCatalog.mu.Lock() | |
| 574 | 574 | require.NotEqual(t, remoteHostRefreshing, d.remoteCatalog.status["arch"]) | |
| 575 | 575 | d.remoteCatalog.mu.Unlock() | |
| @@ -950,7 +950,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) { | |||
| 950 | 950 | instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker} | |
| 951 | 951 | before[owner] = owner.overlays.picker | |
| 952 | 952 | owner.overlays.pickerMu.Unlock() | |
| 953 | - | require.True(t, d.registerRemotePicker(instance)) | |
| 953 | + | require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance())) | |
| 954 | 954 | } | |
| 955 | 955 | d.remoteCatalog.mu.Lock() | |
| 956 | 956 | d.remoteCatalog.refresh = 4 | |
| @@ -1002,7 +1002,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) { | |||
| 1002 | 1002 | test.teardown(d, sess, ac) | |
| 1003 | 1003 | ||
| 1004 | 1004 | d.remoteCatalog.mu.Lock() | |
| 1005 | - | _, registered := d.remoteCatalog.pickers[ac] | |
| 1005 | + | _, registered := d.remoteCatalog.consumers[ac] | |
| 1006 | 1006 | d.remoteCatalog.mu.Unlock() | |
| 1007 | 1007 | ac.overlays.pickerMu.Lock() | |
| 1008 | 1008 | 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 | 1029 | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1030 | 1030 | hosts := &remoteRefreshHostStore{hosts: []string{"arch"}} | |
| 1031 | 1031 | d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(475, 0)) | |
| @@ -1033,7 +1033,10 @@ func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing. | |||
| 1033 | 1033 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 1034 | 1034 | ac.resumeCapable = true | |
| 1035 | 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 | 1041 | require.True(t, d.parkAttachment(sess, ac)) | |
| 1039 | 1042 | timer := receiveRemotePicker(t, clock.timers, "park expiry timer") | |
| @@ -1043,8 +1046,11 @@ func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing. | |||
| 1043 | 1046 | ac.overlays.pickerMu.Lock() | |
| 1044 | 1047 | require.Nil(t, ac.overlays.picker) | |
| 1045 | 1048 | ac.overlays.pickerMu.Unlock() | |
| 1049 | + | ac.overlays.paletteMu.Lock() | |
| 1050 | + | require.Nil(t, ac.overlays.palette) | |
| 1051 | + | ac.overlays.paletteMu.Unlock() | |
| 1046 | 1052 | d.remoteCatalog.mu.Lock() | |
| 1047 | - | require.NotContains(t, d.remoteCatalog.pickers, ac) | |
| 1053 | + | require.NotContains(t, d.remoteCatalog.consumers, ac) | |
| 1048 | 1054 | d.remoteCatalog.mu.Unlock() | |
| 1049 | 1055 | } | |
| 1050 | 1056 | ||
| @@ -1155,12 +1161,12 @@ func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) { | |||
| 1155 | 1161 | instance.ac.overlays.pickerMu.Lock() | |
| 1156 | 1162 | instance.ac.overlays.picker = nil | |
| 1157 | 1163 | instance.ac.overlays.pickerMu.Unlock() | |
| 1158 | - | d.remotePickerClosed(instance) | |
| 1164 | + | d.remoteDiscoveryClosed(instance.discoveryInstance()) | |
| 1159 | 1165 | } | |
| 1160 | 1166 | first, second := newInstance(), newInstance() | |
| 1161 | - | d.remotePickerOpened(first) | |
| 1167 | + | d.remoteDiscoveryOpened(first.discoveryInstance()) | |
| 1162 | 1168 | request := receiveRemotePicker(t, catalog.requests, "catalog request") | |
| 1163 | - | d.remotePickerOpened(second) | |
| 1169 | + | d.remoteDiscoveryOpened(second.discoveryInstance()) | |
| 1164 | 1170 | receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation") | |
| 1165 | 1171 | request = receiveRemotePicker(t, catalog.requests, "catalog request") | |
| 1166 | 1172 | ||
| @@ -1174,7 +1180,7 @@ func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) { | |||
| 1174 | 1180 | receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation") | |
| 1175 | 1181 | ||
| 1176 | 1182 | d.remoteCatalog.mu.Lock() | |
| 1177 | - | require.Empty(t, d.remoteCatalog.pickers) | |
| 1183 | + | require.Empty(t, d.remoteCatalog.consumers) | |
| 1178 | 1184 | require.Nil(t, d.remoteCatalog.cancel) | |
| 1179 | 1185 | d.remoteCatalog.mu.Unlock() | |
| 1180 | 1186 | } | |
| @@ -1193,19 +1199,19 @@ func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) { | |||
| 1193 | 1199 | ||
| 1194 | 1200 | // Stop at the production seam after exact picker ownership registration but | |
| 1195 | 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 | 1203 | ac.overlays.pickerMu.Lock() | |
| 1198 | 1204 | ac.overlays.picker = nil | |
| 1199 | 1205 | ac.overlays.pickerMu.Unlock() | |
| 1200 | - | d.remotePickerClosed(instance) | |
| 1206 | + | d.remoteDiscoveryClosed(instance.discoveryInstance()) | |
| 1201 | 1207 | ||
| 1202 | 1208 | d.remoteCatalog.mu.Lock() | |
| 1203 | 1209 | refreshBefore := d.remoteCatalog.refresh | |
| 1204 | 1210 | d.remoteCatalog.mu.Unlock() | |
| 1205 | - | require.Zero(t, d.startRemotePickerRefresh(instance)) | |
| 1211 | + | require.Zero(t, d.startRemoteDiscoveryRefresh(instance.discoveryInstance())) | |
| 1206 | 1212 | ||
| 1207 | 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 | 1215 | require.Equal(t, refreshBefore, d.remoteCatalog.refresh, "a close that wins before startup must prevent a later refresh generation") | |
| 1210 | 1216 | require.Nil(t, d.remoteCatalog.cancel, "a close that wins before startup must prevent a later cancel installation") | |
| 1211 | 1217 | d.remoteCatalog.mu.Unlock() | |
| @@ -1232,7 +1238,7 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) { | |||
| 1232 | 1238 | staleClose.afterClose() | |
| 1233 | 1239 | ||
| 1234 | 1240 | d.remoteCatalog.mu.Lock() | |
| 1235 | - | _, registered := d.remoteCatalog.pickers[ac] | |
| 1241 | + | _, registered := d.remoteCatalog.consumers[ac] | |
| 1236 | 1242 | d.remoteCatalog.mu.Unlock() | |
| 1237 | 1243 | require.True(t, registered, "stale close cleanup must retain the reopened picker registration") | |
| 1238 | 1244 | ac.overlays.pickerMu.Lock() | |
| @@ -1255,7 +1261,7 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | |||
| 1255 | 1261 | } | |
| 1256 | 1262 | refreshed := make(chan struct{}) | |
| 1257 | 1263 | go func() { | |
| 1258 | - | d.refreshRemoteOpenPickers() | |
| 1264 | + | d.refreshRemoteDiscoveryConsumers() | |
| 1259 | 1265 | close(refreshed) | |
| 1260 | 1266 | }() | |
| 1261 | 1267 | 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"] = remoteHostFresh | 138 | 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.picker | 951 | 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 = 4 | 956 | 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.pickerGeneration | 1008 | 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 = true | 1034 | 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 = nil | 1162 | 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 but | 1200 | // 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 = nil | 1204 | 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.refresh | 1209 | 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 { | |||
| 221 | 221 | // cannot leave closed panes retained through its capture cache. This must | |
| 222 | 222 | // remain outside d.mu: sendMu is ordered before the daemon lock. | |
| 223 | 223 | ac.clearCaptureFrames() | |
| 224 | - | var pickerGeneration uint64 | |
| 224 | + | var pickerGeneration, paletteGeneration uint64 | |
| 225 | 225 | if ac.overlays != nil { | |
| 226 | 226 | ac.overlays.pickerMu.Lock() | |
| 227 | 227 | pickerGeneration = ac.overlays.pickerGeneration | |
| 228 | 228 | ac.overlays.pickerMu.Unlock() | |
| 229 | + | ac.overlays.paletteMu.Lock() | |
| 230 | + | paletteGeneration = ac.overlays.paletteGeneration | |
| 231 | + | ac.overlays.paletteMu.Unlock() | |
| 229 | 232 | } | |
| 230 | 233 | d.mu.Lock() | |
| 231 | 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 | 252 | grace := d.resumeParkGrace | |
| 250 | 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 | 258 | ac.parked = true | |
| 253 | 259 | d.parked[token] = parked | |
| 254 | 260 | d.clearParkingInFlightLocked(token, ac) | |
| @@ -291,6 +297,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | |||
| 291 | 297 | d.removeParkedLocked(token, parked) | |
| 292 | 298 | d.mu.Unlock() | |
| 293 | 299 | d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration) | |
| 300 | + | d.closePaletteIfCurrent(parked.ac, parked.paletteGeneration) | |
| 294 | 301 | d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot()) | |
| 295 | 302 | return | |
| 296 | 303 | } | |
| @@ -373,11 +380,12 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |||
| 373 | 380 | } | |
| 374 | 381 | parked.closeDone() | |
| 375 | 382 | 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{}), | |
| 381 | 389 | } | |
| 382 | 390 | d.parked[token] = rearmed | |
| 383 | 391 | captured := ac.transportSnapshot().transport | |
| @@ -394,9 +402,10 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |||
| 394 | 402 | } | |
| 395 | 403 | ||
| 396 | 404 | type parkedAttachmentRetirement struct { | |
| 397 | - | parked *parkedAttachment | |
| 398 | - | pickerGeneration uint64 | |
| 399 | - | transport transportSnapshot | |
| 405 | + | parked *parkedAttachment | |
| 406 | + | pickerGeneration uint64 | |
| 407 | + | paletteGeneration uint64 | |
| 408 | + | transport transportSnapshot | |
| 400 | 409 | } | |
| 401 | 410 | ||
| 402 | 411 | func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement { | |
| @@ -406,9 +415,10 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach | |||
| 406 | 415 | parked.ac.connectionGeneration.Add(1) | |
| 407 | 416 | parked.ac.setSession(nil) | |
| 408 | 417 | 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(), | |
| 412 | 422 | } | |
| 413 | 423 | } | |
| 414 | 424 | ||
| @@ -433,8 +443,8 @@ func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement { | |||
| 433 | 443 | return retirements | |
| 434 | 444 | } | |
| 435 | 445 | ||
| 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. | |
| 438 | 448 | func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) { | |
| 439 | 449 | for _, retirement := range retirements { | |
| 440 | 450 | if retirement.parked.timer != nil { | |
| @@ -443,6 +453,7 @@ func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmen | |||
| 443 | 453 | retirement.parked.closeDone() | |
| 444 | 454 | ac := retirement.parked.ac | |
| 445 | 455 | d.closePickerIfCurrent(ac, nil, retirement.pickerGeneration) | |
| 456 | + | d.closePaletteIfCurrent(ac, retirement.paletteGeneration) | |
| 446 | 457 | _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport)) | |
| 447 | 458 | } | |
| 448 | 459 | } | |
| @@ -221,11 +221,14 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||||
| 221 | // cannot leave closed panes retained through its capture cache. This must | 221 | // 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 uint64 | 224 | + | 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.pickerGeneration | 227 | 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.resumeParkGrace | 252 | 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 = true | 258 | ac.parked = true | ||
| 253 | d.parked[token] = parked | 259 | 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 | return | 302 | 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] = rearmed | 390 | d.parked[token] = rearmed | ||
| 383 | captured := ac.transportSnapshot().transport | 391 | 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 *parkedAttachment | 405 | + | parked *parkedAttachment |
| 398 | - | pickerGeneration uint64 | 406 | + | pickerGeneration uint64 |
| 399 | - | transport transportSnapshot | 407 | + | 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 retirements | 443 | return retirements | ||
| 434 | } | 444 | } | ||
| 435 | 445 | ||||
| 436 | - | // finishParkedAttachmentRetirements stops timers and retires picker ownership | 446 | + | // 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.ac | 454 | 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 +11 -0
| @@ -57,6 +57,17 @@ func New(results []Result) *Model { | |||
| 57 | 57 | ||
| 58 | 58 | func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) } | |
| 59 | 59 | ||
| 60 | + | // ReplaceResults atomically replaces the model's immutable result source while | |
| 61 | + | // preserving the current query, selection index, and scroll position. | |
| 62 | + | func (m *Model) ReplaceResults(results []Result) { | |
| 63 | + | if m == nil { | |
| 64 | + | return | |
| 65 | + | } | |
| 66 | + | m.results = append([]Result(nil), results...) | |
| 67 | + | m.argumentResults = requiredArgumentResults(m.results) | |
| 68 | + | m.refresh() | |
| 69 | + | } | |
| 70 | + | ||
| 60 | 71 | func DefaultRenderStyles() RenderStyles { | |
| 61 | 72 | selection := renderer.DefaultStyle() | |
| 62 | 73 | selection.Inverse = true | |
| @@ -57,6 +57,17 @@ 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 atomically replaces the model's immutable result source while | |||
| 61 | + | // preserving the current query, selection index, and scroll position. | |||
| 62 | + | func (m *Model) ReplaceResults(results []Result) { | |||
| 63 | + | if m == nil { | |||
| 64 | + | return | |||
| 65 | + | } | |||
| 66 | + | m.results = append([]Result(nil), results...) | |||
| 67 | + | m.argumentResults = requiredArgumentResults(m.results) | |||
| 68 | + | m.refresh() | |||
| 69 | + | } | |||
| 70 | + | ||||
| 60 | func DefaultRenderStyles() RenderStyles { | 71 | func DefaultRenderStyles() RenderStyles { | ||
| 61 | selection := renderer.DefaultStyle() | 72 | selection := renderer.DefaultStyle() | ||
| 62 | selection.Inverse = true | 73 | selection.Inverse = true | ||
M internal/usecase/palette/result.go +44 -4
| @@ -3,6 +3,7 @@ package palette | |||
| 3 | 3 | import ( | |
| 4 | 4 | "time" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | "github.com/bnema/vev/internal/ports" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| 8 | 9 | ) | |
| @@ -14,16 +15,18 @@ const ( | |||
| 14 | 15 | ResultKindCommand ResultKind = iota | |
| 15 | 16 | ResultKindActiveSession | |
| 16 | 17 | ResultKindStoppedSession | |
| 18 | + | ResultKindRemoteSession | |
| 17 | 19 | ResultKindRecentRoute | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 20 | 22 | // Result is an immutable palette target. Its kind is the sole discriminator | |
| 21 | 23 | // for its private command or session payload. | |
| 22 | 24 | 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 | |
| 27 | 30 | } | |
| 28 | 31 | ||
| 29 | 32 | type sessionPayload struct { | |
| @@ -32,6 +35,12 @@ type sessionPayload struct { | |||
| 32 | 35 | 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 | 44 | type routePayload struct { | |
| 36 | 45 | label string | |
| 37 | 46 | 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 | 80 | // NewRecentRouteResult creates an immutable client-ledger route target. | |
| 62 | 81 | func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Result { | |
| 63 | 82 | return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}} | |
| @@ -69,6 +88,9 @@ func (r Result) DisplayText() string { | |||
| 69 | 88 | if r.kind == ResultKindCommand { | |
| 70 | 89 | return r.command.Code | |
| 71 | 90 | } | |
| 91 | + | if r.kind == ResultKindRemoteSession { | |
| 92 | + | return activeSessionDisplayPrefix + r.remoteSession.key.Display() | |
| 93 | + | } | |
| 72 | 94 | if r.kind == ResultKindRecentRoute { | |
| 73 | 95 | return activeSessionDisplayPrefix + r.route.label | |
| 74 | 96 | } | |
| @@ -107,6 +129,24 @@ func (r Result) SessionTarget() (ports.ExactSessionTarget, bool) { | |||
| 107 | 129 | return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | |
| 108 | 130 | } | |
| 109 | 131 | ||
| 132 | + | // RemoteSessionTarget returns the structured catalog target only for a remote | |
| 133 | + | // session result. | |
| 134 | + | func (r Result) RemoteSessionTarget() (domain.RemoteSessionTarget, bool) { | |
| 135 | + | return r.remoteSession.target, r.kind == ResultKindRemoteSession | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // RemoteSessionKey returns the structured catalog identity only for a remote | |
| 139 | + | // session result. | |
| 140 | + | func (r Result) RemoteSessionKey() (domain.RemoteSessionKey, bool) { | |
| 141 | + | return r.remoteSession.key, r.kind == ResultKindRemoteSession | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // RemoteSessionUnavailableReason returns the catalog availability reason only | |
| 145 | + | // for a remote session result. | |
| 146 | + | func (r Result) RemoteSessionUnavailableReason() (string, bool) { | |
| 147 | + | return r.remoteSession.unavailableReason, r.kind == ResultKindRemoteSession | |
| 148 | + | } | |
| 149 | + | ||
| 110 | 150 | // RouteNavigationAction returns the exact client-ledger target only for a | |
| 111 | 151 | // recent-route result. | |
| 112 | 152 | 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 = iota | 15 | ResultKindCommand ResultKind = iota | ||
| 15 | ResultKindActiveSession | 16 | ResultKindActiveSession | ||
| 16 | ResultKindStoppedSession | 17 | ResultKindStoppedSession | ||
| 18 | + | ResultKindRemoteSession | |||
| 17 | ResultKindRecentRoute | 19 | ResultKindRecentRoute | ||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| 20 | // Result is an immutable palette target. Its kind is the sole discriminator | 22 | // 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 or session payload. | ||
| 22 | type Result struct { | 24 | type Result struct { | ||
| 23 | - | kind ResultKind | 25 | + | kind ResultKind |
| 24 | - | command command.Command | 26 | + | command command.Command |
| 25 | - | session sessionPayload | 27 | + | session sessionPayload |
| 26 | - | route routePayload | 28 | + | 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.ExactSessionTarget | 35 | 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 string | 45 | label string | ||
| 37 | action ports.RouteNavigationAction | 46 | 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}} | ||
| @@ -69,6 +88,9 @@ func (r Result) DisplayText() string { | |||||
| 69 | if r.kind == ResultKindCommand { | 88 | if r.kind == ResultKindCommand { | ||
| 70 | return r.command.Code | 89 | return r.command.Code | ||
| 71 | } | 90 | } | ||
| 91 | + | if r.kind == ResultKindRemoteSession { | |||
| 92 | + | return activeSessionDisplayPrefix + r.remoteSession.key.Display() | |||
| 93 | + | } | |||
| 72 | if r.kind == ResultKindRecentRoute { | 94 | if r.kind == ResultKindRecentRoute { | ||
| 73 | return activeSessionDisplayPrefix + r.route.label | 95 | return activeSessionDisplayPrefix + r.route.label | ||
| 74 | } | 96 | } | ||
| @@ -107,6 +129,24 @@ func (r Result) SessionTarget() (ports.ExactSessionTarget, bool) { | |||||
| 107 | return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | 129 | return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | ||
| 108 | } | 130 | } | ||
| 109 | 131 | ||||
| 132 | + | // RemoteSessionTarget returns the structured catalog target only for a remote | |||
| 133 | + | // session result. | |||
| 134 | + | func (r Result) RemoteSessionTarget() (domain.RemoteSessionTarget, bool) { | |||
| 135 | + | return r.remoteSession.target, r.kind == ResultKindRemoteSession | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // RemoteSessionKey returns the structured catalog identity only for a remote | |||
| 139 | + | // session result. | |||
| 140 | + | func (r Result) RemoteSessionKey() (domain.RemoteSessionKey, bool) { | |||
| 141 | + | return r.remoteSession.key, r.kind == ResultKindRemoteSession | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // RemoteSessionUnavailableReason returns the catalog availability reason only | |||
| 145 | + | // for a remote session result. | |||
| 146 | + | func (r Result) RemoteSessionUnavailableReason() (string, bool) { | |||
| 147 | + | return r.remoteSession.unavailableReason, r.kind == ResultKindRemoteSession | |||
| 148 | + | } | |||
| 149 | + | ||||
| 110 | // RouteNavigationAction returns the exact client-ledger target only for a | 150 | // RouteNavigationAction returns the exact client-ledger target only for a | ||
| 111 | // recent-route result. | 151 | // recent-route result. | ||
| 112 | func (r Result) RouteNavigationAction() (ports.RouteNavigationAction, bool) { | 152 | func (r Result) RouteNavigationAction() (ports.RouteNavigationAction, bool) { | ||