fix(remote): reject ambiguous catalogue routes
- commit
- e23c63518ff52db479e4b0c82d463daf0c07c39f
- author
- brice · · 2026-08-24 12:25 UTC
- parent
- 0bca6cd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +35 -17
M internal/domain/lifecycle.go +1 -11
| @@ -170,17 +170,7 @@ func (t RemoteSessionTarget) ResolveTab(tabs []TabSelectorTab) (int, bool) { | |||
| 170 | 170 | } | |
| 171 | 171 | return t.StoppedTab.Resolve(tabs) | |
| 172 | 172 | } | |
| 173 | - | found := -1 | |
| 174 | - | for i, tab := range tabs { | |
| 175 | - | if tab.ID != t.LiveTabID { | |
| 176 | - | continue | |
| 177 | - | } | |
| 178 | - | if found >= 0 { | |
| 179 | - | return 0, false | |
| 180 | - | } | |
| 181 | - | found = i | |
| 182 | - | } | |
| 183 | - | return found, found >= 0 | |
| 173 | + | return NewStableTabSelector(t.LiveTabID).Resolve(tabs) | |
| 184 | 174 | } | |
| 185 | 175 | ||
| 186 | 176 | // ValidateRemoteDisplayOrigin validates presentation data without imposing | |
| @@ -170,17 +170,7 @@ func (t RemoteSessionTarget) ResolveTab(tabs []TabSelectorTab) (int, bool) { | |||||
| 170 | } | 170 | } | ||
| 171 | return t.StoppedTab.Resolve(tabs) | 171 | return t.StoppedTab.Resolve(tabs) | ||
| 172 | } | 172 | } | ||
| 173 | - | found := -1 | 173 | + | return NewStableTabSelector(t.LiveTabID).Resolve(tabs) |
| 174 | - | for i, tab := range tabs { | |||
| 175 | - | if tab.ID != t.LiveTabID { | |||
| 176 | - | continue | |||
| 177 | - | } | |||
| 178 | - | if found >= 0 { | |||
| 179 | - | return 0, false | |||
| 180 | - | } | |||
| 181 | - | found = i | |||
| 182 | - | } | |||
| 183 | - | return found, found >= 0 | |||
| 184 | } | 174 | } | ||
| 185 | 175 | ||||
| 186 | // ValidateRemoteDisplayOrigin validates presentation data without imposing | 176 | // ValidateRemoteDisplayOrigin validates presentation data without imposing | ||
M internal/domain/lifecycle_test.go +3 -0
| @@ -34,7 +34,10 @@ func TestRemoteSessionTargetResolveTab(t *testing.T) { | |||
| 34 | 34 | {name: "down fresh default tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "fresh"}}, ok: true}, | |
| 35 | 35 | {name: "down without selector rejects ambiguity", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}}, | |
| 36 | 36 | {name: "down stable selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: NewStableTabSelector("two")}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true}, | |
| 37 | + | {name: "down invalid selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: TabSelector{Kind: TabSelectorByStableID, Ordinal: 1}}, tabs: []TabSelectorTab{{ID: "one"}}}, | |
| 37 | 38 | {name: "live stable tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true}, | |
| 39 | + | {name: "live missing tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "missing"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: -1}, | |
| 40 | + | {name: "live duplicate tab rejected", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "two"}, {ID: "two"}}}, | |
| 38 | 41 | } | |
| 39 | 42 | for _, test := range tests { | |
| 40 | 43 | t.Run(test.name, func(t *testing.T) { | |
| @@ -34,7 +34,10 @@ func TestRemoteSessionTargetResolveTab(t *testing.T) { | |||||
| 34 | {name: "down fresh default tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "fresh"}}, ok: true}, | 34 | {name: "down fresh default tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "fresh"}}, ok: true}, | ||
| 35 | {name: "down without selector rejects ambiguity", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}}, | 35 | {name: "down without selector rejects ambiguity", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}}, | ||
| 36 | {name: "down stable selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: NewStableTabSelector("two")}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true}, | 36 | {name: "down stable selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: NewStableTabSelector("two")}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true}, | ||
| 37 | + | {name: "down invalid selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: TabSelector{Kind: TabSelectorByStableID, Ordinal: 1}}, tabs: []TabSelectorTab{{ID: "one"}}}, | |||
| 37 | {name: "live stable tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true}, | 38 | {name: "live stable tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true}, | ||
| 39 | + | {name: "live missing tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "missing"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: -1}, | |||
| 40 | + | {name: "live duplicate tab rejected", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "two"}, {ID: "two"}}}, | |||
| 38 | } | 41 | } | ||
| 39 | for _, test := range tests { | 42 | for _, test := range tests { | ||
| 40 | t.Run(test.name, func(t *testing.T) { | 43 | t.Run(test.name, func(t *testing.T) { | ||
M internal/usecase/daemon/control.go +5 -0
| @@ -715,6 +715,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 715 | 715 | e.d.mu.Unlock() | |
| 716 | 716 | ||
| 717 | 717 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped)) | |
| 718 | + | liveNames := make(map[string]struct{}, len(sessions)) | |
| 718 | 719 | for _, sess := range sessions { | |
| 719 | 720 | e.d.refreshSessionFocusedTitles(sess) | |
| 720 | 721 | snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false}) | |
| @@ -735,8 +736,12 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 735 | 736 | row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID | |
| 736 | 737 | } | |
| 737 | 738 | rows = append(rows, row) | |
| 739 | + | liveNames[row.Name] = struct{}{} | |
| 738 | 740 | } | |
| 739 | 741 | for _, entry := range stopped { | |
| 742 | + | if _, live := liveNames[entry.name]; live { | |
| 743 | + | continue | |
| 744 | + | } | |
| 740 | 745 | tabs, err := remoteCatalogStoppedTabs(entry) | |
| 741 | 746 | if err != nil { | |
| 742 | 747 | return "", err | |
| @@ -715,6 +715,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||||
| 715 | e.d.mu.Unlock() | 715 | e.d.mu.Unlock() | ||
| 716 | 716 | ||||
| 717 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped)) | 717 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped)) | ||
| 718 | + | liveNames := make(map[string]struct{}, len(sessions)) | |||
| 718 | for _, sess := range sessions { | 719 | for _, sess := range sessions { | ||
| 719 | e.d.refreshSessionFocusedTitles(sess) | 720 | e.d.refreshSessionFocusedTitles(sess) | ||
| 720 | snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false}) | 721 | snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false}) | ||
| @@ -735,8 +736,12 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||||
| 735 | row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID | 736 | row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID | ||
| 736 | } | 737 | } | ||
| 737 | rows = append(rows, row) | 738 | rows = append(rows, row) | ||
| 739 | + | liveNames[row.Name] = struct{}{} | |||
| 738 | } | 740 | } | ||
| 739 | for _, entry := range stopped { | 741 | for _, entry := range stopped { | ||
| 742 | + | if _, live := liveNames[entry.name]; live { | |||
| 743 | + | continue | |||
| 744 | + | } | |||
| 740 | tabs, err := remoteCatalogStoppedTabs(entry) | 745 | tabs, err := remoteCatalogStoppedTabs(entry) | ||
| 741 | if err != nil { | 746 | if err != nil { | ||
| 742 | return "", err | 747 | return "", err | ||
M internal/usecase/daemon/control_test.go +2 -0
| @@ -736,6 +736,8 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||
| 736 | 736 | ||
| 737 | 737 | d.mu.Lock() | |
| 738 | 738 | d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: ports.SessionDown} | |
| 739 | + | // Live runtime authority wins if both registries contain the same name. | |
| 740 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: domain.IncarnationID{4}, state: ports.SessionDown} | |
| 739 | 741 | d.mu.Unlock() | |
| 740 | 742 | ||
| 741 | 743 | listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | |
| @@ -736,6 +736,8 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||||
| 736 | 736 | ||||
| 737 | d.mu.Lock() | 737 | d.mu.Lock() | ||
| 738 | d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: ports.SessionDown} | 738 | d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: ports.SessionDown} | ||
| 739 | + | // Live runtime authority wins if both registries contain the same name. | |||
| 740 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: domain.IncarnationID{4}, state: ports.SessionDown} | |||
| 739 | d.mu.Unlock() | 741 | d.mu.Unlock() | ||
| 740 | 742 | ||||
| 741 | listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | 743 | listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | ||
M internal/usecase/daemon/remote_stopped_route_test.go +3 -3
| @@ -85,7 +85,7 @@ func TestRouteRemoteTargetRejectsLiveIntentForInactiveSession(t *testing.T) { | |||
| 85 | 85 | ||
| 86 | 86 | var protocol *protoErr | |
| 87 | 87 | require.ErrorAs(t, err, &protocol) | |
| 88 | - | require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code) | |
| 88 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) | |
| 89 | 89 | d.mu.Lock() | |
| 90 | 90 | defer d.mu.Unlock() | |
| 91 | 91 | require.Empty(t, d.sessions) | |
| @@ -117,7 +117,7 @@ func TestRouteRemoteTargetMapsUnavailableInactiveStatesToNoSuchTarget(t *testing | |||
| 117 | 117 | ||
| 118 | 118 | var protocol *protoErr | |
| 119 | 119 | require.ErrorAs(t, err, &protocol) | |
| 120 | - | require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code) | |
| 120 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) | |
| 121 | 121 | }) | |
| 122 | 122 | } | |
| 123 | 123 | } | |
| @@ -140,7 +140,7 @@ func TestRouteRemoteTargetMapsConcurrentInactiveResumeToNoSuchTarget(t *testing. | |||
| 140 | 140 | ||
| 141 | 141 | var protocol *protoErr | |
| 142 | 142 | require.ErrorAs(t, err, &protocol) | |
| 143 | - | require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code) | |
| 143 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | 146 | func TestRouteRemoteTargetPreservesInactiveResumeFailures(t *testing.T) { | |
| @@ -85,7 +85,7 @@ func TestRouteRemoteTargetRejectsLiveIntentForInactiveSession(t *testing.T) { | |||||
| 85 | 85 | ||||
| 86 | var protocol *protoErr | 86 | var protocol *protoErr | ||
| 87 | require.ErrorAs(t, err, &protocol) | 87 | require.ErrorAs(t, err, &protocol) | ||
| 88 | - | require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code) | 88 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) |
| 89 | d.mu.Lock() | 89 | d.mu.Lock() | ||
| 90 | defer d.mu.Unlock() | 90 | defer d.mu.Unlock() | ||
| 91 | require.Empty(t, d.sessions) | 91 | require.Empty(t, d.sessions) | ||
| @@ -117,7 +117,7 @@ func TestRouteRemoteTargetMapsUnavailableInactiveStatesToNoSuchTarget(t *testing | |||||
| 117 | 117 | ||||
| 118 | var protocol *protoErr | 118 | var protocol *protoErr | ||
| 119 | require.ErrorAs(t, err, &protocol) | 119 | require.ErrorAs(t, err, &protocol) | ||
| 120 | - | require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code) | 120 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) |
| 121 | }) | 121 | }) | ||
| 122 | } | 122 | } | ||
| 123 | } | 123 | } | ||
| @@ -140,7 +140,7 @@ func TestRouteRemoteTargetMapsConcurrentInactiveResumeToNoSuchTarget(t *testing. | |||||
| 140 | 140 | ||||
| 141 | var protocol *protoErr | 141 | var protocol *protoErr | ||
| 142 | require.ErrorAs(t, err, &protocol) | 142 | require.ErrorAs(t, err, &protocol) | ||
| 143 | - | require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code) | 143 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) |
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| 146 | func TestRouteRemoteTargetPreservesInactiveResumeFailures(t *testing.T) { | 146 | func TestRouteRemoteTargetPreservesInactiveResumeFailures(t *testing.T) { | ||
M internal/usecase/picker/model.go +10 -3
| @@ -317,6 +317,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 317 | 317 | continue | |
| 318 | 318 | } | |
| 319 | 319 | tabRow := common | |
| 320 | + | remoteTargetResolvable := true | |
| 320 | 321 | tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention | |
| 321 | 322 | tabRow.tabID, tabRow.tabIndex = tab.TabID, i | |
| 322 | 323 | tabRow.selectable = tabRow.kind.selectable(config.Mode) | |
| @@ -330,8 +331,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 330 | 331 | if tab.TabID != "" { | |
| 331 | 332 | remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID) | |
| 332 | 333 | } else if remoteTarget.StoppedTab != (domain.TabSelector{}) { | |
| 333 | - | tabCount := min(len(session.Tabs), math.MaxUint16) | |
| 334 | - | remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount)) | |
| 334 | + | remoteTarget.StoppedTab, remoteTargetResolvable = remoteStoppedOrdinalSelector(i, tab.RawName, len(session.Tabs)) | |
| 335 | 335 | } | |
| 336 | 336 | } else { | |
| 337 | 337 | remoteTarget.StoppedTab = domain.TabSelector{} | |
| @@ -348,7 +348,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 348 | 348 | tabRow.selectable = false | |
| 349 | 349 | } else { | |
| 350 | 350 | tabRow.focusable = true | |
| 351 | - | tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session) | |
| 351 | + | tabRow.selectable = remoteTargetResolvable && tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session) | |
| 352 | 352 | } | |
| 353 | 353 | tabRow.dim = session.RemoteActivation == RemoteUnavailable | |
| 354 | 354 | } | |
| @@ -363,6 +363,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 363 | 363 | return rows | |
| 364 | 364 | } | |
| 365 | 365 | ||
| 366 | + | func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) { | |
| 367 | + | if tabCount > math.MaxUint16 { | |
| 368 | + | return domain.TabSelector{}, false | |
| 369 | + | } | |
| 370 | + | return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true | |
| 371 | + | } | |
| 372 | + | ||
| 366 | 373 | func remoteActivatable(session SessionView) bool { | |
| 367 | 374 | return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart | |
| 368 | 375 | } | |
| @@ -317,6 +317,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 317 | continue | 317 | continue | ||
| 318 | } | 318 | } | ||
| 319 | tabRow := common | 319 | tabRow := common | ||
| 320 | + | remoteTargetResolvable := true | |||
| 320 | tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention | 321 | tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention | ||
| 321 | tabRow.tabID, tabRow.tabIndex = tab.TabID, i | 322 | tabRow.tabID, tabRow.tabIndex = tab.TabID, i | ||
| 322 | tabRow.selectable = tabRow.kind.selectable(config.Mode) | 323 | tabRow.selectable = tabRow.kind.selectable(config.Mode) | ||
| @@ -330,8 +331,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 330 | if tab.TabID != "" { | 331 | if tab.TabID != "" { | ||
| 331 | remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID) | 332 | remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID) | ||
| 332 | } else if remoteTarget.StoppedTab != (domain.TabSelector{}) { | 333 | } else if remoteTarget.StoppedTab != (domain.TabSelector{}) { | ||
| 333 | - | tabCount := min(len(session.Tabs), math.MaxUint16) | 334 | + | remoteTarget.StoppedTab, remoteTargetResolvable = remoteStoppedOrdinalSelector(i, tab.RawName, len(session.Tabs)) |
| 334 | - | remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount)) | |||
| 335 | } | 335 | } | ||
| 336 | } else { | 336 | } else { | ||
| 337 | remoteTarget.StoppedTab = domain.TabSelector{} | 337 | remoteTarget.StoppedTab = domain.TabSelector{} | ||
| @@ -348,7 +348,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 348 | tabRow.selectable = false | 348 | tabRow.selectable = false | ||
| 349 | } else { | 349 | } else { | ||
| 350 | tabRow.focusable = true | 350 | tabRow.focusable = true | ||
| 351 | - | tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session) | 351 | + | tabRow.selectable = remoteTargetResolvable && tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session) |
| 352 | } | 352 | } | ||
| 353 | tabRow.dim = session.RemoteActivation == RemoteUnavailable | 353 | tabRow.dim = session.RemoteActivation == RemoteUnavailable | ||
| 354 | } | 354 | } | ||
| @@ -363,6 +363,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 363 | return rows | 363 | return rows | ||
| 364 | } | 364 | } | ||
| 365 | 365 | ||||
| 366 | + | func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) { | |||
| 367 | + | if tabCount > math.MaxUint16 { | |||
| 368 | + | return domain.TabSelector{}, false | |||
| 369 | + | } | |||
| 370 | + | return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true | |||
| 371 | + | } | |||
| 372 | + | ||||
| 366 | func remoteActivatable(session SessionView) bool { | 373 | func remoteActivatable(session SessionView) bool { | ||
| 367 | return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart | 374 | return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart | ||
| 368 | } | 375 | } | ||
M internal/usecase/picker/model_test.go +11 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package picker | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 4 | 5 | "reflect" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| @@ -776,6 +777,16 @@ func TestStructuredRemoteTargetOwnsStoppedState(t *testing.T) { | |||
| 776 | 777 | } | |
| 777 | 778 | } | |
| 778 | 779 | ||
| 780 | + | func TestRemoteStoppedOrdinalSelectorRejectsUnrepresentableTabCount(t *testing.T) { | |
| 781 | + | selector, ok := remoteStoppedOrdinalSelector(math.MaxUint16-1, "last", math.MaxUint16) | |
| 782 | + | require.True(t, ok) | |
| 783 | + | require.Equal(t, domain.NewOrdinalTabSelector(math.MaxUint16-1, "last", math.MaxUint16), selector) | |
| 784 | + | ||
| 785 | + | selector, ok = remoteStoppedOrdinalSelector(math.MaxUint16, "wrapped", math.MaxUint16+1) | |
| 786 | + | require.False(t, ok) | |
| 787 | + | require.Equal(t, domain.TabSelector{}, selector) | |
| 788 | + | } | |
| 789 | + | ||
| 779 | 790 | func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) { | |
| 780 | 791 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 781 | 792 | local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}} | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package picker | 1 | package picker | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "math" | |||
| 4 | "reflect" | 5 | "reflect" | ||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" | ||
| @@ -776,6 +777,16 @@ func TestStructuredRemoteTargetOwnsStoppedState(t *testing.T) { | |||||
| 776 | } | 777 | } | ||
| 777 | } | 778 | } | ||
| 778 | 779 | ||||
| 780 | + | func TestRemoteStoppedOrdinalSelectorRejectsUnrepresentableTabCount(t *testing.T) { | |||
| 781 | + | selector, ok := remoteStoppedOrdinalSelector(math.MaxUint16-1, "last", math.MaxUint16) | |||
| 782 | + | require.True(t, ok) | |||
| 783 | + | require.Equal(t, domain.NewOrdinalTabSelector(math.MaxUint16-1, "last", math.MaxUint16), selector) | |||
| 784 | + | ||||
| 785 | + | selector, ok = remoteStoppedOrdinalSelector(math.MaxUint16, "wrapped", math.MaxUint16+1) | |||
| 786 | + | require.False(t, ok) | |||
| 787 | + | require.Equal(t, domain.TabSelector{}, selector) | |||
| 788 | + | } | |||
| 789 | + | ||||
| 779 | func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) { | 790 | func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) { | ||
| 780 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 791 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | ||
| 781 | local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}} | 792 | local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}} | ||