detached 543bf31

fix(remote): restore unified remote navigation

commit
543bf31ee006b4b7b94bb4fd812c51a62cae7883
author
brice · · 2026-08-07 08:35 UTC
parent
3dbe137
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +267 -10
M internal/usecase/daemon/client.go +6 -2
@@ -103,8 +103,12 @@ type attachedClient struct {
103103 // previousOwner is guarded independently. It retains navigation history
104104 // through temporary owner hand-offs and is cleared only on terminal teardown.
105105 previousOwner Guarded[attachmentOwner]
106- linkMu sync.Mutex
107- sendMu sync.Mutex
106+ // remoteRecent is attachment-local, display-only MRU state. Remote views do
107+ // not enter the local session registry, so these entries must not be usable
108+ // by navigation, palette, or persistence paths.
109+ remoteRecent Guarded[[]remoteRecentSession]
110+ linkMu sync.Mutex
111+ sendMu sync.Mutex
108112 // routeCreatedSession marks a session created by this attachment's route.
109113 // A handshake that never commits Welcome must tear down that exact empty
110114 // session, while an attachment routed to an existing session must not.
@@ -103,8 +103,12 @@ type attachedClient struct {
103 // previousOwner is guarded independently. It retains navigation history103 // previousOwner is guarded independently. It retains navigation history
104 // through temporary owner hand-offs and is cleared only on terminal teardown.104 // through temporary owner hand-offs and is cleared only on terminal teardown.
105 previousOwner Guarded[attachmentOwner]105 previousOwner Guarded[attachmentOwner]
106- linkMu sync.Mutex106+ // remoteRecent is attachment-local, display-only MRU state. Remote views do
107- sendMu sync.Mutex107+ // not enter the local session registry, so these entries must not be usable
108+ // by navigation, palette, or persistence paths.
109+ remoteRecent Guarded[[]remoteRecentSession]
110+ linkMu sync.Mutex
111+ sendMu sync.Mutex
108 // routeCreatedSession marks a session created by this attachment's route.112 // routeCreatedSession marks a session created by this attachment's route.
109 // A handshake that never commits Welcome must tear down that exact empty113 // A handshake that never commits Welcome must tear down that exact empty
110 // session, while an attachment routed to an existing session must not.114 // session, while an attachment routed to an existing session must not.
M internal/usecase/daemon/palette.go +9 -3
@@ -37,10 +37,16 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
3737
3838 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
3939 // Capture daemon/session state before taking paletteMu: lock ordering forbids
40- // holding an overlay lock while inspecting live sessions.
41- recent := d.recentSessions(sess)
40+ // holding an overlay lock while inspecting live sessions. A remote view is
41+ // not a local session, so its retained local source must remain offered as a
42+ // palette result and JRS candidate.
43+ current := sess
44+ if _, remote := normalizeAttachmentOwner(ac.currentAttachmentOwner()).(*remoteView); remote {
45+ current = nil
46+ }
47+ recent := d.recentSessions(current)
4248 commands := d.paletteCommands()
43- results := d.paletteResults(sess, commands)
49+ results := d.paletteResults(current, commands)
4450 ac.overlays.paletteMu.Lock()
4551 ac.overlays.paletteGeneration++
4652 ac.overlays.palette = palette.New(results)
@@ -37,10 +37,16 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
37 37
38 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {38 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
39 // Capture daemon/session state before taking paletteMu: lock ordering forbids39 // Capture daemon/session state before taking paletteMu: lock ordering forbids
40- // holding an overlay lock while inspecting live sessions.40+ // holding an overlay lock while inspecting live sessions. A remote view is
41- recent := d.recentSessions(sess)41+ // not a local session, so its retained local source must remain offered as a
42+ // palette result and JRS candidate.
43+ current := sess
44+ if _, remote := normalizeAttachmentOwner(ac.currentAttachmentOwner()).(*remoteView); remote {
45+ current = nil
46+ }
47+ recent := d.recentSessions(current)
42 commands := d.paletteCommands()48 commands := d.paletteCommands()
43- results := d.paletteResults(sess, commands)49+ results := d.paletteResults(current, commands)
44 ac.overlays.paletteMu.Lock()50 ac.overlays.paletteMu.Lock()
45 ac.overlays.paletteGeneration++51 ac.overlays.paletteGeneration++
46 ac.overlays.palette = palette.New(results)52 ac.overlays.palette = palette.New(results)
M internal/usecase/daemon/picker.go +41 -1
@@ -888,12 +888,52 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
888888 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
889889 }
890890
891+// retainedLocalTargetMatches reports whether target is the local session held
892+// as a remote attachment's exact return owner. It compares structured session
893+// and lifecycle identity; a remote attachment must never infer a return route
894+// from a rendered label.
895+func retainedLocalTargetMatches(target picker.Target, owner attachmentOwner) bool {
896+ if target.Session == "" && target.Incarnation == (domain.IncarnationID{}) && target.ExpectedCreatedAt == nil {
897+ return false
898+ }
899+ retained := localSession(owner)
900+ if retained == nil {
901+ return false
902+ }
903+ snapshot := retained.snapshotView(viewOptions{})
904+ if target.Session != "" && target.Session != snapshot.id {
905+ return false
906+ }
907+ if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != snapshot.incarnation {
908+ return false
909+ }
910+ return targetMatchesLifecycle(target, snapshot.name, snapshot.createdAt)
911+}
912+
891913 // switchToTargetForAttachment is the sole client-originated navigation entry. The
892914 // intent captures the exact initiating capability before admission is released;
893915 // every active, stopped, and same-session target then uses transitionAttachment
894916 // for frozen, atomic source-token preflight.
895917 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
896- if token.localSession() == nil || token.ac == nil || token.effect == nil {
918+ if token.ac == nil {
919+ return nil
920+ }
921+ if _, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {
922+ if target.RemoteTarget != nil {
923+ return d.startRemotePickerSelection(token, target, guard, action)
924+ }
925+ if target.RemoteKey != nil {
926+ return errAttachmentTransition
927+ }
928+ if !retainedLocalTargetMatches(target, token.ac.previousOwner.Get()) {
929+ return errAttachmentTransition
930+ }
931+ if guard.closePicker {
932+ d.closePicker(token.ac)
933+ }
934+ return d.backSessionForAttachment(token)
935+ }
936+ if token.localSession() == nil || token.effect == nil {
897937 return nil
898938 }
899939 if target.RemoteTarget != nil {
@@ -888,12 +888,52 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
888 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})888 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
889 }889 }
890 890
891+// retainedLocalTargetMatches reports whether target is the local session held
892+// as a remote attachment's exact return owner. It compares structured session
893+// and lifecycle identity; a remote attachment must never infer a return route
894+// from a rendered label.
895+func retainedLocalTargetMatches(target picker.Target, owner attachmentOwner) bool {
896+ if target.Session == "" && target.Incarnation == (domain.IncarnationID{}) && target.ExpectedCreatedAt == nil {
897+ return false
898+ }
899+ retained := localSession(owner)
900+ if retained == nil {
901+ return false
902+ }
903+ snapshot := retained.snapshotView(viewOptions{})
904+ if target.Session != "" && target.Session != snapshot.id {
905+ return false
906+ }
907+ if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != snapshot.incarnation {
908+ return false
909+ }
910+ return targetMatchesLifecycle(target, snapshot.name, snapshot.createdAt)
911+}
912+
891 // switchToTargetForAttachment is the sole client-originated navigation entry. The913 // switchToTargetForAttachment is the sole client-originated navigation entry. The
892 // intent captures the exact initiating capability before admission is released;914 // intent captures the exact initiating capability before admission is released;
893 // every active, stopped, and same-session target then uses transitionAttachment915 // every active, stopped, and same-session target then uses transitionAttachment
894 // for frozen, atomic source-token preflight.916 // for frozen, atomic source-token preflight.
895 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {917 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
896- if token.localSession() == nil || token.ac == nil || token.effect == nil {918+ if token.ac == nil {
919+ return nil
920+ }
921+ if _, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {
922+ if target.RemoteTarget != nil {
923+ return d.startRemotePickerSelection(token, target, guard, action)
924+ }
925+ if target.RemoteKey != nil {
926+ return errAttachmentTransition
927+ }
928+ if !retainedLocalTargetMatches(target, token.ac.previousOwner.Get()) {
929+ return errAttachmentTransition
930+ }
931+ if guard.closePicker {
932+ d.closePicker(token.ac)
933+ }
934+ return d.backSessionForAttachment(token)
935+ }
936+ if token.localSession() == nil || token.effect == nil {
897 return nil937 return nil
898 }938 }
899 if target.RemoteTarget != nil {939 if target.RemoteTarget != nil {
M internal/usecase/daemon/recent_sessions.go +80 -0
@@ -15,6 +15,26 @@ type recentSession struct {
1515 mruAt uint64
1616 }
1717
18+// remoteRecentSession retains display-only recency for a remote view. It
19+// stores value identity rather than a view pointer so a retained warm view
20+// cannot gain navigation authority through the local status bar.
21+type remoteRecentSession struct {
22+ key remoteViewKey
23+ displayOrigin string
24+ mruAt uint64
25+}
26+
27+func (r remoteRecentSession) name() string {
28+ origin := r.displayOrigin
29+ if origin == "" {
30+ origin = r.key.endpoint
31+ }
32+ if origin == "" {
33+ return r.key.sessionName
34+ }
35+ return r.key.sessionName + "@" + origin
36+}
37+
1838 // recentSessions returns the current session's capped named-session MRU list.
1939 // Callers may retain the returned values for the lifetime of an interaction.
2040 func (d *Daemon) recentSessions(current *session) []recentSession {
@@ -35,6 +55,21 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
3555 recent = append(recent, entry)
3656 }
3757 d.mu.Unlock()
58+ return trimRecentSessions(recent)
59+}
60+
61+// recentSessionsForAttachment merges the daemon-wide local MRU with the
62+// attachment's display-only remote entries. Only chrome composition calls this
63+// method; picker, palette, and command routing keep using recentSessions.
64+func (d *Daemon) recentSessionsForAttachment(current *session, ac *attachedClient) []recentSession {
65+ recent := d.recentSessions(current)
66+ if ac == nil {
67+ return recent
68+ }
69+ return trimRecentSessions(append(recent, ac.remoteRecentSessions()...))
70+}
71+
72+func trimRecentSessions(recent []recentSession) []recentSession {
3873 sort.SliceStable(recent, func(i, j int) bool {
3974 if recent[i].mruAt == recent[j].mruAt {
4075 return recent[i].name < recent[j].name
@@ -46,3 +81,48 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
4681 }
4782 return recent
4883 }
84+
85+// recordRemoteRecent records only a successfully published remote owner. The
86+// caller invokes it after the attachment hand-off, outside daemon and view
87+// locks, so failed selections cannot affect the local status-bar history.
88+func (d *Daemon) recordRemoteRecent(ac *attachedClient, view *remoteView) {
89+ if d == nil || ac == nil || view == nil {
90+ return
91+ }
92+ view.mu.Lock()
93+ entry := remoteRecentSession{key: view.key, displayOrigin: view.displayOrigin}
94+ view.mu.Unlock()
95+ if entry.key.sessionName == "" {
96+ return
97+ }
98+ entry.mruAt = d.mruSeq.Add(1)
99+ ac.remoteRecent.With(func(entries *[]remoteRecentSession) {
100+ for i := range *entries {
101+ if (*entries)[i].key == entry.key {
102+ (*entries)[i] = entry
103+ return
104+ }
105+ }
106+ *entries = append(*entries, entry)
107+ if len(*entries) > maxMRUSessions {
108+ sort.SliceStable(*entries, func(i, j int) bool {
109+ return (*entries)[i].mruAt > (*entries)[j].mruAt
110+ })
111+ *entries = (*entries)[:maxMRUSessions]
112+ }
113+ })
114+}
115+
116+func (ac *attachedClient) remoteRecentSessions() []recentSession {
117+ if ac == nil {
118+ return nil
119+ }
120+ var recent []recentSession
121+ ac.remoteRecent.With(func(entries *[]remoteRecentSession) {
122+ recent = make([]recentSession, 0, len(*entries))
123+ for _, entry := range *entries {
124+ recent = append(recent, recentSession{name: entry.name(), mruAt: entry.mruAt})
125+ }
126+ })
127+ return recent
128+}
@@ -15,6 +15,26 @@ type recentSession struct {
15 mruAt uint6415 mruAt uint64
16 }16 }
17 17
18+// remoteRecentSession retains display-only recency for a remote view. It
19+// stores value identity rather than a view pointer so a retained warm view
20+// cannot gain navigation authority through the local status bar.
21+type remoteRecentSession struct {
22+ key remoteViewKey
23+ displayOrigin string
24+ mruAt uint64
25+}
26+
27+func (r remoteRecentSession) name() string {
28+ origin := r.displayOrigin
29+ if origin == "" {
30+ origin = r.key.endpoint
31+ }
32+ if origin == "" {
33+ return r.key.sessionName
34+ }
35+ return r.key.sessionName + "@" + origin
36+}
37+
18 // recentSessions returns the current session's capped named-session MRU list.38 // recentSessions returns the current session's capped named-session MRU list.
19 // Callers may retain the returned values for the lifetime of an interaction.39 // Callers may retain the returned values for the lifetime of an interaction.
20 func (d *Daemon) recentSessions(current *session) []recentSession {40 func (d *Daemon) recentSessions(current *session) []recentSession {
@@ -35,6 +55,21 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
35 recent = append(recent, entry)55 recent = append(recent, entry)
36 }56 }
37 d.mu.Unlock()57 d.mu.Unlock()
58+ return trimRecentSessions(recent)
59+}
60+
61+// recentSessionsForAttachment merges the daemon-wide local MRU with the
62+// attachment's display-only remote entries. Only chrome composition calls this
63+// method; picker, palette, and command routing keep using recentSessions.
64+func (d *Daemon) recentSessionsForAttachment(current *session, ac *attachedClient) []recentSession {
65+ recent := d.recentSessions(current)
66+ if ac == nil {
67+ return recent
68+ }
69+ return trimRecentSessions(append(recent, ac.remoteRecentSessions()...))
70+}
71+
72+func trimRecentSessions(recent []recentSession) []recentSession {
38 sort.SliceStable(recent, func(i, j int) bool {73 sort.SliceStable(recent, func(i, j int) bool {
39 if recent[i].mruAt == recent[j].mruAt {74 if recent[i].mruAt == recent[j].mruAt {
40 return recent[i].name < recent[j].name75 return recent[i].name < recent[j].name
@@ -46,3 +81,48 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
46 }81 }
47 return recent82 return recent
48 }83 }
84+
85+// recordRemoteRecent records only a successfully published remote owner. The
86+// caller invokes it after the attachment hand-off, outside daemon and view
87+// locks, so failed selections cannot affect the local status-bar history.
88+func (d *Daemon) recordRemoteRecent(ac *attachedClient, view *remoteView) {
89+ if d == nil || ac == nil || view == nil {
90+ return
91+ }
92+ view.mu.Lock()
93+ entry := remoteRecentSession{key: view.key, displayOrigin: view.displayOrigin}
94+ view.mu.Unlock()
95+ if entry.key.sessionName == "" {
96+ return
97+ }
98+ entry.mruAt = d.mruSeq.Add(1)
99+ ac.remoteRecent.With(func(entries *[]remoteRecentSession) {
100+ for i := range *entries {
101+ if (*entries)[i].key == entry.key {
102+ (*entries)[i] = entry
103+ return
104+ }
105+ }
106+ *entries = append(*entries, entry)
107+ if len(*entries) > maxMRUSessions {
108+ sort.SliceStable(*entries, func(i, j int) bool {
109+ return (*entries)[i].mruAt > (*entries)[j].mruAt
110+ })
111+ *entries = (*entries)[:maxMRUSessions]
112+ }
113+ })
114+}
115+
116+func (ac *attachedClient) remoteRecentSessions() []recentSession {
117+ if ac == nil {
118+ return nil
119+ }
120+ var recent []recentSession
121+ ac.remoteRecent.With(func(entries *[]remoteRecentSession) {
122+ recent = make([]recentSession, 0, len(*entries))
123+ for _, entry := range *entries {
124+ recent = append(recent, recentSession{name: entry.name(), mruAt: entry.mruAt})
125+ }
126+ })
127+ return recent
128+}
M internal/usecase/daemon/remote_attachment_transition.go +1 -0
@@ -99,6 +99,7 @@ func (d *Daemon) transitionToRemoteViewGuarded(token attachmentConnectionToken,
9999 }
100100 d.recalculateSessionGeometryAndInvalidateAsync(source, "remote_attachment_transition.go")
101101 token.ac.recordPreviousOwner(source)
102+ d.recordRemoteRecent(token.ac, target)
102103 return published, nil
103104 }
104105
@@ -99,6 +99,7 @@ func (d *Daemon) transitionToRemoteViewGuarded(token attachmentConnectionToken,
99 }99 }
100 d.recalculateSessionGeometryAndInvalidateAsync(source, "remote_attachment_transition.go")100 d.recalculateSessionGeometryAndInvalidateAsync(source, "remote_attachment_transition.go")
101 token.ac.recordPreviousOwner(source)101 token.ac.recordPreviousOwner(source)
102+ d.recordRemoteRecent(token.ac, target)
102 return published, nil103 return published, nil
103 }104 }
104 105
M internal/usecase/daemon/remote_attachment_transition_back_test.go +10 -0
@@ -39,6 +39,16 @@ func TestBackSessionForAttachmentReturnsFromRemoteViewToPreviousLocal(t *testing
3939 require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease")
4040 }
4141
42+func TestRemoteRecentAppearsInLocalStatusMRUAfterReturn(t *testing.T) {
43+ d, local, ac, _, token := remoteBackFixture(t)
44+
45+ require.NoError(t, d.backSessionForAttachment(token))
46+
47+ state := d.barStateForAttachmentPaletteHintsFor(local, ac, "", nil, nil)
48+ require.Equal(t, []recentSession{{name: "remote@host", mruAt: 1}}, state.mru)
49+ require.Empty(t, d.recentSessions(local), "remote recency stays out of global navigation MRU")
50+}
51+
4252 func TestBackSessionForAttachmentFailsClosedForStaleOrNonLocalPredecessor(t *testing.T) {
4353 tests := []struct {
4454 name string
@@ -39,6 +39,16 @@ func TestBackSessionForAttachmentReturnsFromRemoteViewToPreviousLocal(t *testing
39 require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease")39 require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease")
40 }40 }
41 41
42+func TestRemoteRecentAppearsInLocalStatusMRUAfterReturn(t *testing.T) {
43+ d, local, ac, _, token := remoteBackFixture(t)
44+
45+ require.NoError(t, d.backSessionForAttachment(token))
46+
47+ state := d.barStateForAttachmentPaletteHintsFor(local, ac, "", nil, nil)
48+ require.Equal(t, []recentSession{{name: "remote@host", mruAt: 1}}, state.mru)
49+ require.Empty(t, d.recentSessions(local), "remote recency stays out of global navigation MRU")
50+}
51+
42 func TestBackSessionForAttachmentFailsClosedForStaleOrNonLocalPredecessor(t *testing.T) {52 func TestBackSessionForAttachmentFailsClosedForStaleOrNonLocalPredecessor(t *testing.T) {
43 tests := []struct {53 tests := []struct {
44 name string54 name string
M internal/usecase/daemon/remote_frame_routing_test.go +62 -0
@@ -7,6 +7,7 @@ import (
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
99 "github.com/bnema/vev/internal/usecase/keys"
10+ "github.com/bnema/vev/internal/usecase/picker"
1011 "github.com/bnema/vev/pkg/renderer"
1112 "github.com/bnema/vev/pkg/vt"
1213 "github.com/stretchr/testify/require"
@@ -54,6 +55,14 @@ func TestRemoteViewRenderSnapshotDoesNotResizeSharedScreen(t *testing.T) {
5455 view.mu.Unlock()
5556 }
5657
58+func TestRemoteStatusSnapshotUsesSessionHostSyntax(t *testing.T) {
59+ view := &remoteView{key: remoteViewKey{endpoint: "remote", sessionName: "test1"}}
60+
61+ status := remoteStatusSnapshot(view, remoteViewRenderSnapshot{origin: "remote"})
62+
63+ require.Equal(t, "test1@remote", status.session)
64+}
65+
5766 func TestRemoteFrameRoutingKeepsPickerNavigationLocal(t *testing.T) {
5867 d, source, ac, view, token := remoteBackFixture(t)
5968 remoteTransport := newRemoteLinkTestTransport()
@@ -125,6 +134,59 @@ func TestRemoteFrameRoutingOpensPaletteLocally(t *testing.T) {
125134 }
126135 }
127136
137+func TestRemoteFrameRoutingReturnsToRetainedLocalSession(t *testing.T) {
138+ for _, test := range []struct {
139+ name string
140+ run func(*Daemon, *attachedClient, attachmentConnectionToken)
141+ }{
142+ {
143+ name: "palette session result",
144+ run: func(d *Daemon, _ *attachedClient, token attachmentConnectionToken) {
145+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
146+ d.handleAttachmentClientFrame(token, frameInput([]byte("work\r")))
147+ },
148+ },
149+ {
150+ name: "jump recent session",
151+ run: func(d *Daemon, _ *attachedClient, token attachmentConnectionToken) {
152+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
153+ d.handleAttachmentClientFrame(token, frameInput([]byte("JRS 1\r")))
154+ },
155+ },
156+ {
157+ name: "session picker",
158+ run: func(d *Daemon, ac *attachedClient, token attachmentConnectionToken) {
159+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
160+ d.handleAttachmentClientFrame(token, frameInput([]byte("SSP\r")))
161+ require.True(t, ac.overlays.pickerActive())
162+ d.handleAttachmentClientFrame(token, frameInput([]byte("\r")))
163+ require.False(t, ac.overlays.pickerActive(), "the picker closes before the remote-to-local handoff")
164+ },
165+ },
166+ } {
167+ t.Run(test.name, func(t *testing.T) {
168+ d, source, ac, view, token := remoteBackFixture(t)
169+
170+ test.run(d, ac, token)
171+
172+ require.True(t, ac.currentAttachmentSession() == source)
173+ require.True(t, source.attachmentRegistered(ac))
174+ require.False(t, view.attachmentRegistered(ac))
175+ require.True(t, ac.previousOwner.Get() == view)
176+ })
177+ }
178+}
179+
180+func TestRemoteFrameRoutingRejectsUnidentifiedLocalTarget(t *testing.T) {
181+ d, source, ac, view, token := remoteBackFixture(t)
182+
183+ err := d.switchToTargetForAttachment(token, picker.Target{}, sessionHandoffGuard{}, "picker-select")
184+
185+ require.ErrorIs(t, err, errAttachmentTransition)
186+ require.Same(t, view, ac.currentAttachmentOwner())
187+ require.Same(t, source, ac.previousOwner.Get())
188+}
189+
128190 func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) {
129191 d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
130192
@@ -7,6 +7,7 @@ import (
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/keys"9 "github.com/bnema/vev/internal/usecase/keys"
10+ "github.com/bnema/vev/internal/usecase/picker"
10 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
11 "github.com/bnema/vev/pkg/vt"12 "github.com/bnema/vev/pkg/vt"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
@@ -54,6 +55,14 @@ func TestRemoteViewRenderSnapshotDoesNotResizeSharedScreen(t *testing.T) {
54 view.mu.Unlock()55 view.mu.Unlock()
55 }56 }
56 57
58+func TestRemoteStatusSnapshotUsesSessionHostSyntax(t *testing.T) {
59+ view := &remoteView{key: remoteViewKey{endpoint: "remote", sessionName: "test1"}}
60+
61+ status := remoteStatusSnapshot(view, remoteViewRenderSnapshot{origin: "remote"})
62+
63+ require.Equal(t, "test1@remote", status.session)
64+}
65+
57 func TestRemoteFrameRoutingKeepsPickerNavigationLocal(t *testing.T) {66 func TestRemoteFrameRoutingKeepsPickerNavigationLocal(t *testing.T) {
58 d, source, ac, view, token := remoteBackFixture(t)67 d, source, ac, view, token := remoteBackFixture(t)
59 remoteTransport := newRemoteLinkTestTransport()68 remoteTransport := newRemoteLinkTestTransport()
@@ -125,6 +134,59 @@ func TestRemoteFrameRoutingOpensPaletteLocally(t *testing.T) {
125 }134 }
126 }135 }
127 136
137+func TestRemoteFrameRoutingReturnsToRetainedLocalSession(t *testing.T) {
138+ for _, test := range []struct {
139+ name string
140+ run func(*Daemon, *attachedClient, attachmentConnectionToken)
141+ }{
142+ {
143+ name: "palette session result",
144+ run: func(d *Daemon, _ *attachedClient, token attachmentConnectionToken) {
145+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
146+ d.handleAttachmentClientFrame(token, frameInput([]byte("work\r")))
147+ },
148+ },
149+ {
150+ name: "jump recent session",
151+ run: func(d *Daemon, _ *attachedClient, token attachmentConnectionToken) {
152+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
153+ d.handleAttachmentClientFrame(token, frameInput([]byte("JRS 1\r")))
154+ },
155+ },
156+ {
157+ name: "session picker",
158+ run: func(d *Daemon, ac *attachedClient, token attachmentConnectionToken) {
159+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
160+ d.handleAttachmentClientFrame(token, frameInput([]byte("SSP\r")))
161+ require.True(t, ac.overlays.pickerActive())
162+ d.handleAttachmentClientFrame(token, frameInput([]byte("\r")))
163+ require.False(t, ac.overlays.pickerActive(), "the picker closes before the remote-to-local handoff")
164+ },
165+ },
166+ } {
167+ t.Run(test.name, func(t *testing.T) {
168+ d, source, ac, view, token := remoteBackFixture(t)
169+
170+ test.run(d, ac, token)
171+
172+ require.True(t, ac.currentAttachmentSession() == source)
173+ require.True(t, source.attachmentRegistered(ac))
174+ require.False(t, view.attachmentRegistered(ac))
175+ require.True(t, ac.previousOwner.Get() == view)
176+ })
177+ }
178+}
179+
180+func TestRemoteFrameRoutingRejectsUnidentifiedLocalTarget(t *testing.T) {
181+ d, source, ac, view, token := remoteBackFixture(t)
182+
183+ err := d.switchToTargetForAttachment(token, picker.Target{}, sessionHandoffGuard{}, "picker-select")
184+
185+ require.ErrorIs(t, err, errAttachmentTransition)
186+ require.Same(t, view, ac.currentAttachmentOwner())
187+ require.Same(t, source, ac.previousOwner.Get())
188+}
189+
128 func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) {190 func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) {
129 d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)191 d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
130 192
M internal/usecase/daemon/remote_picker.go +5 -0
@@ -453,6 +453,11 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
453453 d.remoteCatalog.mu.Unlock()
454454 if cacheChanged {
455455 d.persistRemoteCatalogCache()
456+ }
457+ // A picker model may have been built from a fresh cached entry just before
458+ // this refresh marked its host pending. Rebuild it before the catalog I/O so
459+ // Enter cannot commit that stale selectable target during the refresh window.
460+ if cacheChanged || len(hosts) != 0 {
456461 d.refreshRemoteOpenPickers()
457462 }
458463
@@ -453,6 +453,11 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
453 d.remoteCatalog.mu.Unlock()453 d.remoteCatalog.mu.Unlock()
454 if cacheChanged {454 if cacheChanged {
455 d.persistRemoteCatalogCache()455 d.persistRemoteCatalogCache()
456+ }
457+ // A picker model may have been built from a fresh cached entry just before
458+ // this refresh marked its host pending. Rebuild it before the catalog I/O so
459+ // Enter cannot commit that stale selectable target during the refresh window.
460+ if cacheChanged || len(hosts) != 0 {
456 d.refreshRemoteOpenPickers()461 d.refreshRemoteOpenPickers()
457 }462 }
458 463
M internal/usecase/daemon/remote_picker_test.go +49 -0
@@ -431,6 +431,55 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
431431 d.remoteCatalog.mu.Unlock()
432432 }
433433
434+func TestRemotePickerRefreshDisablesCachedTargetUntilCatalogCompletes(t *testing.T) {
435+ now := time.Unix(120, 0)
436+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
437+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, now)
438+ lifecycle := remoteLifecycleForTest()
439+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
440+ Host: "arch",
441+ FetchedAt: now,
442+ Sessions: []ports.RemoteCatalogSession{{
443+ LifecycleID: lifecycle,
444+ Name: "work",
445+ State: "running",
446+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
447+ }},
448+ }})
449+ d.remoteCatalog.mu.Lock()
450+ d.remoteCatalog.status["arch"] = remoteHostFresh
451+ d.remoteCatalog.mu.Unlock()
452+
453+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
454+ model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
455+ model.Down()
456+ _, selectable := model.Selected()
457+ require.True(t, selectable, "the cached remote tab starts selectable before refresh begins")
458+
459+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
460+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
461+
462+ ac.overlays.pickerMu.Lock()
463+ updated := ac.overlays.picker.Clone()
464+ ac.overlays.pickerMu.Unlock()
465+ cursor, cursorOK := updated.Cursor()
466+ _, selectable = updated.Selected()
467+ require.True(t, cursorOK)
468+ require.NotNil(t, cursor.RemoteTarget)
469+ require.Equal(t, "refreshing", cursor.UnavailableReason)
470+ require.False(t, selectable, "a refresh-in-flight picker must not retain the old selectable target")
471+
472+ request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
473+ LifecycleID: lifecycle,
474+ Name: "work",
475+ State: "running",
476+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
477+ }}}}
478+ receiveRemotePicker(t, cache.stores, "remote cache store")
479+ requireNoRemoteLockViolations(t, catalog, cache)
480+ d.shutdownAll(ports.ReasonServerShutdown)
481+}
482+
434483 func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
435484 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
436485 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
@@ -431,6 +431,55 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
431 d.remoteCatalog.mu.Unlock()431 d.remoteCatalog.mu.Unlock()
432 }432 }
433 433
434+func TestRemotePickerRefreshDisablesCachedTargetUntilCatalogCompletes(t *testing.T) {
435+ now := time.Unix(120, 0)
436+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
437+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, now)
438+ lifecycle := remoteLifecycleForTest()
439+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
440+ Host: "arch",
441+ FetchedAt: now,
442+ Sessions: []ports.RemoteCatalogSession{{
443+ LifecycleID: lifecycle,
444+ Name: "work",
445+ State: "running",
446+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
447+ }},
448+ }})
449+ d.remoteCatalog.mu.Lock()
450+ d.remoteCatalog.status["arch"] = remoteHostFresh
451+ d.remoteCatalog.mu.Unlock()
452+
453+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
454+ model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
455+ model.Down()
456+ _, selectable := model.Selected()
457+ require.True(t, selectable, "the cached remote tab starts selectable before refresh begins")
458+
459+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
460+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
461+
462+ ac.overlays.pickerMu.Lock()
463+ updated := ac.overlays.picker.Clone()
464+ ac.overlays.pickerMu.Unlock()
465+ cursor, cursorOK := updated.Cursor()
466+ _, selectable = updated.Selected()
467+ require.True(t, cursorOK)
468+ require.NotNil(t, cursor.RemoteTarget)
469+ require.Equal(t, "refreshing", cursor.UnavailableReason)
470+ require.False(t, selectable, "a refresh-in-flight picker must not retain the old selectable target")
471+
472+ request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
473+ LifecycleID: lifecycle,
474+ Name: "work",
475+ State: "running",
476+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
477+ }}}}
478+ receiveRemotePicker(t, cache.stores, "remote cache store")
479+ requireNoRemoteLockViolations(t, catalog, cache)
480+ d.shutdownAll(ports.ReasonServerShutdown)
481+}
482+
434 func TestRemoteRefreshLockProbesSerialized(t *testing.T) {483 func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
435 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}484 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
436 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))485 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
M internal/usecase/daemon/remote_render.go +1 -1
@@ -106,7 +106,7 @@ func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statu
106106 }
107107 }
108108 if snap.origin != "" {
109- name += " at " + snap.origin
109+ name += "@" + snap.origin
110110 }
111111 switch snap.linkState {
112112 case remoteViewLinkReconnecting:
@@ -106,7 +106,7 @@ func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statu
106 }106 }
107 }107 }
108 if snap.origin != "" {108 if snap.origin != "" {
109- name += " at " + snap.origin109+ name += "@" + snap.origin
110 }110 }
111 switch snap.linkState {111 switch snap.linkState {
112 case remoteViewLinkReconnecting:112 case remoteViewLinkReconnecting:
M internal/usecase/daemon/status.go +2 -2
@@ -193,7 +193,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
193193 name += "*"
194194 }
195195 if ac != nil && ac.remoteOrigin != "" {
196- name += " at " + ac.remoteOrigin
196+ name += "@" + ac.remoteOrigin
197197 }
198198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
199199 activeIndex := 0
@@ -276,7 +276,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
276276 if ranked != nil {
277277 state.rankedRecent = ranked
278278 } else if d != nil {
279- state.mru = d.recentSessions(cur)
279+ state.mru = d.recentSessionsForAttachment(cur, ac)
280280 }
281281 return state
282282 }
@@ -193,7 +193,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
193 name += "*"193 name += "*"
194 }194 }
195 if ac != nil && ac.remoteOrigin != "" {195 if ac != nil && ac.remoteOrigin != "" {
196- name += " at " + ac.remoteOrigin196+ name += "@" + ac.remoteOrigin
197 }197 }
198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
199 activeIndex := 0199 activeIndex := 0
@@ -276,7 +276,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
276 if ranked != nil {276 if ranked != nil {
277 state.rankedRecent = ranked277 state.rankedRecent = ranked
278 } else if d != nil {278 } else if d != nil {
279- state.mru = d.recentSessions(cur)279+ state.mru = d.recentSessionsForAttachment(cur, ac)
280 }280 }
281 return state281 return state
282 }282 }
M internal/usecase/daemon/status_test.go +1 -1
@@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
115115 remoteOrigin string
116116 wantAttachmentBar string
117117 }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"},
118+ {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},
119119 {name: "local attachment", wantAttachmentBar: "vive"},
120120 }
121121
@@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
115 remoteOrigin string115 remoteOrigin string
116 wantAttachmentBar string116 wantAttachmentBar string
117 }{117 }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"},118+ {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},
119 {name: "local attachment", wantAttachmentBar: "vive"},119 {name: "local attachment", wantAttachmentBar: "vive"},
120 }120 }
121 121

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 2

Tags 13

↑↓ moveenter openesc close