detached 0380a23

fix(picker): clarify remote session activation

commit
0380a23d9a228b2bcab2154957e4c0a2a848f621
author
brice · · 2026-08-11 17:58 UTC
parent
7d45f2b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +177 -108
M internal/usecase/daemon/remote_picker.go +51 -44
@@ -102,6 +102,8 @@ func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability
102102
103103 func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
104104 switch status {
105+ case remoteHostCached, remoteHostRefreshing:
106+ return "checking remote…"
105107 case remoteHostStale:
106108 if !fetchedAt.IsZero() {
107109 return "catalog stale since " + fetchedAt.Format(time.RFC3339)
@@ -151,67 +153,71 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
151153 })
152154 }
153155 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- }
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))
171168 } else {
172- target.LiveTabID = domain.TabStableID(tabs[active].ID)
169+ target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs)))
173170 }
171+ } else {
172+ target.LiveTabID = domain.TabStableID(tabs[active].ID)
174173 }
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
180174 }
181- reason := ""
182- if !legacy && status != remoteHostFresh {
183- reason = remoteReasonForStatus(status)
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+ var remoteTarget *domain.RemoteSessionTarget
180+ if !legacy {
181+ remoteTarget = &target
184182 }
183+ reason := remoteReasonForStatus(status)
185184 targetValid := remoteTarget != nil && remoteTarget.Validate() == nil
185+ activation := picker.RemoteUnavailable
186186 if broken {
187187 reason = "session_broken"
188188 } else if !legacy && !targetValid {
189189 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)
190+ } else if status == remoteHostFresh {
191+ if legacy {
192+ if !stopped {
193+ activation = picker.RemoteAttach
194+ }
195+ } else if stopped {
196+ activation = picker.RemoteRestart
197+ } else {
198+ activation = picker.RemoteAttach
199199 }
200200 }
201+
201202 detail := remotePickerStatusDetail(status, fetchedAt)
202203 if detail == "" {
203- switch session.State {
204- case "up":
204+ switch {
205+ case broken:
206+ detail = "session broken"
207+ case !legacy && !targetValid:
208+ detail = "identity changed"
209+ case legacy && stopped:
210+ detail = "stopped — exact identity required"
211+ case activation == picker.RemoteRestart:
212+ detail = "stopped — Enter to restart"
213+ case session.State == "up":
205214 detail = "up"
206- case "down", "stopped":
215+ case stopped:
207216 detail = "stopped"
208- case "broken":
209- detail = "broken"
217+ default:
218+ detail = session.State
210219 }
211220 }
212- if broken {
213- detail = "session broken"
214- }
215221 return picker.SessionView{
216222 ID: key.ID(),
217223 Name: key.Display(),
@@ -221,11 +227,11 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
221227 RemoteAvailability: availability,
222228 RemoteDetail: detail,
223229 RemoteReason: reason,
230+ RemoteActivation: activation,
224231 Tabs: viewTabs,
225232 Active: active,
226233 Stopped: stopped,
227234 ConnectOnly: legacy,
228- RemoteAttachReady: ready,
229235 CannotAcceptMoves: true,
230236 }
231237 }
@@ -257,6 +263,7 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi
257263 RemoteReason: remoteReasonForStatus(status),
258264 RemoteAvailability: remotePickerAvailability(status),
259265 RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
266+ RemoteActivation: picker.RemoteUnavailable,
260267 ConnectOnly: true,
261268 CannotAcceptMoves: true,
262269 }
@@ -102,6 +102,8 @@ func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability
102 102
103 func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {103 func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
104 switch status {104 switch status {
105+ case remoteHostCached, remoteHostRefreshing:
106+ return "checking remote…"
105 case remoteHostStale:107 case remoteHostStale:
106 if !fetchedAt.IsZero() {108 if !fetchedAt.IsZero() {
107 return "catalog stale since " + fetchedAt.Format(time.RFC3339)109 return "catalog stale since " + fetchedAt.Format(time.RFC3339)
@@ -151,67 +153,71 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
151 })153 })
152 }154 }
153 legacy := session.LifecycleID == (domain.SessionLifecycleID{})155 legacy := session.LifecycleID == (domain.SessionLifecycleID{})
154- var remoteTarget *domain.RemoteSessionTarget156+ target := domain.RemoteSessionTarget{
155- if !legacy {157+ Endpoint: key.Host,
156- target := domain.RemoteSessionTarget{158+ DisplayOrigin: key.DisplayOrigin,
157- Endpoint: key.Host,159+ LifecycleID: session.LifecycleID,
158- DisplayOrigin: key.DisplayOrigin,160+ SessionName: session.Name,
159- LifecycleID: session.LifecycleID,161+ Stopped: stopped,
160- SessionName: session.Name,162+ }
161- Stopped: stopped,163+ if len(tabs) != 0 {
162- }164+ first := tabs[0]
163- if len(tabs) != 0 {165+ if stopped {
164- first := tabs[0]166+ if first.ID != "" {
165- if stopped {167+ 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 {168 } else {
172- target.LiveTabID = domain.TabStableID(tabs[active].ID)169+ target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs)))
173 }170 }
171+ } else {
172+ target.LiveTabID = domain.TabStableID(tabs[active].ID)
174 }173 }
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 }174 }
181- reason := ""175+ // Keep the structured session identity on rows even when one tab ID is
182- if !legacy && status != remoteHostFresh {176+ // malformed or a broken session cannot be activated. Cursor navigation
183- reason = remoteReasonForStatus(status)177+ // must remain possible, while sendRemoteAttachTargetForAttachment will
178+ // fail closed on the invalid target.
179+ var remoteTarget *domain.RemoteSessionTarget
180+ if !legacy {
181+ remoteTarget = &target
184 }182 }
183+ reason := remoteReasonForStatus(status)
185 targetValid := remoteTarget != nil && remoteTarget.Validate() == nil184 targetValid := remoteTarget != nil && remoteTarget.Validate() == nil
185+ activation := picker.RemoteUnavailable
186 if broken {186 if broken {
187 reason = "session_broken"187 reason = "session_broken"
188 } else if !legacy && !targetValid {188 } else if !legacy && !targetValid {
189 reason = "identity_changed"189 reason = "identity_changed"
190- }190+ } else if status == remoteHostFresh {
191- ready := status == remoteHostFresh && targetValid && !broken191+ if legacy {
192- if legacy {192+ if !stopped {
193- // Count-only peers cannot prove lifecycle/tab identity. Preserve the193+ activation = picker.RemoteAttach
194- // historical row for compatibility, but make it read-only until a fresh194+ }
195- // catalog response authorizes a name attach.195+ } else if stopped {
196- ready = status == remoteHostFresh && !broken && !stopped196+ activation = picker.RemoteRestart
197- if status == remoteHostVersionMismatch {197+ } else {
198- reason = remoteReasonForStatus(status)198+ activation = picker.RemoteAttach
199 }199 }
200 }200 }
201+
201 detail := remotePickerStatusDetail(status, fetchedAt)202 detail := remotePickerStatusDetail(status, fetchedAt)
202 if detail == "" {203 if detail == "" {
203- switch session.State {204+ switch {
204- case "up":205+ case broken:
206+ detail = "session broken"
207+ case !legacy && !targetValid:
208+ detail = "identity changed"
209+ case legacy && stopped:
210+ detail = "stopped — exact identity required"
211+ case activation == picker.RemoteRestart:
212+ detail = "stopped — Enter to restart"
213+ case session.State == "up":
205 detail = "up"214 detail = "up"
206- case "down", "stopped":215+ case stopped:
207 detail = "stopped"216 detail = "stopped"
208- case "broken":217+ default:
209- detail = "broken"218+ detail = session.State
210 }219 }
211 }220 }
212- if broken {
213- detail = "session broken"
214- }
215 return picker.SessionView{221 return picker.SessionView{
216 ID: key.ID(),222 ID: key.ID(),
217 Name: key.Display(),223 Name: key.Display(),
@@ -221,11 +227,11 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
221 RemoteAvailability: availability,227 RemoteAvailability: availability,
222 RemoteDetail: detail,228 RemoteDetail: detail,
223 RemoteReason: reason,229 RemoteReason: reason,
230+ RemoteActivation: activation,
224 Tabs: viewTabs,231 Tabs: viewTabs,
225 Active: active,232 Active: active,
226 Stopped: stopped,233 Stopped: stopped,
227 ConnectOnly: legacy,234 ConnectOnly: legacy,
228- RemoteAttachReady: ready,
229 CannotAcceptMoves: true,235 CannotAcceptMoves: true,
230 }236 }
231 }237 }
@@ -257,6 +263,7 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi
257 RemoteReason: remoteReasonForStatus(status),263 RemoteReason: remoteReasonForStatus(status),
258 RemoteAvailability: remotePickerAvailability(status),264 RemoteAvailability: remotePickerAvailability(status),
259 RemoteDetail: remotePickerStatusDetail(status, time.Time{}),265 RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
266+ RemoteActivation: picker.RemoteUnavailable,
260 ConnectOnly: true,267 ConnectOnly: true,
261 CannotAcceptMoves: true,268 CannotAcceptMoves: true,
262 }269 }
M internal/usecase/daemon/remote_picker_test.go +86 -13
@@ -70,14 +70,16 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T)
7070 require.True(t, view.Stopped)
7171 require.NotNil(t, view.RemoteTarget)
7272 require.True(t, view.RemoteTarget.Stopped)
73- require.True(t, view.RemoteAttachReady, "structured stopped rows have an explicit safe restore target")
73+ require.Equal(t, picker.RemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target")
74+ require.Equal(t, "stopped — Enter to restart", view.RemoteDetail)
7475
7576 legacy := session
7677 legacy.LifecycleID = domain.SessionLifecycleID{}
7778 legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0))
7879 require.True(t, legacyView.Stopped)
7980 require.True(t, legacyView.ConnectOnly)
80- require.False(t, legacyView.RemoteAttachReady, "legacy stopped rows cannot be selected without lifecycle identity")
81+ require.Equal(t, picker.RemoteUnavailable, legacyView.RemoteActivation, "legacy stopped rows cannot be selected without lifecycle identity")
82+ require.Equal(t, "stopped — exact identity required", legacyView.RemoteDetail)
8183 })
8284 }
8385 }
@@ -682,6 +684,77 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
682684 d.mu.Unlock()
683685 }
684686
687+func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
688+ lifecycle := remoteLifecycleForTest()
689+ remoteSession := ports.RemoteCatalogSession{
690+ LifecycleID: lifecycle,
691+ Name: "work",
692+ State: "down",
693+ Tabs: []ports.RemoteCatalogTab{
694+ {ID: "tab-a", Name: "alpha", Index: 0},
695+ {ID: "tab-b", Name: "beta", Index: 1},
696+ },
697+ }
698+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
699+ remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0))
700+ model := picker.New([]picker.SessionView{
701+ {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}},
702+ remoteView,
703+ }, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
704+ var selected picker.Target
705+ for range 4 {
706+ candidate, ok := model.Selected()
707+ if ok && candidate.RemoteTarget != nil && candidate.RemoteTarget.StoppedTab.StableID == "tab-b" {
708+ selected = candidate
709+ break
710+ }
711+ model.Down()
712+ }
713+ require.NotNil(t, selected.RemoteTarget)
714+ require.True(t, selected.RemoteTarget.Stopped)
715+ require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab)
716+
717+ local := newRemotePickerDaemon(nil)
718+ local.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession}}})
719+ local.remoteCatalog.mu.Lock()
720+ local.remoteCatalog.status["arch"] = remoteHostFresh
721+ local.remoteCatalog.mu.Unlock()
722+ localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")
723+ token := localSession.attachmentToken(localAttachment, localAttachment.transport())
724+ effect, admitted := localAttachment.beginAttachmentEffect(token)
725+ require.True(t, admitted)
726+ token.effect = effect
727+ require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
728+
729+ frame := receiveRemotePicker(t, sends, "stopped remote target")
730+ handoff, err := ports.UnmarshalAttachTarget(frame.Payload)
731+ require.NoError(t, err)
732+ require.NotNil(t, handoff.RemoteTarget)
733+ require.Equal(t, selected.RemoteTarget, handoff.RemoteTarget)
734+ local.mu.Lock()
735+ require.NotContains(t, local.sessions, key.ID(), "picker handoff must not create a local remote shadow")
736+ local.mu.Unlock()
737+
738+ remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
739+ remote.mu.Lock()
740+ remote.stopped["work"] = stoppedSession{
741+ name: "work", cwd: "/remote/work", incarnation: lifecycle,
742+ tabNames: []string{"alpha", "beta"},
743+ tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-a", Name: "alpha"}, {StableID: "tab-b", Name: "beta"}},
744+ }
745+ remote.mu.Unlock()
746+ transport, _ := newCapturingTransport(t)
747+ restored, attachment, err := remote.routeWithContext(context.Background(), ports.Hello{
748+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: handoff.Session,
749+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: handoff.RemoteTarget,
750+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
751+ }, transport)
752+ require.NoError(t, err)
753+ require.Equal(t, lifecycle, restored.incarnation)
754+ require.Equal(t, domain.TabStableID("tab-b"), attachment.viewSnapshot().tabID)
755+ remote.clientGone(restored, attachment, transport, false)
756+}
757+
685758 func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
686759 tests := []struct {
687760 name string
@@ -1105,23 +1178,23 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
11051178 func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) {
11061179 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
11071180 for _, tt := range []struct {
1108- status remoteHostStatus
1109- ready bool
1181+ status remoteHostStatus
1182+ activation picker.RemoteActivation
11101183 }{
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},
1184+ {status: remoteHostCached, activation: picker.RemoteUnavailable},
1185+ {status: remoteHostRefreshing, activation: picker.RemoteUnavailable},
1186+ {status: remoteHostStale, activation: picker.RemoteUnavailable},
1187+ {status: remoteHostMalformed, activation: picker.RemoteUnavailable},
1188+ {status: remoteHostFresh, activation: picker.RemoteAttach},
1189+ {status: remoteHostUnreachable, activation: picker.RemoteUnavailable},
11171190 } {
11181191 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)
1192+ require.Equal(t, tt.activation, view.RemoteActivation, "status %d activation", tt.status)
11201193 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1121- require.Equal(t, tt.ready, selectable, "status %d selection", tt.status)
1194+ require.Equal(t, tt.activation == picker.RemoteAttach, selectable, "status %d selection", tt.status)
11221195 }
11231196 mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{})
1124- require.False(t, mismatch.RemoteAttachReady)
1197+ require.Equal(t, picker.RemoteUnavailable, mismatch.RemoteActivation)
11251198 _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
11261199 require.False(t, selectable)
11271200 }
@@ -70,14 +70,16 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T)
70 require.True(t, view.Stopped)70 require.True(t, view.Stopped)
71 require.NotNil(t, view.RemoteTarget)71 require.NotNil(t, view.RemoteTarget)
72 require.True(t, view.RemoteTarget.Stopped)72 require.True(t, view.RemoteTarget.Stopped)
73- require.True(t, view.RemoteAttachReady, "structured stopped rows have an explicit safe restore target")73+ require.Equal(t, picker.RemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target")
74+ require.Equal(t, "stopped — Enter to restart", view.RemoteDetail)
74 75
75 legacy := session76 legacy := session
76 legacy.LifecycleID = domain.SessionLifecycleID{}77 legacy.LifecycleID = domain.SessionLifecycleID{}
77 legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0))78 legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0))
78 require.True(t, legacyView.Stopped)79 require.True(t, legacyView.Stopped)
79 require.True(t, legacyView.ConnectOnly)80 require.True(t, legacyView.ConnectOnly)
80- require.False(t, legacyView.RemoteAttachReady, "legacy stopped rows cannot be selected without lifecycle identity")81+ require.Equal(t, picker.RemoteUnavailable, legacyView.RemoteActivation, "legacy stopped rows cannot be selected without lifecycle identity")
82+ require.Equal(t, "stopped — exact identity required", legacyView.RemoteDetail)
81 })83 })
82 }84 }
83 }85 }
@@ -682,6 +684,77 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
682 d.mu.Unlock()684 d.mu.Unlock()
683 }685 }
684 686
687+func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
688+ lifecycle := remoteLifecycleForTest()
689+ remoteSession := ports.RemoteCatalogSession{
690+ LifecycleID: lifecycle,
691+ Name: "work",
692+ State: "down",
693+ Tabs: []ports.RemoteCatalogTab{
694+ {ID: "tab-a", Name: "alpha", Index: 0},
695+ {ID: "tab-b", Name: "beta", Index: 1},
696+ },
697+ }
698+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
699+ remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0))
700+ model := picker.New([]picker.SessionView{
701+ {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}},
702+ remoteView,
703+ }, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
704+ var selected picker.Target
705+ for range 4 {
706+ candidate, ok := model.Selected()
707+ if ok && candidate.RemoteTarget != nil && candidate.RemoteTarget.StoppedTab.StableID == "tab-b" {
708+ selected = candidate
709+ break
710+ }
711+ model.Down()
712+ }
713+ require.NotNil(t, selected.RemoteTarget)
714+ require.True(t, selected.RemoteTarget.Stopped)
715+ require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab)
716+
717+ local := newRemotePickerDaemon(nil)
718+ local.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession}}})
719+ local.remoteCatalog.mu.Lock()
720+ local.remoteCatalog.status["arch"] = remoteHostFresh
721+ local.remoteCatalog.mu.Unlock()
722+ localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")
723+ token := localSession.attachmentToken(localAttachment, localAttachment.transport())
724+ effect, admitted := localAttachment.beginAttachmentEffect(token)
725+ require.True(t, admitted)
726+ token.effect = effect
727+ require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
728+
729+ frame := receiveRemotePicker(t, sends, "stopped remote target")
730+ handoff, err := ports.UnmarshalAttachTarget(frame.Payload)
731+ require.NoError(t, err)
732+ require.NotNil(t, handoff.RemoteTarget)
733+ require.Equal(t, selected.RemoteTarget, handoff.RemoteTarget)
734+ local.mu.Lock()
735+ require.NotContains(t, local.sessions, key.ID(), "picker handoff must not create a local remote shadow")
736+ local.mu.Unlock()
737+
738+ remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
739+ remote.mu.Lock()
740+ remote.stopped["work"] = stoppedSession{
741+ name: "work", cwd: "/remote/work", incarnation: lifecycle,
742+ tabNames: []string{"alpha", "beta"},
743+ tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-a", Name: "alpha"}, {StableID: "tab-b", Name: "beta"}},
744+ }
745+ remote.mu.Unlock()
746+ transport, _ := newCapturingTransport(t)
747+ restored, attachment, err := remote.routeWithContext(context.Background(), ports.Hello{
748+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: handoff.Session,
749+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: handoff.RemoteTarget,
750+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
751+ }, transport)
752+ require.NoError(t, err)
753+ require.Equal(t, lifecycle, restored.incarnation)
754+ require.Equal(t, domain.TabStableID("tab-b"), attachment.viewSnapshot().tabID)
755+ remote.clientGone(restored, attachment, transport, false)
756+}
757+
685 func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {758 func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
686 tests := []struct {759 tests := []struct {
687 name string760 name string
@@ -1105,23 +1178,23 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
1105 func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) {1178 func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) {
1106 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}1179 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1107 for _, tt := range []struct {1180 for _, tt := range []struct {
1108- status remoteHostStatus1181+ status remoteHostStatus
1109- ready bool1182+ activation picker.RemoteActivation
1110 }{1183 }{
1111- {status: remoteHostCached, ready: false},1184+ {status: remoteHostCached, activation: picker.RemoteUnavailable},
1112- {status: remoteHostRefreshing, ready: false},1185+ {status: remoteHostRefreshing, activation: picker.RemoteUnavailable},
1113- {status: remoteHostStale, ready: false},1186+ {status: remoteHostStale, activation: picker.RemoteUnavailable},
1114- {status: remoteHostMalformed, ready: false},1187+ {status: remoteHostMalformed, activation: picker.RemoteUnavailable},
1115- {status: remoteHostFresh, ready: true},1188+ {status: remoteHostFresh, activation: picker.RemoteAttach},
1116- {status: remoteHostUnreachable, ready: false},1189+ {status: remoteHostUnreachable, activation: picker.RemoteUnavailable},
1117 } {1190 } {
1118 view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, tt.status, time.Unix(1, 0))1191 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)1192+ 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()1193 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1121- require.Equal(t, tt.ready, selectable, "status %d selection", tt.status)1194+ require.Equal(t, tt.activation == picker.RemoteAttach, selectable, "status %d selection", tt.status)
1122 }1195 }
1123 mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{})1196 mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{})
1124- require.False(t, mismatch.RemoteAttachReady)1197+ require.Equal(t, picker.RemoteUnavailable, mismatch.RemoteActivation)
1125 _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()1198 _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1126 require.False(t, selectable)1199 require.False(t, selectable)
1127 }1200 }
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 +22 -28
@@ -25,6 +25,17 @@ const (
2525 RemoteVersionMismatch
2626 )
2727
28+// RemoteActivation is the picker action authorized by the current remote
29+// catalog snapshot. It is presentation state only; RemoteSessionTarget remains
30+// the exact route and is revalidated when the picker hands off the client.
31+type RemoteActivation uint8
32+
33+const (
34+ RemoteUnavailable RemoteActivation = iota
35+ RemoteAttach
36+ RemoteRestart
37+)
38+
2839 type SessionView struct {
2940 ID domain.SessionID
3041 Incarnation domain.IncarnationID
@@ -43,8 +54,8 @@ type SessionView struct {
4354 RemoteAvailability RemoteAvailability
4455 RemoteDetail string
4556 RemoteReason string
57+ RemoteActivation RemoteActivation
4658 ConnectOnly bool
47- RemoteAttachReady bool
4859 // CannotAcceptMoves reports whether this session cannot receive a moved tab
4960 // or pane. False for ordinary local (and stopped) sessions; true for
5061 // restricted remote rows.
@@ -275,28 +286,25 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
275286 header.selectable = header.kind.selectable(config.Mode)
276287 header.focusable = header.selectable
277288 if common.remote {
278- if remoteDetail := remoteRowDetail(session); remoteDetail != "" {
289+ if session.RemoteDetail != "" {
279290 if header.detail != "" {
280- header.detail += " (" + remoteDetail + ")"
291+ header.detail += " (" + session.RemoteDetail + ")"
281292 } else {
282- header.detail = remoteDetail
293+ header.detail = session.RemoteDetail
283294 }
284295 }
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.
288296 if common.hasRemoteTarget {
289297 header.selectable = false
290298 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0
291299 } else {
292- header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session)
300+ header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteActivatable(session)
293301 header.focusable = header.selectable
294302 if !common.hasRemoteKey && !common.hasRemoteTarget {
295303 header.focusable = config.Mode == SelectNavigationTab
296304 }
297305 }
298306 header.unavailableReason = session.RemoteReason
299- header.dim = remoteDim(session)
307+ header.dim = session.RemoteActivation == RemoteUnavailable
300308 }
301309 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
302310 header.selectable, header.focusable, header.dim = false, false, true
@@ -334,14 +342,14 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
334342 tabRow.hasRemoteTarget = true
335343 }
336344 if !tabRow.hasRemoteTarget {
337- legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteSelectable(session)
345+ legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteActivatable(session)
338346 tabRow.focusable = legacyRemote
339347 tabRow.selectable = legacyRemote
340348 } else {
341349 tabRow.focusable = true
342- tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteSelectable(session)
350+ tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)
343351 }
344- tabRow.dim = remoteDim(session)
352+ tabRow.dim = session.RemoteActivation == RemoteUnavailable
345353 }
346354 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
347355 tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
@@ -355,22 +363,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
355363 return rows
356364 }
357365
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
366+func remoteActivatable(session SessionView) bool {
367+ return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart
374368 }
375369
376370 func sourceMatchesSession(source SourceFilter, session SessionView) bool {
@@ -25,6 +25,17 @@ const (
25 RemoteVersionMismatch25 RemoteVersionMismatch
26 )26 )
27 27
28+// RemoteActivation is the picker action authorized by the current remote
29+// catalog snapshot. It is presentation state only; RemoteSessionTarget remains
30+// the exact route and is revalidated when the picker hands off the client.
31+type RemoteActivation uint8
32+
33+const (
34+ RemoteUnavailable RemoteActivation = iota
35+ RemoteAttach
36+ RemoteRestart
37+)
38+
28 type SessionView struct {39 type SessionView struct {
29 ID domain.SessionID40 ID domain.SessionID
30 Incarnation domain.IncarnationID41 Incarnation domain.IncarnationID
@@ -43,8 +54,8 @@ type SessionView struct {
43 RemoteAvailability RemoteAvailability54 RemoteAvailability RemoteAvailability
44 RemoteDetail string55 RemoteDetail string
45 RemoteReason string56 RemoteReason string
57+ RemoteActivation RemoteActivation
46 ConnectOnly bool58 ConnectOnly bool
47- RemoteAttachReady bool
48 // CannotAcceptMoves reports whether this session cannot receive a moved tab59 // CannotAcceptMoves reports whether this session cannot receive a moved tab
49 // or pane. False for ordinary local (and stopped) sessions; true for60 // or pane. False for ordinary local (and stopped) sessions; true for
50 // restricted remote rows.61 // restricted remote rows.
@@ -275,28 +286,25 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
275 header.selectable = header.kind.selectable(config.Mode)286 header.selectable = header.kind.selectable(config.Mode)
276 header.focusable = header.selectable287 header.focusable = header.selectable
277 if common.remote {288 if common.remote {
278- if remoteDetail := remoteRowDetail(session); remoteDetail != "" {289+ if session.RemoteDetail != "" {
279 if header.detail != "" {290 if header.detail != "" {
280- header.detail += " (" + remoteDetail + ")"291+ header.detail += " (" + session.RemoteDetail + ")"
281 } else {292 } else {
282- header.detail = remoteDetail293+ header.detail = session.RemoteDetail
283 }294 }
284 }295 }
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 {296 if common.hasRemoteTarget {
289 header.selectable = false297 header.selectable = false
290 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0298 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0
291 } else {299 } else {
292- header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session)300+ header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteActivatable(session)
293 header.focusable = header.selectable301 header.focusable = header.selectable
294 if !common.hasRemoteKey && !common.hasRemoteTarget {302 if !common.hasRemoteKey && !common.hasRemoteTarget {
295 header.focusable = config.Mode == SelectNavigationTab303 header.focusable = config.Mode == SelectNavigationTab
296 }304 }
297 }305 }
298 header.unavailableReason = session.RemoteReason306 header.unavailableReason = session.RemoteReason
299- header.dim = remoteDim(session)307+ header.dim = session.RemoteActivation == RemoteUnavailable
300 }308 }
301 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {309 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
302 header.selectable, header.focusable, header.dim = false, false, true310 header.selectable, header.focusable, header.dim = false, false, true
@@ -334,14 +342,14 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
334 tabRow.hasRemoteTarget = true342 tabRow.hasRemoteTarget = true
335 }343 }
336 if !tabRow.hasRemoteTarget {344 if !tabRow.hasRemoteTarget {
337- legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteSelectable(session)345+ legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteActivatable(session)
338 tabRow.focusable = legacyRemote346 tabRow.focusable = legacyRemote
339 tabRow.selectable = legacyRemote347 tabRow.selectable = legacyRemote
340 } else {348 } else {
341 tabRow.focusable = true349 tabRow.focusable = true
342- tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteSelectable(session)350+ tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)
343 }351 }
344- tabRow.dim = remoteDim(session)352+ tabRow.dim = session.RemoteActivation == RemoteUnavailable
345 }353 }
346 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {354 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
347 tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true355 tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
@@ -355,22 +363,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
355 return rows363 return rows
356 }364 }
357 365
358-func remoteSelectable(session SessionView) bool {366+func remoteActivatable(session SessionView) bool {
359- return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch && session.RemoteReason == ""367+ 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 }368 }
375 369
376 func sourceMatchesSession(source SourceFilter, session SessionView) bool {370 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