detached 0425df6

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 UnverifiedB5690EEEBB952194 The 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
8080 return paletteSessionIdentity{kind: ports.RouteKindRemote, endpoint: endpoint, lifecycle: lifecycle}
8181 }
8282
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+
83107 func remotePaletteUnavailableReason(status remoteHostStatus, session ports.RemoteCatalogSession) string {
84108 if session.State == ports.RemoteCatalogSessionBroken {
85109 return "session_broken"
@@ -101,6 +125,13 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
101125 }
102126 }
103127 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)
104135
105136 remoteCatalog := d.remoteCatalogSnapshot()
106137 sortRemoteCatalog(remoteCatalog, d.remoteHostRanks())
@@ -124,7 +155,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
124155 continue
125156 }
126157 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))
128159 }
129160 sort.Slice(active, func(i, j int) bool {
130161 left, _ := active[i].SessionName()
@@ -135,7 +166,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
135166 results = append(results, active...)
136167 for _, candidate := range stopped {
137168 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))
139170 represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{}
140171 }
141172 type discoveredRemote struct {
@@ -176,7 +207,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
176207 continue
177208 }
178209 identity := localPaletteSessionIdentity(entry.Target.LifecycleID)
179- if _, exists := represented[identity]; entry.Kind == ports.RouteKindLocal && exists {
210+ if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) {
180211 continue
181212 }
182213 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
@@ -498,7 +529,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
498529 }
499530 var err error
500531 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")
502533 } else {
503534 err = d.switchToTarget(sess, ac, target)
504535 }
@@ -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 continue155 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 continue207 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 continue211 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 error530 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
464464 }, remoteTarget)
465465 }
466466
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+
467503 func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) {
468504 tests := []struct {
469505 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 string505 name string
M internal/usecase/daemon/picker.go +12 -7
@@ -423,7 +423,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
423423 }
424424 var err error
425425 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")
427427 } else {
428428 d.closePicker(ac)
429429 err = d.switchToTarget(sess, ac, target)
@@ -780,12 +780,13 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
780780 return d.sessions[id]
781781 }
782782
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.
786786 type sessionHandoffGuard struct {
787787 expectedSource *tab
788788 closePicker bool
789+ allowSamePeer bool
789790 }
790791
791792 // switchActiveTargetForAttachment hands a frame-bound navigation request to the
@@ -932,7 +933,11 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
932933 if payload == nil || exactTarget == nil {
933934 return errAttachmentTransition
934935 }
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
936941 if samePeerEligible {
937942 token.ac.offerSamePeerTarget(*exactTarget)
938943 }
@@ -943,8 +948,8 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
943948 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err)
944949 }
945950 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.
948953 if guard.closePicker {
949954 d.closePicker(token.ac)
950955 }
@@ -423,7 +423,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
423 }423 }
424 var err error424 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 the783+// sessionHandoffGuard carries explicit capabilities and source constraints for
784-// tab that initiated navigation. Its zero value preserves ordinary picker and784+// 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 *tab787 expectedSource *tab
788 closePicker bool788 closePicker bool
789+ allowSamePeer bool
789 }790 }
790 791
791 // switchActiveTargetForAttachment hands a frame-bound navigation request to the792 // 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 errAttachmentTransition934 return errAttachmentTransition
934 }935 }
935- samePeerEligible := action == "picker-select" && targetSess != nil && target.TabIndex == 0936+ // 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 (
55 "github.com/bnema/vev/internal/ports"
66 )
77
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+
817 func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation {
918 if snapshot.Entries == nil {
1019 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 nil19 return nil
M internal/usecase/daemon/remote_attach_test.go +1 -1
@@ -28,7 +28,7 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) {
2828 token.effect = effect
2929 defer effect.End()
3030
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"))
3232 frame := receiveRemotePicker(t, sends, "local attach target")
3333 got, err := ports.UnmarshalAttachTarget(frame.Payload)
3434 require.NoError(t, err)
@@ -28,7 +28,7 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) {
28 token.effect = effect28 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 (
99 "github.com/bnema/vev/internal/ports"
1010 )
1111
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+
1251 func TestSamePeerSwitchTransitionsExactTargetAndPreferredTab(t *testing.T) {
1352 d, source, ac, sends, releases := newManualTabSession(t, 1)
1453 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
197197 s.mu.Lock()
198198 defer s.mu.Unlock()
199199 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 {
203201 presentation.hostLabel = domain.RemoteDisplayOrigin(active.HostLabel)
204202 if active.Kind == ports.RouteKindRemote {
205203 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.ActiveEntry200+ 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 = recentRouteRemote203 presentation.kind = recentRouteRemote
M internal/usecase/palette/result.go +25 -5
@@ -30,7 +30,7 @@ type Result struct {
3030 }
3131
3232 type sessionPayload struct {
33- name string
33+ display string
3434 createdAt time.Time
3535 target ports.ExactSessionTarget
3636 }
@@ -53,18 +53,38 @@ func NewCommandResult(cmd command.Command) Result {
5353
5454 // NewActiveSessionResult creates an immutable active named-session target.
5555 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 {
5662 return Result{
5763 kind: ResultKindActiveSession,
58- session: sessionPayload{name: target.SessionName, createdAt: createdAt, target: target},
64+ session: newSessionPayload(target, createdAt, displayOrigin),
5965 }
6066 }
6167
6268 // NewStoppedSessionResult creates an immutable stopped named-session target.
6369 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 {
6476 return Result{
6577 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)
6786 }
87+ return sessionPayload{display: display, createdAt: createdAt, target: target}
6888 }
6989
7090 // NewRemoteSessionResult creates an immutable catalog-backed remote target.
@@ -112,7 +132,7 @@ func (r Result) DisplayText() string {
112132 if r.kind == ResultKindRecentRoute {
113133 return activeSessionDisplayPrefix + r.route.label
114134 }
115- return r.sessionDisplayPrefix() + r.session.name
135+ return r.sessionDisplayPrefix() + r.session.display
116136 }
117137
118138 func (r Result) sessionDisplayPrefix() string {
@@ -133,7 +153,7 @@ func (r Result) Command() (command.Command, bool) {
133153
134154 // SessionName returns the session name only for session results.
135155 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
137157 }
138158
139159 // 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 string33+ display string
34 createdAt time.Time34 createdAt time.Time
35 target ports.ExactSessionTarget35 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.label133 return activeSessionDisplayPrefix + r.route.label
114 }134 }
115- return r.sessionDisplayPrefix() + r.session.name135+ 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 == ResultKindStoppedSession156+ 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) {
2121 stoppedTarget := testExactTarget("archive", 2)
2222 active := NewActiveSessionResult(activeTarget, created)
2323 stopped := NewStoppedSessionResult(stoppedTarget, created)
24+ remoteActive := NewActiveSessionResultWithDisplayOrigin(activeTarget, created, "user@remote-host")
25+ remoteStopped := NewStoppedSessionResultWithDisplayOrigin(stoppedTarget, created, "user@remote-host")
2426
2527 require.Equal(t, ResultKindCommand, commandResult.Kind())
2628 require.Equal(t, "NT", commandResult.DisplayText())
@@ -41,6 +43,13 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
4143 target, ok := active.SessionTarget()
4244 require.True(t, ok)
4345 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)
4453
4554 require.Equal(t, ResultKindStoppedSession, stopped.Kind())
4655 name, ok = stopped.SessionName()
@@ -54,6 +63,13 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
5463 target, ok = stopped.SessionTarget()
5564 require.True(t, ok)
5665 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)
5773 }
5874
5975 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close