fix(remote): restore unified remote navigation
- commit
- 543bf31ee006b4b7b94bb4fd812c51a62cae7883
- author
- brice · · 2026-08-07 08:35 UTC
- parent
- 3dbe137
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 103 | 103 | // previousOwner is guarded independently. It retains navigation history | |
| 104 | 104 | // through temporary owner hand-offs and is cleared only on terminal teardown. | |
| 105 | 105 | 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 | |
| 108 | 112 | // routeCreatedSession marks a session created by this attachment's route. | |
| 109 | 113 | // A handshake that never commits Welcome must tear down that exact empty | |
| 110 | 114 | // 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 history | 103 | // 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.Mutex | 106 | + | // remoteRecent is attachment-local, display-only MRU state. Remote views do |
| 107 | - | sendMu sync.Mutex | 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 | |||
| 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 empty | 113 | // 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 { | |||
| 37 | 37 | ||
| 38 | 38 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |
| 39 | 39 | // 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) | |
| 42 | 48 | commands := d.paletteCommands() | |
| 43 | - | results := d.paletteResults(sess, commands) | |
| 49 | + | results := d.paletteResults(current, commands) | |
| 44 | 50 | ac.overlays.paletteMu.Lock() | |
| 45 | 51 | ac.overlays.paletteGeneration++ | |
| 46 | 52 | 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 forbids | 39 | // 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 | |||
| 888 | 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 | 913 | // switchToTargetForAttachment is the sole client-originated navigation entry. The | |
| 892 | 914 | // intent captures the exact initiating capability before admission is released; | |
| 893 | 915 | // every active, stopped, and same-session target then uses transitionAttachment | |
| 894 | 916 | // for frozen, atomic source-token preflight. | |
| 895 | 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 | 937 | return nil | |
| 898 | 938 | } | |
| 899 | 939 | 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. The | 913 | // 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 transitionAttachment | 915 | // 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 nil | 937 | 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 { | |||
| 15 | 15 | 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 | 38 | // recentSessions returns the current session's capped named-session MRU list. | |
| 19 | 39 | // Callers may retain the returned values for the lifetime of an interaction. | |
| 20 | 40 | func (d *Daemon) recentSessions(current *session) []recentSession { | |
| @@ -35,6 +55,21 @@ func (d *Daemon) recentSessions(current *session) []recentSession { | |||
| 35 | 55 | recent = append(recent, entry) | |
| 36 | 56 | } | |
| 37 | 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 | 73 | sort.SliceStable(recent, func(i, j int) bool { | |
| 39 | 74 | if recent[i].mruAt == recent[j].mruAt { | |
| 40 | 75 | return recent[i].name < recent[j].name | |
| @@ -46,3 +81,48 @@ func (d *Daemon) recentSessions(current *session) []recentSession { | |||
| 46 | 81 | } | |
| 47 | 82 | 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 | + | } | |
| @@ -15,6 +15,26 @@ type recentSession struct { | |||||
| 15 | mruAt uint64 | 15 | 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].name | 75 | return recent[i].name < recent[j].name | ||
| @@ -46,3 +81,48 @@ func (d *Daemon) recentSessions(current *session) []recentSession { | |||||
| 46 | } | 81 | } | ||
| 47 | return recent | 82 | 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, | |||
| 99 | 99 | } | |
| 100 | 100 | d.recalculateSessionGeometryAndInvalidateAsync(source, "remote_attachment_transition.go") | |
| 101 | 101 | token.ac.recordPreviousOwner(source) | |
| 102 | + | d.recordRemoteRecent(token.ac, target) | |
| 102 | 103 | return published, nil | |
| 103 | 104 | } | |
| 104 | 105 | ||
| @@ -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, nil | 103 | 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 | |||
| 39 | 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 | 52 | func TestBackSessionForAttachmentFailsClosedForStaleOrNonLocalPredecessor(t *testing.T) { | |
| 43 | 53 | tests := []struct { | |
| 44 | 54 | 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 string | 54 | name string | ||
M internal/usecase/daemon/remote_frame_routing_test.go +62 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | 9 | "github.com/bnema/vev/internal/usecase/keys" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 10 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 12 | "github.com/bnema/vev/pkg/vt" | |
| 12 | 13 | "github.com/stretchr/testify/require" | |
| @@ -54,6 +55,14 @@ func TestRemoteViewRenderSnapshotDoesNotResizeSharedScreen(t *testing.T) { | |||
| 54 | 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 | 66 | func TestRemoteFrameRoutingKeepsPickerNavigationLocal(t *testing.T) { | |
| 58 | 67 | d, source, ac, view, token := remoteBackFixture(t) | |
| 59 | 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 | 190 | func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) { | |
| 129 | 191 | d, _, ac, token, sends := newRemoteFrameRoutingFixture(t) | |
| 130 | 192 | ||
| @@ -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 | |||
| 453 | 453 | d.remoteCatalog.mu.Unlock() | |
| 454 | 454 | if cacheChanged { | |
| 455 | 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 | 461 | d.refreshRemoteOpenPickers() | |
| 457 | 462 | } | |
| 458 | 463 | ||
| @@ -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) | |||
| 431 | 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 | 483 | func TestRemoteRefreshLockProbesSerialized(t *testing.T) { | |
| 435 | 484 | hosts := &remoteRefreshHostStore{hosts: []string{"arch"}} | |
| 436 | 485 | 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 | |||
| 106 | 106 | } | |
| 107 | 107 | } | |
| 108 | 108 | if snap.origin != "" { | |
| 109 | - | name += " at " + snap.origin | |
| 109 | + | name += "@" + snap.origin | |
| 110 | 110 | } | |
| 111 | 111 | switch snap.linkState { | |
| 112 | 112 | 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.origin | 109 | + | 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 | |||
| 193 | 193 | name += "*" | |
| 194 | 194 | } | |
| 195 | 195 | if ac != nil && ac.remoteOrigin != "" { | |
| 196 | - | name += " at " + ac.remoteOrigin | |
| 196 | + | name += "@" + ac.remoteOrigin | |
| 197 | 197 | } | |
| 198 | 198 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | |
| 199 | 199 | activeIndex := 0 | |
| @@ -276,7 +276,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||
| 276 | 276 | if ranked != nil { | |
| 277 | 277 | state.rankedRecent = ranked | |
| 278 | 278 | } else if d != nil { | |
| 279 | - | state.mru = d.recentSessions(cur) | |
| 279 | + | state.mru = d.recentSessionsForAttachment(cur, ac) | |
| 280 | 280 | } | |
| 281 | 281 | return state | |
| 282 | 282 | } | |
| @@ -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.remoteOrigin | 196 | + | 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 := 0 | 199 | 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 = ranked | 277 | 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 state | 281 | return state | ||
| 282 | } | 282 | } | ||
M internal/usecase/daemon/status_test.go +1 -1
| @@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) { | |||
| 115 | 115 | remoteOrigin string | |
| 116 | 116 | wantAttachmentBar string | |
| 117 | 117 | }{ | |
| 118 | - | {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"}, | |
| 118 | + | {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"}, | |
| 119 | 119 | {name: "local attachment", wantAttachmentBar: "vive"}, | |
| 120 | 120 | } | |
| 121 | 121 | ||
| @@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) { | |||||
| 115 | remoteOrigin string | 115 | remoteOrigin string | ||
| 116 | wantAttachmentBar string | 116 | 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 | ||||