Merge pull request #201 from bnema/fix/remote-down-session-resurrection
fix(picker): clarify remote session activation
6 files · +195 -109
M internal/usecase/daemon/remote_picker.go +53 -44
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "encoding/base64" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "math" | |
| 8 | 9 | "sort" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| @@ -102,6 +103,8 @@ func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability | |||
| 102 | 103 | ||
| 103 | 104 | func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string { | |
| 104 | 105 | switch status { | |
| 106 | + | case remoteHostCached, remoteHostRefreshing: | |
| 107 | + | return "checking remote…" | |
| 105 | 108 | case remoteHostStale: | |
| 106 | 109 | if !fetchedAt.IsZero() { | |
| 107 | 110 | return "catalog stale since " + fetchedAt.Format(time.RFC3339) | |
| @@ -151,67 +154,72 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||
| 151 | 154 | }) | |
| 152 | 155 | } | |
| 153 | 156 | legacy := session.LifecycleID == (domain.SessionLifecycleID{}) | |
| 154 | - | var remoteTarget *domain.RemoteSessionTarget | |
| 155 | - | if !legacy { | |
| 156 | - | target := domain.RemoteSessionTarget{ | |
| 157 | - | Endpoint: key.Host, | |
| 158 | - | DisplayOrigin: key.DisplayOrigin, | |
| 159 | - | LifecycleID: session.LifecycleID, | |
| 160 | - | SessionName: session.Name, | |
| 161 | - | Stopped: stopped, | |
| 162 | - | } | |
| 163 | - | if len(tabs) != 0 { | |
| 164 | - | first := tabs[0] | |
| 165 | - | if stopped { | |
| 166 | - | if first.ID != "" { | |
| 167 | - | target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID)) | |
| 168 | - | } else { | |
| 169 | - | target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs))) | |
| 170 | - | } | |
| 157 | + | target := domain.RemoteSessionTarget{ | |
| 158 | + | Endpoint: key.Host, | |
| 159 | + | DisplayOrigin: key.DisplayOrigin, | |
| 160 | + | LifecycleID: session.LifecycleID, | |
| 161 | + | SessionName: session.Name, | |
| 162 | + | Stopped: stopped, | |
| 163 | + | } | |
| 164 | + | if len(tabs) != 0 { | |
| 165 | + | first := tabs[0] | |
| 166 | + | if stopped { | |
| 167 | + | if first.ID != "" { | |
| 168 | + | target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID)) | |
| 171 | 169 | } else { | |
| 172 | - | target.LiveTabID = domain.TabStableID(tabs[active].ID) | |
| 170 | + | tabCount := min(len(tabs), math.MaxUint16) | |
| 171 | + | target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(tabCount)) | |
| 173 | 172 | } | |
| 173 | + | } else { | |
| 174 | + | target.LiveTabID = domain.TabStableID(tabs[active].ID) | |
| 174 | 175 | } | |
| 175 | - | // Keep the structured session identity on rows even when one tab ID is | |
| 176 | - | // malformed or a broken session cannot be activated. Cursor navigation | |
| 177 | - | // must remain possible, while sendRemoteAttachTargetForAttachment will | |
| 178 | - | // fail closed on the invalid target. | |
| 179 | - | remoteTarget = &target | |
| 180 | 176 | } | |
| 181 | - | reason := "" | |
| 182 | - | if !legacy && status != remoteHostFresh { | |
| 183 | - | reason = remoteReasonForStatus(status) | |
| 177 | + | // Keep the structured session identity on rows even when one tab ID is | |
| 178 | + | // malformed or a broken session cannot be activated. Cursor navigation | |
| 179 | + | // must remain possible, while sendRemoteAttachTargetForAttachment will | |
| 180 | + | // fail closed on the invalid target. | |
| 181 | + | var remoteTarget *domain.RemoteSessionTarget | |
| 182 | + | if !legacy { | |
| 183 | + | remoteTarget = &target | |
| 184 | 184 | } | |
| 185 | + | reason := remoteReasonForStatus(status) | |
| 185 | 186 | targetValid := remoteTarget != nil && remoteTarget.Validate() == nil | |
| 187 | + | activation := picker.RemoteUnavailable | |
| 186 | 188 | if broken { | |
| 187 | 189 | reason = "session_broken" | |
| 188 | 190 | } else if !legacy && !targetValid { | |
| 189 | 191 | reason = "identity_changed" | |
| 190 | - | } | |
| 191 | - | ready := status == remoteHostFresh && targetValid && !broken | |
| 192 | - | if legacy { | |
| 193 | - | // Count-only peers cannot prove lifecycle/tab identity. Preserve the | |
| 194 | - | // historical row for compatibility, but make it read-only until a fresh | |
| 195 | - | // catalog response authorizes a name attach. | |
| 196 | - | ready = status == remoteHostFresh && !broken && !stopped | |
| 197 | - | if status == remoteHostVersionMismatch { | |
| 198 | - | reason = remoteReasonForStatus(status) | |
| 192 | + | } else if status == remoteHostFresh { | |
| 193 | + | if legacy { | |
| 194 | + | if !stopped { | |
| 195 | + | activation = picker.RemoteAttach | |
| 196 | + | } | |
| 197 | + | } else if stopped { | |
| 198 | + | activation = picker.RemoteRestart | |
| 199 | + | } else { | |
| 200 | + | activation = picker.RemoteAttach | |
| 199 | 201 | } | |
| 200 | 202 | } | |
| 203 | + | ||
| 201 | 204 | detail := remotePickerStatusDetail(status, fetchedAt) | |
| 202 | 205 | if detail == "" { | |
| 203 | - | switch session.State { | |
| 204 | - | case "up": | |
| 206 | + | switch { | |
| 207 | + | case broken: | |
| 208 | + | detail = "session broken" | |
| 209 | + | case !legacy && !targetValid: | |
| 210 | + | detail = "identity changed" | |
| 211 | + | case legacy && stopped: | |
| 212 | + | detail = "stopped — exact identity required" | |
| 213 | + | case activation == picker.RemoteRestart: | |
| 214 | + | detail = "stopped — Enter to restart" | |
| 215 | + | case session.State == "up": | |
| 205 | 216 | detail = "up" | |
| 206 | - | case "down", "stopped": | |
| 217 | + | case stopped: | |
| 207 | 218 | detail = "stopped" | |
| 208 | - | case "broken": | |
| 209 | - | detail = "broken" | |
| 219 | + | default: | |
| 220 | + | detail = session.State | |
| 210 | 221 | } | |
| 211 | 222 | } | |
| 212 | - | if broken { | |
| 213 | - | detail = "session broken" | |
| 214 | - | } | |
| 215 | 223 | return picker.SessionView{ | |
| 216 | 224 | ID: key.ID(), | |
| 217 | 225 | Name: key.Display(), | |
| @@ -221,11 +229,11 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||
| 221 | 229 | RemoteAvailability: availability, | |
| 222 | 230 | RemoteDetail: detail, | |
| 223 | 231 | RemoteReason: reason, | |
| 232 | + | RemoteActivation: activation, | |
| 224 | 233 | Tabs: viewTabs, | |
| 225 | 234 | Active: active, | |
| 226 | 235 | Stopped: stopped, | |
| 227 | 236 | ConnectOnly: legacy, | |
| 228 | - | RemoteAttachReady: ready, | |
| 229 | 237 | CannotAcceptMoves: true, | |
| 230 | 238 | } | |
| 231 | 239 | } | |
| @@ -257,6 +265,7 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi | |||
| 257 | 265 | RemoteReason: remoteReasonForStatus(status), | |
| 258 | 266 | RemoteAvailability: remotePickerAvailability(status), | |
| 259 | 267 | RemoteDetail: remotePickerStatusDetail(status, time.Time{}), | |
| 268 | + | RemoteActivation: picker.RemoteUnavailable, | |
| 260 | 269 | ConnectOnly: true, | |
| 261 | 270 | CannotAcceptMoves: true, | |
| 262 | 271 | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "math" | |||
| 8 | "sort" | 9 | "sort" | ||
| 9 | "time" | 10 | "time" | ||
| 10 | 11 | ||||
| @@ -102,6 +103,8 @@ func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability | |||||
| 102 | 103 | ||||
| 103 | func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string { | 104 | func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string { | ||
| 104 | switch status { | 105 | switch status { | ||
| 106 | + | case remoteHostCached, remoteHostRefreshing: | |||
| 107 | + | return "checking remote…" | |||
| 105 | case remoteHostStale: | 108 | case remoteHostStale: | ||
| 106 | if !fetchedAt.IsZero() { | 109 | if !fetchedAt.IsZero() { | ||
| 107 | return "catalog stale since " + fetchedAt.Format(time.RFC3339) | 110 | return "catalog stale since " + fetchedAt.Format(time.RFC3339) | ||
| @@ -151,67 +154,72 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||||
| 151 | }) | 154 | }) | ||
| 152 | } | 155 | } | ||
| 153 | legacy := session.LifecycleID == (domain.SessionLifecycleID{}) | 156 | legacy := session.LifecycleID == (domain.SessionLifecycleID{}) | ||
| 154 | - | var remoteTarget *domain.RemoteSessionTarget | 157 | + | target := domain.RemoteSessionTarget{ |
| 155 | - | if !legacy { | 158 | + | Endpoint: key.Host, |
| 156 | - | target := domain.RemoteSessionTarget{ | 159 | + | DisplayOrigin: key.DisplayOrigin, |
| 157 | - | Endpoint: key.Host, | 160 | + | LifecycleID: session.LifecycleID, |
| 158 | - | DisplayOrigin: key.DisplayOrigin, | 161 | + | SessionName: session.Name, |
| 159 | - | LifecycleID: session.LifecycleID, | 162 | + | Stopped: stopped, |
| 160 | - | SessionName: session.Name, | 163 | + | } |
| 161 | - | Stopped: stopped, | 164 | + | if len(tabs) != 0 { |
| 162 | - | } | 165 | + | first := tabs[0] |
| 163 | - | if len(tabs) != 0 { | 166 | + | if stopped { |
| 164 | - | first := tabs[0] | 167 | + | if first.ID != "" { |
| 165 | - | if stopped { | 168 | + | target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID)) |
| 166 | - | if first.ID != "" { | |||
| 167 | - | target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID)) | |||
| 168 | - | } else { | |||
| 169 | - | target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs))) | |||
| 170 | - | } | |||
| 171 | } else { | 169 | } else { | ||
| 172 | - | target.LiveTabID = domain.TabStableID(tabs[active].ID) | 170 | + | tabCount := min(len(tabs), math.MaxUint16) |
| 171 | + | target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(tabCount)) | |||
| 173 | } | 172 | } | ||
| 173 | + | } else { | |||
| 174 | + | target.LiveTabID = domain.TabStableID(tabs[active].ID) | |||
| 174 | } | 175 | } | ||
| 175 | - | // Keep the structured session identity on rows even when one tab ID is | |||
| 176 | - | // malformed or a broken session cannot be activated. Cursor navigation | |||
| 177 | - | // must remain possible, while sendRemoteAttachTargetForAttachment will | |||
| 178 | - | // fail closed on the invalid target. | |||
| 179 | - | remoteTarget = &target | |||
| 180 | } | 176 | } | ||
| 181 | - | reason := "" | 177 | + | // Keep the structured session identity on rows even when one tab ID is |
| 182 | - | if !legacy && status != remoteHostFresh { | 178 | + | // malformed or a broken session cannot be activated. Cursor navigation |
| 183 | - | reason = remoteReasonForStatus(status) | 179 | + | // must remain possible, while sendRemoteAttachTargetForAttachment will |
| 180 | + | // fail closed on the invalid target. | |||
| 181 | + | var remoteTarget *domain.RemoteSessionTarget | |||
| 182 | + | if !legacy { | |||
| 183 | + | remoteTarget = &target | |||
| 184 | } | 184 | } | ||
| 185 | + | reason := remoteReasonForStatus(status) | |||
| 185 | targetValid := remoteTarget != nil && remoteTarget.Validate() == nil | 186 | targetValid := remoteTarget != nil && remoteTarget.Validate() == nil | ||
| 187 | + | activation := picker.RemoteUnavailable | |||
| 186 | if broken { | 188 | if broken { | ||
| 187 | reason = "session_broken" | 189 | reason = "session_broken" | ||
| 188 | } else if !legacy && !targetValid { | 190 | } else if !legacy && !targetValid { | ||
| 189 | reason = "identity_changed" | 191 | reason = "identity_changed" | ||
| 190 | - | } | 192 | + | } else if status == remoteHostFresh { |
| 191 | - | ready := status == remoteHostFresh && targetValid && !broken | 193 | + | if legacy { |
| 192 | - | if legacy { | 194 | + | if !stopped { |
| 193 | - | // Count-only peers cannot prove lifecycle/tab identity. Preserve the | 195 | + | activation = picker.RemoteAttach |
| 194 | - | // historical row for compatibility, but make it read-only until a fresh | 196 | + | } |
| 195 | - | // catalog response authorizes a name attach. | 197 | + | } else if stopped { |
| 196 | - | ready = status == remoteHostFresh && !broken && !stopped | 198 | + | activation = picker.RemoteRestart |
| 197 | - | if status == remoteHostVersionMismatch { | 199 | + | } else { |
| 198 | - | reason = remoteReasonForStatus(status) | 200 | + | activation = picker.RemoteAttach |
| 199 | } | 201 | } | ||
| 200 | } | 202 | } | ||
| 203 | + | ||||
| 201 | detail := remotePickerStatusDetail(status, fetchedAt) | 204 | detail := remotePickerStatusDetail(status, fetchedAt) | ||
| 202 | if detail == "" { | 205 | if detail == "" { | ||
| 203 | - | switch session.State { | 206 | + | switch { |
| 204 | - | case "up": | 207 | + | case broken: |
| 208 | + | detail = "session broken" | |||
| 209 | + | case !legacy && !targetValid: | |||
| 210 | + | detail = "identity changed" | |||
| 211 | + | case legacy && stopped: | |||
| 212 | + | detail = "stopped — exact identity required" | |||
| 213 | + | case activation == picker.RemoteRestart: | |||
| 214 | + | detail = "stopped — Enter to restart" | |||
| 215 | + | case session.State == "up": | |||
| 205 | detail = "up" | 216 | detail = "up" | ||
| 206 | - | case "down", "stopped": | 217 | + | case stopped: |
| 207 | detail = "stopped" | 218 | detail = "stopped" | ||
| 208 | - | case "broken": | 219 | + | default: |
| 209 | - | detail = "broken" | 220 | + | detail = session.State |
| 210 | } | 221 | } | ||
| 211 | } | 222 | } | ||
| 212 | - | if broken { | |||
| 213 | - | detail = "session broken" | |||
| 214 | - | } | |||
| 215 | return picker.SessionView{ | 223 | return picker.SessionView{ | ||
| 216 | ID: key.ID(), | 224 | ID: key.ID(), | ||
| 217 | Name: key.Display(), | 225 | Name: key.Display(), | ||
| @@ -221,11 +229,11 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||||
| 221 | RemoteAvailability: availability, | 229 | RemoteAvailability: availability, | ||
| 222 | RemoteDetail: detail, | 230 | RemoteDetail: detail, | ||
| 223 | RemoteReason: reason, | 231 | RemoteReason: reason, | ||
| 232 | + | RemoteActivation: activation, | |||
| 224 | Tabs: viewTabs, | 233 | Tabs: viewTabs, | ||
| 225 | Active: active, | 234 | Active: active, | ||
| 226 | Stopped: stopped, | 235 | Stopped: stopped, | ||
| 227 | ConnectOnly: legacy, | 236 | ConnectOnly: legacy, | ||
| 228 | - | RemoteAttachReady: ready, | |||
| 229 | CannotAcceptMoves: true, | 237 | CannotAcceptMoves: true, | ||
| 230 | } | 238 | } | ||
| 231 | } | 239 | } | ||
| @@ -257,6 +265,7 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi | |||||
| 257 | RemoteReason: remoteReasonForStatus(status), | 265 | RemoteReason: remoteReasonForStatus(status), | ||
| 258 | RemoteAvailability: remotePickerAvailability(status), | 266 | RemoteAvailability: remotePickerAvailability(status), | ||
| 259 | RemoteDetail: remotePickerStatusDetail(status, time.Time{}), | 267 | RemoteDetail: remotePickerStatusDetail(status, time.Time{}), | ||
| 268 | + | RemoteActivation: picker.RemoteUnavailable, | |||
| 260 | ConnectOnly: true, | 269 | ConnectOnly: true, | ||
| 261 | CannotAcceptMoves: true, | 270 | CannotAcceptMoves: true, | ||
| 262 | } | 271 | } | ||
M internal/usecase/daemon/remote_picker_test.go +98 -13
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | + | "math" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "testing" | |
| 11 | 12 | "time" | |
| @@ -70,18 +71,31 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) | |||
| 70 | 71 | require.True(t, view.Stopped) | |
| 71 | 72 | require.NotNil(t, view.RemoteTarget) | |
| 72 | 73 | require.True(t, view.RemoteTarget.Stopped) | |
| 73 | - | require.True(t, view.RemoteAttachReady, "structured stopped rows have an explicit safe restore target") | |
| 74 | + | require.Equal(t, picker.RemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target") | |
| 75 | + | require.Equal(t, "stopped — Enter to restart", view.RemoteDetail) | |
| 74 | 76 | ||
| 75 | 77 | legacy := session | |
| 76 | 78 | legacy.LifecycleID = domain.SessionLifecycleID{} | |
| 77 | 79 | legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0)) | |
| 78 | 80 | require.True(t, legacyView.Stopped) | |
| 79 | 81 | require.True(t, legacyView.ConnectOnly) | |
| 80 | - | require.False(t, legacyView.RemoteAttachReady, "legacy stopped rows cannot be selected without lifecycle identity") | |
| 82 | + | require.Equal(t, picker.RemoteUnavailable, legacyView.RemoteActivation, "legacy stopped rows cannot be selected without lifecycle identity") | |
| 83 | + | require.Equal(t, "stopped — exact identity required", legacyView.RemoteDetail) | |
| 81 | 84 | }) | |
| 82 | 85 | } | |
| 83 | 86 | } | |
| 84 | 87 | ||
| 88 | + | func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) { | |
| 89 | + | tabs := make([]ports.RemoteCatalogTab, math.MaxUint16+1) | |
| 90 | + | tabs[0] = ports.RemoteCatalogTab{Name: "main"} | |
| 91 | + | view := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ports.RemoteCatalogSession{ | |
| 92 | + | LifecycleID: remoteLifecycleForTest(), Name: "work", State: "down", Tabs: tabs, | |
| 93 | + | }, remoteHostFresh, time.Unix(100, 0)) | |
| 94 | + | ||
| 95 | + | require.NotNil(t, view.RemoteTarget) | |
| 96 | + | require.Equal(t, uint16(math.MaxUint16), view.RemoteTarget.StoppedTab.ExpectedCount) | |
| 97 | + | } | |
| 98 | + | ||
| 85 | 99 | func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) { | |
| 86 | 100 | for _, attached := range []bool{false, true} { | |
| 87 | 101 | view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, ports.RemoteCatalogSession{ | |
| @@ -682,6 +696,77 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |||
| 682 | 696 | d.mu.Unlock() | |
| 683 | 697 | } | |
| 684 | 698 | ||
| 699 | + | func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |
| 700 | + | lifecycle := remoteLifecycleForTest() | |
| 701 | + | remoteSession := ports.RemoteCatalogSession{ | |
| 702 | + | LifecycleID: lifecycle, | |
| 703 | + | Name: "work", | |
| 704 | + | State: "down", | |
| 705 | + | Tabs: []ports.RemoteCatalogTab{ | |
| 706 | + | {ID: "tab-a", Name: "alpha", Index: 0}, | |
| 707 | + | {ID: "tab-b", Name: "beta", Index: 1}, | |
| 708 | + | }, | |
| 709 | + | } | |
| 710 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 711 | + | remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0)) | |
| 712 | + | model := picker.New([]picker.SessionView{ | |
| 713 | + | {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}}, | |
| 714 | + | remoteView, | |
| 715 | + | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 716 | + | var selected picker.Target | |
| 717 | + | for range 4 { | |
| 718 | + | candidate, ok := model.Selected() | |
| 719 | + | if ok && candidate.RemoteTarget != nil && candidate.RemoteTarget.StoppedTab.StableID == "tab-b" { | |
| 720 | + | selected = candidate | |
| 721 | + | break | |
| 722 | + | } | |
| 723 | + | model.Down() | |
| 724 | + | } | |
| 725 | + | require.NotNil(t, selected.RemoteTarget) | |
| 726 | + | require.True(t, selected.RemoteTarget.Stopped) | |
| 727 | + | require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab) | |
| 728 | + | ||
| 729 | + | local := newRemotePickerDaemon(nil) | |
| 730 | + | local.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession}}}) | |
| 731 | + | local.remoteCatalog.mu.Lock() | |
| 732 | + | local.remoteCatalog.status["arch"] = remoteHostFresh | |
| 733 | + | local.remoteCatalog.mu.Unlock() | |
| 734 | + | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | |
| 735 | + | token := localSession.attachmentToken(localAttachment, localAttachment.transport()) | |
| 736 | + | effect, admitted := localAttachment.beginAttachmentEffect(token) | |
| 737 | + | require.True(t, admitted) | |
| 738 | + | token.effect = effect | |
| 739 | + | require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, key, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 740 | + | ||
| 741 | + | frame := receiveRemotePicker(t, sends, "stopped remote target") | |
| 742 | + | handoff, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| 743 | + | require.NoError(t, err) | |
| 744 | + | require.NotNil(t, handoff.RemoteTarget) | |
| 745 | + | require.Equal(t, selected.RemoteTarget, handoff.RemoteTarget) | |
| 746 | + | local.mu.Lock() | |
| 747 | + | require.NotContains(t, local.sessions, key.ID(), "picker handoff must not create a local remote shadow") | |
| 748 | + | local.mu.Unlock() | |
| 749 | + | ||
| 750 | + | remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 751 | + | remote.mu.Lock() | |
| 752 | + | remote.stopped["work"] = stoppedSession{ | |
| 753 | + | name: "work", cwd: "/remote/work", incarnation: lifecycle, | |
| 754 | + | tabNames: []string{"alpha", "beta"}, | |
| 755 | + | tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-a", Name: "alpha"}, {StableID: "tab-b", Name: "beta"}}, | |
| 756 | + | } | |
| 757 | + | remote.mu.Unlock() | |
| 758 | + | transport, _ := newCapturingTransport(t) | |
| 759 | + | restored, attachment, err := remote.routeWithContext(context.Background(), ports.Hello{ | |
| 760 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: handoff.Session, | |
| 761 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: handoff.RemoteTarget, | |
| 762 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 763 | + | }, transport) | |
| 764 | + | require.NoError(t, err) | |
| 765 | + | t.Cleanup(func() { remote.clientGone(restored, attachment, transport, false) }) | |
| 766 | + | require.Equal(t, lifecycle, restored.incarnation) | |
| 767 | + | require.Equal(t, domain.TabStableID("tab-b"), attachment.viewSnapshot().tabID) | |
| 768 | + | } | |
| 769 | + | ||
| 685 | 770 | func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) { | |
| 686 | 771 | tests := []struct { | |
| 687 | 772 | name string | |
| @@ -1105,23 +1190,23 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | |||
| 1105 | 1190 | func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) { | |
| 1106 | 1191 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 1107 | 1192 | for _, tt := range []struct { | |
| 1108 | - | status remoteHostStatus | |
| 1109 | - | ready bool | |
| 1193 | + | status remoteHostStatus | |
| 1194 | + | activation picker.RemoteActivation | |
| 1110 | 1195 | }{ | |
| 1111 | - | {status: remoteHostCached, ready: false}, | |
| 1112 | - | {status: remoteHostRefreshing, ready: false}, | |
| 1113 | - | {status: remoteHostStale, ready: false}, | |
| 1114 | - | {status: remoteHostMalformed, ready: false}, | |
| 1115 | - | {status: remoteHostFresh, ready: true}, | |
| 1116 | - | {status: remoteHostUnreachable, ready: false}, | |
| 1196 | + | {status: remoteHostCached, activation: picker.RemoteUnavailable}, | |
| 1197 | + | {status: remoteHostRefreshing, activation: picker.RemoteUnavailable}, | |
| 1198 | + | {status: remoteHostStale, activation: picker.RemoteUnavailable}, | |
| 1199 | + | {status: remoteHostMalformed, activation: picker.RemoteUnavailable}, | |
| 1200 | + | {status: remoteHostFresh, activation: picker.RemoteAttach}, | |
| 1201 | + | {status: remoteHostUnreachable, activation: picker.RemoteUnavailable}, | |
| 1117 | 1202 | } { | |
| 1118 | 1203 | view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, tt.status, time.Unix(1, 0)) | |
| 1119 | - | require.Equal(t, tt.ready, view.RemoteAttachReady, "status %d readiness", tt.status) | |
| 1204 | + | require.Equal(t, tt.activation, view.RemoteActivation, "status %d activation", tt.status) | |
| 1120 | 1205 | _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() | |
| 1121 | - | require.Equal(t, tt.ready, selectable, "status %d selection", tt.status) | |
| 1206 | + | require.Equal(t, tt.activation == picker.RemoteAttach, selectable, "status %d selection", tt.status) | |
| 1122 | 1207 | } | |
| 1123 | 1208 | mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{}) | |
| 1124 | - | require.False(t, mismatch.RemoteAttachReady) | |
| 1209 | + | require.Equal(t, picker.RemoteUnavailable, mismatch.RemoteActivation) | |
| 1125 | 1210 | _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() | |
| 1126 | 1211 | require.False(t, selectable) | |
| 1127 | 1212 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | "log/slog" | 8 | "log/slog" | ||
| 9 | + | "math" | |||
| 9 | "sync" | 10 | "sync" | ||
| 10 | "testing" | 11 | "testing" | ||
| 11 | "time" | 12 | "time" | ||
| @@ -70,18 +71,31 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) | |||||
| 70 | require.True(t, view.Stopped) | 71 | require.True(t, view.Stopped) | ||
| 71 | require.NotNil(t, view.RemoteTarget) | 72 | require.NotNil(t, view.RemoteTarget) | ||
| 72 | require.True(t, view.RemoteTarget.Stopped) | 73 | require.True(t, view.RemoteTarget.Stopped) | ||
| 73 | - | require.True(t, view.RemoteAttachReady, "structured stopped rows have an explicit safe restore target") | 74 | + | require.Equal(t, picker.RemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target") |
| 75 | + | require.Equal(t, "stopped — Enter to restart", view.RemoteDetail) | |||
| 74 | 76 | ||||
| 75 | legacy := session | 77 | legacy := session | ||
| 76 | legacy.LifecycleID = domain.SessionLifecycleID{} | 78 | legacy.LifecycleID = domain.SessionLifecycleID{} | ||
| 77 | legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0)) | 79 | legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0)) | ||
| 78 | require.True(t, legacyView.Stopped) | 80 | require.True(t, legacyView.Stopped) | ||
| 79 | require.True(t, legacyView.ConnectOnly) | 81 | require.True(t, legacyView.ConnectOnly) | ||
| 80 | - | require.False(t, legacyView.RemoteAttachReady, "legacy stopped rows cannot be selected without lifecycle identity") | 82 | + | require.Equal(t, picker.RemoteUnavailable, legacyView.RemoteActivation, "legacy stopped rows cannot be selected without lifecycle identity") |
| 83 | + | require.Equal(t, "stopped — exact identity required", legacyView.RemoteDetail) | |||
| 81 | }) | 84 | }) | ||
| 82 | } | 85 | } | ||
| 83 | } | 86 | } | ||
| 84 | 87 | ||||
| 88 | + | func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) { | |||
| 89 | + | tabs := make([]ports.RemoteCatalogTab, math.MaxUint16+1) | |||
| 90 | + | tabs[0] = ports.RemoteCatalogTab{Name: "main"} | |||
| 91 | + | view := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ports.RemoteCatalogSession{ | |||
| 92 | + | LifecycleID: remoteLifecycleForTest(), Name: "work", State: "down", Tabs: tabs, | |||
| 93 | + | }, remoteHostFresh, time.Unix(100, 0)) | |||
| 94 | + | ||||
| 95 | + | require.NotNil(t, view.RemoteTarget) | |||
| 96 | + | require.Equal(t, uint16(math.MaxUint16), view.RemoteTarget.StoppedTab.ExpectedCount) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 85 | func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) { | 99 | func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) { | ||
| 86 | for _, attached := range []bool{false, true} { | 100 | for _, attached := range []bool{false, true} { | ||
| 87 | view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, ports.RemoteCatalogSession{ | 101 | view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, ports.RemoteCatalogSession{ | ||
| @@ -682,6 +696,77 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |||||
| 682 | d.mu.Unlock() | 696 | d.mu.Unlock() | ||
| 683 | } | 697 | } | ||
| 684 | 698 | ||||
| 699 | + | func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||
| 700 | + | lifecycle := remoteLifecycleForTest() | |||
| 701 | + | remoteSession := ports.RemoteCatalogSession{ | |||
| 702 | + | LifecycleID: lifecycle, | |||
| 703 | + | Name: "work", | |||
| 704 | + | State: "down", | |||
| 705 | + | Tabs: []ports.RemoteCatalogTab{ | |||
| 706 | + | {ID: "tab-a", Name: "alpha", Index: 0}, | |||
| 707 | + | {ID: "tab-b", Name: "beta", Index: 1}, | |||
| 708 | + | }, | |||
| 709 | + | } | |||
| 710 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |||
| 711 | + | remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0)) | |||
| 712 | + | model := picker.New([]picker.SessionView{ | |||
| 713 | + | {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}}, | |||
| 714 | + | remoteView, | |||
| 715 | + | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |||
| 716 | + | var selected picker.Target | |||
| 717 | + | for range 4 { | |||
| 718 | + | candidate, ok := model.Selected() | |||
| 719 | + | if ok && candidate.RemoteTarget != nil && candidate.RemoteTarget.StoppedTab.StableID == "tab-b" { | |||
| 720 | + | selected = candidate | |||
| 721 | + | break | |||
| 722 | + | } | |||
| 723 | + | model.Down() | |||
| 724 | + | } | |||
| 725 | + | require.NotNil(t, selected.RemoteTarget) | |||
| 726 | + | require.True(t, selected.RemoteTarget.Stopped) | |||
| 727 | + | require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab) | |||
| 728 | + | ||||
| 729 | + | local := newRemotePickerDaemon(nil) | |||
| 730 | + | local.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession}}}) | |||
| 731 | + | local.remoteCatalog.mu.Lock() | |||
| 732 | + | local.remoteCatalog.status["arch"] = remoteHostFresh | |||
| 733 | + | local.remoteCatalog.mu.Unlock() | |||
| 734 | + | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | |||
| 735 | + | token := localSession.attachmentToken(localAttachment, localAttachment.transport()) | |||
| 736 | + | effect, admitted := localAttachment.beginAttachmentEffect(token) | |||
| 737 | + | require.True(t, admitted) | |||
| 738 | + | token.effect = effect | |||
| 739 | + | require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, key, sessionHandoffGuard{closePicker: true}, "picker-select")) | |||
| 740 | + | ||||
| 741 | + | frame := receiveRemotePicker(t, sends, "stopped remote target") | |||
| 742 | + | handoff, err := ports.UnmarshalAttachTarget(frame.Payload) | |||
| 743 | + | require.NoError(t, err) | |||
| 744 | + | require.NotNil(t, handoff.RemoteTarget) | |||
| 745 | + | require.Equal(t, selected.RemoteTarget, handoff.RemoteTarget) | |||
| 746 | + | local.mu.Lock() | |||
| 747 | + | require.NotContains(t, local.sessions, key.ID(), "picker handoff must not create a local remote shadow") | |||
| 748 | + | local.mu.Unlock() | |||
| 749 | + | ||||
| 750 | + | remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |||
| 751 | + | remote.mu.Lock() | |||
| 752 | + | remote.stopped["work"] = stoppedSession{ | |||
| 753 | + | name: "work", cwd: "/remote/work", incarnation: lifecycle, | |||
| 754 | + | tabNames: []string{"alpha", "beta"}, | |||
| 755 | + | tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-a", Name: "alpha"}, {StableID: "tab-b", Name: "beta"}}, | |||
| 756 | + | } | |||
| 757 | + | remote.mu.Unlock() | |||
| 758 | + | transport, _ := newCapturingTransport(t) | |||
| 759 | + | restored, attachment, err := remote.routeWithContext(context.Background(), ports.Hello{ | |||
| 760 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: handoff.Session, | |||
| 761 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: handoff.RemoteTarget, | |||
| 762 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 763 | + | }, transport) | |||
| 764 | + | require.NoError(t, err) | |||
| 765 | + | t.Cleanup(func() { remote.clientGone(restored, attachment, transport, false) }) | |||
| 766 | + | require.Equal(t, lifecycle, restored.incarnation) | |||
| 767 | + | require.Equal(t, domain.TabStableID("tab-b"), attachment.viewSnapshot().tabID) | |||
| 768 | + | } | |||
| 769 | + | ||||
| 685 | func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) { | 770 | func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) { | ||
| 686 | tests := []struct { | 771 | tests := []struct { | ||
| 687 | name string | 772 | name string | ||
| @@ -1105,23 +1190,23 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | |||||
| 1105 | func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) { | 1190 | func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) { | ||
| 1106 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 1191 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | ||
| 1107 | for _, tt := range []struct { | 1192 | for _, tt := range []struct { | ||
| 1108 | - | status remoteHostStatus | 1193 | + | status remoteHostStatus |
| 1109 | - | ready bool | 1194 | + | activation picker.RemoteActivation |
| 1110 | }{ | 1195 | }{ | ||
| 1111 | - | {status: remoteHostCached, ready: false}, | 1196 | + | {status: remoteHostCached, activation: picker.RemoteUnavailable}, |
| 1112 | - | {status: remoteHostRefreshing, ready: false}, | 1197 | + | {status: remoteHostRefreshing, activation: picker.RemoteUnavailable}, |
| 1113 | - | {status: remoteHostStale, ready: false}, | 1198 | + | {status: remoteHostStale, activation: picker.RemoteUnavailable}, |
| 1114 | - | {status: remoteHostMalformed, ready: false}, | 1199 | + | {status: remoteHostMalformed, activation: picker.RemoteUnavailable}, |
| 1115 | - | {status: remoteHostFresh, ready: true}, | 1200 | + | {status: remoteHostFresh, activation: picker.RemoteAttach}, |
| 1116 | - | {status: remoteHostUnreachable, ready: false}, | 1201 | + | {status: remoteHostUnreachable, activation: picker.RemoteUnavailable}, |
| 1117 | } { | 1202 | } { | ||
| 1118 | view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, tt.status, time.Unix(1, 0)) | 1203 | view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, tt.status, time.Unix(1, 0)) | ||
| 1119 | - | require.Equal(t, tt.ready, view.RemoteAttachReady, "status %d readiness", tt.status) | 1204 | + | require.Equal(t, tt.activation, view.RemoteActivation, "status %d activation", tt.status) |
| 1120 | _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() | 1205 | _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() | ||
| 1121 | - | require.Equal(t, tt.ready, selectable, "status %d selection", tt.status) | 1206 | + | require.Equal(t, tt.activation == picker.RemoteAttach, selectable, "status %d selection", tt.status) |
| 1122 | } | 1207 | } | ||
| 1123 | mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{}) | 1208 | mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{}) | ||
| 1124 | - | require.False(t, mismatch.RemoteAttachReady) | 1209 | + | require.Equal(t, picker.RemoteUnavailable, mismatch.RemoteActivation) |
| 1125 | _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() | 1210 | _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() | ||
| 1126 | require.False(t, selectable) | 1211 | require.False(t, selectable) | ||
| 1127 | } | 1212 | } | ||
M internal/usecase/daemon/remote_preview_cache_test.go +7 -7
| @@ -271,7 +271,7 @@ func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *test | |||
| 271 | 271 | ID: firstKey.ID(), Name: firstTarget.SessionName, | |
| 272 | 272 | Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}}, | |
| 273 | 273 | RemoteKey: &firstKey, RemoteTarget: &firstTarget, | |
| 274 | - | RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true, | |
| 274 | + | RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach, | |
| 275 | 275 | } | |
| 276 | 276 | firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 277 | 277 | firstSelected, ok := firstModel.Selected() | |
| @@ -301,7 +301,7 @@ func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *test | |||
| 301 | 301 | ID: secondKey.ID(), Name: secondTarget.SessionName, | |
| 302 | 302 | Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}}, | |
| 303 | 303 | RemoteKey: &secondKey, RemoteTarget: &secondTarget, | |
| 304 | - | RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true, | |
| 304 | + | RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach, | |
| 305 | 305 | } | |
| 306 | 306 | secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 307 | 307 | secondSelected, ok := secondModel.Selected() | |
| @@ -353,8 +353,8 @@ func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) { | |||
| 353 | 353 | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | |
| 354 | 354 | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | |
| 355 | 355 | views := []picker.SessionView{ | |
| 356 | - | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | |
| 357 | - | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | |
| 356 | + | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, | |
| 357 | + | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, | |
| 358 | 358 | } | |
| 359 | 359 | model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 360 | 360 | ac.overlays.pickerMu.Lock() | |
| @@ -396,8 +396,8 @@ func TestRemotePickerPreviewErrorCannotPublishAfterSelectionChange(t *testing.T) | |||
| 396 | 396 | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | |
| 397 | 397 | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | |
| 398 | 398 | model := picker.New([]picker.SessionView{ | |
| 399 | - | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | |
| 400 | - | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | |
| 399 | + | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, | |
| 400 | + | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, | |
| 401 | 401 | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 402 | 402 | selected, ok := model.Selected() | |
| 403 | 403 | require.True(t, ok) | |
| @@ -438,7 +438,7 @@ func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) { | |||
| 438 | 438 | ID: key.ID(), Name: target.SessionName, | |
| 439 | 439 | Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}}, | |
| 440 | 440 | RemoteKey: &key, RemoteTarget: &target, | |
| 441 | - | RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true, | |
| 441 | + | RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach, | |
| 442 | 442 | } | |
| 443 | 443 | model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 444 | 444 | selected, ok := model.Selected() | |
| @@ -271,7 +271,7 @@ func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *test | |||||
| 271 | ID: firstKey.ID(), Name: firstTarget.SessionName, | 271 | ID: firstKey.ID(), Name: firstTarget.SessionName, | ||
| 272 | Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}}, | 272 | Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}}, | ||
| 273 | RemoteKey: &firstKey, RemoteTarget: &firstTarget, | 273 | RemoteKey: &firstKey, RemoteTarget: &firstTarget, | ||
| 274 | - | RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true, | 274 | + | RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach, |
| 275 | } | 275 | } | ||
| 276 | firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | 276 | firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | ||
| 277 | firstSelected, ok := firstModel.Selected() | 277 | firstSelected, ok := firstModel.Selected() | ||
| @@ -301,7 +301,7 @@ func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *test | |||||
| 301 | ID: secondKey.ID(), Name: secondTarget.SessionName, | 301 | ID: secondKey.ID(), Name: secondTarget.SessionName, | ||
| 302 | Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}}, | 302 | Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}}, | ||
| 303 | RemoteKey: &secondKey, RemoteTarget: &secondTarget, | 303 | RemoteKey: &secondKey, RemoteTarget: &secondTarget, | ||
| 304 | - | RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true, | 304 | + | RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach, |
| 305 | } | 305 | } | ||
| 306 | secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | 306 | secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | ||
| 307 | secondSelected, ok := secondModel.Selected() | 307 | secondSelected, ok := secondModel.Selected() | ||
| @@ -353,8 +353,8 @@ func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) { | |||||
| 353 | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | 353 | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | ||
| 354 | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | 354 | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | ||
| 355 | views := []picker.SessionView{ | 355 | views := []picker.SessionView{ | ||
| 356 | - | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | 356 | + | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, |
| 357 | - | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | 357 | + | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, |
| 358 | } | 358 | } | ||
| 359 | model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | 359 | model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | ||
| 360 | ac.overlays.pickerMu.Lock() | 360 | ac.overlays.pickerMu.Lock() | ||
| @@ -396,8 +396,8 @@ func TestRemotePickerPreviewErrorCannotPublishAfterSelectionChange(t *testing.T) | |||||
| 396 | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | 396 | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | ||
| 397 | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | 397 | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | ||
| 398 | model := picker.New([]picker.SessionView{ | 398 | model := picker.New([]picker.SessionView{ | ||
| 399 | - | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | 399 | + | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, |
| 400 | - | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | 400 | + | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, |
| 401 | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | 401 | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | ||
| 402 | selected, ok := model.Selected() | 402 | selected, ok := model.Selected() | ||
| 403 | require.True(t, ok) | 403 | require.True(t, ok) | ||
| @@ -438,7 +438,7 @@ func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) { | |||||
| 438 | ID: key.ID(), Name: target.SessionName, | 438 | ID: key.ID(), Name: target.SessionName, | ||
| 439 | Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}}, | 439 | Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}}, | ||
| 440 | RemoteKey: &key, RemoteTarget: &target, | 440 | RemoteKey: &key, RemoteTarget: &target, | ||
| 441 | - | RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true, | 441 | + | RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach, |
| 442 | } | 442 | } | ||
| 443 | model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | 443 | model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | ||
| 444 | selected, ok := model.Selected() | 444 | selected, ok := model.Selected() | ||
M internal/usecase/picker/model.go +26 -29
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package picker | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 4 | 6 | renderer "github.com/bnema/vev-vt" | |
| 5 | 7 | "github.com/bnema/vev/internal/domain" | |
| 6 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -25,6 +27,17 @@ const ( | |||
| 25 | 27 | RemoteVersionMismatch | |
| 26 | 28 | ) | |
| 27 | 29 | ||
| 30 | + | // RemoteActivation is the picker action authorized by the current remote | |
| 31 | + | // catalog snapshot. It is presentation state only; RemoteSessionTarget remains | |
| 32 | + | // the exact route and is revalidated when the picker hands off the client. | |
| 33 | + | type RemoteActivation uint8 | |
| 34 | + | ||
| 35 | + | const ( | |
| 36 | + | RemoteUnavailable RemoteActivation = iota | |
| 37 | + | RemoteAttach | |
| 38 | + | RemoteRestart | |
| 39 | + | ) | |
| 40 | + | ||
| 28 | 41 | type SessionView struct { | |
| 29 | 42 | ID domain.SessionID | |
| 30 | 43 | Incarnation domain.IncarnationID | |
| @@ -43,8 +56,8 @@ type SessionView struct { | |||
| 43 | 56 | RemoteAvailability RemoteAvailability | |
| 44 | 57 | RemoteDetail string | |
| 45 | 58 | RemoteReason string | |
| 59 | + | RemoteActivation RemoteActivation | |
| 46 | 60 | ConnectOnly bool | |
| 47 | - | RemoteAttachReady bool | |
| 48 | 61 | // CannotAcceptMoves reports whether this session cannot receive a moved tab | |
| 49 | 62 | // or pane. False for ordinary local (and stopped) sessions; true for | |
| 50 | 63 | // restricted remote rows. | |
| @@ -275,28 +288,25 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 275 | 288 | header.selectable = header.kind.selectable(config.Mode) | |
| 276 | 289 | header.focusable = header.selectable | |
| 277 | 290 | if common.remote { | |
| 278 | - | if remoteDetail := remoteRowDetail(session); remoteDetail != "" { | |
| 291 | + | if session.RemoteDetail != "" { | |
| 279 | 292 | if header.detail != "" { | |
| 280 | - | header.detail += " (" + remoteDetail + ")" | |
| 293 | + | header.detail += " (" + session.RemoteDetail + ")" | |
| 281 | 294 | } else { | |
| 282 | - | header.detail = remoteDetail | |
| 295 | + | header.detail = session.RemoteDetail | |
| 283 | 296 | } | |
| 284 | 297 | } | |
| 285 | - | // Rich catalog rows use tab targets. Legacy count-only/connect-only | |
| 286 | - | // callers retain their header target until all peers carry the richer | |
| 287 | - | // identity contract. | |
| 288 | 298 | if common.hasRemoteTarget { | |
| 289 | 299 | header.selectable = false | |
| 290 | 300 | header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0 | |
| 291 | 301 | } else { | |
| 292 | - | header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session) | |
| 302 | + | header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteActivatable(session) | |
| 293 | 303 | header.focusable = header.selectable | |
| 294 | 304 | if !common.hasRemoteKey && !common.hasRemoteTarget { | |
| 295 | 305 | header.focusable = config.Mode == SelectNavigationTab | |
| 296 | 306 | } | |
| 297 | 307 | } | |
| 298 | 308 | header.unavailableReason = session.RemoteReason | |
| 299 | - | header.dim = remoteDim(session) | |
| 309 | + | header.dim = session.RemoteActivation == RemoteUnavailable | |
| 300 | 310 | } | |
| 301 | 311 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | |
| 302 | 312 | header.selectable, header.focusable, header.dim = false, false, true | |
| @@ -321,7 +331,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 321 | 331 | if tab.TabID != "" { | |
| 322 | 332 | remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID) | |
| 323 | 333 | } else { | |
| 324 | - | remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(len(session.Tabs))) | |
| 334 | + | tabCount := min(len(session.Tabs), math.MaxUint16) | |
| 335 | + | remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount)) | |
| 325 | 336 | } | |
| 326 | 337 | } else { | |
| 327 | 338 | remoteTarget.StoppedTab = domain.TabSelector{} | |
| @@ -334,14 +345,14 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 334 | 345 | tabRow.hasRemoteTarget = true | |
| 335 | 346 | } | |
| 336 | 347 | if !tabRow.hasRemoteTarget { | |
| 337 | - | legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteSelectable(session) | |
| 348 | + | legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteActivatable(session) | |
| 338 | 349 | tabRow.focusable = legacyRemote | |
| 339 | 350 | tabRow.selectable = legacyRemote | |
| 340 | 351 | } else { | |
| 341 | 352 | tabRow.focusable = true | |
| 342 | - | tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteSelectable(session) | |
| 353 | + | tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session) | |
| 343 | 354 | } | |
| 344 | - | tabRow.dim = remoteDim(session) | |
| 355 | + | tabRow.dim = session.RemoteActivation == RemoteUnavailable | |
| 345 | 356 | } | |
| 346 | 357 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | |
| 347 | 358 | tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true | |
| @@ -355,22 +366,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 355 | 366 | return rows | |
| 356 | 367 | } | |
| 357 | 368 | ||
| 358 | - | func remoteSelectable(session SessionView) bool { | |
| 359 | - | return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch && session.RemoteReason == "" | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | func remoteDim(session SessionView) bool { | |
| 363 | - | return !session.RemoteAttachReady || session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch | |
| 364 | - | } | |
| 365 | - | ||
| 366 | - | func remoteRowDetail(session SessionView) string { | |
| 367 | - | if session.RemoteDetail != "" && (session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch) { | |
| 368 | - | return session.RemoteDetail | |
| 369 | - | } | |
| 370 | - | if !session.RemoteAttachReady { | |
| 371 | - | return "proxy activation pending" | |
| 372 | - | } | |
| 373 | - | return session.RemoteDetail | |
| 369 | + | func remoteActivatable(session SessionView) bool { | |
| 370 | + | return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart | |
| 374 | 371 | } | |
| 375 | 372 | ||
| 376 | 373 | func sourceMatchesSession(source SourceFilter, session SessionView) bool { | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package picker | 1 | package picker | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "math" | |||
| 5 | + | ||||
| 4 | renderer "github.com/bnema/vev-vt" | 6 | renderer "github.com/bnema/vev-vt" | ||
| 5 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 6 | "github.com/bnema/vev/internal/usecase/ui" | 8 | "github.com/bnema/vev/internal/usecase/ui" | ||
| @@ -25,6 +27,17 @@ const ( | |||||
| 25 | RemoteVersionMismatch | 27 | RemoteVersionMismatch | ||
| 26 | ) | 28 | ) | ||
| 27 | 29 | ||||
| 30 | + | // RemoteActivation is the picker action authorized by the current remote | |||
| 31 | + | // catalog snapshot. It is presentation state only; RemoteSessionTarget remains | |||
| 32 | + | // the exact route and is revalidated when the picker hands off the client. | |||
| 33 | + | type RemoteActivation uint8 | |||
| 34 | + | ||||
| 35 | + | const ( | |||
| 36 | + | RemoteUnavailable RemoteActivation = iota | |||
| 37 | + | RemoteAttach | |||
| 38 | + | RemoteRestart | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 28 | type SessionView struct { | 41 | type SessionView struct { | ||
| 29 | ID domain.SessionID | 42 | ID domain.SessionID | ||
| 30 | Incarnation domain.IncarnationID | 43 | Incarnation domain.IncarnationID | ||
| @@ -43,8 +56,8 @@ type SessionView struct { | |||||
| 43 | RemoteAvailability RemoteAvailability | 56 | RemoteAvailability RemoteAvailability | ||
| 44 | RemoteDetail string | 57 | RemoteDetail string | ||
| 45 | RemoteReason string | 58 | RemoteReason string | ||
| 59 | + | RemoteActivation RemoteActivation | |||
| 46 | ConnectOnly bool | 60 | ConnectOnly bool | ||
| 47 | - | RemoteAttachReady bool | |||
| 48 | // CannotAcceptMoves reports whether this session cannot receive a moved tab | 61 | // CannotAcceptMoves reports whether this session cannot receive a moved tab | ||
| 49 | // or pane. False for ordinary local (and stopped) sessions; true for | 62 | // or pane. False for ordinary local (and stopped) sessions; true for | ||
| 50 | // restricted remote rows. | 63 | // restricted remote rows. | ||
| @@ -275,28 +288,25 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 275 | header.selectable = header.kind.selectable(config.Mode) | 288 | header.selectable = header.kind.selectable(config.Mode) | ||
| 276 | header.focusable = header.selectable | 289 | header.focusable = header.selectable | ||
| 277 | if common.remote { | 290 | if common.remote { | ||
| 278 | - | if remoteDetail := remoteRowDetail(session); remoteDetail != "" { | 291 | + | if session.RemoteDetail != "" { |
| 279 | if header.detail != "" { | 292 | if header.detail != "" { | ||
| 280 | - | header.detail += " (" + remoteDetail + ")" | 293 | + | header.detail += " (" + session.RemoteDetail + ")" |
| 281 | } else { | 294 | } else { | ||
| 282 | - | header.detail = remoteDetail | 295 | + | header.detail = session.RemoteDetail |
| 283 | } | 296 | } | ||
| 284 | } | 297 | } | ||
| 285 | - | // Rich catalog rows use tab targets. Legacy count-only/connect-only | |||
| 286 | - | // callers retain their header target until all peers carry the richer | |||
| 287 | - | // identity contract. | |||
| 288 | if common.hasRemoteTarget { | 298 | if common.hasRemoteTarget { | ||
| 289 | header.selectable = false | 299 | header.selectable = false | ||
| 290 | header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0 | 300 | header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0 | ||
| 291 | } else { | 301 | } else { | ||
| 292 | - | header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session) | 302 | + | header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteActivatable(session) |
| 293 | header.focusable = header.selectable | 303 | header.focusable = header.selectable | ||
| 294 | if !common.hasRemoteKey && !common.hasRemoteTarget { | 304 | if !common.hasRemoteKey && !common.hasRemoteTarget { | ||
| 295 | header.focusable = config.Mode == SelectNavigationTab | 305 | header.focusable = config.Mode == SelectNavigationTab | ||
| 296 | } | 306 | } | ||
| 297 | } | 307 | } | ||
| 298 | header.unavailableReason = session.RemoteReason | 308 | header.unavailableReason = session.RemoteReason | ||
| 299 | - | header.dim = remoteDim(session) | 309 | + | header.dim = session.RemoteActivation == RemoteUnavailable |
| 300 | } | 310 | } | ||
| 301 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | 311 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | ||
| 302 | header.selectable, header.focusable, header.dim = false, false, true | 312 | header.selectable, header.focusable, header.dim = false, false, true | ||
| @@ -321,7 +331,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 321 | if tab.TabID != "" { | 331 | if tab.TabID != "" { | ||
| 322 | remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID) | 332 | remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID) | ||
| 323 | } else { | 333 | } else { | ||
| 324 | - | remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(len(session.Tabs))) | 334 | + | tabCount := min(len(session.Tabs), math.MaxUint16) |
| 335 | + | remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount)) | |||
| 325 | } | 336 | } | ||
| 326 | } else { | 337 | } else { | ||
| 327 | remoteTarget.StoppedTab = domain.TabSelector{} | 338 | remoteTarget.StoppedTab = domain.TabSelector{} | ||
| @@ -334,14 +345,14 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 334 | tabRow.hasRemoteTarget = true | 345 | tabRow.hasRemoteTarget = true | ||
| 335 | } | 346 | } | ||
| 336 | if !tabRow.hasRemoteTarget { | 347 | if !tabRow.hasRemoteTarget { | ||
| 337 | - | legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteSelectable(session) | 348 | + | legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteActivatable(session) |
| 338 | tabRow.focusable = legacyRemote | 349 | tabRow.focusable = legacyRemote | ||
| 339 | tabRow.selectable = legacyRemote | 350 | tabRow.selectable = legacyRemote | ||
| 340 | } else { | 351 | } else { | ||
| 341 | tabRow.focusable = true | 352 | tabRow.focusable = true | ||
| 342 | - | tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteSelectable(session) | 353 | + | tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session) |
| 343 | } | 354 | } | ||
| 344 | - | tabRow.dim = remoteDim(session) | 355 | + | tabRow.dim = session.RemoteActivation == RemoteUnavailable |
| 345 | } | 356 | } | ||
| 346 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | 357 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | ||
| 347 | tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true | 358 | tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true | ||
| @@ -355,22 +366,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 355 | return rows | 366 | return rows | ||
| 356 | } | 367 | } | ||
| 357 | 368 | ||||
| 358 | - | func remoteSelectable(session SessionView) bool { | 369 | + | func remoteActivatable(session SessionView) bool { |
| 359 | - | return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch && session.RemoteReason == "" | 370 | + | return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart |
| 360 | - | } | |||
| 361 | - | ||||
| 362 | - | func remoteDim(session SessionView) bool { | |||
| 363 | - | return !session.RemoteAttachReady || session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch | |||
| 364 | - | } | |||
| 365 | - | ||||
| 366 | - | func remoteRowDetail(session SessionView) string { | |||
| 367 | - | if session.RemoteDetail != "" && (session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch) { | |||
| 368 | - | return session.RemoteDetail | |||
| 369 | - | } | |||
| 370 | - | if !session.RemoteAttachReady { | |||
| 371 | - | return "proxy activation pending" | |||
| 372 | - | } | |||
| 373 | - | return session.RemoteDetail | |||
| 374 | } | 371 | } | ||
| 375 | 372 | ||||
| 376 | func sourceMatchesSession(source SourceFilter, session SessionView) bool { | 373 | func sourceMatchesSession(source SourceFilter, session SessionView) bool { | ||
M internal/usecase/picker/model_test.go +8 -13
| @@ -752,8 +752,8 @@ func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) { | |||
| 752 | 752 | RemoteKey: &key, | |
| 753 | 753 | RemoteAvailability: state, | |
| 754 | 754 | RemoteDetail: "remote state detail", | |
| 755 | + | RemoteActivation: RemoteUnavailable, | |
| 755 | 756 | ConnectOnly: true, | |
| 756 | - | RemoteAttachReady: false, | |
| 757 | 757 | }) | |
| 758 | 758 | } | |
| 759 | 759 | ||
| @@ -765,11 +765,7 @@ func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) { | |||
| 765 | 765 | require.True(t, pickerRow.hasRemoteKey) | |
| 766 | 766 | require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i]) | |
| 767 | 767 | require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i]) | |
| 768 | - | wantDetail := "@mule (proxy activation pending)" | |
| 769 | - | if availability[i] == RemoteStale || availability[i] == RemoteVersionMismatch { | |
| 770 | - | wantDetail = "@mule (remote state detail)" | |
| 771 | - | } | |
| 772 | - | require.Equal(t, wantDetail, pickerRow.detail) | |
| 768 | + | require.Equal(t, "@mule (remote state detail)", pickerRow.detail) | |
| 773 | 769 | } | |
| 774 | 770 | _, ok := model.Selected() | |
| 775 | 771 | require.False(t, ok) | |
| @@ -783,7 +779,7 @@ func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *test | |||
| 783 | 779 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 784 | 780 | cached := New([]SessionView{{ | |
| 785 | 781 | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached, | |
| 786 | - | ConnectOnly: true, RemoteAttachReady: false, | |
| 782 | + | RemoteActivation: RemoteUnavailable, ConnectOnly: true, | |
| 787 | 783 | }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}}) | |
| 788 | 784 | ||
| 789 | 785 | cursor, ok := cached.Cursor() | |
| @@ -795,7 +791,7 @@ func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *test | |||
| 795 | 791 | ||
| 796 | 792 | live := New([]SessionView{{ | |
| 797 | 793 | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh, | |
| 798 | - | ConnectOnly: true, RemoteAttachReady: false, | |
| 794 | + | RemoteActivation: RemoteUnavailable, ConnectOnly: true, | |
| 799 | 795 | }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{ | |
| 800 | 796 | Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey, | |
| 801 | 797 | }}) | |
| @@ -815,7 +811,7 @@ func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) { | |||
| 815 | 811 | Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}}, | |
| 816 | 812 | RemoteKey: &key, | |
| 817 | 813 | RemoteAvailability: RemoteFresh, | |
| 818 | - | RemoteAttachReady: false, | |
| 814 | + | RemoteActivation: RemoteUnavailable, | |
| 819 | 815 | }}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 820 | 816 | ||
| 821 | 817 | require.Len(t, model.rows, 2) | |
| @@ -827,15 +823,15 @@ func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) { | |||
| 827 | 823 | ||
| 828 | 824 | func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) { | |
| 829 | 825 | key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"} | |
| 830 | - | wantKey := key | |
| 831 | 826 | model := New([]SessionView{{ | |
| 832 | 827 | ID: key.ID(), | |
| 833 | 828 | Name: "not a parseable remote display label", | |
| 834 | 829 | RemoteKey: &key, | |
| 835 | 830 | RemoteAvailability: RemoteFresh, | |
| 831 | + | RemoteActivation: RemoteAttach, | |
| 836 | 832 | ConnectOnly: true, | |
| 837 | - | RemoteAttachReady: true, | |
| 838 | 833 | }}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 834 | + | wantKey := key | |
| 839 | 835 | key.Host = "mutated-after-model-construction" | |
| 840 | 836 | ||
| 841 | 837 | got, ok := model.Selected() | |
| @@ -873,8 +869,7 @@ func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) { | |||
| 873 | 869 | Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}}, | |
| 874 | 870 | RemoteKey: &key, | |
| 875 | 871 | RemoteAvailability: availabilityCase.state, | |
| 876 | - | ConnectOnly: true, | |
| 877 | - | RemoteAttachReady: true, | |
| 872 | + | RemoteActivation: RemoteAttach, | |
| 878 | 873 | CannotAcceptMoves: true, | |
| 879 | 874 | } | |
| 880 | 875 | for _, mode := range modes { | |
| @@ -752,8 +752,8 @@ func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) { | |||||
| 752 | RemoteKey: &key, | 752 | RemoteKey: &key, | ||
| 753 | RemoteAvailability: state, | 753 | RemoteAvailability: state, | ||
| 754 | RemoteDetail: "remote state detail", | 754 | RemoteDetail: "remote state detail", | ||
| 755 | + | RemoteActivation: RemoteUnavailable, | |||
| 755 | ConnectOnly: true, | 756 | ConnectOnly: true, | ||
| 756 | - | RemoteAttachReady: false, | |||
| 757 | }) | 757 | }) | ||
| 758 | } | 758 | } | ||
| 759 | 759 | ||||
| @@ -765,11 +765,7 @@ func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) { | |||||
| 765 | require.True(t, pickerRow.hasRemoteKey) | 765 | require.True(t, pickerRow.hasRemoteKey) | ||
| 766 | require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i]) | 766 | require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i]) | ||
| 767 | require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i]) | 767 | require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i]) | ||
| 768 | - | wantDetail := "@mule (proxy activation pending)" | 768 | + | require.Equal(t, "@mule (remote state detail)", pickerRow.detail) |
| 769 | - | if availability[i] == RemoteStale || availability[i] == RemoteVersionMismatch { | |||
| 770 | - | wantDetail = "@mule (remote state detail)" | |||
| 771 | - | } | |||
| 772 | - | require.Equal(t, wantDetail, pickerRow.detail) | |||
| 773 | } | 769 | } | ||
| 774 | _, ok := model.Selected() | 770 | _, ok := model.Selected() | ||
| 775 | require.False(t, ok) | 771 | require.False(t, ok) | ||
| @@ -783,7 +779,7 @@ func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *test | |||||
| 783 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 779 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | ||
| 784 | cached := New([]SessionView{{ | 780 | cached := New([]SessionView{{ | ||
| 785 | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached, | 781 | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached, | ||
| 786 | - | ConnectOnly: true, RemoteAttachReady: false, | 782 | + | RemoteActivation: RemoteUnavailable, ConnectOnly: true, |
| 787 | }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}}) | 783 | }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}}) | ||
| 788 | 784 | ||||
| 789 | cursor, ok := cached.Cursor() | 785 | cursor, ok := cached.Cursor() | ||
| @@ -795,7 +791,7 @@ func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *test | |||||
| 795 | 791 | ||||
| 796 | live := New([]SessionView{{ | 792 | live := New([]SessionView{{ | ||
| 797 | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh, | 793 | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh, | ||
| 798 | - | ConnectOnly: true, RemoteAttachReady: false, | 794 | + | RemoteActivation: RemoteUnavailable, ConnectOnly: true, |
| 799 | }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{ | 795 | }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{ | ||
| 800 | Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey, | 796 | Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey, | ||
| 801 | }}) | 797 | }}) | ||
| @@ -815,7 +811,7 @@ func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) { | |||||
| 815 | Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}}, | 811 | Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}}, | ||
| 816 | RemoteKey: &key, | 812 | RemoteKey: &key, | ||
| 817 | RemoteAvailability: RemoteFresh, | 813 | RemoteAvailability: RemoteFresh, | ||
| 818 | - | RemoteAttachReady: false, | 814 | + | RemoteActivation: RemoteUnavailable, |
| 819 | }}, SelectionConfig{Mode: SelectNavigationTab}) | 815 | }}, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 820 | 816 | ||||
| 821 | require.Len(t, model.rows, 2) | 817 | require.Len(t, model.rows, 2) | ||
| @@ -827,15 +823,15 @@ func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) { | |||||
| 827 | 823 | ||||
| 828 | func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) { | 824 | func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) { | ||
| 829 | key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"} | 825 | key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"} | ||
| 830 | - | wantKey := key | |||
| 831 | model := New([]SessionView{{ | 826 | model := New([]SessionView{{ | ||
| 832 | ID: key.ID(), | 827 | ID: key.ID(), | ||
| 833 | Name: "not a parseable remote display label", | 828 | Name: "not a parseable remote display label", | ||
| 834 | RemoteKey: &key, | 829 | RemoteKey: &key, | ||
| 835 | RemoteAvailability: RemoteFresh, | 830 | RemoteAvailability: RemoteFresh, | ||
| 831 | + | RemoteActivation: RemoteAttach, | |||
| 836 | ConnectOnly: true, | 832 | ConnectOnly: true, | ||
| 837 | - | RemoteAttachReady: true, | |||
| 838 | }}, SelectionConfig{Mode: SelectNavigationTab}) | 833 | }}, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 834 | + | wantKey := key | |||
| 839 | key.Host = "mutated-after-model-construction" | 835 | key.Host = "mutated-after-model-construction" | ||
| 840 | 836 | ||||
| 841 | got, ok := model.Selected() | 837 | got, ok := model.Selected() | ||
| @@ -873,8 +869,7 @@ func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) { | |||||
| 873 | Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}}, | 869 | Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}}, | ||
| 874 | RemoteKey: &key, | 870 | RemoteKey: &key, | ||
| 875 | RemoteAvailability: availabilityCase.state, | 871 | RemoteAvailability: availabilityCase.state, | ||
| 876 | - | ConnectOnly: true, | 872 | + | RemoteActivation: RemoteAttach, |
| 877 | - | RemoteAttachReady: true, | |||
| 878 | CannotAcceptMoves: true, | 873 | CannotAcceptMoves: true, | ||
| 879 | } | 874 | } | ||
| 880 | for _, mode := range modes { | 875 | for _, mode := range modes { | ||
M internal/usecase/picker/remote_parity_test.go +3 -3
| @@ -21,7 +21,7 @@ func TestRemoteSessionHeaderIncludesOrigin(t *testing.T) { | |||
| 21 | 21 | target := domain.RemoteSessionTarget{Endpoint: key.Host, DisplayOrigin: key.DisplayOrigin, LifecycleID: lifecycle, SessionName: key.Name, LiveTabID: "tab-1"} | |
| 22 | 22 | model := New([]SessionView{{ | |
| 23 | 23 | ID: key.ID(), Name: key.Name, Tabs: []TabEntry{{TabID: "tab-1", Name: "main"}}, | |
| 24 | - | RemoteKey: &key, RemoteTarget: &target, RemoteAvailability: RemoteFresh, RemoteAttachReady: true, | |
| 24 | + | RemoteKey: &key, RemoteTarget: &target, RemoteAvailability: RemoteFresh, RemoteActivation: RemoteAttach, | |
| 25 | 25 | }}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 26 | 26 | ||
| 27 | 27 | require.Equal(t, "work", model.rows[0].dispName) | |
| @@ -47,11 +47,11 @@ func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) | |||
| 47 | 47 | model := New([]SessionView{ | |
| 48 | 48 | { | |
| 49 | 49 | ID: firstKey.ID(), Name: "same visible label", Tabs: []TabEntry{{TabID: "tab-a", Name: "main"}}, | |
| 50 | - | RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: RemoteFresh, RemoteAttachReady: true, | |
| 50 | + | RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: RemoteFresh, RemoteActivation: RemoteAttach, | |
| 51 | 51 | }, | |
| 52 | 52 | { | |
| 53 | 53 | ID: secondKey.ID(), Name: "same visible label", Tabs: []TabEntry{{TabID: "tab-b", Name: "main"}}, | |
| 54 | - | RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: RemoteFresh, RemoteAttachReady: true, | |
| 54 | + | RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: RemoteFresh, RemoteActivation: RemoteAttach, | |
| 55 | 55 | }, | |
| 56 | 56 | }, SelectionConfig{Mode: SelectNavigationTab}) | |
| 57 | 57 | ||
| @@ -21,7 +21,7 @@ func TestRemoteSessionHeaderIncludesOrigin(t *testing.T) { | |||||
| 21 | target := domain.RemoteSessionTarget{Endpoint: key.Host, DisplayOrigin: key.DisplayOrigin, LifecycleID: lifecycle, SessionName: key.Name, LiveTabID: "tab-1"} | 21 | target := domain.RemoteSessionTarget{Endpoint: key.Host, DisplayOrigin: key.DisplayOrigin, LifecycleID: lifecycle, SessionName: key.Name, LiveTabID: "tab-1"} | ||
| 22 | model := New([]SessionView{{ | 22 | model := New([]SessionView{{ | ||
| 23 | ID: key.ID(), Name: key.Name, Tabs: []TabEntry{{TabID: "tab-1", Name: "main"}}, | 23 | ID: key.ID(), Name: key.Name, Tabs: []TabEntry{{TabID: "tab-1", Name: "main"}}, | ||
| 24 | - | RemoteKey: &key, RemoteTarget: &target, RemoteAvailability: RemoteFresh, RemoteAttachReady: true, | 24 | + | RemoteKey: &key, RemoteTarget: &target, RemoteAvailability: RemoteFresh, RemoteActivation: RemoteAttach, |
| 25 | }}, SelectionConfig{Mode: SelectNavigationTab}) | 25 | }}, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 26 | 26 | ||||
| 27 | require.Equal(t, "work", model.rows[0].dispName) | 27 | require.Equal(t, "work", model.rows[0].dispName) | ||
| @@ -47,11 +47,11 @@ func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) | |||||
| 47 | model := New([]SessionView{ | 47 | model := New([]SessionView{ | ||
| 48 | { | 48 | { | ||
| 49 | ID: firstKey.ID(), Name: "same visible label", Tabs: []TabEntry{{TabID: "tab-a", Name: "main"}}, | 49 | ID: firstKey.ID(), Name: "same visible label", Tabs: []TabEntry{{TabID: "tab-a", Name: "main"}}, | ||
| 50 | - | RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: RemoteFresh, RemoteAttachReady: true, | 50 | + | RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: RemoteFresh, RemoteActivation: RemoteAttach, |
| 51 | }, | 51 | }, | ||
| 52 | { | 52 | { | ||
| 53 | ID: secondKey.ID(), Name: "same visible label", Tabs: []TabEntry{{TabID: "tab-b", Name: "main"}}, | 53 | ID: secondKey.ID(), Name: "same visible label", Tabs: []TabEntry{{TabID: "tab-b", Name: "main"}}, | ||
| 54 | - | RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: RemoteFresh, RemoteAttachReady: true, | 54 | + | RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: RemoteFresh, RemoteActivation: RemoteAttach, |
| 55 | }, | 55 | }, | ||
| 56 | }, SelectionConfig{Mode: SelectNavigationTab}) | 56 | }, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 57 | 57 | ||||