Fix hybrid palette session switching (#216)
- commit
- 0425df66b70180de6bcb500c2e885af09788ad2c
- author
- bnema · · 2026-08-26 06:32 UTC
- committer
- GitHub · · 2026-08-26 06:32 UTC
- parent
- b605444
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
9 files · +174 -20
M internal/usecase/daemon/palette.go +35 -4
| @@ -80,6 +80,30 @@ func remotePaletteSessionIdentity(endpoint string, lifecycle domain.SessionLifec | |||
| 80 | 80 | return paletteSessionIdentity{kind: ports.RouteKindRemote, endpoint: endpoint, lifecycle: lifecycle} | |
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | + | func paletteRemoteDisplayOrigin(hostLabel string) string { | |
| 84 | + | origin := domain.RemoteDisplayOrigin(hostLabel) | |
| 85 | + | if origin == "" { | |
| 86 | + | return "remote" | |
| 87 | + | } | |
| 88 | + | return origin | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func paletteDaemonDisplayOrigin(snapshot ports.RecentRouteSnapshot, lifecycle domain.SessionLifecycleID) string { | |
| 92 | + | active, ok := activeRouteEntryForLifecycle(snapshot, lifecycle) | |
| 93 | + | if !ok || active.Kind != ports.RouteKindRemote { | |
| 94 | + | return "" | |
| 95 | + | } | |
| 96 | + | return paletteRemoteDisplayOrigin(active.HostLabel) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func paletteRouteRepresentsDaemonSession(entry ports.RecentRouteEntry, daemonDisplayOrigin string) bool { | |
| 100 | + | if entry.Kind == ports.RouteKindLocal { | |
| 101 | + | return true | |
| 102 | + | } | |
| 103 | + | return entry.Kind == ports.RouteKindRemote && daemonDisplayOrigin != "" && | |
| 104 | + | paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin | |
| 105 | + | } | |
| 106 | + | ||
| 83 | 107 | func remotePaletteUnavailableReason(status remoteHostStatus, session ports.RemoteCatalogSession) string { | |
| 84 | 108 | if session.State == ports.RemoteCatalogSessionBroken { | |
| 85 | 109 | return "session_broken" | |
| @@ -101,6 +125,13 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 101 | 125 | } | |
| 102 | 126 | } | |
| 103 | 127 | d.mu.Unlock() | |
| 128 | + | var currentLifecycle domain.SessionLifecycleID | |
| 129 | + | if current != nil { | |
| 130 | + | current.mu.Lock() | |
| 131 | + | currentLifecycle = current.incarnation | |
| 132 | + | current.mu.Unlock() | |
| 133 | + | } | |
| 134 | + | daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) | |
| 104 | 135 | ||
| 105 | 136 | remoteCatalog := d.remoteCatalogSnapshot() | |
| 106 | 137 | sortRemoteCatalog(remoteCatalog, d.remoteHostRanks()) | |
| @@ -124,7 +155,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 124 | 155 | continue | |
| 125 | 156 | } | |
| 126 | 157 | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | |
| 127 | - | active = append(active, palette.NewActiveSessionResult(target, time.Unix(0, snap.createdAt))) | |
| 158 | + | active = append(active, palette.NewActiveSessionResultWithDisplayOrigin(target, time.Unix(0, snap.createdAt), daemonDisplayOrigin)) | |
| 128 | 159 | } | |
| 129 | 160 | sort.Slice(active, func(i, j int) bool { | |
| 130 | 161 | left, _ := active[i].SessionName() | |
| @@ -135,7 +166,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 135 | 166 | results = append(results, active...) | |
| 136 | 167 | for _, candidate := range stopped { | |
| 137 | 168 | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} | |
| 138 | - | results = append(results, palette.NewStoppedSessionResult(target, time.Unix(0, candidate.createdAt))) | |
| 169 | + | results = append(results, palette.NewStoppedSessionResultWithDisplayOrigin(target, time.Unix(0, candidate.createdAt), daemonDisplayOrigin)) | |
| 139 | 170 | represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{} | |
| 140 | 171 | } | |
| 141 | 172 | type discoveredRemote struct { | |
| @@ -176,7 +207,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 176 | 207 | continue | |
| 177 | 208 | } | |
| 178 | 209 | identity := localPaletteSessionIdentity(entry.Target.LifecycleID) | |
| 179 | - | if _, exists := represented[identity]; entry.Kind == ports.RouteKindLocal && exists { | |
| 210 | + | if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) { | |
| 180 | 211 | continue | |
| 181 | 212 | } | |
| 182 | 213 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | |
| @@ -498,7 +529,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 498 | 529 | } | |
| 499 | 530 | var err error | |
| 500 | 531 | if effect != nil { | |
| 501 | - | err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session") | |
| 532 | + | err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{allowSamePeer: true}, "palette-session") | |
| 502 | 533 | } else { | |
| 503 | 534 | err = d.switchToTarget(sess, ac, target) | |
| 504 | 535 | } | |
| @@ -80,6 +80,30 @@ func remotePaletteSessionIdentity(endpoint string, lifecycle domain.SessionLifec | |||||
| 80 | return paletteSessionIdentity{kind: ports.RouteKindRemote, endpoint: endpoint, lifecycle: lifecycle} | 80 | return paletteSessionIdentity{kind: ports.RouteKindRemote, endpoint: endpoint, lifecycle: lifecycle} | ||
| 81 | } | 81 | } | ||
| 82 | 82 | ||||
| 83 | + | func paletteRemoteDisplayOrigin(hostLabel string) string { | |||
| 84 | + | origin := domain.RemoteDisplayOrigin(hostLabel) | |||
| 85 | + | if origin == "" { | |||
| 86 | + | return "remote" | |||
| 87 | + | } | |||
| 88 | + | return origin | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func paletteDaemonDisplayOrigin(snapshot ports.RecentRouteSnapshot, lifecycle domain.SessionLifecycleID) string { | |||
| 92 | + | active, ok := activeRouteEntryForLifecycle(snapshot, lifecycle) | |||
| 93 | + | if !ok || active.Kind != ports.RouteKindRemote { | |||
| 94 | + | return "" | |||
| 95 | + | } | |||
| 96 | + | return paletteRemoteDisplayOrigin(active.HostLabel) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func paletteRouteRepresentsDaemonSession(entry ports.RecentRouteEntry, daemonDisplayOrigin string) bool { | |||
| 100 | + | if entry.Kind == ports.RouteKindLocal { | |||
| 101 | + | return true | |||
| 102 | + | } | |||
| 103 | + | return entry.Kind == ports.RouteKindRemote && daemonDisplayOrigin != "" && | |||
| 104 | + | paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin | |||
| 105 | + | } | |||
| 106 | + | ||||
| 83 | func remotePaletteUnavailableReason(status remoteHostStatus, session ports.RemoteCatalogSession) string { | 107 | func remotePaletteUnavailableReason(status remoteHostStatus, session ports.RemoteCatalogSession) string { | ||
| 84 | if session.State == ports.RemoteCatalogSessionBroken { | 108 | if session.State == ports.RemoteCatalogSessionBroken { | ||
| 85 | return "session_broken" | 109 | return "session_broken" | ||
| @@ -101,6 +125,13 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 101 | } | 125 | } | ||
| 102 | } | 126 | } | ||
| 103 | d.mu.Unlock() | 127 | d.mu.Unlock() | ||
| 128 | + | var currentLifecycle domain.SessionLifecycleID | |||
| 129 | + | if current != nil { | |||
| 130 | + | current.mu.Lock() | |||
| 131 | + | currentLifecycle = current.incarnation | |||
| 132 | + | current.mu.Unlock() | |||
| 133 | + | } | |||
| 134 | + | daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) | |||
| 104 | 135 | ||||
| 105 | remoteCatalog := d.remoteCatalogSnapshot() | 136 | remoteCatalog := d.remoteCatalogSnapshot() | ||
| 106 | sortRemoteCatalog(remoteCatalog, d.remoteHostRanks()) | 137 | sortRemoteCatalog(remoteCatalog, d.remoteHostRanks()) | ||
| @@ -124,7 +155,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 124 | continue | 155 | continue | ||
| 125 | } | 156 | } | ||
| 126 | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | 157 | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | ||
| 127 | - | active = append(active, palette.NewActiveSessionResult(target, time.Unix(0, snap.createdAt))) | 158 | + | active = append(active, palette.NewActiveSessionResultWithDisplayOrigin(target, time.Unix(0, snap.createdAt), daemonDisplayOrigin)) |
| 128 | } | 159 | } | ||
| 129 | sort.Slice(active, func(i, j int) bool { | 160 | sort.Slice(active, func(i, j int) bool { | ||
| 130 | left, _ := active[i].SessionName() | 161 | left, _ := active[i].SessionName() | ||
| @@ -135,7 +166,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 135 | results = append(results, active...) | 166 | results = append(results, active...) | ||
| 136 | for _, candidate := range stopped { | 167 | for _, candidate := range stopped { | ||
| 137 | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} | 168 | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} | ||
| 138 | - | results = append(results, palette.NewStoppedSessionResult(target, time.Unix(0, candidate.createdAt))) | 169 | + | results = append(results, palette.NewStoppedSessionResultWithDisplayOrigin(target, time.Unix(0, candidate.createdAt), daemonDisplayOrigin)) |
| 139 | represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{} | 170 | represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{} | ||
| 140 | } | 171 | } | ||
| 141 | type discoveredRemote struct { | 172 | type discoveredRemote struct { | ||
| @@ -176,7 +207,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 176 | continue | 207 | continue | ||
| 177 | } | 208 | } | ||
| 178 | identity := localPaletteSessionIdentity(entry.Target.LifecycleID) | 209 | identity := localPaletteSessionIdentity(entry.Target.LifecycleID) | ||
| 179 | - | if _, exists := represented[identity]; entry.Kind == ports.RouteKindLocal && exists { | 210 | + | if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) { |
| 180 | continue | 211 | continue | ||
| 181 | } | 212 | } | ||
| 182 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | 213 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | ||
| @@ -498,7 +529,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 498 | } | 529 | } | ||
| 499 | var err error | 530 | var err error | ||
| 500 | if effect != nil { | 531 | if effect != nil { | ||
| 501 | - | err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session") | 532 | + | err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{allowSamePeer: true}, "palette-session") |
| 502 | } else { | 533 | } else { | ||
| 503 | err = d.switchToTarget(sess, ac, target) | 534 | err = d.switchToTarget(sess, ac, target) | ||
| 504 | } | 535 | } | ||
M internal/usecase/daemon/palette_test.go +36 -0
| @@ -464,6 +464,42 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te | |||
| 464 | 464 | }, remoteTarget) | |
| 465 | 465 | } | |
| 466 | 466 | ||
| 467 | + | func TestPaletteQualifiesDaemonSessionForRemoteAttachment(t *testing.T) { | |
| 468 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 469 | + | current := addControlSession(d, "current", "tab-current", "pane-current") | |
| 470 | + | current.ephemeral = false | |
| 471 | + | current.incarnation = domain.SessionLifecycleID{41} | |
| 472 | + | target := addControlSession(d, "target", "tab-target", "pane-target") | |
| 473 | + | target.ephemeral = false | |
| 474 | + | target.incarnation = domain.SessionLifecycleID{42} | |
| 475 | + | ||
| 476 | + | results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{ | |
| 477 | + | Generation: 1, | |
| 478 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 479 | + | ActiveEntry: ports.RecentRouteEntry{ | |
| 480 | + | Key: 1, Generation: 1, | |
| 481 | + | Target: ports.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "current"}, | |
| 482 | + | Name: "current", HostLabel: "user@remote-host", Kind: ports.RouteKindRemote, | |
| 483 | + | }, | |
| 484 | + | Entries: []ports.RecentRouteEntry{{ | |
| 485 | + | Key: 2, Generation: 1, | |
| 486 | + | Target: ports.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: "target"}, | |
| 487 | + | Name: "target", HostLabel: "user@remote-host", Kind: ports.RouteKindRemote, | |
| 488 | + | }}, | |
| 489 | + | }) | |
| 490 | + | ||
| 491 | + | var matching []palette.Result | |
| 492 | + | for _, result := range results { | |
| 493 | + | if result.DisplayText() == "Switch to session target@remote-host" { | |
| 494 | + | matching = append(matching, result) | |
| 495 | + | } | |
| 496 | + | } | |
| 497 | + | require.Len(t, matching, 1) | |
| 498 | + | got, ok := matching[0].SessionTarget() | |
| 499 | + | require.True(t, ok) | |
| 500 | + | require.Equal(t, ports.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: "target"}, got) | |
| 501 | + | } | |
| 502 | + | ||
| 467 | 503 | func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) { | |
| 468 | 504 | tests := []struct { | |
| 469 | 505 | name string | |
| @@ -464,6 +464,42 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te | |||||
| 464 | }, remoteTarget) | 464 | }, remoteTarget) | ||
| 465 | } | 465 | } | ||
| 466 | 466 | ||||
| 467 | + | func TestPaletteQualifiesDaemonSessionForRemoteAttachment(t *testing.T) { | |||
| 468 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 469 | + | current := addControlSession(d, "current", "tab-current", "pane-current") | |||
| 470 | + | current.ephemeral = false | |||
| 471 | + | current.incarnation = domain.SessionLifecycleID{41} | |||
| 472 | + | target := addControlSession(d, "target", "tab-target", "pane-target") | |||
| 473 | + | target.ephemeral = false | |||
| 474 | + | target.incarnation = domain.SessionLifecycleID{42} | |||
| 475 | + | ||||
| 476 | + | results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{ | |||
| 477 | + | Generation: 1, | |||
| 478 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |||
| 479 | + | ActiveEntry: ports.RecentRouteEntry{ | |||
| 480 | + | Key: 1, Generation: 1, | |||
| 481 | + | Target: ports.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "current"}, | |||
| 482 | + | Name: "current", HostLabel: "user@remote-host", Kind: ports.RouteKindRemote, | |||
| 483 | + | }, | |||
| 484 | + | Entries: []ports.RecentRouteEntry{{ | |||
| 485 | + | Key: 2, Generation: 1, | |||
| 486 | + | Target: ports.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: "target"}, | |||
| 487 | + | Name: "target", HostLabel: "user@remote-host", Kind: ports.RouteKindRemote, | |||
| 488 | + | }}, | |||
| 489 | + | }) | |||
| 490 | + | ||||
| 491 | + | var matching []palette.Result | |||
| 492 | + | for _, result := range results { | |||
| 493 | + | if result.DisplayText() == "Switch to session target@remote-host" { | |||
| 494 | + | matching = append(matching, result) | |||
| 495 | + | } | |||
| 496 | + | } | |||
| 497 | + | require.Len(t, matching, 1) | |||
| 498 | + | got, ok := matching[0].SessionTarget() | |||
| 499 | + | require.True(t, ok) | |||
| 500 | + | require.Equal(t, ports.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: "target"}, got) | |||
| 501 | + | } | |||
| 502 | + | ||||
| 467 | func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) { | 503 | func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) { | ||
| 468 | tests := []struct { | 504 | tests := []struct { | ||
| 469 | name string | 505 | name string | ||
M internal/usecase/daemon/picker.go +12 -7
| @@ -423,7 +423,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 423 | 423 | } | |
| 424 | 424 | var err error | |
| 425 | 425 | if len(effects) != 0 && effects[0] != nil { | |
| 426 | - | err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | |
| 426 | + | err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select") | |
| 427 | 427 | } else { | |
| 428 | 428 | d.closePicker(ac) | |
| 429 | 429 | err = d.switchToTarget(sess, ac, target) | |
| @@ -780,12 +780,13 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 780 | 780 | return d.sessions[id] | |
| 781 | 781 | } | |
| 782 | 782 | ||
| 783 | - | // sessionHandoffGuard optionally constrains an active-session handoff to the | |
| 784 | - | // tab that initiated navigation. Its zero value preserves ordinary picker and | |
| 785 | - | // command switching behavior. | |
| 783 | + | // sessionHandoffGuard carries explicit capabilities and source constraints for | |
| 784 | + | // one active-session handoff. Its zero value keeps daemon-owned transitions on | |
| 785 | + | // their established path. | |
| 786 | 786 | type sessionHandoffGuard struct { | |
| 787 | 787 | expectedSource *tab | |
| 788 | 788 | closePicker bool | |
| 789 | + | allowSamePeer bool | |
| 789 | 790 | } | |
| 790 | 791 | ||
| 791 | 792 | // switchActiveTargetForAttachment hands a frame-bound navigation request to the | |
| @@ -932,7 +933,11 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||
| 932 | 933 | if payload == nil || exactTarget == nil { | |
| 933 | 934 | return errAttachmentTransition | |
| 934 | 935 | } | |
| 935 | - | samePeerEligible := action == "picker-select" && targetSess != nil && target.TabIndex == 0 | |
| 936 | + | // An active session row with no explicit non-default tab is on the | |
| 937 | + | // authenticated daemon and can use the client-confirmed same-peer path. | |
| 938 | + | // Explicit tab rows already take the direct transition path above, while | |
| 939 | + | // stopped sessions have no active target session and retain the fallback. | |
| 940 | + | samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |
| 936 | 941 | if samePeerEligible { | |
| 937 | 942 | token.ac.offerSamePeerTarget(*exactTarget) | |
| 938 | 943 | } | |
| @@ -943,8 +948,8 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||
| 943 | 948 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err) | |
| 944 | 949 | } | |
| 945 | 950 | if !samePeerEligible { | |
| 946 | - | // Only ordinary active picker session rows are eligible. Stopped, | |
| 947 | - | // index-only, and non-picker navigation retain close-and-dial handoff. | |
| 951 | + | // Stopped and explicit non-default index targets retain the close-and-dial | |
| 952 | + | // handoff instead of losing their target-specific transition semantics. | |
| 948 | 953 | if guard.closePicker { | |
| 949 | 954 | d.closePicker(token.ac) | |
| 950 | 955 | } | |
| @@ -423,7 +423,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 423 | } | 423 | } | ||
| 424 | var err error | 424 | var err error | ||
| 425 | if len(effects) != 0 && effects[0] != nil { | 425 | if len(effects) != 0 && effects[0] != nil { | ||
| 426 | - | err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | 426 | + | err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select") |
| 427 | } else { | 427 | } else { | ||
| 428 | d.closePicker(ac) | 428 | d.closePicker(ac) | ||
| 429 | err = d.switchToTarget(sess, ac, target) | 429 | err = d.switchToTarget(sess, ac, target) | ||
| @@ -780,12 +780,13 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||||
| 780 | return d.sessions[id] | 780 | return d.sessions[id] | ||
| 781 | } | 781 | } | ||
| 782 | 782 | ||||
| 783 | - | // sessionHandoffGuard optionally constrains an active-session handoff to the | 783 | + | // sessionHandoffGuard carries explicit capabilities and source constraints for |
| 784 | - | // tab that initiated navigation. Its zero value preserves ordinary picker and | 784 | + | // one active-session handoff. Its zero value keeps daemon-owned transitions on |
| 785 | - | // command switching behavior. | 785 | + | // their established path. |
| 786 | type sessionHandoffGuard struct { | 786 | type sessionHandoffGuard struct { | ||
| 787 | expectedSource *tab | 787 | expectedSource *tab | ||
| 788 | closePicker bool | 788 | closePicker bool | ||
| 789 | + | allowSamePeer bool | |||
| 789 | } | 790 | } | ||
| 790 | 791 | ||||
| 791 | // switchActiveTargetForAttachment hands a frame-bound navigation request to the | 792 | // switchActiveTargetForAttachment hands a frame-bound navigation request to the | ||
| @@ -932,7 +933,11 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||||
| 932 | if payload == nil || exactTarget == nil { | 933 | if payload == nil || exactTarget == nil { | ||
| 933 | return errAttachmentTransition | 934 | return errAttachmentTransition | ||
| 934 | } | 935 | } | ||
| 935 | - | samePeerEligible := action == "picker-select" && targetSess != nil && target.TabIndex == 0 | 936 | + | // An active session row with no explicit non-default tab is on the |
| 937 | + | // authenticated daemon and can use the client-confirmed same-peer path. | |||
| 938 | + | // Explicit tab rows already take the direct transition path above, while | |||
| 939 | + | // stopped sessions have no active target session and retain the fallback. | |||
| 940 | + | samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |||
| 936 | if samePeerEligible { | 941 | if samePeerEligible { | ||
| 937 | token.ac.offerSamePeerTarget(*exactTarget) | 942 | token.ac.offerSamePeerTarget(*exactTarget) | ||
| 938 | } | 943 | } | ||
| @@ -943,8 +948,8 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||||
| 943 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err) | 948 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err) | ||
| 944 | } | 949 | } | ||
| 945 | if !samePeerEligible { | 950 | if !samePeerEligible { | ||
| 946 | - | // Only ordinary active picker session rows are eligible. Stopped, | 951 | + | // Stopped and explicit non-default index targets retain the close-and-dial |
| 947 | - | // index-only, and non-picker navigation retain close-and-dial handoff. | 952 | + | // handoff instead of losing their target-specific transition semantics. |
| 948 | if guard.closePicker { | 953 | if guard.closePicker { | ||
| 949 | d.closePicker(token.ac) | 954 | d.closePicker(token.ac) | ||
| 950 | } | 955 | } | ||
M internal/usecase/daemon/recent_route_snapshot.go +9 -0
| @@ -5,6 +5,15 @@ import ( | |||
| 5 | 5 | "github.com/bnema/vev/internal/ports" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | + | func activeRouteEntryForLifecycle(snapshot ports.RecentRouteSnapshot, lifecycle domain.SessionLifecycleID) (ports.RecentRouteEntry, bool) { | |
| 9 | + | active := snapshot.ActiveEntry | |
| 10 | + | activeRef := ports.RouteRef{Key: active.Key, Generation: active.Generation} | |
| 11 | + | if lifecycle == (domain.SessionLifecycleID{}) || active.Target.LifecycleID != lifecycle || snapshot.Active != activeRef { | |
| 12 | + | return ports.RecentRouteEntry{}, false | |
| 13 | + | } | |
| 14 | + | return active, true | |
| 15 | + | } | |
| 16 | + | ||
| 8 | 17 | func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation { | |
| 9 | 18 | if snapshot.Entries == nil { | |
| 10 | 19 | return nil | |
| @@ -5,6 +5,15 @@ import ( | |||||
| 5 | "github.com/bnema/vev/internal/ports" | 5 | "github.com/bnema/vev/internal/ports" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | + | func activeRouteEntryForLifecycle(snapshot ports.RecentRouteSnapshot, lifecycle domain.SessionLifecycleID) (ports.RecentRouteEntry, bool) { | |||
| 9 | + | active := snapshot.ActiveEntry | |||
| 10 | + | activeRef := ports.RouteRef{Key: active.Key, Generation: active.Generation} | |||
| 11 | + | if lifecycle == (domain.SessionLifecycleID{}) || active.Target.LifecycleID != lifecycle || snapshot.Active != activeRef { | |||
| 12 | + | return ports.RecentRouteEntry{}, false | |||
| 13 | + | } | |||
| 14 | + | return active, true | |||
| 15 | + | } | |||
| 16 | + | ||||
| 8 | func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation { | 17 | func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation { | ||
| 9 | if snapshot.Entries == nil { | 18 | if snapshot.Entries == nil { | ||
| 10 | return nil | 19 | return nil | ||
M internal/usecase/daemon/remote_attach_test.go +1 -1
| @@ -28,7 +28,7 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) { | |||
| 28 | 28 | token.effect = effect | |
| 29 | 29 | defer effect.End() | |
| 30 | 30 | ||
| 31 | - | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select")) | |
| 31 | + | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{allowSamePeer: true}, "picker-select")) | |
| 32 | 32 | frame := receiveRemotePicker(t, sends, "local attach target") | |
| 33 | 33 | got, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| 34 | 34 | require.NoError(t, err) | |
| @@ -28,7 +28,7 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) { | |||||
| 28 | token.effect = effect | 28 | token.effect = effect | ||
| 29 | defer effect.End() | 29 | defer effect.End() | ||
| 30 | 30 | ||||
| 31 | - | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select")) | 31 | + | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{allowSamePeer: true}, "picker-select")) |
| 32 | frame := receiveRemotePicker(t, sends, "local attach target") | 32 | frame := receiveRemotePicker(t, sends, "local attach target") | ||
| 33 | got, err := ports.UnmarshalAttachTarget(frame.Payload) | 33 | got, err := ports.UnmarshalAttachTarget(frame.Payload) | ||
| 34 | require.NoError(t, err) | 34 | require.NoError(t, err) | ||
M internal/usecase/daemon/same_peer_switch_test.go +39 -0
| @@ -9,6 +9,45 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/ports" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | + | func TestPaletteSessionSelectionCompletesSamePeerSwitch(t *testing.T) { | |
| 13 | + | d, source, ac, sends, releases := newManualTabSession(t, 1) | |
| 14 | + | defer releaseAll(releases) | |
| 15 | + | ||
| 16 | + | lifecycle := domain.SessionLifecycleID{7} | |
| 17 | + | target := &session{ | |
| 18 | + | sessionCore: sessionCore{ | |
| 19 | + | id: "target", name: "target", incarnation: lifecycle, | |
| 20 | + | attachments: make(map[*attachedClient]struct{}), | |
| 21 | + | }, | |
| 22 | + | ctx: source.ctx, cancel: func() {}, | |
| 23 | + | tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}, | |
| 24 | + | } | |
| 25 | + | publishTiledPaneOwners(target, target.tabs[0]) | |
| 26 | + | d.mu.Lock() | |
| 27 | + | d.sessions[target.id] = target | |
| 28 | + | d.mu.Unlock() | |
| 29 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |
| 30 | + | ||
| 31 | + | token := beginRecentRoutePaletteEffect(t, d, source, ac) | |
| 32 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| 33 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 34 | + | d.handleInputForAttachment(token, []byte("target\r")) | |
| 35 | + | targetFrame := awaitFrame(t, sends, ports.MsgAttachTarget) | |
| 36 | + | attachTarget, err := ports.UnmarshalAttachTarget(targetFrame.Payload) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}, attachTarget.ExactTarget) | |
| 39 | + | ||
| 40 | + | d.switchSamePeerForAttachment(token, ports.SamePeerSwitchRequest{ | |
| 41 | + | RequestID: 1, Target: *attachTarget.ExactTarget, | |
| 42 | + | }) | |
| 43 | + | ||
| 44 | + | require.Same(t, target, ac.currentAttachmentSession()) | |
| 45 | + | identityFrame := awaitFrame(t, sends, ports.MsgCommittedRouteIdentity) | |
| 46 | + | identity, err := ports.UnmarshalCommittedRouteIdentity(identityFrame.Payload) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Equal(t, attachTarget.ExactTarget, &identity.Target) | |
| 49 | + | } | |
| 50 | + | ||
| 12 | 51 | func TestSamePeerSwitchTransitionsExactTargetAndPreferredTab(t *testing.T) { | |
| 13 | 52 | d, source, ac, sends, releases := newManualTabSession(t, 1) | |
| 14 | 53 | defer releaseAll(releases) | |
| @@ -9,6 +9,45 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | + | func TestPaletteSessionSelectionCompletesSamePeerSwitch(t *testing.T) { | |||
| 13 | + | d, source, ac, sends, releases := newManualTabSession(t, 1) | |||
| 14 | + | defer releaseAll(releases) | |||
| 15 | + | ||||
| 16 | + | lifecycle := domain.SessionLifecycleID{7} | |||
| 17 | + | target := &session{ | |||
| 18 | + | sessionCore: sessionCore{ | |||
| 19 | + | id: "target", name: "target", incarnation: lifecycle, | |||
| 20 | + | attachments: make(map[*attachedClient]struct{}), | |||
| 21 | + | }, | |||
| 22 | + | ctx: source.ctx, cancel: func() {}, | |||
| 23 | + | tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}, | |||
| 24 | + | } | |||
| 25 | + | publishTiledPaneOwners(target, target.tabs[0]) | |||
| 26 | + | d.mu.Lock() | |||
| 27 | + | d.sessions[target.id] = target | |||
| 28 | + | d.mu.Unlock() | |||
| 29 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |||
| 30 | + | ||||
| 31 | + | token := beginRecentRoutePaletteEffect(t, d, source, ac) | |||
| 32 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |||
| 33 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 34 | + | d.handleInputForAttachment(token, []byte("target\r")) | |||
| 35 | + | targetFrame := awaitFrame(t, sends, ports.MsgAttachTarget) | |||
| 36 | + | attachTarget, err := ports.UnmarshalAttachTarget(targetFrame.Payload) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}, attachTarget.ExactTarget) | |||
| 39 | + | ||||
| 40 | + | d.switchSamePeerForAttachment(token, ports.SamePeerSwitchRequest{ | |||
| 41 | + | RequestID: 1, Target: *attachTarget.ExactTarget, | |||
| 42 | + | }) | |||
| 43 | + | ||||
| 44 | + | require.Same(t, target, ac.currentAttachmentSession()) | |||
| 45 | + | identityFrame := awaitFrame(t, sends, ports.MsgCommittedRouteIdentity) | |||
| 46 | + | identity, err := ports.UnmarshalCommittedRouteIdentity(identityFrame.Payload) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.Equal(t, attachTarget.ExactTarget, &identity.Target) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 12 | func TestSamePeerSwitchTransitionsExactTargetAndPreferredTab(t *testing.T) { | 51 | func TestSamePeerSwitchTransitionsExactTargetAndPreferredTab(t *testing.T) { | ||
| 13 | d, source, ac, sends, releases := newManualTabSession(t, 1) | 52 | d, source, ac, sends, releases := newManualTabSession(t, 1) | ||
| 14 | defer releaseAll(releases) | 53 | defer releaseAll(releases) | ||
M internal/usecase/daemon/status.go +1 -3
| @@ -197,9 +197,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo | |||
| 197 | 197 | s.mu.Lock() | |
| 198 | 198 | defer s.mu.Unlock() | |
| 199 | 199 | presentation := recentRoutePresentation{name: s.name, ephemeral: s.ephemeral} | |
| 200 | - | active := routeSnapshot.ActiveEntry | |
| 201 | - | activeRef := ports.RouteRef{Key: active.Key, Generation: active.Generation} | |
| 202 | - | if s.incarnation != (domain.SessionLifecycleID{}) && active.Target.LifecycleID == s.incarnation && routeSnapshot.Active == activeRef { | |
| 200 | + | if active, ok := activeRouteEntryForLifecycle(routeSnapshot, s.incarnation); ok { | |
| 203 | 201 | presentation.hostLabel = domain.RemoteDisplayOrigin(active.HostLabel) | |
| 204 | 202 | if active.Kind == ports.RouteKindRemote { | |
| 205 | 203 | presentation.kind = recentRouteRemote | |
| @@ -197,9 +197,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo | |||||
| 197 | s.mu.Lock() | 197 | s.mu.Lock() | ||
| 198 | defer s.mu.Unlock() | 198 | defer s.mu.Unlock() | ||
| 199 | presentation := recentRoutePresentation{name: s.name, ephemeral: s.ephemeral} | 199 | presentation := recentRoutePresentation{name: s.name, ephemeral: s.ephemeral} | ||
| 200 | - | active := routeSnapshot.ActiveEntry | 200 | + | if active, ok := activeRouteEntryForLifecycle(routeSnapshot, s.incarnation); ok { |
| 201 | - | activeRef := ports.RouteRef{Key: active.Key, Generation: active.Generation} | |||
| 202 | - | if s.incarnation != (domain.SessionLifecycleID{}) && active.Target.LifecycleID == s.incarnation && routeSnapshot.Active == activeRef { | |||
| 203 | presentation.hostLabel = domain.RemoteDisplayOrigin(active.HostLabel) | 201 | presentation.hostLabel = domain.RemoteDisplayOrigin(active.HostLabel) | ||
| 204 | if active.Kind == ports.RouteKindRemote { | 202 | if active.Kind == ports.RouteKindRemote { | ||
| 205 | presentation.kind = recentRouteRemote | 203 | presentation.kind = recentRouteRemote | ||
M internal/usecase/palette/result.go +25 -5
| @@ -30,7 +30,7 @@ type Result struct { | |||
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | type sessionPayload struct { | |
| 33 | - | name string | |
| 33 | + | display string | |
| 34 | 34 | createdAt time.Time | |
| 35 | 35 | target ports.ExactSessionTarget | |
| 36 | 36 | } | |
| @@ -53,18 +53,38 @@ func NewCommandResult(cmd command.Command) Result { | |||
| 53 | 53 | ||
| 54 | 54 | // NewActiveSessionResult creates an immutable active named-session target. | |
| 55 | 55 | func NewActiveSessionResult(target ports.ExactSessionTarget, createdAt time.Time) Result { | |
| 56 | + | return NewActiveSessionResultWithDisplayOrigin(target, createdAt, "") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // NewActiveSessionResultWithDisplayOrigin creates an active named-session target | |
| 60 | + | // qualified for presentation through a remote attachment origin. | |
| 61 | + | func NewActiveSessionResultWithDisplayOrigin(target ports.ExactSessionTarget, createdAt time.Time, displayOrigin string) Result { | |
| 56 | 62 | return Result{ | |
| 57 | 63 | kind: ResultKindActiveSession, | |
| 58 | - | session: sessionPayload{name: target.SessionName, createdAt: createdAt, target: target}, | |
| 64 | + | session: newSessionPayload(target, createdAt, displayOrigin), | |
| 59 | 65 | } | |
| 60 | 66 | } | |
| 61 | 67 | ||
| 62 | 68 | // NewStoppedSessionResult creates an immutable stopped named-session target. | |
| 63 | 69 | func NewStoppedSessionResult(target ports.ExactSessionTarget, createdAt time.Time) Result { | |
| 70 | + | return NewStoppedSessionResultWithDisplayOrigin(target, createdAt, "") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // NewStoppedSessionResultWithDisplayOrigin creates a stopped named-session target | |
| 74 | + | // qualified for presentation through a remote attachment origin. | |
| 75 | + | func NewStoppedSessionResultWithDisplayOrigin(target ports.ExactSessionTarget, createdAt time.Time, displayOrigin string) Result { | |
| 64 | 76 | return Result{ | |
| 65 | 77 | kind: ResultKindStoppedSession, | |
| 66 | - | session: sessionPayload{name: target.SessionName, createdAt: createdAt, target: target}, | |
| 78 | + | session: newSessionPayload(target, createdAt, displayOrigin), | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func newSessionPayload(target ports.ExactSessionTarget, createdAt time.Time, displayOrigin string) sessionPayload { | |
| 83 | + | display := target.SessionName | |
| 84 | + | if displayOrigin != "" { | |
| 85 | + | display = domain.RemoteSessionDisplay(target.SessionName, displayOrigin) | |
| 67 | 86 | } | |
| 87 | + | return sessionPayload{display: display, createdAt: createdAt, target: target} | |
| 68 | 88 | } | |
| 69 | 89 | ||
| 70 | 90 | // NewRemoteSessionResult creates an immutable catalog-backed remote target. | |
| @@ -112,7 +132,7 @@ func (r Result) DisplayText() string { | |||
| 112 | 132 | if r.kind == ResultKindRecentRoute { | |
| 113 | 133 | return activeSessionDisplayPrefix + r.route.label | |
| 114 | 134 | } | |
| 115 | - | return r.sessionDisplayPrefix() + r.session.name | |
| 135 | + | return r.sessionDisplayPrefix() + r.session.display | |
| 116 | 136 | } | |
| 117 | 137 | ||
| 118 | 138 | func (r Result) sessionDisplayPrefix() string { | |
| @@ -133,7 +153,7 @@ func (r Result) Command() (command.Command, bool) { | |||
| 133 | 153 | ||
| 134 | 154 | // SessionName returns the session name only for session results. | |
| 135 | 155 | func (r Result) SessionName() (string, bool) { | |
| 136 | - | return r.session.name, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | |
| 156 | + | return r.session.target.SessionName, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | |
| 137 | 157 | } | |
| 138 | 158 | ||
| 139 | 159 | // SessionCreatedAt returns the session creation time only for session results. | |
| @@ -30,7 +30,7 @@ type Result struct { | |||||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | type sessionPayload struct { | 32 | type sessionPayload struct { | ||
| 33 | - | name string | 33 | + | display string |
| 34 | createdAt time.Time | 34 | createdAt time.Time | ||
| 35 | target ports.ExactSessionTarget | 35 | target ports.ExactSessionTarget | ||
| 36 | } | 36 | } | ||
| @@ -53,18 +53,38 @@ func NewCommandResult(cmd command.Command) Result { | |||||
| 53 | 53 | ||||
| 54 | // NewActiveSessionResult creates an immutable active named-session target. | 54 | // NewActiveSessionResult creates an immutable active named-session target. | ||
| 55 | func NewActiveSessionResult(target ports.ExactSessionTarget, createdAt time.Time) Result { | 55 | func NewActiveSessionResult(target ports.ExactSessionTarget, createdAt time.Time) Result { | ||
| 56 | + | return NewActiveSessionResultWithDisplayOrigin(target, createdAt, "") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // NewActiveSessionResultWithDisplayOrigin creates an active named-session target | |||
| 60 | + | // qualified for presentation through a remote attachment origin. | |||
| 61 | + | func NewActiveSessionResultWithDisplayOrigin(target ports.ExactSessionTarget, createdAt time.Time, displayOrigin string) Result { | |||
| 56 | return Result{ | 62 | return Result{ | ||
| 57 | kind: ResultKindActiveSession, | 63 | kind: ResultKindActiveSession, | ||
| 58 | - | session: sessionPayload{name: target.SessionName, createdAt: createdAt, target: target}, | 64 | + | session: newSessionPayload(target, createdAt, displayOrigin), |
| 59 | } | 65 | } | ||
| 60 | } | 66 | } | ||
| 61 | 67 | ||||
| 62 | // NewStoppedSessionResult creates an immutable stopped named-session target. | 68 | // NewStoppedSessionResult creates an immutable stopped named-session target. | ||
| 63 | func NewStoppedSessionResult(target ports.ExactSessionTarget, createdAt time.Time) Result { | 69 | func NewStoppedSessionResult(target ports.ExactSessionTarget, createdAt time.Time) Result { | ||
| 70 | + | return NewStoppedSessionResultWithDisplayOrigin(target, createdAt, "") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // NewStoppedSessionResultWithDisplayOrigin creates a stopped named-session target | |||
| 74 | + | // qualified for presentation through a remote attachment origin. | |||
| 75 | + | func NewStoppedSessionResultWithDisplayOrigin(target ports.ExactSessionTarget, createdAt time.Time, displayOrigin string) Result { | |||
| 64 | return Result{ | 76 | return Result{ | ||
| 65 | kind: ResultKindStoppedSession, | 77 | kind: ResultKindStoppedSession, | ||
| 66 | - | session: sessionPayload{name: target.SessionName, createdAt: createdAt, target: target}, | 78 | + | session: newSessionPayload(target, createdAt, displayOrigin), |
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func newSessionPayload(target ports.ExactSessionTarget, createdAt time.Time, displayOrigin string) sessionPayload { | |||
| 83 | + | display := target.SessionName | |||
| 84 | + | if displayOrigin != "" { | |||
| 85 | + | display = domain.RemoteSessionDisplay(target.SessionName, displayOrigin) | |||
| 67 | } | 86 | } | ||
| 87 | + | return sessionPayload{display: display, createdAt: createdAt, target: target} | |||
| 68 | } | 88 | } | ||
| 69 | 89 | ||||
| 70 | // NewRemoteSessionResult creates an immutable catalog-backed remote target. | 90 | // NewRemoteSessionResult creates an immutable catalog-backed remote target. | ||
| @@ -112,7 +132,7 @@ func (r Result) DisplayText() string { | |||||
| 112 | if r.kind == ResultKindRecentRoute { | 132 | if r.kind == ResultKindRecentRoute { | ||
| 113 | return activeSessionDisplayPrefix + r.route.label | 133 | return activeSessionDisplayPrefix + r.route.label | ||
| 114 | } | 134 | } | ||
| 115 | - | return r.sessionDisplayPrefix() + r.session.name | 135 | + | return r.sessionDisplayPrefix() + r.session.display |
| 116 | } | 136 | } | ||
| 117 | 137 | ||||
| 118 | func (r Result) sessionDisplayPrefix() string { | 138 | func (r Result) sessionDisplayPrefix() string { | ||
| @@ -133,7 +153,7 @@ func (r Result) Command() (command.Command, bool) { | |||||
| 133 | 153 | ||||
| 134 | // SessionName returns the session name only for session results. | 154 | // SessionName returns the session name only for session results. | ||
| 135 | func (r Result) SessionName() (string, bool) { | 155 | func (r Result) SessionName() (string, bool) { | ||
| 136 | - | return r.session.name, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | 156 | + | return r.session.target.SessionName, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession |
| 137 | } | 157 | } | ||
| 138 | 158 | ||||
| 139 | // SessionCreatedAt returns the session creation time only for session results. | 159 | // SessionCreatedAt returns the session creation time only for session results. | ||
M internal/usecase/palette/result_test.go +16 -0
| @@ -21,6 +21,8 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 21 | 21 | stoppedTarget := testExactTarget("archive", 2) | |
| 22 | 22 | active := NewActiveSessionResult(activeTarget, created) | |
| 23 | 23 | stopped := NewStoppedSessionResult(stoppedTarget, created) | |
| 24 | + | remoteActive := NewActiveSessionResultWithDisplayOrigin(activeTarget, created, "user@remote-host") | |
| 25 | + | remoteStopped := NewStoppedSessionResultWithDisplayOrigin(stoppedTarget, created, "user@remote-host") | |
| 24 | 26 | ||
| 25 | 27 | require.Equal(t, ResultKindCommand, commandResult.Kind()) | |
| 26 | 28 | require.Equal(t, "NT", commandResult.DisplayText()) | |
| @@ -41,6 +43,13 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 41 | 43 | target, ok := active.SessionTarget() | |
| 42 | 44 | require.True(t, ok) | |
| 43 | 45 | require.Equal(t, activeTarget, target) | |
| 46 | + | require.Equal(t, "Switch to session work@remote-host", remoteActive.DisplayText()) | |
| 47 | + | name, ok = remoteActive.SessionName() | |
| 48 | + | require.True(t, ok) | |
| 49 | + | require.Equal(t, "work", name) | |
| 50 | + | target, ok = remoteActive.SessionTarget() | |
| 51 | + | require.True(t, ok) | |
| 52 | + | require.Equal(t, activeTarget, target) | |
| 44 | 53 | ||
| 45 | 54 | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | |
| 46 | 55 | name, ok = stopped.SessionName() | |
| @@ -54,6 +63,13 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 54 | 63 | target, ok = stopped.SessionTarget() | |
| 55 | 64 | require.True(t, ok) | |
| 56 | 65 | require.Equal(t, stoppedTarget, target) | |
| 66 | + | require.Equal(t, "Resume session archive@remote-host", remoteStopped.DisplayText()) | |
| 67 | + | name, ok = remoteStopped.SessionName() | |
| 68 | + | require.True(t, ok) | |
| 69 | + | require.Equal(t, "archive", name) | |
| 70 | + | target, ok = remoteStopped.SessionTarget() | |
| 71 | + | require.True(t, ok) | |
| 72 | + | require.Equal(t, stoppedTarget, target) | |
| 57 | 73 | } | |
| 58 | 74 | ||
| 59 | 75 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | |
| @@ -21,6 +21,8 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||||
| 21 | stoppedTarget := testExactTarget("archive", 2) | 21 | stoppedTarget := testExactTarget("archive", 2) | ||
| 22 | active := NewActiveSessionResult(activeTarget, created) | 22 | active := NewActiveSessionResult(activeTarget, created) | ||
| 23 | stopped := NewStoppedSessionResult(stoppedTarget, created) | 23 | stopped := NewStoppedSessionResult(stoppedTarget, created) | ||
| 24 | + | remoteActive := NewActiveSessionResultWithDisplayOrigin(activeTarget, created, "user@remote-host") | |||
| 25 | + | remoteStopped := NewStoppedSessionResultWithDisplayOrigin(stoppedTarget, created, "user@remote-host") | |||
| 24 | 26 | ||||
| 25 | require.Equal(t, ResultKindCommand, commandResult.Kind()) | 27 | require.Equal(t, ResultKindCommand, commandResult.Kind()) | ||
| 26 | require.Equal(t, "NT", commandResult.DisplayText()) | 28 | require.Equal(t, "NT", commandResult.DisplayText()) | ||
| @@ -41,6 +43,13 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||||
| 41 | target, ok := active.SessionTarget() | 43 | target, ok := active.SessionTarget() | ||
| 42 | require.True(t, ok) | 44 | require.True(t, ok) | ||
| 43 | require.Equal(t, activeTarget, target) | 45 | require.Equal(t, activeTarget, target) | ||
| 46 | + | require.Equal(t, "Switch to session work@remote-host", remoteActive.DisplayText()) | |||
| 47 | + | name, ok = remoteActive.SessionName() | |||
| 48 | + | require.True(t, ok) | |||
| 49 | + | require.Equal(t, "work", name) | |||
| 50 | + | target, ok = remoteActive.SessionTarget() | |||
| 51 | + | require.True(t, ok) | |||
| 52 | + | require.Equal(t, activeTarget, target) | |||
| 44 | 53 | ||||
| 45 | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | 54 | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | ||
| 46 | name, ok = stopped.SessionName() | 55 | name, ok = stopped.SessionName() | ||
| @@ -54,6 +63,13 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||||
| 54 | target, ok = stopped.SessionTarget() | 63 | target, ok = stopped.SessionTarget() | ||
| 55 | require.True(t, ok) | 64 | require.True(t, ok) | ||
| 56 | require.Equal(t, stoppedTarget, target) | 65 | require.Equal(t, stoppedTarget, target) | ||
| 66 | + | require.Equal(t, "Resume session archive@remote-host", remoteStopped.DisplayText()) | |||
| 67 | + | name, ok = remoteStopped.SessionName() | |||
| 68 | + | require.True(t, ok) | |||
| 69 | + | require.Equal(t, "archive", name) | |||
| 70 | + | target, ok = remoteStopped.SessionTarget() | |||
| 71 | + | require.True(t, ok) | |||
| 72 | + | require.Equal(t, stoppedTarget, target) | |||
| 57 | } | 73 | } | ||
| 58 | 74 | ||||
| 59 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | 75 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | ||