detached 99dc266

Merge pull request #201 from bnema/fix/remote-down-session-resurrection

fix(picker): clarify remote session activation
commit
99dc2668cabb6d31723d28d797884b19a0b8665a
author
bnema · · 2026-08-11 19:49 UTC
committer
GitHub · · 2026-08-11 19:49 UTC
merge of
7d45f2b + 0c2bd95 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.0
6 files · +195 -109
M internal/usecase/daemon/remote_picker.go +53 -44
@@ -5,6 +5,7 @@ import (
55 "encoding/base64"
66 "errors"
77 "fmt"
8+ "math"
89 "sort"
910 "time"
1011
@@ -102,6 +103,8 @@ func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability
102103
103104 func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
104105 switch status {
106+ case remoteHostCached, remoteHostRefreshing:
107+ return "checking remote…"
105108 case remoteHostStale:
106109 if !fetchedAt.IsZero() {
107110 return "catalog stale since " + fetchedAt.Format(time.RFC3339)
@@ -151,67 +154,72 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
151154 })
152155 }
153156 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))
171169 } 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))
173172 }
173+ } else {
174+ target.LiveTabID = domain.TabStableID(tabs[active].ID)
174175 }
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
180176 }
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
184184 }
185+ reason := remoteReasonForStatus(status)
185186 targetValid := remoteTarget != nil && remoteTarget.Validate() == nil
187+ activation := picker.RemoteUnavailable
186188 if broken {
187189 reason = "session_broken"
188190 } else if !legacy && !targetValid {
189191 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
199201 }
200202 }
203+
201204 detail := remotePickerStatusDetail(status, fetchedAt)
202205 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":
205216 detail = "up"
206- case "down", "stopped":
217+ case stopped:
207218 detail = "stopped"
208- case "broken":
209- detail = "broken"
219+ default:
220+ detail = session.State
210221 }
211222 }
212- if broken {
213- detail = "session broken"
214- }
215223 return picker.SessionView{
216224 ID: key.ID(),
217225 Name: key.Display(),
@@ -221,11 +229,11 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
221229 RemoteAvailability: availability,
222230 RemoteDetail: detail,
223231 RemoteReason: reason,
232+ RemoteActivation: activation,
224233 Tabs: viewTabs,
225234 Active: active,
226235 Stopped: stopped,
227236 ConnectOnly: legacy,
228- RemoteAttachReady: ready,
229237 CannotAcceptMoves: true,
230238 }
231239 }
@@ -257,6 +265,7 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi
257265 RemoteReason: remoteReasonForStatus(status),
258266 RemoteAvailability: remotePickerAvailability(status),
259267 RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
268+ RemoteActivation: picker.RemoteUnavailable,
260269 ConnectOnly: true,
261270 CannotAcceptMoves: true,
262271 }
@@ -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.RemoteSessionTarget157+ 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() == nil186 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 && !broken193+ if legacy {
192- if legacy {194+ if !stopped {
193- // Count-only peers cannot prove lifecycle/tab identity. Preserve the195+ activation = picker.RemoteAttach
194- // historical row for compatibility, but make it read-only until a fresh196+ }
195- // catalog response authorizes a name attach.197+ } else if stopped {
196- ready = status == remoteHostFresh && !broken && !stopped198+ 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 (
66 "fmt"
77 "io"
88 "log/slog"
9+ "math"
910 "sync"
1011 "testing"
1112 "time"
@@ -70,18 +71,31 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T)
7071 require.True(t, view.Stopped)
7172 require.NotNil(t, view.RemoteTarget)
7273 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)
7476
7577 legacy := session
7678 legacy.LifecycleID = domain.SessionLifecycleID{}
7779 legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0))
7880 require.True(t, legacyView.Stopped)
7981 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)
8184 })
8285 }
8386 }
8487
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+
8599 func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) {
86100 for _, attached := range []bool{false, true} {
87101 view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, ports.RemoteCatalogSession{
@@ -682,6 +696,77 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
682696 d.mu.Unlock()
683697 }
684698
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+
685770 func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
686771 tests := []struct {
687772 name string
@@ -1105,23 +1190,23 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
11051190 func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) {
11061191 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
11071192 for _, tt := range []struct {
1108- status remoteHostStatus
1109- ready bool
1193+ status remoteHostStatus
1194+ activation picker.RemoteActivation
11101195 }{
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},
11171202 } {
11181203 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)
11201205 _, 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)
11221207 }
11231208 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)
11251210 _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
11261211 require.False(t, selectable)
11271212 }
@@ -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 := session77 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 string772 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 remoteHostStatus1193+ status remoteHostStatus
1109- ready bool1194+ 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
271271 ID: firstKey.ID(), Name: firstTarget.SessionName,
272272 Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}},
273273 RemoteKey: &firstKey, RemoteTarget: &firstTarget,
274- RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true,
274+ RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
275275 }
276276 firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
277277 firstSelected, ok := firstModel.Selected()
@@ -301,7 +301,7 @@ func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *test
301301 ID: secondKey.ID(), Name: secondTarget.SessionName,
302302 Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}},
303303 RemoteKey: &secondKey, RemoteTarget: &secondTarget,
304- RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true,
304+ RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
305305 }
306306 secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
307307 secondSelected, ok := secondModel.Selected()
@@ -353,8 +353,8 @@ func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) {
353353 firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
354354 secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
355355 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},
358358 }
359359 model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
360360 ac.overlays.pickerMu.Lock()
@@ -396,8 +396,8 @@ func TestRemotePickerPreviewErrorCannotPublishAfterSelectionChange(t *testing.T)
396396 firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
397397 secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
398398 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},
401401 }, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
402402 selected, ok := model.Selected()
403403 require.True(t, ok)
@@ -438,7 +438,7 @@ func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) {
438438 ID: key.ID(), Name: target.SessionName,
439439 Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}},
440440 RemoteKey: &key, RemoteTarget: &target,
441- RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true,
441+ RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
442442 }
443443 model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
444444 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 @@
11 package picker
22
33 import (
4+ "math"
5+
46 renderer "github.com/bnema/vev-vt"
57 "github.com/bnema/vev/internal/domain"
68 "github.com/bnema/vev/internal/usecase/ui"
@@ -25,6 +27,17 @@ const (
2527 RemoteVersionMismatch
2628 )
2729
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+
2841 type SessionView struct {
2942 ID domain.SessionID
3043 Incarnation domain.IncarnationID
@@ -43,8 +56,8 @@ type SessionView struct {
4356 RemoteAvailability RemoteAvailability
4457 RemoteDetail string
4558 RemoteReason string
59+ RemoteActivation RemoteActivation
4660 ConnectOnly bool
47- RemoteAttachReady bool
4861 // CannotAcceptMoves reports whether this session cannot receive a moved tab
4962 // or pane. False for ordinary local (and stopped) sessions; true for
5063 // restricted remote rows.
@@ -275,28 +288,25 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
275288 header.selectable = header.kind.selectable(config.Mode)
276289 header.focusable = header.selectable
277290 if common.remote {
278- if remoteDetail := remoteRowDetail(session); remoteDetail != "" {
291+ if session.RemoteDetail != "" {
279292 if header.detail != "" {
280- header.detail += " (" + remoteDetail + ")"
293+ header.detail += " (" + session.RemoteDetail + ")"
281294 } else {
282- header.detail = remoteDetail
295+ header.detail = session.RemoteDetail
283296 }
284297 }
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.
288298 if common.hasRemoteTarget {
289299 header.selectable = false
290300 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0
291301 } 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)
293303 header.focusable = header.selectable
294304 if !common.hasRemoteKey && !common.hasRemoteTarget {
295305 header.focusable = config.Mode == SelectNavigationTab
296306 }
297307 }
298308 header.unavailableReason = session.RemoteReason
299- header.dim = remoteDim(session)
309+ header.dim = session.RemoteActivation == RemoteUnavailable
300310 }
301311 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
302312 header.selectable, header.focusable, header.dim = false, false, true
@@ -321,7 +331,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
321331 if tab.TabID != "" {
322332 remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)
323333 } 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))
325336 }
326337 } else {
327338 remoteTarget.StoppedTab = domain.TabSelector{}
@@ -334,14 +345,14 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
334345 tabRow.hasRemoteTarget = true
335346 }
336347 if !tabRow.hasRemoteTarget {
337- legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteSelectable(session)
348+ legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteActivatable(session)
338349 tabRow.focusable = legacyRemote
339350 tabRow.selectable = legacyRemote
340351 } else {
341352 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)
343354 }
344- tabRow.dim = remoteDim(session)
355+ tabRow.dim = session.RemoteActivation == RemoteUnavailable
345356 }
346357 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
347358 tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
@@ -355,22 +366,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
355366 return rows
356367 }
357368
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
374371 }
375372
376373 func sourceMatchesSession(source SourceFilter, session SessionView) bool {
@@ -1,6 +1,8 @@
1 package picker1 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 RemoteVersionMismatch27 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.SessionID42 ID domain.SessionID
30 Incarnation domain.IncarnationID43 Incarnation domain.IncarnationID
@@ -43,8 +56,8 @@ type SessionView struct {
43 RemoteAvailability RemoteAvailability56 RemoteAvailability RemoteAvailability
44 RemoteDetail string57 RemoteDetail string
45 RemoteReason string58 RemoteReason string
59+ RemoteActivation RemoteActivation
46 ConnectOnly bool60 ConnectOnly bool
47- RemoteAttachReady bool
48 // CannotAcceptMoves reports whether this session cannot receive a moved tab61 // CannotAcceptMoves reports whether this session cannot receive a moved tab
49 // or pane. False for ordinary local (and stopped) sessions; true for62 // 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.selectable289 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 = remoteDetail295+ 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 = false299 header.selectable = false
290 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0300 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.selectable303 header.focusable = header.selectable
294 if !common.hasRemoteKey && !common.hasRemoteTarget {304 if !common.hasRemoteKey && !common.hasRemoteTarget {
295 header.focusable = config.Mode == SelectNavigationTab305 header.focusable = config.Mode == SelectNavigationTab
296 }306 }
297 }307 }
298 header.unavailableReason = session.RemoteReason308 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, true312 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 = true345 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 = legacyRemote349 tabRow.focusable = legacyRemote
339 tabRow.selectable = legacyRemote350 tabRow.selectable = legacyRemote
340 } else {351 } else {
341 tabRow.focusable = true352 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, true358 tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
@@ -355,22 +366,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
355 return rows366 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) {
752752 RemoteKey: &key,
753753 RemoteAvailability: state,
754754 RemoteDetail: "remote state detail",
755+ RemoteActivation: RemoteUnavailable,
755756 ConnectOnly: true,
756- RemoteAttachReady: false,
757757 })
758758 }
759759
@@ -765,11 +765,7 @@ func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) {
765765 require.True(t, pickerRow.hasRemoteKey)
766766 require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])
767767 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)
773769 }
774770 _, ok := model.Selected()
775771 require.False(t, ok)
@@ -783,7 +779,7 @@ func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *test
783779 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
784780 cached := New([]SessionView{{
785781 ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached,
786- ConnectOnly: true, RemoteAttachReady: false,
782+ RemoteActivation: RemoteUnavailable, ConnectOnly: true,
787783 }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}})
788784
789785 cursor, ok := cached.Cursor()
@@ -795,7 +791,7 @@ func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *test
795791
796792 live := New([]SessionView{{
797793 ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh,
798- ConnectOnly: true, RemoteAttachReady: false,
794+ RemoteActivation: RemoteUnavailable, ConnectOnly: true,
799795 }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{
800796 Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
801797 }})
@@ -815,7 +811,7 @@ func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) {
815811 Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
816812 RemoteKey: &key,
817813 RemoteAvailability: RemoteFresh,
818- RemoteAttachReady: false,
814+ RemoteActivation: RemoteUnavailable,
819815 }}, SelectionConfig{Mode: SelectNavigationTab})
820816
821817 require.Len(t, model.rows, 2)
@@ -827,15 +823,15 @@ func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) {
827823
828824 func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) {
829825 key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"}
830- wantKey := key
831826 model := New([]SessionView{{
832827 ID: key.ID(),
833828 Name: "not a parseable remote display label",
834829 RemoteKey: &key,
835830 RemoteAvailability: RemoteFresh,
831+ RemoteActivation: RemoteAttach,
836832 ConnectOnly: true,
837- RemoteAttachReady: true,
838833 }}, SelectionConfig{Mode: SelectNavigationTab})
834+ wantKey := key
839835 key.Host = "mutated-after-model-construction"
840836
841837 got, ok := model.Selected()
@@ -873,8 +869,7 @@ func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
873869 Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
874870 RemoteKey: &key,
875871 RemoteAvailability: availabilityCase.state,
876- ConnectOnly: true,
877- RemoteAttachReady: true,
872+ RemoteActivation: RemoteAttach,
878873 CannotAcceptMoves: true,
879874 }
880875 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) {
2121 target := domain.RemoteSessionTarget{Endpoint: key.Host, DisplayOrigin: key.DisplayOrigin, LifecycleID: lifecycle, SessionName: key.Name, LiveTabID: "tab-1"}
2222 model := New([]SessionView{{
2323 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,
2525 }}, SelectionConfig{Mode: SelectNavigationTab})
2626
2727 require.Equal(t, "work", model.rows[0].dispName)
@@ -47,11 +47,11 @@ func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T)
4747 model := New([]SessionView{
4848 {
4949 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,
5151 },
5252 {
5353 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,
5555 },
5656 }, SelectionConfig{Mode: SelectNavigationTab})
5757
@@ -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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close