detached 86aeb12

fix(navigation): enforce lifecycle identity at handoff

commit
86aeb129e91de34f2fe561e284fb3218b6a8e26a
author
brice · · 2026-08-18 20:19 UTC
committer
brice · · 2026-08-19 04:15 UTC
parent
f22cb3f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +91 -40
M internal/app/remote_hosts.go +1 -1
@@ -199,7 +199,7 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [
199199 out := make([]ports.SessionInfo, 0, len(sessions))
200200 for _, session := range sessions {
201201 info := ports.SessionInfo{
202- Name: session.Name + "@" + host,
202+ Name: domain.RemoteSessionDisplay(session.Name, host),
203203 Ephemeral: session.Ephemeral,
204204 Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)),
205205 Attached: session.Attached,
@@ -199,7 +199,7 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [
199 out := make([]ports.SessionInfo, 0, len(sessions))199 out := make([]ports.SessionInfo, 0, len(sessions))
200 for _, session := range sessions {200 for _, session := range sessions {
201 info := ports.SessionInfo{201 info := ports.SessionInfo{
202- Name: session.Name + "@" + host,202+ Name: domain.RemoteSessionDisplay(session.Name, host),
203 Ephemeral: session.Ephemeral,203 Ephemeral: session.Ephemeral,
204 Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)),204 Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)),
205 Attached: session.Attached,205 Attached: session.Attached,
M internal/app/remote_test.go +1 -1
@@ -272,7 +272,7 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
272272 }
273273 for _, tt := range tests {
274274 t.Run(tt.name, func(t *testing.T) {
275- infos := catalogSessionsAsInfo("arch", []ports.RemoteCatalogSession{tt.session})
275+ infos := catalogSessionsAsInfo("user@arch", []ports.RemoteCatalogSession{tt.session})
276276 require.Len(t, infos, 1)
277277 require.Equal(t, "dev@arch", infos[0].Name)
278278 require.Equal(t, tt.wantState, infos[0].State)
@@ -272,7 +272,7 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
272 }272 }
273 for _, tt := range tests {273 for _, tt := range tests {
274 t.Run(tt.name, func(t *testing.T) {274 t.Run(tt.name, func(t *testing.T) {
275- infos := catalogSessionsAsInfo("arch", []ports.RemoteCatalogSession{tt.session})275+ infos := catalogSessionsAsInfo("user@arch", []ports.RemoteCatalogSession{tt.session})
276 require.Len(t, infos, 1)276 require.Len(t, infos, 1)
277 require.Equal(t, "dev@arch", infos[0].Name)277 require.Equal(t, "dev@arch", infos[0].Name)
278 require.Equal(t, tt.wantState, infos[0].State)278 require.Equal(t, tt.wantState, infos[0].State)
M internal/domain/remote_session.go +10 -3
@@ -42,17 +42,24 @@ func (k RemoteSessionKey) ID() SessionID {
4242 // the login prefix regardless of whether the route came from the CLI or a
4343 // discovered picker entry.
4444 func RemoteDisplayOrigin(endpoint string) string {
45- if i := strings.LastIndexByte(endpoint, '@'); i > 0 && i < len(endpoint)-1 {
45+ if i := strings.LastIndexByte(endpoint, '@'); i >= 0 && i < len(endpoint)-1 {
4646 return endpoint[i+1:]
4747 }
4848 return endpoint
4949 }
5050
51+// RemoteSessionDisplay returns the canonical presentation label for a remote
52+// session. endpoint may be a routing target or an already-derived origin; the
53+// login prefix is always removed.
54+func RemoteSessionDisplay(name, endpoint string) string {
55+ return name + "@" + RemoteDisplayOrigin(endpoint)
56+}
57+
5158 // Display returns the presentation label for this remote session.
5259 func (k RemoteSessionKey) Display() string {
5360 origin := k.DisplayOrigin
5461 if origin == "" {
55- origin = RemoteDisplayOrigin(k.Host)
62+ origin = k.Host
5663 }
57- return k.Name + "@" + origin
64+ return RemoteSessionDisplay(k.Name, origin)
5865 }
@@ -42,17 +42,24 @@ func (k RemoteSessionKey) ID() SessionID {
42 // the login prefix regardless of whether the route came from the CLI or a42 // the login prefix regardless of whether the route came from the CLI or a
43 // discovered picker entry.43 // discovered picker entry.
44 func RemoteDisplayOrigin(endpoint string) string {44 func RemoteDisplayOrigin(endpoint string) string {
45- if i := strings.LastIndexByte(endpoint, '@'); i > 0 && i < len(endpoint)-1 {45+ if i := strings.LastIndexByte(endpoint, '@'); i >= 0 && i < len(endpoint)-1 {
46 return endpoint[i+1:]46 return endpoint[i+1:]
47 }47 }
48 return endpoint48 return endpoint
49 }49 }
50 50
51+// RemoteSessionDisplay returns the canonical presentation label for a remote
52+// session. endpoint may be a routing target or an already-derived origin; the
53+// login prefix is always removed.
54+func RemoteSessionDisplay(name, endpoint string) string {
55+ return name + "@" + RemoteDisplayOrigin(endpoint)
56+}
57+
51 // Display returns the presentation label for this remote session.58 // Display returns the presentation label for this remote session.
52 func (k RemoteSessionKey) Display() string {59 func (k RemoteSessionKey) Display() string {
53 origin := k.DisplayOrigin60 origin := k.DisplayOrigin
54 if origin == "" {61 if origin == "" {
55- origin = RemoteDisplayOrigin(k.Host)62+ origin = k.Host
56 }63 }
57- return k.Name + "@" + origin64+ return RemoteSessionDisplay(k.Name, origin)
58 }65 }
M internal/domain/remote_session_test.go +6 -4
@@ -90,19 +90,21 @@ func TestRemoteSessionKeyValidate(t *testing.T) {
9090
9191 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {
9292 tests := []struct {
93- host string
94- want string
93+ host string
94+ displayOrigin string
95+ want string
9596 }{
9697 {host: "arch", want: "hello@arch"},
98+ {host: "route", displayOrigin: "user@arch", want: "hello@arch"},
9799 {host: "test@arch", want: "hello@arch"},
98100 {host: "test@arch:2222", want: "hello@arch:2222"},
99101 {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"},
100102 {host: "test@", want: "hello@test@"},
101- {host: "@arch", want: "hello@@arch"},
103+ {host: "@arch", want: "hello@arch"},
102104 }
103105 for _, test := range tests {
104106 t.Run(test.host, func(t *testing.T) {
105- key := RemoteSessionKey{Host: test.host, Name: "hello"}
107+ key := RemoteSessionKey{Host: test.host, Name: "hello", DisplayOrigin: test.displayOrigin}
106108 if got := key.Display(); got != test.want {
107109 t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want)
108110 }
@@ -90,19 +90,21 @@ func TestRemoteSessionKeyValidate(t *testing.T) {
90 90
91 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {91 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {
92 tests := []struct {92 tests := []struct {
93- host string93+ host string
94- want string94+ displayOrigin string
95+ want string
95 }{96 }{
96 {host: "arch", want: "hello@arch"},97 {host: "arch", want: "hello@arch"},
98+ {host: "route", displayOrigin: "user@arch", want: "hello@arch"},
97 {host: "test@arch", want: "hello@arch"},99 {host: "test@arch", want: "hello@arch"},
98 {host: "test@arch:2222", want: "hello@arch:2222"},100 {host: "test@arch:2222", want: "hello@arch:2222"},
99 {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"},101 {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"},
100 {host: "test@", want: "hello@test@"},102 {host: "test@", want: "hello@test@"},
101- {host: "@arch", want: "hello@@arch"},103+ {host: "@arch", want: "hello@arch"},
102 }104 }
103 for _, test := range tests {105 for _, test := range tests {
104 t.Run(test.host, func(t *testing.T) {106 t.Run(test.host, func(t *testing.T) {
105- key := RemoteSessionKey{Host: test.host, Name: "hello"}107+ key := RemoteSessionKey{Host: test.host, Name: "hello", DisplayOrigin: test.displayOrigin}
106 if got := key.Display(); got != test.want {108 if got := key.Display(); got != test.want {
107 t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want)109 t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want)
108 }110 }
M internal/ports/routes.go +1 -1
@@ -262,5 +262,5 @@ const (
262262 // of work a receiver performs before returning to its transport loop. The
263263 // private client history may use a smaller product cap.
264264 RouteSnapshotMaxEntries = 32
265- RouteLabelMaxBytes = 128
265+ RouteLabelMaxBytes = 256
266266 )
@@ -262,5 +262,5 @@ const (
262 // of work a receiver performs before returning to its transport loop. The262 // of work a receiver performs before returning to its transport loop. The
263 // private client history may use a smaller product cap.263 // private client history may use a smaller product cap.
264 RouteSnapshotMaxEntries = 32264 RouteSnapshotMaxEntries = 32
265- RouteLabelMaxBytes = 128265+ RouteLabelMaxBytes = 256
266 )266 )
M internal/ports/routes_test.go +6 -0
@@ -2,6 +2,7 @@ package ports
22
33 import (
44 "encoding/hex"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/vev/internal/domain"
@@ -195,6 +196,11 @@ func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) {
195196 require.ErrorIs(t, err, ErrInvalidRouteWire)
196197 }
197198
199+func TestRouteLabelBoundsMatchRemoteDisplayOrigin(t *testing.T) {
200+ require.NoError(t, ValidateRouteLabel(strings.Repeat("a", 256), false))
201+ require.Error(t, ValidateRouteLabel(strings.Repeat("a", 257), false))
202+}
203+
198204 func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {
199205 values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})}
200206 for _, value := range values {
@@ -2,6 +2,7 @@ package ports
2 2
3 import (3 import (
4 "encoding/hex"4 "encoding/hex"
5+ "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -195,6 +196,11 @@ func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) {
195 require.ErrorIs(t, err, ErrInvalidRouteWire)196 require.ErrorIs(t, err, ErrInvalidRouteWire)
196 }197 }
197 198
199+func TestRouteLabelBoundsMatchRemoteDisplayOrigin(t *testing.T) {
200+ require.NoError(t, ValidateRouteLabel(strings.Repeat("a", 256), false))
201+ require.Error(t, ValidateRouteLabel(strings.Repeat("a", 257), false))
202+}
203+
198 func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {204 func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {
199 values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})}205 values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})}
200 for _, value := range values {206 for _, value := range values {
M internal/usecase/daemon/move_picker.go +1 -1
@@ -128,7 +128,7 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
128128 sess.mu.Lock()
129129 d.mu.Unlock()
130130 defer sess.mu.Unlock()
131- if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
131+ if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) {
132132 return nil, nil
133133 }
134134 if intent == pickerMoveTab {
@@ -128,7 +128,7 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
128 sess.mu.Lock()128 sess.mu.Lock()
129 d.mu.Unlock()129 d.mu.Unlock()
130 defer sess.mu.Unlock()130 defer sess.mu.Unlock()
131- if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {131+ if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) {
132 return nil, nil132 return nil, nil
133 }133 }
134 if intent == pickerMoveTab {134 if intent == pickerMoveTab {
M internal/usecase/daemon/palette.go +9 -11
@@ -73,15 +73,18 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
7373 results = append(results, palette.NewCommandResult(cmd))
7474 }
7575 active := make([]palette.Result, 0, len(sessions))
76+ localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(stopped))
7677 for _, candidate := range sessions {
77- if candidate == current {
78+ snap := candidate.snapshotView(viewOptions{})
79+ if snap.name == "" || snap.ephemeral {
7880 continue
7981 }
80- snap := candidate.snapshotView(viewOptions{})
81- if snap.name != "" && !snap.ephemeral {
82- target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}
83- active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target))
82+ localLifecycles[snap.incarnation] = struct{}{}
83+ if candidate == current {
84+ continue
8485 }
86+ target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}
87+ active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target))
8588 }
8689 sort.Slice(active, func(i, j int) bool {
8790 left, _ := active[i].SessionName()
@@ -90,11 +93,6 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
9093 })
9194 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
9295 results = append(results, active...)
93- localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(active)+len(stopped))
94- for _, candidate := range active {
95- target, _ := candidate.SessionTarget()
96- localLifecycles[target.LifecycleID] = struct{}{}
97- }
9896 for _, candidate := range stopped {
9997 target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}
10098 results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt), target))
@@ -274,7 +272,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
274272 } else if action, ok := selected.RouteNavigationAction(); ok {
275273 routeTarget = action
276274 hasRouteTarget = true
277- } else if _, ok := selected.SessionName(); ok {
275+ } else if _, ok := selected.SessionTarget(); ok {
278276 sessionTarget = selected
279277 hasSessionTarget = true
280278 } else {
@@ -73,15 +73,18 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
73 results = append(results, palette.NewCommandResult(cmd))73 results = append(results, palette.NewCommandResult(cmd))
74 }74 }
75 active := make([]palette.Result, 0, len(sessions))75 active := make([]palette.Result, 0, len(sessions))
76+ localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(stopped))
76 for _, candidate := range sessions {77 for _, candidate := range sessions {
77- if candidate == current {78+ snap := candidate.snapshotView(viewOptions{})
79+ if snap.name == "" || snap.ephemeral {
78 continue80 continue
79 }81 }
80- snap := candidate.snapshotView(viewOptions{})82+ localLifecycles[snap.incarnation] = struct{}{}
81- if snap.name != "" && !snap.ephemeral {83+ if candidate == current {
82- target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}84+ continue
83- active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target))
84 }85 }
86+ target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}
87+ active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target))
85 }88 }
86 sort.Slice(active, func(i, j int) bool {89 sort.Slice(active, func(i, j int) bool {
87 left, _ := active[i].SessionName()90 left, _ := active[i].SessionName()
@@ -90,11 +93,6 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
90 })93 })
91 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })94 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
92 results = append(results, active...)95 results = append(results, active...)
93- localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(active)+len(stopped))
94- for _, candidate := range active {
95- target, _ := candidate.SessionTarget()
96- localLifecycles[target.LifecycleID] = struct{}{}
97- }
98 for _, candidate := range stopped {96 for _, candidate := range stopped {
99 target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}97 target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}
100 results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt), target))98 results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt), target))
@@ -274,7 +272,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
274 } else if action, ok := selected.RouteNavigationAction(); ok {272 } else if action, ok := selected.RouteNavigationAction(); ok {
275 routeTarget = action273 routeTarget = action
276 hasRouteTarget = true274 hasRouteTarget = true
277- } else if _, ok := selected.SessionName(); ok {275+ } else if _, ok := selected.SessionTarget(); ok {
278 sessionTarget = selected276 sessionTarget = selected
279 hasSessionTarget = true277 hasSessionTarget = true
280 } else {278 } else {
M internal/usecase/daemon/palette_test.go +42 -9
@@ -2,7 +2,6 @@ package daemon
22
33 import (
44 "errors"
5- "strings"
65 "sync"
76 "testing"
87
@@ -15,6 +14,7 @@ import (
1514 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1615 "github.com/bnema/vev/internal/usecase/command"
1716 "github.com/bnema/vev/internal/usecase/palette"
17+ "github.com/bnema/vev/internal/usecase/picker"
1818 themeui "github.com/bnema/vev/internal/usecase/theme"
1919 "github.com/bnema/vev/internal/usecase/ui"
2020 )
@@ -423,13 +423,17 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
423423 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
424424 d := newTestDaemon(t, nil, stubClock{})
425425 current := addControlSession(d, "hi", "tab-1", "pane-1")
426+ current.ephemeral = false
427+ current.incarnation = domain.SessionLifecycleID{1}
426428 local := addControlSession(d, "vev", "tab-2", "pane-2")
427429 local.ephemeral = false
430+ local.incarnation = domain.SessionLifecycleID{2}
428431 remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"}
429432 remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"}
430433 snapshot := ports.RecentRouteSnapshot{
431434 Generation: 2,
432435 Entries: []ports.RecentRouteEntry{
436+ {Key: 1, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "hi"}, Name: "hi", Kind: ports.RouteKindLocal},
433437 {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal},
434438 {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
435439 {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
@@ -437,17 +441,46 @@ func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
437441 }
438442
439443 results := d.paletteResults(current, nil, snapshot)
440- var matching []string
444+ var sessionTargets []ports.ExactSessionTarget
445+ var routeActions []ports.RouteNavigationAction
441446 for _, result := range results {
442- if strings.Contains(result.DisplayText(), "session vev") {
443- matching = append(matching, result.DisplayText())
447+ if target, ok := result.SessionTarget(); ok {
448+ sessionTargets = append(sessionTargets, target)
444449 }
450+ if action, ok := result.RouteNavigationAction(); ok {
451+ routeActions = append(routeActions, action)
452+ }
453+ }
454+ require.Equal(t, []ports.ExactSessionTarget{{LifecycleID: local.incarnation, SessionName: "vev"}}, sessionTargets)
455+ require.Equal(t, []ports.RouteNavigationAction{
456+ {SnapshotGeneration: 2, Key: 3, Generation: 1},
457+ {SnapshotGeneration: 2, Key: 4, Generation: 1},
458+ }, routeActions)
459+ require.Equal(t, "Switch to session vev@arch", results[len(results)-2].DisplayText())
460+ require.Equal(t, "Switch to session vev@arch", results[len(results)-1].DisplayText())
461+}
462+
463+func TestPaletteLifecycleTargetRejectsSameNameReplacement(t *testing.T) {
464+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
465+ defer releaseAll(releases)
466+ target := d.sessions["recent"]
467+ target.incarnation = domain.SessionLifecycleID{2}
468+ target.createdAt = 7
469+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
470+ expectedCreatedAt := target.createdAt
471+
472+ err := d.switchToTargetForAttachment(token, picker.Target{
473+ Name: "recent", Incarnation: domain.SessionLifecycleID{9},
474+ ExpectedCreatedAt: &expectedCreatedAt, TabIndex: -1,
475+ }, sessionHandoffGuard{}, "palette-session")
476+
477+ require.ErrorIs(t, err, errAttachmentTransition)
478+ require.Same(t, current, ac.currentSession())
479+ select {
480+ case frame := <-sends:
481+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
482+ default:
445483 }
446- require.Equal(t, []string{
447- "Switch to session vev",
448- "Switch to session vev@arch",
449- "Switch to session vev@arch",
450- }, matching)
451484 }
452485
453486 func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
@@ -2,7 +2,6 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5- "strings"
6 "sync"5 "sync"
7 "testing"6 "testing"
8 7
@@ -15,6 +14,7 @@ import (
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/usecase/command"15 "github.com/bnema/vev/internal/usecase/command"
17 "github.com/bnema/vev/internal/usecase/palette"16 "github.com/bnema/vev/internal/usecase/palette"
17+ "github.com/bnema/vev/internal/usecase/picker"
18 themeui "github.com/bnema/vev/internal/usecase/theme"18 themeui "github.com/bnema/vev/internal/usecase/theme"
19 "github.com/bnema/vev/internal/usecase/ui"19 "github.com/bnema/vev/internal/usecase/ui"
20 )20 )
@@ -423,13 +423,17 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
423 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {423 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
424 d := newTestDaemon(t, nil, stubClock{})424 d := newTestDaemon(t, nil, stubClock{})
425 current := addControlSession(d, "hi", "tab-1", "pane-1")425 current := addControlSession(d, "hi", "tab-1", "pane-1")
426+ current.ephemeral = false
427+ current.incarnation = domain.SessionLifecycleID{1}
426 local := addControlSession(d, "vev", "tab-2", "pane-2")428 local := addControlSession(d, "vev", "tab-2", "pane-2")
427 local.ephemeral = false429 local.ephemeral = false
430+ local.incarnation = domain.SessionLifecycleID{2}
428 remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"}431 remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"}
429 remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"}432 remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"}
430 snapshot := ports.RecentRouteSnapshot{433 snapshot := ports.RecentRouteSnapshot{
431 Generation: 2,434 Generation: 2,
432 Entries: []ports.RecentRouteEntry{435 Entries: []ports.RecentRouteEntry{
436+ {Key: 1, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "hi"}, Name: "hi", Kind: ports.RouteKindLocal},
433 {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal},437 {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal},
434 {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},438 {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
435 {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},439 {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
@@ -437,17 +441,46 @@ func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
437 }441 }
438 442
439 results := d.paletteResults(current, nil, snapshot)443 results := d.paletteResults(current, nil, snapshot)
440- var matching []string444+ var sessionTargets []ports.ExactSessionTarget
445+ var routeActions []ports.RouteNavigationAction
441 for _, result := range results {446 for _, result := range results {
442- if strings.Contains(result.DisplayText(), "session vev") {447+ if target, ok := result.SessionTarget(); ok {
443- matching = append(matching, result.DisplayText())448+ sessionTargets = append(sessionTargets, target)
444 }449 }
450+ if action, ok := result.RouteNavigationAction(); ok {
451+ routeActions = append(routeActions, action)
452+ }
453+ }
454+ require.Equal(t, []ports.ExactSessionTarget{{LifecycleID: local.incarnation, SessionName: "vev"}}, sessionTargets)
455+ require.Equal(t, []ports.RouteNavigationAction{
456+ {SnapshotGeneration: 2, Key: 3, Generation: 1},
457+ {SnapshotGeneration: 2, Key: 4, Generation: 1},
458+ }, routeActions)
459+ require.Equal(t, "Switch to session vev@arch", results[len(results)-2].DisplayText())
460+ require.Equal(t, "Switch to session vev@arch", results[len(results)-1].DisplayText())
461+}
462+
463+func TestPaletteLifecycleTargetRejectsSameNameReplacement(t *testing.T) {
464+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
465+ defer releaseAll(releases)
466+ target := d.sessions["recent"]
467+ target.incarnation = domain.SessionLifecycleID{2}
468+ target.createdAt = 7
469+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
470+ expectedCreatedAt := target.createdAt
471+
472+ err := d.switchToTargetForAttachment(token, picker.Target{
473+ Name: "recent", Incarnation: domain.SessionLifecycleID{9},
474+ ExpectedCreatedAt: &expectedCreatedAt, TabIndex: -1,
475+ }, sessionHandoffGuard{}, "palette-session")
476+
477+ require.ErrorIs(t, err, errAttachmentTransition)
478+ require.Same(t, current, ac.currentSession())
479+ select {
480+ case frame := <-sends:
481+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
482+ default:
445 }483 }
446- require.Equal(t, []string{
447- "Switch to session vev",
448- "Switch to session vev@arch",
449- "Switch to session vev@arch",
450- }, matching)
451 }484 }
452 485
453 func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {486 func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
M internal/usecase/daemon/picker.go +11 -8
@@ -238,7 +238,7 @@ func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Targ
238238 }
239239 message := "Remote session unavailable"
240240 if target.RemoteTarget != nil {
241- message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin
241+ message += ": " + domain.RemoteSessionDisplay(target.RemoteTarget.SessionName, target.RemoteTarget.DisplayOrigin)
242242 } else if target.RemoteHost != "" {
243243 message += ": " + target.RemoteHost
244244 }
@@ -1169,12 +1169,12 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
11691169 }
11701170 if active := d.findByNameLocked(target.Name); active != nil {
11711171 active.mu.Lock()
1172- matches := targetMatchesLifecycle(target, active.name, active.createdAt)
1172+ matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation)
11731173 active.mu.Unlock()
11741174 return active, stoppedSession{}, false, matches
11751175 }
11761176 stopped, ok := d.stopped[target.Name]
1177- if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1177+ if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
11781178 return nil, stoppedSession{}, false, false
11791179 }
11801180 return nil, stopped, true, true
@@ -1191,7 +1191,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
11911191 targetSess.mu.Lock()
11921192 defer targetSess.mu.Unlock()
11931193 _, attached := targetSess.attachments[ac]
1194- if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
1194+ if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
11951195 return nil, attachmentTransitionResult{}, false
11961196 }
11971197 if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
@@ -1212,7 +1212,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
12121212
12131213 unlock := lockAttachmentSessions(from, targetSess)
12141214 _, sourceAttached := from.attachments[ac]
1215- if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
1215+ if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) {
12161216 unlock()
12171217 return nil, attachmentTransitionResult{}, false
12181218 }
@@ -1278,7 +1278,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
12781278 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
12791279 createTargetLocked: func() (*session, error) {
12801280 current, ok := d.stopped[target.Name]
1281- if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) {
1281+ if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
12821282 return nil, errAttachmentTransition
12831283 }
12841284 from.mu.Lock()
@@ -1331,7 +1331,10 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
13311331 return targetSess, transition, true, nil
13321332 }
13331333
1334-func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
1334+func targetMatchesLifecycle(target picker.Target, name string, createdAt int64, incarnation domain.IncarnationID) bool {
1335+ if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != incarnation {
1336+ return false
1337+ }
13351338 return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
13361339 }
13371340
@@ -1356,7 +1359,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
13561359 transition attachmentTransitionResult
13571360 switched bool
13581361 )
1359- if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1362+ if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
13601363 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
13611364 }
13621365 d.mu.Unlock()
@@ -238,7 +238,7 @@ func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Targ
238 }238 }
239 message := "Remote session unavailable"239 message := "Remote session unavailable"
240 if target.RemoteTarget != nil {240 if target.RemoteTarget != nil {
241- message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin241+ message += ": " + domain.RemoteSessionDisplay(target.RemoteTarget.SessionName, target.RemoteTarget.DisplayOrigin)
242 } else if target.RemoteHost != "" {242 } else if target.RemoteHost != "" {
243 message += ": " + target.RemoteHost243 message += ": " + target.RemoteHost
244 }244 }
@@ -1169,12 +1169,12 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
1169 }1169 }
1170 if active := d.findByNameLocked(target.Name); active != nil {1170 if active := d.findByNameLocked(target.Name); active != nil {
1171 active.mu.Lock()1171 active.mu.Lock()
1172- matches := targetMatchesLifecycle(target, active.name, active.createdAt)1172+ matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation)
1173 active.mu.Unlock()1173 active.mu.Unlock()
1174 return active, stoppedSession{}, false, matches1174 return active, stoppedSession{}, false, matches
1175 }1175 }
1176 stopped, ok := d.stopped[target.Name]1176 stopped, ok := d.stopped[target.Name]
1177- if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {1177+ if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
1178 return nil, stoppedSession{}, false, false1178 return nil, stoppedSession{}, false, false
1179 }1179 }
1180 return nil, stopped, true, true1180 return nil, stopped, true, true
@@ -1191,7 +1191,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1191 targetSess.mu.Lock()1191 targetSess.mu.Lock()
1192 defer targetSess.mu.Unlock()1192 defer targetSess.mu.Unlock()
1193 _, attached := targetSess.attachments[ac]1193 _, attached := targetSess.attachments[ac]
1194- if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {1194+ if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
1195 return nil, attachmentTransitionResult{}, false1195 return nil, attachmentTransitionResult{}, false
1196 }1196 }
1197 if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {1197 if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
@@ -1212,7 +1212,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1212 1212
1213 unlock := lockAttachmentSessions(from, targetSess)1213 unlock := lockAttachmentSessions(from, targetSess)
1214 _, sourceAttached := from.attachments[ac]1214 _, sourceAttached := from.attachments[ac]
1215- if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {1215+ if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) {
1216 unlock()1216 unlock()
1217 return nil, attachmentTransitionResult{}, false1217 return nil, attachmentTransitionResult{}, false
1218 }1218 }
@@ -1278,7 +1278,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1278 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,1278 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
1279 createTargetLocked: func() (*session, error) {1279 createTargetLocked: func() (*session, error) {
1280 current, ok := d.stopped[target.Name]1280 current, ok := d.stopped[target.Name]
1281- if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) {1281+ if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
1282 return nil, errAttachmentTransition1282 return nil, errAttachmentTransition
1283 }1283 }
1284 from.mu.Lock()1284 from.mu.Lock()
@@ -1331,7 +1331,10 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1331 return targetSess, transition, true, nil1331 return targetSess, transition, true, nil
1332 }1332 }
1333 1333
1334-func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {1334+func targetMatchesLifecycle(target picker.Target, name string, createdAt int64, incarnation domain.IncarnationID) bool {
1335+ if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != incarnation {
1336+ return false
1337+ }
1335 return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)1338 return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
1336 }1339 }
1337 1340
@@ -1356,7 +1359,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
1356 transition attachmentTransitionResult1359 transition attachmentTransitionResult
1357 switched bool1360 switched bool
1358 )1361 )
1359- if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {1362+ if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
1360 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")1363 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
1361 }1364 }
1362 d.mu.Unlock()1365 d.mu.Unlock()
M internal/usecase/daemon/recent_route_presentation.go +3 -1
@@ -1,5 +1,7 @@
11 package daemon
22
3+import "github.com/bnema/vev/internal/domain"
4+
35 // recentRouteKind identifies the origin represented by a presentation value.
46 // It is intentionally daemon-local in this layer; ports route kinds are
57 // projected into this render-only value at the daemon boundary.
@@ -72,5 +74,5 @@ func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string
7274 host = "local"
7375 }
7476 }
75- return name + "@" + host
77+ return domain.RemoteSessionDisplay(name, host)
7678 }
@@ -1,5 +1,7 @@
1 package daemon1 package daemon
2 2
3+import "github.com/bnema/vev/internal/domain"
4+
3 // recentRouteKind identifies the origin represented by a presentation value.5 // recentRouteKind identifies the origin represented by a presentation value.
4 // It is intentionally daemon-local in this layer; ports route kinds are6 // It is intentionally daemon-local in this layer; ports route kinds are
5 // projected into this render-only value at the daemon boundary.7 // projected into this render-only value at the daemon boundary.
@@ -72,5 +74,5 @@ func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string
72 host = "local"74 host = "local"
73 }75 }
74 }76 }
75- return name + "@" + host77+ return domain.RemoteSessionDisplay(name, host)
76 }78 }

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