detached a851fc9

feat(remote): unify remote sessions in the picker

commit
a851fc933d94bd635bbe21ad09c412d2802b5a29
author
brice · · 2026-07-30 18:30 UTC
committer
brice · · 2026-07-31 15:32 UTC
parent
3f4b8b3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +1559 -61
M internal/usecase/daemon/control_test.go +14 -6
@@ -150,7 +150,7 @@ func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
150150 tr, sends, _ := newConn(t, frame)
151151
152152 d := newTestDaemon(t, nil, stubClock{})
153- d.handleCommand(tr, frame)
153+ require.NoError(t, d.handleCommand(tr, frame))
154154
155155 result := awaitCommandResult(t, sends)
156156 require.False(t, result.OK)
@@ -773,7 +773,9 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
773773 firstTransport, firstSends, _ := newConn(t, firstFrame)
774774 firstDone := make(chan struct{})
775775 go func() {
776- d.handleCommand(firstTransport, firstFrame)
776+ if err := d.handleCommand(firstTransport, firstFrame); err != nil {
777+ t.Errorf("handle first command: %v", err)
778+ }
777779 close(firstDone)
778780 }()
779781 select {
@@ -789,7 +791,9 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
789791 secondTransport, secondSends, _ := newConn(t, secondFrame)
790792 secondDone := make(chan struct{})
791793 go func() {
792- d.handleCommand(secondTransport, secondFrame)
794+ if err := d.handleCommand(secondTransport, secondFrame); err != nil {
795+ t.Errorf("handle second command: %v", err)
796+ }
793797 close(secondDone)
794798 }()
795799 select {
@@ -1146,12 +1150,16 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
11461150 done := make(chan struct{}, 2)
11471151 go func() {
11481152 <-start
1149- d.handleCommand(leftTransport, leftFrame)
1153+ if err := d.handleCommand(leftTransport, leftFrame); err != nil {
1154+ t.Errorf("handle left command: %v", err)
1155+ }
11501156 done <- struct{}{}
11511157 }()
11521158 go func() {
11531159 <-start
1154- d.handleCommand(rightTransport, rightFrame)
1160+ if err := d.handleCommand(rightTransport, rightFrame); err != nil {
1161+ t.Errorf("handle right command: %v", err)
1162+ }
11551163 done <- struct{}{}
11561164 }()
11571165 close(start)
@@ -1194,7 +1202,7 @@ func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.Co
11941202 t.Helper()
11951203 frame := commandFrame(t, request)
11961204 tr, sends, _ := newConn(t, frame)
1197- d.handleCommand(tr, frame)
1205+ require.NoError(t, d.handleCommand(tr, frame))
11981206 return awaitCommandResult(t, sends)
11991207 }
12001208
@@ -150,7 +150,7 @@ func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
150 tr, sends, _ := newConn(t, frame)150 tr, sends, _ := newConn(t, frame)
151 151
152 d := newTestDaemon(t, nil, stubClock{})152 d := newTestDaemon(t, nil, stubClock{})
153- d.handleCommand(tr, frame)153+ require.NoError(t, d.handleCommand(tr, frame))
154 154
155 result := awaitCommandResult(t, sends)155 result := awaitCommandResult(t, sends)
156 require.False(t, result.OK)156 require.False(t, result.OK)
@@ -773,7 +773,9 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
773 firstTransport, firstSends, _ := newConn(t, firstFrame)773 firstTransport, firstSends, _ := newConn(t, firstFrame)
774 firstDone := make(chan struct{})774 firstDone := make(chan struct{})
775 go func() {775 go func() {
776- d.handleCommand(firstTransport, firstFrame)776+ if err := d.handleCommand(firstTransport, firstFrame); err != nil {
777+ t.Errorf("handle first command: %v", err)
778+ }
777 close(firstDone)779 close(firstDone)
778 }()780 }()
779 select {781 select {
@@ -789,7 +791,9 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
789 secondTransport, secondSends, _ := newConn(t, secondFrame)791 secondTransport, secondSends, _ := newConn(t, secondFrame)
790 secondDone := make(chan struct{})792 secondDone := make(chan struct{})
791 go func() {793 go func() {
792- d.handleCommand(secondTransport, secondFrame)794+ if err := d.handleCommand(secondTransport, secondFrame); err != nil {
795+ t.Errorf("handle second command: %v", err)
796+ }
793 close(secondDone)797 close(secondDone)
794 }()798 }()
795 select {799 select {
@@ -1146,12 +1150,16 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1146 done := make(chan struct{}, 2)1150 done := make(chan struct{}, 2)
1147 go func() {1151 go func() {
1148 <-start1152 <-start
1149- d.handleCommand(leftTransport, leftFrame)1153+ if err := d.handleCommand(leftTransport, leftFrame); err != nil {
1154+ t.Errorf("handle left command: %v", err)
1155+ }
1150 done <- struct{}{}1156 done <- struct{}{}
1151 }()1157 }()
1152 go func() {1158 go func() {
1153 <-start1159 <-start
1154- d.handleCommand(rightTransport, rightFrame)1160+ if err := d.handleCommand(rightTransport, rightFrame); err != nil {
1161+ t.Errorf("handle right command: %v", err)
1162+ }
1155 done <- struct{}{}1163 done <- struct{}{}
1156 }()1164 }()
1157 close(start)1165 close(start)
@@ -1194,7 +1202,7 @@ func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.Co
1194 t.Helper()1202 t.Helper()
1195 frame := commandFrame(t, request)1203 frame := commandFrame(t, request)
1196 tr, sends, _ := newConn(t, frame)1204 tr, sends, _ := newConn(t, frame)
1197- d.handleCommand(tr, frame)1205+ require.NoError(t, d.handleCommand(tr, frame))
1198 return awaitCommandResult(t, sends)1206 return awaitCommandResult(t, sends)
1199 }1207 }
1200 1208
M internal/usecase/daemon/daemon.go +4 -1
@@ -795,6 +795,7 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
795795 }
796796
797797 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
798+ d.cancelRemotePickerRefresh()
798799 d.closeMoveLifecycles()
799800 d.mu.Lock()
800801 d.closing = true
@@ -970,7 +971,9 @@ func (d *Daemon) handleConn(tr ports.Transport) {
970971 case ports.MsgList:
971972 d.handleList(tr)
972973 case ports.MsgCommand:
973- d.handleCommand(tr, first)
974+ if err := d.handleCommand(tr, first); err != nil {
975+ d.log.Warn("command handler failed", "err", err)
976+ }
974977 case ports.MsgKill:
975978 d.handleKill(tr, first)
976979 case ports.MsgHello:
@@ -795,6 +795,7 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
795 }795 }
796 796
797 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {797 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
798+ d.cancelRemotePickerRefresh()
798 d.closeMoveLifecycles()799 d.closeMoveLifecycles()
799 d.mu.Lock()800 d.mu.Lock()
800 d.closing = true801 d.closing = true
@@ -970,7 +971,9 @@ func (d *Daemon) handleConn(tr ports.Transport) {
970 case ports.MsgList:971 case ports.MsgList:
971 d.handleList(tr)972 d.handleList(tr)
972 case ports.MsgCommand:973 case ports.MsgCommand:
973- d.handleCommand(tr, first)974+ if err := d.handleCommand(tr, first); err != nil {
975+ d.log.Warn("command handler failed", "err", err)
976+ }
974 case ports.MsgKill:977 case ports.MsgKill:
975 d.handleKill(tr, first)978 d.handleKill(tr, first)
976 case ports.MsgHello:979 case ports.MsgHello:
M internal/usecase/daemon/move_picker.go +9 -3
@@ -161,13 +161,19 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
161161 return
162162 }
163163 intent, source := rt.pickerIntent, rt.pickerSource
164+ observedModel, observedGeneration := rt.picker, rt.pickerGeneration
164165 current := picker.SourceFilter{}
165166 if intent != pickerNavigate || opts.preserveSelection {
166- selected, _ := rt.picker.Selected()
167- current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
167+ cursor, _ := rt.picker.Cursor()
168+ current = picker.SourceFilter{
169+ Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
170+ }
168171 }
169172 rt.pickerMu.Unlock()
170173 model := d.newPickerModel(sess, intent, source, current)
174+ if rt.afterPickerRefreshBuild != nil {
175+ rt.afterPickerRefreshBuild(model)
176+ }
171177 if intent != pickerNavigate {
172178 if _, ok := model.Selected(); !ok {
173179 d.closePicker(ac)
@@ -180,7 +186,7 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
180186 model.SelectNearestRow(opts.nearestRow)
181187 }
182188 rt.pickerMu.Lock()
183- updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
189+ updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerIntent == intent && rt.pickerSource == source
184190 if updated {
185191 rt.picker = model
186192 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
@@ -161,13 +161,19 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
161 return161 return
162 }162 }
163 intent, source := rt.pickerIntent, rt.pickerSource163 intent, source := rt.pickerIntent, rt.pickerSource
164+ observedModel, observedGeneration := rt.picker, rt.pickerGeneration
164 current := picker.SourceFilter{}165 current := picker.SourceFilter{}
165 if intent != pickerNavigate || opts.preserveSelection {166 if intent != pickerNavigate || opts.preserveSelection {
166- selected, _ := rt.picker.Selected()167+ cursor, _ := rt.picker.Cursor()
167- current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}168+ current = picker.SourceFilter{
169+ Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
170+ }
168 }171 }
169 rt.pickerMu.Unlock()172 rt.pickerMu.Unlock()
170 model := d.newPickerModel(sess, intent, source, current)173 model := d.newPickerModel(sess, intent, source, current)
174+ if rt.afterPickerRefreshBuild != nil {
175+ rt.afterPickerRefreshBuild(model)
176+ }
171 if intent != pickerNavigate {177 if intent != pickerNavigate {
172 if _, ok := model.Selected(); !ok {178 if _, ok := model.Selected(); !ok {
173 d.closePicker(ac)179 d.closePicker(ac)
@@ -180,7 +186,7 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
180 model.SelectNearestRow(opts.nearestRow)186 model.SelectNearestRow(opts.nearestRow)
181 }187 }
182 rt.pickerMu.Lock()188 rt.pickerMu.Lock()
183- updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source189+ updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerIntent == intent && rt.pickerSource == source
184 if updated {190 if updated {
185 rt.picker = model191 rt.picker = model
186 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))192 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
M internal/usecase/daemon/overlay_runtime.go +15 -2
@@ -16,8 +16,12 @@ import (
1616 type overlayRuntime struct {
1717 ac *attachedClient
1818
19- pickerMu sync.Mutex
20- picker *picker.Model
19+ pickerMu sync.Mutex
20+ picker *picker.Model
21+ // pickerGeneration identifies one open lifecycle. It advances only when a
22+ // picker is published, so delayed close and registration work can prove it
23+ // still owns the exact lifecycle it captured.
24+ pickerGeneration uint64
2125 pickerTitle string
2226 pickerIntent pickerIntent
2327 pickerSource moveSourceLocator
@@ -27,6 +31,11 @@ type overlayRuntime struct {
2731 pickerPending []byte
2832 pickerESC pendingByteTimer
2933
34+ // Deterministic lifecycle seams used by ownership race tests. Hooks run
35+ // without pickerMu or remoteCatalog.mu held.
36+ beforeRemotePickerRegistration func()
37+ afterPickerRefreshBuild func(*picker.Model)
38+
3039 paletteMu sync.Mutex
3140 palette *palette.Model
3241 paletteRecent []recentSession // immutable for this palette interaction
@@ -257,6 +266,7 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
257266 }
258267 previewSession := rt.pickerPreviewSession
259268 previewGeneration := rt.pickerPreviewGeneration
269+ pickerInstance := remotePickerInstance{ac: token.ac, generation: rt.pickerGeneration, model: rt.picker}
260270 rt.picker = nil
261271 rt.pickerIntent = pickerNavigate
262272 rt.pickerSource = moveSourceLocator{}
@@ -270,6 +280,9 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
270280 // Subscription teardown may enter a coordinator and therefore runs outside
271281 // every overlay-family lock.
272282 d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration)
283+ if pickerInstance.model != nil {
284+ d.remotePickerClosed(pickerInstance)
285+ }
273286
274287 rt.paletteMu.Lock()
275288 if !current() {
@@ -16,8 +16,12 @@ import (
16 type overlayRuntime struct {16 type overlayRuntime struct {
17 ac *attachedClient17 ac *attachedClient
18 18
19- pickerMu sync.Mutex19+ pickerMu sync.Mutex
20- picker *picker.Model20+ picker *picker.Model
21+ // pickerGeneration identifies one open lifecycle. It advances only when a
22+ // picker is published, so delayed close and registration work can prove it
23+ // still owns the exact lifecycle it captured.
24+ pickerGeneration uint64
21 pickerTitle string25 pickerTitle string
22 pickerIntent pickerIntent26 pickerIntent pickerIntent
23 pickerSource moveSourceLocator27 pickerSource moveSourceLocator
@@ -27,6 +31,11 @@ type overlayRuntime struct {
27 pickerPending []byte31 pickerPending []byte
28 pickerESC pendingByteTimer32 pickerESC pendingByteTimer
29 33
34+ // Deterministic lifecycle seams used by ownership race tests. Hooks run
35+ // without pickerMu or remoteCatalog.mu held.
36+ beforeRemotePickerRegistration func()
37+ afterPickerRefreshBuild func(*picker.Model)
38+
30 paletteMu sync.Mutex39 paletteMu sync.Mutex
31 palette *palette.Model40 palette *palette.Model
32 paletteRecent []recentSession // immutable for this palette interaction41 paletteRecent []recentSession // immutable for this palette interaction
@@ -257,6 +266,7 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
257 }266 }
258 previewSession := rt.pickerPreviewSession267 previewSession := rt.pickerPreviewSession
259 previewGeneration := rt.pickerPreviewGeneration268 previewGeneration := rt.pickerPreviewGeneration
269+ pickerInstance := remotePickerInstance{ac: token.ac, generation: rt.pickerGeneration, model: rt.picker}
260 rt.picker = nil270 rt.picker = nil
261 rt.pickerIntent = pickerNavigate271 rt.pickerIntent = pickerNavigate
262 rt.pickerSource = moveSourceLocator{}272 rt.pickerSource = moveSourceLocator{}
@@ -270,6 +280,9 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
270 // Subscription teardown may enter a coordinator and therefore runs outside280 // Subscription teardown may enter a coordinator and therefore runs outside
271 // every overlay-family lock.281 // every overlay-family lock.
272 d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration)282 d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration)
283+ if pickerInstance.model != nil {
284+ d.remotePickerClosed(pickerInstance)
285+ }
273 286
274 rt.paletteMu.Lock()287 rt.paletteMu.Lock()
275 if !current() {288 if !current() {
M internal/usecase/daemon/picker.go +72 -23
@@ -64,6 +64,8 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
6464 rt.pickerPreviewSession = nil
6565 rt.pickerPreview = nil
6666 rt.picker = model
67+ rt.pickerGeneration++
68+ instance := remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: model}
6769 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
6870 rt.pickerIntent = intent
6971 rt.pickerSource = source
@@ -73,13 +75,22 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
7375 d.teardownPreviewSubscription(ac, previous, previousGeneration)
7476 d.registerPreviewForSelection(ac)
7577 d.invalidateRender(sess, ac, true, "picker.go")
78+ if rt.beforeRemotePickerRegistration != nil {
79+ rt.beforeRemotePickerRegistration()
80+ }
81+ d.remotePickerOpened(instance)
7682 }
7783
7884 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
7985 // knows nothing about picker intent; picker.New owns all destination policy.
8086 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) {
8187 d.mu.Lock()
82- sessions := d.sessionsSnapshotLocked()
88+ sessions := make([]attachmentSession, 0, len(d.sessions))
89+ registry := make(map[domain.SessionID]attachmentSession, len(d.sessions))
90+ for id, entry := range d.sessions {
91+ sessions = append(sessions, entry)
92+ registry[id] = entry
93+ }
8394 stopped := make([]stoppedSession, 0, len(d.stopped))
8495 for _, s := range d.stopped {
8596 if !s.purging {
@@ -88,29 +99,38 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
8899 }
89100 d.mu.Unlock()
90101
91- for _, s := range sessions {
92- d.refreshSessionFocusedTitles(s)
102+ for _, entry := range sessions {
103+ if local, ok := localSession(entry); ok {
104+ d.refreshSessionFocusedTitles(local)
105+ }
93106 }
94107 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
95108
96- // One snapshot per session: sorting and view building read the same
109+ type liveSnapshot struct {
110+ entry attachmentSession
111+ view sessionView
112+ }
113+ // One snapshot per live session: sorting and view building read the same
97114 // capture, so comparators cannot observe a concurrent touchMRU or
98- // renameSession mid-sort (the invariant previously enforced by the
99- // pickerSortSnapshot map).
100- snaps := make([]sessionView, 0, len(sessions))
115+ // renameSession mid-sort. Proxy and remote-catalog locks are released before
116+ // sorting and picker-row construction.
117+ live := make([]liveSnapshot, 0, len(sessions))
101118 var current picker.SourceFilter
102- for _, s := range sessions {
103- snap := s.snapshotView(opts)
104- if s == cur && snap.active >= 0 && snap.active < len(snap.tabs) {
119+ for _, entry := range sessions {
120+ snap := entry.snapshotView(opts)
121+ if entry == cur && snap.active >= 0 && snap.active < len(snap.tabs) {
105122 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}
106123 }
107- snaps = append(snaps, snap)
124+ live = append(live, liveSnapshot{entry: entry, view: snap})
108125 }
109- sort.Slice(snaps, func(i, j int) bool {
110- if snaps[i].mruAt != snaps[j].mruAt {
111- return snaps[i].mruAt > snaps[j].mruAt
126+ sort.Slice(live, func(i, j int) bool {
127+ if live[i].view.mruAt != live[j].view.mruAt {
128+ return live[i].view.mruAt > live[j].view.mruAt
112129 }
113- return snaps[i].name < snaps[j].name
130+ if live[i].view.name != live[j].view.name {
131+ return live[i].view.name < live[j].view.name
132+ }
133+ return live[i].view.id < live[j].view.id
114134 })
115135 sort.Slice(stopped, func(i, j int) bool {
116136 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
@@ -121,14 +141,36 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
121141 if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
122142 // Stable partition: named sessions first, ephemeral after, each keeping
123143 // its MRU order from the sort above.
124- sort.SliceStable(snaps, func(i, j int) bool {
125- return !snaps[i].ephemeral && snaps[j].ephemeral
144+ sort.SliceStable(live, func(i, j int) bool {
145+ return !live[i].view.ephemeral && live[j].view.ephemeral
126146 })
127147 }
128148
129- views := make([]picker.SessionView, 0, len(snaps)+len(stopped))
130- for _, snap := range snaps {
131- views = append(views, snap.pickerView())
149+ catalog := d.remotePickerCatalogSnapshot()
150+ sortRemotePickerCatalog(catalog, d.remotePickerHostRanks())
151+
152+ views := make([]picker.SessionView, 0, len(live)+len(stopped))
153+ for _, item := range live {
154+ if proxy, ok := item.entry.(*proxySession); ok {
155+ views = append(views, remoteProxyPickerView(proxy.key, item.view))
156+ continue
157+ }
158+ views = append(views, item.view.pickerView())
159+ }
160+ for _, host := range catalog {
161+ for _, session := range host.entry.Sessions {
162+ key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
163+ if key.Validate() != nil {
164+ continue
165+ }
166+ if proxy, ok := registry[key.ID()].(*proxySession); ok && proxy.key == key {
167+ continue
168+ }
169+ views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
170+ }
171+ if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
172+ views = append(views, remotePickerHostView(host.entry.Host, host.status))
173+ }
132174 }
133175 for _, s := range stopped {
134176 createdAt := s.createdAt
@@ -165,7 +207,8 @@ func attentionSuffix(label string) string {
165207
166208 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
167209 rt := ac.overlays
168- var generation uint64
210+ var previewGeneration uint64
211+ var instance remotePickerInstance
169212 return listInputState{
170213 pending: &rt.pickerPending,
171214 esc: &rt.pickerESC,
@@ -175,13 +218,15 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
175218 unlock: rt.pickerMu.Unlock,
176219 active: func() bool { return rt.picker != nil },
177220 closeLocked: func() {
221+ instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
178222 rt.picker = nil
179223 rt.pickerIntent = pickerNavigate
180224 rt.pickerSource = moveSourceLocator{}
181- generation = rt.pickerPreviewGeneration
225+ previewGeneration = rt.pickerPreviewGeneration
182226 },
183227 afterClose: func() {
184- d.clearPreviewGeneration(ac, generation)
228+ d.clearPreviewGeneration(ac, previewGeneration)
229+ d.remotePickerClosed(instance)
185230 if sess := ac.currentSession(); sess != nil {
186231 d.invalidateRender(sess, ac, true, "picker.go")
187232 }
@@ -452,6 +497,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
452497
453498 func (d *Daemon) closePicker(ac *attachedClient) {
454499 ac.overlays.pickerMu.Lock()
500+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
455501 ac.overlays.picker = nil
456502 ac.overlays.pickerTitle = ""
457503 ac.overlays.pickerIntent = pickerNavigate
@@ -461,6 +507,9 @@ func (d *Daemon) closePicker(ac *attachedClient) {
461507 generation := ac.overlays.pickerPreviewGeneration
462508 ac.overlays.pickerMu.Unlock()
463509 d.clearPreviewGeneration(ac, generation)
510+ if instance.model != nil {
511+ d.remotePickerClosed(instance)
512+ }
464513 }
465514
466515 func pickerTargetsEqual(left, right picker.Target) bool {
@@ -64,6 +64,8 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
64 rt.pickerPreviewSession = nil64 rt.pickerPreviewSession = nil
65 rt.pickerPreview = nil65 rt.pickerPreview = nil
66 rt.picker = model66 rt.picker = model
67+ rt.pickerGeneration++
68+ instance := remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: model}
67 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))69 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
68 rt.pickerIntent = intent70 rt.pickerIntent = intent
69 rt.pickerSource = source71 rt.pickerSource = source
@@ -73,13 +75,22 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
73 d.teardownPreviewSubscription(ac, previous, previousGeneration)75 d.teardownPreviewSubscription(ac, previous, previousGeneration)
74 d.registerPreviewForSelection(ac)76 d.registerPreviewForSelection(ac)
75 d.invalidateRender(sess, ac, true, "picker.go")77 d.invalidateRender(sess, ac, true, "picker.go")
78+ if rt.beforeRemotePickerRegistration != nil {
79+ rt.beforeRemotePickerRegistration()
80+ }
81+ d.remotePickerOpened(instance)
76 }82 }
77 83
78 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally84 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
79 // knows nothing about picker intent; picker.New owns all destination policy.85 // knows nothing about picker intent; picker.New owns all destination policy.
80 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) {86 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) {
81 d.mu.Lock()87 d.mu.Lock()
82- sessions := d.sessionsSnapshotLocked()88+ sessions := make([]attachmentSession, 0, len(d.sessions))
89+ registry := make(map[domain.SessionID]attachmentSession, len(d.sessions))
90+ for id, entry := range d.sessions {
91+ sessions = append(sessions, entry)
92+ registry[id] = entry
93+ }
83 stopped := make([]stoppedSession, 0, len(d.stopped))94 stopped := make([]stoppedSession, 0, len(d.stopped))
84 for _, s := range d.stopped {95 for _, s := range d.stopped {
85 if !s.purging {96 if !s.purging {
@@ -88,29 +99,38 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
88 }99 }
89 d.mu.Unlock()100 d.mu.Unlock()
90 101
91- for _, s := range sessions {102+ for _, entry := range sessions {
92- d.refreshSessionFocusedTitles(s)103+ if local, ok := localSession(entry); ok {
104+ d.refreshSessionFocusedTitles(local)
105+ }
93 }106 }
94 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}107 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
95 108
96- // One snapshot per session: sorting and view building read the same109+ type liveSnapshot struct {
110+ entry attachmentSession
111+ view sessionView
112+ }
113+ // One snapshot per live session: sorting and view building read the same
97 // capture, so comparators cannot observe a concurrent touchMRU or114 // capture, so comparators cannot observe a concurrent touchMRU or
98- // renameSession mid-sort (the invariant previously enforced by the115+ // renameSession mid-sort. Proxy and remote-catalog locks are released before
99- // pickerSortSnapshot map).116+ // sorting and picker-row construction.
100- snaps := make([]sessionView, 0, len(sessions))117+ live := make([]liveSnapshot, 0, len(sessions))
101 var current picker.SourceFilter118 var current picker.SourceFilter
102- for _, s := range sessions {119+ for _, entry := range sessions {
103- snap := s.snapshotView(opts)120+ snap := entry.snapshotView(opts)
104- if s == cur && snap.active >= 0 && snap.active < len(snap.tabs) {121+ if entry == cur && snap.active >= 0 && snap.active < len(snap.tabs) {
105 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}122 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}
106 }123 }
107- snaps = append(snaps, snap)124+ live = append(live, liveSnapshot{entry: entry, view: snap})
108 }125 }
109- sort.Slice(snaps, func(i, j int) bool {126+ sort.Slice(live, func(i, j int) bool {
110- if snaps[i].mruAt != snaps[j].mruAt {127+ if live[i].view.mruAt != live[j].view.mruAt {
111- return snaps[i].mruAt > snaps[j].mruAt128+ return live[i].view.mruAt > live[j].view.mruAt
112 }129 }
113- return snaps[i].name < snaps[j].name130+ if live[i].view.name != live[j].view.name {
131+ return live[i].view.name < live[j].view.name
132+ }
133+ return live[i].view.id < live[j].view.id
114 })134 })
115 sort.Slice(stopped, func(i, j int) bool {135 sort.Slice(stopped, func(i, j int) bool {
116 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {136 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
@@ -121,14 +141,36 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
121 if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {141 if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
122 // Stable partition: named sessions first, ephemeral after, each keeping142 // Stable partition: named sessions first, ephemeral after, each keeping
123 // its MRU order from the sort above.143 // its MRU order from the sort above.
124- sort.SliceStable(snaps, func(i, j int) bool {144+ sort.SliceStable(live, func(i, j int) bool {
125- return !snaps[i].ephemeral && snaps[j].ephemeral145+ return !live[i].view.ephemeral && live[j].view.ephemeral
126 })146 })
127 }147 }
128 148
129- views := make([]picker.SessionView, 0, len(snaps)+len(stopped))149+ catalog := d.remotePickerCatalogSnapshot()
130- for _, snap := range snaps {150+ sortRemotePickerCatalog(catalog, d.remotePickerHostRanks())
131- views = append(views, snap.pickerView())151+
152+ views := make([]picker.SessionView, 0, len(live)+len(stopped))
153+ for _, item := range live {
154+ if proxy, ok := item.entry.(*proxySession); ok {
155+ views = append(views, remoteProxyPickerView(proxy.key, item.view))
156+ continue
157+ }
158+ views = append(views, item.view.pickerView())
159+ }
160+ for _, host := range catalog {
161+ for _, session := range host.entry.Sessions {
162+ key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
163+ if key.Validate() != nil {
164+ continue
165+ }
166+ if proxy, ok := registry[key.ID()].(*proxySession); ok && proxy.key == key {
167+ continue
168+ }
169+ views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
170+ }
171+ if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
172+ views = append(views, remotePickerHostView(host.entry.Host, host.status))
173+ }
132 }174 }
133 for _, s := range stopped {175 for _, s := range stopped {
134 createdAt := s.createdAt176 createdAt := s.createdAt
@@ -165,7 +207,8 @@ func attentionSuffix(label string) string {
165 207
166 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {208 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
167 rt := ac.overlays209 rt := ac.overlays
168- var generation uint64210+ var previewGeneration uint64
211+ var instance remotePickerInstance
169 return listInputState{212 return listInputState{
170 pending: &rt.pickerPending,213 pending: &rt.pickerPending,
171 esc: &rt.pickerESC,214 esc: &rt.pickerESC,
@@ -175,13 +218,15 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
175 unlock: rt.pickerMu.Unlock,218 unlock: rt.pickerMu.Unlock,
176 active: func() bool { return rt.picker != nil },219 active: func() bool { return rt.picker != nil },
177 closeLocked: func() {220 closeLocked: func() {
221+ instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
178 rt.picker = nil222 rt.picker = nil
179 rt.pickerIntent = pickerNavigate223 rt.pickerIntent = pickerNavigate
180 rt.pickerSource = moveSourceLocator{}224 rt.pickerSource = moveSourceLocator{}
181- generation = rt.pickerPreviewGeneration225+ previewGeneration = rt.pickerPreviewGeneration
182 },226 },
183 afterClose: func() {227 afterClose: func() {
184- d.clearPreviewGeneration(ac, generation)228+ d.clearPreviewGeneration(ac, previewGeneration)
229+ d.remotePickerClosed(instance)
185 if sess := ac.currentSession(); sess != nil {230 if sess := ac.currentSession(); sess != nil {
186 d.invalidateRender(sess, ac, true, "picker.go")231 d.invalidateRender(sess, ac, true, "picker.go")
187 }232 }
@@ -452,6 +497,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
452 497
453 func (d *Daemon) closePicker(ac *attachedClient) {498 func (d *Daemon) closePicker(ac *attachedClient) {
454 ac.overlays.pickerMu.Lock()499 ac.overlays.pickerMu.Lock()
500+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
455 ac.overlays.picker = nil501 ac.overlays.picker = nil
456 ac.overlays.pickerTitle = ""502 ac.overlays.pickerTitle = ""
457 ac.overlays.pickerIntent = pickerNavigate503 ac.overlays.pickerIntent = pickerNavigate
@@ -461,6 +507,9 @@ func (d *Daemon) closePicker(ac *attachedClient) {
461 generation := ac.overlays.pickerPreviewGeneration507 generation := ac.overlays.pickerPreviewGeneration
462 ac.overlays.pickerMu.Unlock()508 ac.overlays.pickerMu.Unlock()
463 d.clearPreviewGeneration(ac, generation)509 d.clearPreviewGeneration(ac, generation)
510+ if instance.model != nil {
511+ d.remotePickerClosed(instance)
512+ }
464 }513 }
465 514
466 func pickerTargetsEqual(left, right picker.Target) bool {515 func pickerTargetsEqual(left, right picker.Target) bool {
M internal/usecase/daemon/remote_catalog_state.go +8 -1
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "context"
45 "sort"
56 "sync"
67 "time"
@@ -25,6 +26,9 @@ type remoteCatalogState struct {
2526 cache map[string]ports.RemoteCatalogCacheEntry
2627 status map[string]remoteHostStatus
2728 failure map[string]time.Time
29+ refresh uint64
30+ cancel context.CancelFunc
31+ pickers map[*attachedClient]struct{}
2832 writeMu sync.Mutex
2933 }
3034
@@ -33,14 +37,17 @@ func newRemoteCatalogState() remoteCatalogState {
3337 cache: make(map[string]ports.RemoteCatalogCacheEntry),
3438 status: make(map[string]remoteHostStatus),
3539 failure: make(map[string]time.Time),
40+ pickers: make(map[*attachedClient]struct{}),
3641 }
3742 }
3843
3944 func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {
45+ sessions := make([]ports.RemoteCatalogSession, len(entry.Sessions))
46+ copy(sessions, entry.Sessions)
4047 return ports.RemoteCatalogCacheEntry{
4148 Host: entry.Host,
4249 FetchedAt: entry.FetchedAt,
43- Sessions: append([]ports.RemoteCatalogSession(nil), entry.Sessions...),
50+ Sessions: sessions,
4451 }
4552 }
4653
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
4 "sort"5 "sort"
5 "sync"6 "sync"
6 "time"7 "time"
@@ -25,6 +26,9 @@ type remoteCatalogState struct {
25 cache map[string]ports.RemoteCatalogCacheEntry26 cache map[string]ports.RemoteCatalogCacheEntry
26 status map[string]remoteHostStatus27 status map[string]remoteHostStatus
27 failure map[string]time.Time28 failure map[string]time.Time
29+ refresh uint64
30+ cancel context.CancelFunc
31+ pickers map[*attachedClient]struct{}
28 writeMu sync.Mutex32 writeMu sync.Mutex
29 }33 }
30 34
@@ -33,14 +37,17 @@ func newRemoteCatalogState() remoteCatalogState {
33 cache: make(map[string]ports.RemoteCatalogCacheEntry),37 cache: make(map[string]ports.RemoteCatalogCacheEntry),
34 status: make(map[string]remoteHostStatus),38 status: make(map[string]remoteHostStatus),
35 failure: make(map[string]time.Time),39 failure: make(map[string]time.Time),
40+ pickers: make(map[*attachedClient]struct{}),
36 }41 }
37 }42 }
38 43
39 func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {44 func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {
45+ sessions := make([]ports.RemoteCatalogSession, len(entry.Sessions))
46+ copy(sessions, entry.Sessions)
40 return ports.RemoteCatalogCacheEntry{47 return ports.RemoteCatalogCacheEntry{
41 Host: entry.Host,48 Host: entry.Host,
42 FetchedAt: entry.FetchedAt,49 FetchedAt: entry.FetchedAt,
43- Sessions: append([]ports.RemoteCatalogSession(nil), entry.Sessions...),50+ Sessions: sessions,
44 }51 }
45 }52 }
46 53
A internal/usecase/daemon/remote_picker.go +461 -0
@@ -0,0 +1,461 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "sort"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/picker"
14+)
15+
16+type remotePickerCatalogEntry struct {
17+ entry ports.RemoteCatalogCacheEntry
18+ status remoteHostStatus
19+}
20+
21+// remotePickerInstance is an immutable ownership capability for one published
22+// picker lifecycle. The model identity prevents delayed registration from
23+// attaching ownership to a replacement within the same client.
24+type remotePickerInstance struct {
25+ ac *attachedClient
26+ generation uint64
27+ model *picker.Model
28+}
29+
30+// remotePickerCatalogSnapshot copies the cache-derived state needed for a
31+// picker before any row sorting begins. No remote-state lock survives this
32+// snapshot.
33+func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
34+ if d == nil {
35+ return nil
36+ }
37+ d.remoteCatalog.mu.Lock()
38+ entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
39+ seen := make(map[string]struct{}, len(d.remoteCatalog.cache))
40+ for host, entry := range d.remoteCatalog.cache {
41+ entries = append(entries, remotePickerCatalogEntry{
42+ entry: cloneRemoteCatalogEntry(entry),
43+ status: d.remoteCatalog.status[host],
44+ })
45+ seen[host] = struct{}{}
46+ }
47+ for host, status := range d.remoteCatalog.status {
48+ if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch) {
49+ continue
50+ }
51+ entries = append(entries, remotePickerCatalogEntry{
52+ entry: ports.RemoteCatalogCacheEntry{Host: host},
53+ status: status,
54+ })
55+ }
56+ d.remoteCatalog.mu.Unlock()
57+ return entries
58+}
59+
60+func (d *Daemon) remotePickerHostRanks() map[string]int {
61+ ranks := make(map[string]int)
62+ if d == nil || d.remoteHostStore == nil {
63+ return ranks
64+ }
65+ pinned, learned, err := d.remoteHostStore.Hosts()
66+ if err != nil {
67+ d.log.Warn("listing remote hosts for picker failed", "err", err)
68+ return ranks
69+ }
70+ for _, host := range append(pinned, learned...) {
71+ if _, exists := ranks[host]; !exists {
72+ ranks[host] = len(ranks)
73+ }
74+ }
75+ return ranks
76+}
77+
78+func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {
79+ switch status {
80+ case remoteHostFresh:
81+ return picker.RemoteFresh
82+ case remoteHostUnreachable:
83+ return picker.RemoteStale
84+ case remoteHostVersionMismatch:
85+ return picker.RemoteVersionMismatch
86+ default:
87+ return picker.RemoteCached
88+ }
89+}
90+
91+func remotePickerDetail(tabs uint16) string {
92+ if tabs == 1 {
93+ return "1 tab"
94+ }
95+ return fmt.Sprintf("%d tabs", tabs)
96+}
97+
98+func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
99+ switch status {
100+ case remoteHostUnreachable:
101+ if !fetchedAt.IsZero() {
102+ return "stale since " + fetchedAt.Format(time.RFC3339)
103+ }
104+ return "unreachable"
105+ case remoteHostVersionMismatch:
106+ return "version mismatch"
107+ default:
108+ return ""
109+ }
110+}
111+
112+func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
113+ detail := remotePickerStatusDetail(status, fetchedAt)
114+ if detail == "" {
115+ detail = remotePickerDetail(session.Tabs)
116+ }
117+ return picker.SessionView{
118+ ID: key.ID(),
119+ Name: key.Display(),
120+ RemoteKey: &key,
121+ RemoteHost: key.Host,
122+ RemoteAvailability: remotePickerAvailability(status),
123+ RemoteDetail: detail,
124+ ConnectOnly: true,
125+ RemoteAttachReady: status != remoteHostVersionMismatch,
126+ CannotAcceptMoves: true,
127+ }
128+}
129+
130+func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView {
131+ return picker.SessionView{
132+ ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))),
133+ Name: host,
134+ RemoteHost: host,
135+ RemoteAvailability: remotePickerAvailability(status),
136+ RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
137+ ConnectOnly: true,
138+ CannotAcceptMoves: true,
139+ }
140+}
141+
142+func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker.SessionView {
143+ view := snap.pickerView()
144+ // Proxy construction uses this same ID and label. Restating them from the
145+ // structured key makes the cache-to-live row upgrade independent of mutable
146+ // presentation or relayed metadata. Lifecycle is carried separately from
147+ // presentation so a display-name change can never spoof expiry.
148+ view.ID = key.ID()
149+ view.Name = key.Display()
150+ if snap.expired {
151+ view.Name += " [expired]"
152+ }
153+ view.TargetName = ""
154+ view.RemoteKey = &key
155+ view.RemoteAvailability = picker.RemoteFresh
156+ view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
157+ view.ConnectOnly = true
158+ view.RemoteAttachReady = true
159+ if snap.expired {
160+ view.RemoteAvailability = picker.RemoteStale
161+ view.RemoteDetail = "expired"
162+ view.RemoteAttachReady = false
163+ }
164+ view.CannotAcceptMoves = true
165+ return view
166+}
167+
168+func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
169+ for i := range entries {
170+ sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
171+ return entries[i].entry.Sessions[left].Name < entries[i].entry.Sessions[right].Name
172+ })
173+ }
174+ sort.Slice(entries, func(i, j int) bool {
175+ left, right := entries[i].entry.Host, entries[j].entry.Host
176+ leftRank, leftKnown := ranks[left]
177+ rightRank, rightKnown := ranks[right]
178+ if leftKnown != rightKnown {
179+ return leftKnown
180+ }
181+ if leftKnown && leftRank != rightRank {
182+ return leftRank < rightRank
183+ }
184+ return left < right
185+ })
186+}
187+
188+func (d *Daemon) remotePickerOpened(instance remotePickerInstance) {
189+ if d.registerRemotePicker(instance) {
190+ d.startRemotePickerRefresh(instance)
191+ }
192+}
193+
194+// registerRemotePicker installs one exact picker generation as a refresh owner.
195+// Keeping this step separate gives refresh startup a second atomic validation
196+// point: a close can win between registration and startup without being undone
197+// by a delayed opener.
198+func (d *Daemon) registerRemotePicker(instance remotePickerInstance) bool {
199+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
200+ return false
201+ }
202+ rt := instance.ac.overlays
203+ // Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may
204+ // acquire pickerMu while retaining the catalog lock.
205+ rt.pickerMu.Lock()
206+ defer rt.pickerMu.Unlock()
207+ if rt.pickerGeneration != instance.generation || rt.picker != instance.model {
208+ return false
209+ }
210+ d.remoteCatalog.mu.Lock()
211+ d.remoteCatalog.pickers[instance.ac] = struct{}{}
212+ d.remoteCatalog.mu.Unlock()
213+ return true
214+}
215+
216+func (d *Daemon) remotePickerClosed(instance remotePickerInstance) {
217+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
218+ return
219+ }
220+ rt := instance.ac.overlays
221+ rt.pickerMu.Lock()
222+ current := rt.pickerGeneration == instance.generation && rt.picker == nil
223+ if !current {
224+ rt.pickerMu.Unlock()
225+ return
226+ }
227+ d.remoteCatalog.mu.Lock()
228+ delete(d.remoteCatalog.pickers, instance.ac)
229+ if len(d.remoteCatalog.pickers) != 0 {
230+ d.remoteCatalog.mu.Unlock()
231+ rt.pickerMu.Unlock()
232+ return
233+ }
234+ d.remoteCatalog.refresh++
235+ cancel := d.remoteCatalog.cancel
236+ d.remoteCatalog.cancel = nil
237+ d.remoteCatalog.mu.Unlock()
238+ rt.pickerMu.Unlock()
239+ if cancel != nil {
240+ cancel()
241+ }
242+}
243+
244+func (d *Daemon) cancelRemotePickerRefresh() {
245+ if d == nil {
246+ return
247+ }
248+ d.remoteCatalog.mu.Lock()
249+ d.remoteCatalog.refresh++
250+ cancel := d.remoteCatalog.cancel
251+ d.remoteCatalog.cancel = nil
252+ d.remoteCatalog.mu.Unlock()
253+ if cancel != nil {
254+ cancel()
255+ }
256+}
257+
258+func (d *Daemon) remotePickerHosts() ([]string, error) {
259+ if d == nil || d.remoteHostStore == nil {
260+ return nil, nil
261+ }
262+ pinned, learned, err := d.remoteHostStore.Hosts()
263+ if err != nil {
264+ return nil, err
265+ }
266+ hosts := make([]string, 0, len(pinned)+len(learned))
267+ seen := make(map[string]struct{}, cap(hosts))
268+ add := func(candidates []string) {
269+ for _, host := range candidates {
270+ if _, exists := seen[host]; exists {
271+ continue
272+ }
273+ seen[host] = struct{}{}
274+ hosts = append(hosts, host)
275+ }
276+ }
277+ add(pinned)
278+ add(learned)
279+ return hosts, nil
280+}
281+
282+// startRemotePickerRefresh replaces the current refresh generation and launches
283+// one bounded catalog call per known host. Installing the generation is gated
284+// by the exact picker generation that registered ownership. Host-store and
285+// remote I/O happen after every architecture lock has been released.
286+func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 {
287+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
288+ return 0
289+ }
290+ root := context.Background()
291+ if d.serveCtx != nil {
292+ root = d.serveCtx
293+ }
294+ ctx, cancel := context.WithCancel(root)
295+
296+ rt := instance.ac.overlays
297+ rt.pickerMu.Lock()
298+ d.remoteCatalog.mu.Lock()
299+ _, owner := d.remoteCatalog.pickers[instance.ac]
300+ current := rt.pickerGeneration == instance.generation && rt.picker == instance.model
301+ if !current || !owner || len(d.remoteCatalog.pickers) == 0 {
302+ d.remoteCatalog.mu.Unlock()
303+ rt.pickerMu.Unlock()
304+ cancel()
305+ return 0
306+ }
307+ previous := d.remoteCatalog.cancel
308+ d.remoteCatalog.refresh++
309+ generation := d.remoteCatalog.refresh
310+ d.remoteCatalog.cancel = cancel
311+ d.remoteCatalog.mu.Unlock()
312+ rt.pickerMu.Unlock()
313+ if previous != nil {
314+ previous()
315+ }
316+
317+ hosts, err := d.remotePickerHosts()
318+ if err != nil {
319+ d.log.Warn("listing remote hosts for refresh failed", "err", err)
320+ return generation
321+ }
322+ known := make(map[string]struct{}, len(hosts))
323+ for _, host := range hosts {
324+ known[host] = struct{}{}
325+ }
326+
327+ d.remoteCatalog.mu.Lock()
328+ if d.remoteCatalog.refresh != generation {
329+ d.remoteCatalog.mu.Unlock()
330+ cancel()
331+ return generation
332+ }
333+ cacheChanged := false
334+ for host := range d.remoteCatalog.cache {
335+ if _, exists := known[host]; exists {
336+ continue
337+ }
338+ delete(d.remoteCatalog.cache, host)
339+ delete(d.remoteCatalog.status, host)
340+ delete(d.remoteCatalog.failure, host)
341+ cacheChanged = true
342+ }
343+ for _, host := range hosts {
344+ d.remoteCatalog.status[host] = remoteHostRefreshing
345+ }
346+ d.remoteCatalog.mu.Unlock()
347+ if cacheChanged {
348+ d.persistRemoteCatalogCache()
349+ d.refreshRemoteOpenPickers()
350+ }
351+
352+ if d.remoteCatalogClient == nil {
353+ return generation
354+ }
355+ for _, host := range hosts {
356+ d.sessWg.Go(func() {
357+ catalog, listErr := d.remoteCatalogClient.List(ctx, host)
358+ d.applyRemoteRefreshResult(generation, host, catalog, listErr)
359+ })
360+ }
361+ return generation
362+}
363+
364+func containsRemotePickerHost(hosts []string, target string) bool {
365+ for _, host := range hosts {
366+ if host == target {
367+ return true
368+ }
369+ }
370+ return false
371+}
372+
373+// applyRemoteRefreshResult publishes one host completion only if both its
374+// refresh generation and current host-registry membership remain authoritative.
375+// Its return reports whether durable cache state changed.
376+func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog ports.RemoteCatalog, listErr error) bool {
377+ if d == nil {
378+ return false
379+ }
380+ d.remoteCatalog.mu.Lock()
381+ current := d.remoteCatalog.refresh == generation
382+ d.remoteCatalog.mu.Unlock()
383+ if !current {
384+ return false
385+ }
386+
387+ hosts, hostsErr := d.remotePickerHosts()
388+ if hostsErr != nil {
389+ d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
390+ return false
391+ }
392+ stillKnown := containsRemotePickerHost(hosts, host)
393+ now := d.clock.Now()
394+ if listErr == nil && catalog.ProtocolVersion != ports.ProtocolVersion {
395+ listErr = &ports.RemoteCatalogVersionMismatchError{Got: catalog.ProtocolVersion, Want: ports.ProtocolVersion}
396+ }
397+
398+ d.remoteCatalog.mu.Lock()
399+ if d.remoteCatalog.refresh != generation {
400+ d.remoteCatalog.mu.Unlock()
401+ return false
402+ }
403+ if !stillKnown {
404+ _, cacheChanged := d.remoteCatalog.cache[host]
405+ delete(d.remoteCatalog.cache, host)
406+ delete(d.remoteCatalog.status, host)
407+ delete(d.remoteCatalog.failure, host)
408+ d.remoteCatalog.mu.Unlock()
409+ if cacheChanged {
410+ d.persistRemoteCatalogCache()
411+ d.refreshRemoteOpenPickers()
412+ }
413+ return cacheChanged
414+ }
415+ if listErr != nil {
416+ status := remoteHostUnreachable
417+ var mismatch *ports.RemoteCatalogVersionMismatchError
418+ if errors.As(listErr, &mismatch) {
419+ status = remoteHostVersionMismatch
420+ }
421+ d.remoteCatalog.status[host] = status
422+ d.remoteCatalog.failure[host] = now
423+ d.remoteCatalog.mu.Unlock()
424+ d.refreshRemoteOpenPickers()
425+ return false
426+ }
427+ d.remoteCatalog.cache[host] = ports.RemoteCatalogCacheEntry{
428+ Host: host,
429+ FetchedAt: now,
430+ Sessions: append([]ports.RemoteCatalogSession{}, catalog.Sessions...),
431+ }
432+ d.remoteCatalog.status[host] = remoteHostFresh
433+ delete(d.remoteCatalog.failure, host)
434+ d.remoteCatalog.mu.Unlock()
435+
436+ d.persistRemoteCatalogCache()
437+ d.refreshRemoteOpenPickers()
438+ return true
439+}
440+
441+func (d *Daemon) refreshRemoteOpenPickers() {
442+ if d == nil {
443+ return
444+ }
445+ d.remoteCatalog.mu.Lock()
446+ clients := make([]*attachedClient, 0, len(d.remoteCatalog.pickers))
447+ for ac := range d.remoteCatalog.pickers {
448+ clients = append(clients, ac)
449+ }
450+ d.remoteCatalog.mu.Unlock()
451+
452+ for _, ac := range clients {
453+ if ac == nil || ac.overlays == nil {
454+ continue
455+ }
456+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
457+ if entry := ac.currentAttachmentSession(); entry != nil {
458+ d.invalidateRender(entry, ac, true, "remote_picker.go")
459+ }
460+ }
461+}
@@ -0,0 +1,461 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "sort"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/picker"
14+)
15+
16+type remotePickerCatalogEntry struct {
17+ entry ports.RemoteCatalogCacheEntry
18+ status remoteHostStatus
19+}
20+
21+// remotePickerInstance is an immutable ownership capability for one published
22+// picker lifecycle. The model identity prevents delayed registration from
23+// attaching ownership to a replacement within the same client.
24+type remotePickerInstance struct {
25+ ac *attachedClient
26+ generation uint64
27+ model *picker.Model
28+}
29+
30+// remotePickerCatalogSnapshot copies the cache-derived state needed for a
31+// picker before any row sorting begins. No remote-state lock survives this
32+// snapshot.
33+func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
34+ if d == nil {
35+ return nil
36+ }
37+ d.remoteCatalog.mu.Lock()
38+ entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
39+ seen := make(map[string]struct{}, len(d.remoteCatalog.cache))
40+ for host, entry := range d.remoteCatalog.cache {
41+ entries = append(entries, remotePickerCatalogEntry{
42+ entry: cloneRemoteCatalogEntry(entry),
43+ status: d.remoteCatalog.status[host],
44+ })
45+ seen[host] = struct{}{}
46+ }
47+ for host, status := range d.remoteCatalog.status {
48+ if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch) {
49+ continue
50+ }
51+ entries = append(entries, remotePickerCatalogEntry{
52+ entry: ports.RemoteCatalogCacheEntry{Host: host},
53+ status: status,
54+ })
55+ }
56+ d.remoteCatalog.mu.Unlock()
57+ return entries
58+}
59+
60+func (d *Daemon) remotePickerHostRanks() map[string]int {
61+ ranks := make(map[string]int)
62+ if d == nil || d.remoteHostStore == nil {
63+ return ranks
64+ }
65+ pinned, learned, err := d.remoteHostStore.Hosts()
66+ if err != nil {
67+ d.log.Warn("listing remote hosts for picker failed", "err", err)
68+ return ranks
69+ }
70+ for _, host := range append(pinned, learned...) {
71+ if _, exists := ranks[host]; !exists {
72+ ranks[host] = len(ranks)
73+ }
74+ }
75+ return ranks
76+}
77+
78+func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {
79+ switch status {
80+ case remoteHostFresh:
81+ return picker.RemoteFresh
82+ case remoteHostUnreachable:
83+ return picker.RemoteStale
84+ case remoteHostVersionMismatch:
85+ return picker.RemoteVersionMismatch
86+ default:
87+ return picker.RemoteCached
88+ }
89+}
90+
91+func remotePickerDetail(tabs uint16) string {
92+ if tabs == 1 {
93+ return "1 tab"
94+ }
95+ return fmt.Sprintf("%d tabs", tabs)
96+}
97+
98+func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
99+ switch status {
100+ case remoteHostUnreachable:
101+ if !fetchedAt.IsZero() {
102+ return "stale since " + fetchedAt.Format(time.RFC3339)
103+ }
104+ return "unreachable"
105+ case remoteHostVersionMismatch:
106+ return "version mismatch"
107+ default:
108+ return ""
109+ }
110+}
111+
112+func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
113+ detail := remotePickerStatusDetail(status, fetchedAt)
114+ if detail == "" {
115+ detail = remotePickerDetail(session.Tabs)
116+ }
117+ return picker.SessionView{
118+ ID: key.ID(),
119+ Name: key.Display(),
120+ RemoteKey: &key,
121+ RemoteHost: key.Host,
122+ RemoteAvailability: remotePickerAvailability(status),
123+ RemoteDetail: detail,
124+ ConnectOnly: true,
125+ RemoteAttachReady: status != remoteHostVersionMismatch,
126+ CannotAcceptMoves: true,
127+ }
128+}
129+
130+func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView {
131+ return picker.SessionView{
132+ ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))),
133+ Name: host,
134+ RemoteHost: host,
135+ RemoteAvailability: remotePickerAvailability(status),
136+ RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
137+ ConnectOnly: true,
138+ CannotAcceptMoves: true,
139+ }
140+}
141+
142+func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker.SessionView {
143+ view := snap.pickerView()
144+ // Proxy construction uses this same ID and label. Restating them from the
145+ // structured key makes the cache-to-live row upgrade independent of mutable
146+ // presentation or relayed metadata. Lifecycle is carried separately from
147+ // presentation so a display-name change can never spoof expiry.
148+ view.ID = key.ID()
149+ view.Name = key.Display()
150+ if snap.expired {
151+ view.Name += " [expired]"
152+ }
153+ view.TargetName = ""
154+ view.RemoteKey = &key
155+ view.RemoteAvailability = picker.RemoteFresh
156+ view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
157+ view.ConnectOnly = true
158+ view.RemoteAttachReady = true
159+ if snap.expired {
160+ view.RemoteAvailability = picker.RemoteStale
161+ view.RemoteDetail = "expired"
162+ view.RemoteAttachReady = false
163+ }
164+ view.CannotAcceptMoves = true
165+ return view
166+}
167+
168+func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
169+ for i := range entries {
170+ sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
171+ return entries[i].entry.Sessions[left].Name < entries[i].entry.Sessions[right].Name
172+ })
173+ }
174+ sort.Slice(entries, func(i, j int) bool {
175+ left, right := entries[i].entry.Host, entries[j].entry.Host
176+ leftRank, leftKnown := ranks[left]
177+ rightRank, rightKnown := ranks[right]
178+ if leftKnown != rightKnown {
179+ return leftKnown
180+ }
181+ if leftKnown && leftRank != rightRank {
182+ return leftRank < rightRank
183+ }
184+ return left < right
185+ })
186+}
187+
188+func (d *Daemon) remotePickerOpened(instance remotePickerInstance) {
189+ if d.registerRemotePicker(instance) {
190+ d.startRemotePickerRefresh(instance)
191+ }
192+}
193+
194+// registerRemotePicker installs one exact picker generation as a refresh owner.
195+// Keeping this step separate gives refresh startup a second atomic validation
196+// point: a close can win between registration and startup without being undone
197+// by a delayed opener.
198+func (d *Daemon) registerRemotePicker(instance remotePickerInstance) bool {
199+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
200+ return false
201+ }
202+ rt := instance.ac.overlays
203+ // Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may
204+ // acquire pickerMu while retaining the catalog lock.
205+ rt.pickerMu.Lock()
206+ defer rt.pickerMu.Unlock()
207+ if rt.pickerGeneration != instance.generation || rt.picker != instance.model {
208+ return false
209+ }
210+ d.remoteCatalog.mu.Lock()
211+ d.remoteCatalog.pickers[instance.ac] = struct{}{}
212+ d.remoteCatalog.mu.Unlock()
213+ return true
214+}
215+
216+func (d *Daemon) remotePickerClosed(instance remotePickerInstance) {
217+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
218+ return
219+ }
220+ rt := instance.ac.overlays
221+ rt.pickerMu.Lock()
222+ current := rt.pickerGeneration == instance.generation && rt.picker == nil
223+ if !current {
224+ rt.pickerMu.Unlock()
225+ return
226+ }
227+ d.remoteCatalog.mu.Lock()
228+ delete(d.remoteCatalog.pickers, instance.ac)
229+ if len(d.remoteCatalog.pickers) != 0 {
230+ d.remoteCatalog.mu.Unlock()
231+ rt.pickerMu.Unlock()
232+ return
233+ }
234+ d.remoteCatalog.refresh++
235+ cancel := d.remoteCatalog.cancel
236+ d.remoteCatalog.cancel = nil
237+ d.remoteCatalog.mu.Unlock()
238+ rt.pickerMu.Unlock()
239+ if cancel != nil {
240+ cancel()
241+ }
242+}
243+
244+func (d *Daemon) cancelRemotePickerRefresh() {
245+ if d == nil {
246+ return
247+ }
248+ d.remoteCatalog.mu.Lock()
249+ d.remoteCatalog.refresh++
250+ cancel := d.remoteCatalog.cancel
251+ d.remoteCatalog.cancel = nil
252+ d.remoteCatalog.mu.Unlock()
253+ if cancel != nil {
254+ cancel()
255+ }
256+}
257+
258+func (d *Daemon) remotePickerHosts() ([]string, error) {
259+ if d == nil || d.remoteHostStore == nil {
260+ return nil, nil
261+ }
262+ pinned, learned, err := d.remoteHostStore.Hosts()
263+ if err != nil {
264+ return nil, err
265+ }
266+ hosts := make([]string, 0, len(pinned)+len(learned))
267+ seen := make(map[string]struct{}, cap(hosts))
268+ add := func(candidates []string) {
269+ for _, host := range candidates {
270+ if _, exists := seen[host]; exists {
271+ continue
272+ }
273+ seen[host] = struct{}{}
274+ hosts = append(hosts, host)
275+ }
276+ }
277+ add(pinned)
278+ add(learned)
279+ return hosts, nil
280+}
281+
282+// startRemotePickerRefresh replaces the current refresh generation and launches
283+// one bounded catalog call per known host. Installing the generation is gated
284+// by the exact picker generation that registered ownership. Host-store and
285+// remote I/O happen after every architecture lock has been released.
286+func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 {
287+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
288+ return 0
289+ }
290+ root := context.Background()
291+ if d.serveCtx != nil {
292+ root = d.serveCtx
293+ }
294+ ctx, cancel := context.WithCancel(root)
295+
296+ rt := instance.ac.overlays
297+ rt.pickerMu.Lock()
298+ d.remoteCatalog.mu.Lock()
299+ _, owner := d.remoteCatalog.pickers[instance.ac]
300+ current := rt.pickerGeneration == instance.generation && rt.picker == instance.model
301+ if !current || !owner || len(d.remoteCatalog.pickers) == 0 {
302+ d.remoteCatalog.mu.Unlock()
303+ rt.pickerMu.Unlock()
304+ cancel()
305+ return 0
306+ }
307+ previous := d.remoteCatalog.cancel
308+ d.remoteCatalog.refresh++
309+ generation := d.remoteCatalog.refresh
310+ d.remoteCatalog.cancel = cancel
311+ d.remoteCatalog.mu.Unlock()
312+ rt.pickerMu.Unlock()
313+ if previous != nil {
314+ previous()
315+ }
316+
317+ hosts, err := d.remotePickerHosts()
318+ if err != nil {
319+ d.log.Warn("listing remote hosts for refresh failed", "err", err)
320+ return generation
321+ }
322+ known := make(map[string]struct{}, len(hosts))
323+ for _, host := range hosts {
324+ known[host] = struct{}{}
325+ }
326+
327+ d.remoteCatalog.mu.Lock()
328+ if d.remoteCatalog.refresh != generation {
329+ d.remoteCatalog.mu.Unlock()
330+ cancel()
331+ return generation
332+ }
333+ cacheChanged := false
334+ for host := range d.remoteCatalog.cache {
335+ if _, exists := known[host]; exists {
336+ continue
337+ }
338+ delete(d.remoteCatalog.cache, host)
339+ delete(d.remoteCatalog.status, host)
340+ delete(d.remoteCatalog.failure, host)
341+ cacheChanged = true
342+ }
343+ for _, host := range hosts {
344+ d.remoteCatalog.status[host] = remoteHostRefreshing
345+ }
346+ d.remoteCatalog.mu.Unlock()
347+ if cacheChanged {
348+ d.persistRemoteCatalogCache()
349+ d.refreshRemoteOpenPickers()
350+ }
351+
352+ if d.remoteCatalogClient == nil {
353+ return generation
354+ }
355+ for _, host := range hosts {
356+ d.sessWg.Go(func() {
357+ catalog, listErr := d.remoteCatalogClient.List(ctx, host)
358+ d.applyRemoteRefreshResult(generation, host, catalog, listErr)
359+ })
360+ }
361+ return generation
362+}
363+
364+func containsRemotePickerHost(hosts []string, target string) bool {
365+ for _, host := range hosts {
366+ if host == target {
367+ return true
368+ }
369+ }
370+ return false
371+}
372+
373+// applyRemoteRefreshResult publishes one host completion only if both its
374+// refresh generation and current host-registry membership remain authoritative.
375+// Its return reports whether durable cache state changed.
376+func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog ports.RemoteCatalog, listErr error) bool {
377+ if d == nil {
378+ return false
379+ }
380+ d.remoteCatalog.mu.Lock()
381+ current := d.remoteCatalog.refresh == generation
382+ d.remoteCatalog.mu.Unlock()
383+ if !current {
384+ return false
385+ }
386+
387+ hosts, hostsErr := d.remotePickerHosts()
388+ if hostsErr != nil {
389+ d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
390+ return false
391+ }
392+ stillKnown := containsRemotePickerHost(hosts, host)
393+ now := d.clock.Now()
394+ if listErr == nil && catalog.ProtocolVersion != ports.ProtocolVersion {
395+ listErr = &ports.RemoteCatalogVersionMismatchError{Got: catalog.ProtocolVersion, Want: ports.ProtocolVersion}
396+ }
397+
398+ d.remoteCatalog.mu.Lock()
399+ if d.remoteCatalog.refresh != generation {
400+ d.remoteCatalog.mu.Unlock()
401+ return false
402+ }
403+ if !stillKnown {
404+ _, cacheChanged := d.remoteCatalog.cache[host]
405+ delete(d.remoteCatalog.cache, host)
406+ delete(d.remoteCatalog.status, host)
407+ delete(d.remoteCatalog.failure, host)
408+ d.remoteCatalog.mu.Unlock()
409+ if cacheChanged {
410+ d.persistRemoteCatalogCache()
411+ d.refreshRemoteOpenPickers()
412+ }
413+ return cacheChanged
414+ }
415+ if listErr != nil {
416+ status := remoteHostUnreachable
417+ var mismatch *ports.RemoteCatalogVersionMismatchError
418+ if errors.As(listErr, &mismatch) {
419+ status = remoteHostVersionMismatch
420+ }
421+ d.remoteCatalog.status[host] = status
422+ d.remoteCatalog.failure[host] = now
423+ d.remoteCatalog.mu.Unlock()
424+ d.refreshRemoteOpenPickers()
425+ return false
426+ }
427+ d.remoteCatalog.cache[host] = ports.RemoteCatalogCacheEntry{
428+ Host: host,
429+ FetchedAt: now,
430+ Sessions: append([]ports.RemoteCatalogSession{}, catalog.Sessions...),
431+ }
432+ d.remoteCatalog.status[host] = remoteHostFresh
433+ delete(d.remoteCatalog.failure, host)
434+ d.remoteCatalog.mu.Unlock()
435+
436+ d.persistRemoteCatalogCache()
437+ d.refreshRemoteOpenPickers()
438+ return true
439+}
440+
441+func (d *Daemon) refreshRemoteOpenPickers() {
442+ if d == nil {
443+ return
444+ }
445+ d.remoteCatalog.mu.Lock()
446+ clients := make([]*attachedClient, 0, len(d.remoteCatalog.pickers))
447+ for ac := range d.remoteCatalog.pickers {
448+ clients = append(clients, ac)
449+ }
450+ d.remoteCatalog.mu.Unlock()
451+
452+ for _, ac := range clients {
453+ if ac == nil || ac.overlays == nil {
454+ continue
455+ }
456+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
457+ if entry := ac.currentAttachmentSession(); entry != nil {
458+ d.invalidateRender(entry, ac, true, "remote_picker.go")
459+ }
460+ }
461+}
A internal/usecase/daemon/remote_picker_test.go +696 -0
@@ -0,0 +1,696 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "log/slog"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+ "github.com/bnema/vev/internal/usecase/picker"
18+)
19+
20+func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
21+ return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
22+}
23+
24+func TestRemotePickerMerge(t *testing.T) {
25+ store := portsmocks.NewMockRemoteHostStore(t)
26+ store.EXPECT().Hosts().Return([]string{"pinned"}, []string{"learned"}, nil).Once()
27+ d := newRemotePickerDaemon(store)
28+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{{}}}
29+ local.mruAt.Store(6)
30+ key := domain.RemoteSessionKey{Host: "pinned", Name: "work"}
31+ proxy := &proxySession{
32+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
33+ key: key,
34+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
35+ {Index: 0, Name: "shell"},
36+ }},
37+ }
38+ proxy.mruAt.Store(5)
39+ d.sessions[local.id] = local
40+ d.sessions[proxy.id] = proxy
41+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 2, lastUsedSeq: 9}
42+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
43+ {Host: "learned", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "alpha", State: "running", Tabs: 3}}},
44+ {Host: "pinned", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 9}, {Name: "alpha", State: "running", Tabs: 2}}},
45+ })
46+ d.remoteCatalog.mu.Lock()
47+ d.remoteCatalog.status["pinned"] = remoteHostFresh
48+ d.remoteCatalog.status["learned"] = remoteHostFresh
49+ d.remoteCatalog.mu.Unlock()
50+
51+ views, _ := d.pickerViews(local)
52+
53+ require.Equal(t, []string{"local", "work@pinned", "alpha@pinned", "alpha@learned", "stopped"}, pickerViewNames(views))
54+ require.False(t, views[0].Stopped)
55+ require.Equal(t, key, *views[1].RemoteKey)
56+ require.Equal(t, picker.RemoteFresh, views[1].RemoteAvailability)
57+ require.True(t, views[1].ConnectOnly)
58+ require.Equal(t, "2 tabs", views[2].RemoteDetail)
59+ require.Equal(t, picker.RemoteFresh, views[2].RemoteAvailability)
60+ require.Equal(t, picker.RemoteFresh, views[3].RemoteAvailability)
61+ require.True(t, views[4].Stopped)
62+}
63+
64+func TestRemotePickerDedupeUsesExactKeyAndKeepsLiveID(t *testing.T) {
65+ d := newRemotePickerDaemon(nil)
66+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
67+ proxy := &proxySession{
68+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
69+ key: key,
70+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
71+ {Index: 0, Name: "live tab"},
72+ }},
73+ }
74+ d.sessions[key.ID()] = proxy
75+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
76+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{
77+ {Name: "work", State: "running", Tabs: 7},
78+ },
79+ }})
80+
81+ views, _ := d.pickerViews(nil)
82+
83+ require.Len(t, views, 1)
84+ require.Equal(t, key.ID(), views[0].ID)
85+ require.Equal(t, key, *views[0].RemoteKey)
86+ require.Equal(t, []picker.TabEntry{{Name: "live tab"}}, views[0].Tabs, "live proxy snapshot wins over cached discovery")
87+ require.Equal(t, picker.RemoteFresh, views[0].RemoteAvailability)
88+}
89+
90+func TestRemotePickerSnapshotsDoNotRetainProxyLock(t *testing.T) {
91+ d := newRemotePickerDaemon(nil)
92+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
93+ proxy := &proxySession{
94+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
95+ key: key,
96+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}},
97+ }
98+ d.sessions[key.ID()] = proxy
99+
100+ views, _ := d.pickerViews(nil)
101+
102+ require.Len(t, views, 1)
103+ require.True(t, proxy.mu.TryLock(), "picker construction must release the proxy leaf lock")
104+ proxy.mu.Unlock()
105+ require.True(t, d.remoteCatalog.mu.TryLock(), "picker construction must release the remote catalog lock")
106+ d.remoteCatalog.mu.Unlock()
107+}
108+
109+func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
110+ tests := []struct {
111+ name string
112+ status remoteHostStatus
113+ want picker.RemoteAvailability
114+ }{
115+ {name: "cached", status: remoteHostCached, want: picker.RemoteCached},
116+ {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale},
117+ {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch},
118+ }
119+
120+ for _, test := range tests {
121+ t.Run(test.name, func(t *testing.T) {
122+ d := newRemotePickerDaemon(nil)
123+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
124+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
125+ }})
126+ d.remoteCatalog.mu.Lock()
127+ d.remoteCatalog.status["arch"] = test.status
128+ d.remoteCatalog.mu.Unlock()
129+
130+ views, _ := d.pickerViews(nil)
131+
132+ require.Len(t, views, 1)
133+ require.Equal(t, test.want, views[0].RemoteAvailability)
134+ })
135+ }
136+}
137+
138+func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
139+ d := newRemotePickerDaemon(nil)
140+ fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
141+ failedAt := fetchedAt.Add(6 * time.Hour)
142+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
143+ Host: "arch", FetchedAt: fetchedAt, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
144+ }})
145+ d.remoteCatalog.mu.Lock()
146+ d.remoteCatalog.status["arch"] = remoteHostUnreachable
147+ d.remoteCatalog.failure["arch"] = failedAt
148+ d.remoteCatalog.mu.Unlock()
149+
150+ views, _ := d.pickerViews(nil)
151+
152+ require.Len(t, views, 1)
153+ require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail)
154+ require.NotContains(t, views[0].RemoteDetail, failedAt.Format(time.RFC3339), "failure time remains runtime-only and is not the freshness authority")
155+}
156+
157+func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
158+ for _, test := range []struct {
159+ name string
160+ status remoteHostStatus
161+ want picker.RemoteAvailability
162+ wantDetail string
163+ }{
164+ {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale, wantDetail: "unreachable"},
165+ {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch, wantDetail: "version mismatch"},
166+ } {
167+ t.Run(test.name, func(t *testing.T) {
168+ d := newRemotePickerDaemon(nil)
169+ d.remoteCatalog.mu.Lock()
170+ d.remoteCatalog.status["arch"] = test.status
171+ d.remoteCatalog.failure["arch"] = time.Unix(50, 0)
172+ d.remoteCatalog.mu.Unlock()
173+
174+ views, _ := d.pickerViews(nil)
175+
176+ require.Len(t, views, 1)
177+ require.Equal(t, "arch", views[0].Name)
178+ require.Equal(t, test.want, views[0].RemoteAvailability)
179+ require.Equal(t, test.wantDetail, views[0].RemoteDetail)
180+ model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
181+ _, selectable := model.Selected()
182+ require.False(t, selectable)
183+ })
184+ }
185+}
186+
187+func TestRemotePickerGroupedSortTreatsProxiesAsLiveSessions(t *testing.T) {
188+ d := newRemotePickerDaemon(nil)
189+ d.pickerSort.Store(uint32(pickerSortGrouped))
190+ ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
191+ ephemeral.mruAt.Store(9)
192+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
193+ proxy := &proxySession{sessionCore: sessionCore{id: key.ID(), name: key.Display()}, key: key}
194+ proxy.mruAt.Store(1)
195+ named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
196+ named.mruAt.Store(2)
197+ d.sessions[ephemeral.id] = ephemeral
198+ d.sessions[proxy.id] = proxy
199+ d.sessions[named.id] = named
200+
201+ views, _ := d.pickerViews(nil)
202+
203+ require.Equal(t, []string{"named", "work@arch", "1"}, pickerViewNames(views))
204+}
205+
206+func pickerViewNames(views []picker.SessionView) []string {
207+ names := make([]string, len(views))
208+ for i, view := range views {
209+ names[i] = view.Name
210+ }
211+ return names
212+}
213+
214+type remoteRefreshHostStore struct {
215+ mu sync.Mutex
216+ hosts []string
217+}
218+
219+func (s *remoteRefreshHostStore) Hosts() ([]string, []string, error) {
220+ s.mu.Lock()
221+ defer s.mu.Unlock()
222+ return append([]string(nil), s.hosts...), nil, nil
223+}
224+
225+func (s *remoteRefreshHostStore) set(hosts ...string) {
226+ s.mu.Lock()
227+ s.hosts = append([]string(nil), hosts...)
228+ s.mu.Unlock()
229+}
230+
231+func (*remoteRefreshHostStore) AddPinned(string) error { return nil }
232+func (*remoteRefreshHostStore) RemovePinned(string) error { return nil }
233+func (*remoteRefreshHostStore) Remember(string) error { return nil }
234+func (*remoteRefreshHostStore) Forget(string) error { return nil }
235+func (*remoteRefreshHostStore) Remove(string) (bool, error) {
236+ return false, nil
237+}
238+
239+type remoteRefreshRequest struct {
240+ ctx context.Context
241+ host string
242+ result chan remoteRefreshResult
243+}
244+
245+type remoteRefreshResult struct {
246+ catalog ports.RemoteCatalog
247+ err error
248+}
249+
250+type channelRemoteCatalog struct {
251+ d *Daemon
252+ requests chan remoteRefreshRequest
253+ probeMu sync.Mutex
254+}
255+
256+func (c *channelRemoteCatalog) List(ctx context.Context, host string) (ports.RemoteCatalog, error) {
257+ c.probeMu.Lock()
258+ if !c.d.mu.TryLock() {
259+ c.probeMu.Unlock()
260+ return ports.RemoteCatalog{}, errors.New("daemon lock held during remote catalog I/O")
261+ }
262+ c.d.mu.Unlock()
263+ if !c.d.remoteCatalog.mu.TryLock() {
264+ c.probeMu.Unlock()
265+ return ports.RemoteCatalog{}, errors.New("remote catalog lock held during remote catalog I/O")
266+ }
267+ c.d.remoteCatalog.mu.Unlock()
268+ c.probeMu.Unlock()
269+ request := remoteRefreshRequest{ctx: ctx, host: host, result: make(chan remoteRefreshResult, 1)}
270+ c.requests <- request
271+ select {
272+ case result := <-request.result:
273+ return result.catalog, result.err
274+ case <-ctx.Done():
275+ return ports.RemoteCatalog{}, ctx.Err()
276+ }
277+}
278+
279+type recordingRemoteCache struct {
280+ d *Daemon
281+ stores chan []ports.RemoteCatalogCacheEntry
282+}
283+
284+func (*recordingRemoteCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil }
285+
286+func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
287+ if !c.d.mu.TryLock() {
288+ return errors.New("daemon lock held during remote cache write")
289+ }
290+ c.d.mu.Unlock()
291+ if !c.d.remoteCatalog.mu.TryLock() {
292+ return errors.New("remote catalog lock held during remote cache write")
293+ }
294+ c.d.remoteCatalog.mu.Unlock()
295+ copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries))
296+ for i, entry := range entries {
297+ copyEntries[i] = cloneRemoteCatalogEntry(entry)
298+ }
299+ c.stores <- copyEntries
300+ return nil
301+}
302+
303+type fixedRemoteRefreshClock struct{ now time.Time }
304+
305+func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
306+func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
307+
308+func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {
309+ t.Helper()
310+ catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}
311+ cache := &recordingRemoteCache{stores: make(chan []ports.RemoteCatalogCacheEntry, 8)}
312+ d := New(nil, fixedRemoteRefreshClock{now: now}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(hosts, catalog, cache, nil, ports.RemoteTransportUDP))
313+ catalog.d, cache.d = d, d
314+ return d, catalog, cache
315+}
316+
317+func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T) {
318+ hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
319+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(100, 0))
320+ ac := &attachedClient{}
321+ ac.initOverlays()
322+ model := picker.New(nil, picker.SelectionConfig{})
323+ ac.overlays.pickerMu.Lock()
324+ ac.overlays.picker = model
325+ ac.overlays.pickerGeneration++
326+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
327+ ac.overlays.pickerMu.Unlock()
328+ require.True(t, d.registerRemotePicker(instance))
329+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
330+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "cached", State: "running"}},
331+ }})
332+
333+ firstGeneration := d.startRemotePickerRefresh(instance)
334+ first := map[string]remoteRefreshRequest{}
335+ for range 2 {
336+ request := <-catalog.requests
337+ first[request.host] = request
338+ }
339+ require.Contains(t, first, "arch")
340+ require.Contains(t, first, "mule")
341+ d.remoteCatalog.mu.Lock()
342+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "cached", State: "running"}}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
343+ require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
344+ d.remoteCatalog.mu.Unlock()
345+
346+ secondGeneration := d.startRemotePickerRefresh(instance)
347+ require.Greater(t, secondGeneration, firstGeneration)
348+ for _, request := range first {
349+ <-request.ctx.Done()
350+ }
351+ for range 2 {
352+ request := <-catalog.requests
353+ request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}
354+ }
355+ for range 2 {
356+ <-cache.stores
357+ }
358+
359+ d.remoteCatalog.mu.Lock()
360+ require.Equal(t, secondGeneration, d.remoteCatalog.refresh)
361+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
362+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["mule"])
363+ d.remoteCatalog.mu.Unlock()
364+}
365+
366+func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *testing.T) {
367+ now := time.Unix(200, 0)
368+ hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
369+ d, _, cache := newRemoteRefreshDaemon(t, hosts, now)
370+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
371+ {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}}},
372+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},
373+ })
374+ d.remoteCatalog.mu.Lock()
375+ d.remoteCatalog.refresh = 7
376+ d.remoteCatalog.mu.Unlock()
377+
378+ require.False(t, d.applyRemoteRefreshResult(7, "mule", ports.RemoteCatalog{}, errors.New("offline")))
379+ d.remoteCatalog.mu.Lock()
380+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}, d.remoteCatalog.cache["mule"].Sessions)
381+ require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)
382+ require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
383+ d.remoteCatalog.mu.Unlock()
384+
385+ require.True(t, d.applyRemoteRefreshResult(7, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}, nil))
386+ stored := <-cache.stores
387+ require.Equal(t, []ports.RemoteCatalogCacheEntry{
388+ {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},
389+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},
390+ }, stored, "every successful write persists the newest full cache atomically")
391+ d.remoteCatalog.mu.Lock()
392+ require.Empty(t, d.remoteCatalog.cache["arch"].Sessions, "a successful omission authoritatively evicts old rows")
393+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
394+ d.remoteCatalog.mu.Unlock()
395+}
396+
397+func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
398+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
399+ d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))
400+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
401+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}},
402+ }})
403+ d.remoteCatalog.mu.Lock()
404+ d.remoteCatalog.refresh = 9
405+ d.remoteCatalog.mu.Unlock()
406+
407+ require.False(t, d.applyRemoteRefreshResult(8, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "late", State: "running"}}}, nil))
408+ hosts.set()
409+ require.True(t, d.applyRemoteRefreshResult(9, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "removed", State: "running"}}}, nil))
410+ require.Empty(t, <-cache.stores)
411+ d.remoteCatalog.mu.Lock()
412+ require.NotContains(t, d.remoteCatalog.cache, "arch")
413+ require.NotContains(t, d.remoteCatalog.status, "arch")
414+ d.remoteCatalog.mu.Unlock()
415+}
416+
417+func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
418+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
419+ d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
420+ old := ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}
421+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{old})
422+ d.remoteCatalog.mu.Lock()
423+ d.remoteCatalog.refresh = 3
424+ d.remoteCatalog.mu.Unlock()
425+
426+ require.False(t, d.applyRemoteRefreshResult(3, "arch", ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{Got: 19, Want: ports.ProtocolVersion}))
427+ d.remoteCatalog.mu.Lock()
428+ require.Equal(t, old, d.remoteCatalog.cache["arch"])
429+ require.Equal(t, remoteHostVersionMismatch, d.remoteCatalog.status["arch"])
430+ d.remoteCatalog.mu.Unlock()
431+}
432+
433+func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
434+ t.Helper()
435+ tr, sends := newCapturingTransport(t)
436+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
437+ ac.initOverlays()
438+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
439+ tb.stableID = string(id) + "-tab"
440+ sess := &session{sessionCore: sessionCore{id: id, name: string(id), client: ac}, tabs: []*tab{tb}, active: 0}
441+ publishTiledPaneOwners(sess, tb)
442+ ac.setSession(sess)
443+ d.sessions[id] = sess
444+ return sess, ac, sends
445+}
446+
447+func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
448+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
449+ d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(450, 0))
450+ firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
451+ _, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
452+ before := make(map[*attachedClient]*picker.Model)
453+
454+ for _, owner := range []*attachedClient{first, second} {
455+ owner.overlays.pickerMu.Lock()
456+ owner.overlays.picker = d.newPickerModel(firstSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: "second", TabID: "second-tab"})
457+ owner.overlays.pickerIntent = pickerNavigate
458+ before[owner] = owner.overlays.picker
459+ owner.overlays.pickerMu.Unlock()
460+ d.remoteCatalog.mu.Lock()
461+ d.remoteCatalog.pickers[owner] = struct{}{}
462+ d.remoteCatalog.mu.Unlock()
463+ }
464+ d.remoteCatalog.mu.Lock()
465+ d.remoteCatalog.refresh = 4
466+ d.remoteCatalog.mu.Unlock()
467+
468+ require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{
469+ ProtocolVersion: ports.ProtocolVersion,
470+ Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 2}},
471+ }, nil))
472+ <-cache.stores
473+
474+ for _, owner := range []*attachedClient{first, second} {
475+ owner.overlays.pickerMu.Lock()
476+ updated := owner.overlays.picker
477+ selected, ok := updated.Selected()
478+ owner.overlays.pickerMu.Unlock()
479+ require.NotSame(t, before[owner], updated, "every open picker must receive the refreshed model")
480+ require.True(t, ok)
481+ require.Equal(t, domain.SessionID("second"), selected.Session)
482+ }
483+}
484+
485+func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.T) {
486+ d := newRemotePickerDaemon(nil)
487+ ownerSession, owner, _ := addRemoteRefreshPickerOwner(t, d, "local")
488+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
489+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
490+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
491+ }})
492+ owner.overlays.pickerMu.Lock()
493+ owner.overlays.picker = d.newPickerModel(ownerSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
494+ owner.overlays.pickerIntent = pickerNavigate
495+ before, ok := owner.overlays.picker.Cursor()
496+ owner.overlays.pickerMu.Unlock()
497+ require.True(t, ok)
498+ require.Equal(t, &key, before.RemoteKey)
499+
500+ proxy := &proxySession{
501+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
502+ key: key,
503+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "live"}}},
504+ }
505+ d.mu.Lock()
506+ d.sessions[key.ID()] = proxy
507+ d.mu.Unlock()
508+
509+ d.refreshPickerOpts(owner, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
510+
511+ owner.overlays.pickerMu.Lock()
512+ after, ok := owner.overlays.picker.Cursor()
513+ _, selectable := owner.overlays.picker.Selected()
514+ owner.overlays.pickerMu.Unlock()
515+ require.True(t, ok)
516+ require.Equal(t, before.Session, after.Session)
517+ require.Equal(t, before.RemoteKey, after.RemoteKey)
518+ require.False(t, selectable, "Phase 5 cache-to-live upgrade must not activate the proxy")
519+}
520+
521+func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
522+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
523+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(500, 0))
524+ newInstance := func() remotePickerInstance {
525+ ac := &attachedClient{}
526+ ac.initOverlays()
527+ model := picker.New(nil, picker.SelectionConfig{})
528+ ac.overlays.pickerMu.Lock()
529+ ac.overlays.picker = model
530+ ac.overlays.pickerGeneration++
531+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
532+ ac.overlays.pickerMu.Unlock()
533+ return instance
534+ }
535+ closeInstance := func(instance remotePickerInstance) {
536+ instance.ac.overlays.pickerMu.Lock()
537+ instance.ac.overlays.picker = nil
538+ instance.ac.overlays.pickerMu.Unlock()
539+ d.remotePickerClosed(instance)
540+ }
541+ first, second := newInstance(), newInstance()
542+ d.remotePickerOpened(first)
543+ request := <-catalog.requests
544+ d.remotePickerOpened(second)
545+ <-request.ctx.Done()
546+ request = <-catalog.requests
547+
548+ closeInstance(first)
549+ select {
550+ case <-request.ctx.Done():
551+ t.Fatal("refresh canceled while another picker remained open")
552+ default:
553+ }
554+ closeInstance(second)
555+ <-request.ctx.Done()
556+
557+ d.remoteCatalog.mu.Lock()
558+ require.Empty(t, d.remoteCatalog.pickers)
559+ require.Nil(t, d.remoteCatalog.cancel)
560+ d.remoteCatalog.mu.Unlock()
561+}
562+
563+func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
564+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
565+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(525, 0))
566+ ac := &attachedClient{}
567+ ac.initOverlays()
568+ model := picker.New(nil, picker.SelectionConfig{})
569+ ac.overlays.pickerMu.Lock()
570+ ac.overlays.picker = model
571+ ac.overlays.pickerGeneration++
572+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
573+ ac.overlays.pickerMu.Unlock()
574+
575+ // Stop at the production seam after exact picker ownership registration but
576+ // before refresh startup, then let the last-picker close linearize first.
577+ require.True(t, d.registerRemotePicker(instance))
578+ ac.overlays.pickerMu.Lock()
579+ ac.overlays.picker = nil
580+ ac.overlays.pickerMu.Unlock()
581+ d.remotePickerClosed(instance)
582+
583+ d.remoteCatalog.mu.Lock()
584+ refreshBefore := d.remoteCatalog.refresh
585+ d.remoteCatalog.mu.Unlock()
586+ require.Zero(t, d.startRemotePickerRefresh(instance))
587+
588+ d.remoteCatalog.mu.Lock()
589+ require.Empty(t, d.remoteCatalog.pickers, "the closing picker must remain unregistered")
590+ require.Equal(t, refreshBefore, d.remoteCatalog.refresh, "a close that wins before startup must prevent a later refresh generation")
591+ require.Nil(t, d.remoteCatalog.cancel, "a close that wins before startup must prevent a later cancel installation")
592+ d.remoteCatalog.mu.Unlock()
593+ select {
594+ case request := <-catalog.requests:
595+ t.Fatalf("catalog call remained running after the last picker closed: %s", request.host)
596+ default:
597+ }
598+}
599+
600+func TestRemotePickerPublishCannotRegisterAfterSnatchClear(t *testing.T) {
601+ d := newRemotePickerDaemon(nil)
602+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
603+ ac.roleGeneration.Store(1)
604+ model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
605+ registrationReached := make(chan struct{})
606+ allowRegistration := make(chan struct{})
607+ ac.overlays.beforeRemotePickerRegistration = func() {
608+ close(registrationReached)
609+ <-allowRegistration
610+ }
611+ published := make(chan struct{})
612+ go func() {
613+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
614+ close(published)
615+ }()
616+ <-registrationReached
617+
618+ token := attachmentRoleToken{
619+ sess: sess, ac: ac, role: attachmentSnatched,
620+ generation: 1, transport: ac.transportSnapshot(),
621+ }
622+ require.True(t, d.clearForSnatch(token))
623+ close(allowRegistration)
624+ <-published
625+
626+ d.remoteCatalog.mu.Lock()
627+ _, registered := d.remoteCatalog.pickers[ac]
628+ d.remoteCatalog.mu.Unlock()
629+ require.False(t, registered, "a picker cleared before delayed registration must not become a refresh owner")
630+}
631+
632+func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
633+ d := newRemotePickerDaemon(nil)
634+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
635+ first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
636+ d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
637+
638+ staleClose := d.pickerListInputState(ac)
639+ ac.overlays.pickerMu.Lock()
640+ staleClose.closeLocked()
641+ ac.overlays.pickerMu.Unlock()
642+
643+ reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
644+ d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
645+ staleClose.afterClose()
646+
647+ d.remoteCatalog.mu.Lock()
648+ _, registered := d.remoteCatalog.pickers[ac]
649+ d.remoteCatalog.mu.Unlock()
650+ require.True(t, registered, "stale close cleanup must retain the reopened picker registration")
651+ ac.overlays.pickerMu.Lock()
652+ require.Same(t, reopened, ac.overlays.picker)
653+ ac.overlays.pickerMu.Unlock()
654+}
655+
656+func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
657+ d := newRemotePickerDaemon(nil)
658+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
659+ first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
660+ d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
661+
662+ rebuildReached := make(chan struct{})
663+ allowPublication := make(chan struct{})
664+ ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
665+ close(rebuildReached)
666+ <-allowPublication
667+ }
668+ refreshed := make(chan struct{})
669+ go func() {
670+ d.refreshRemoteOpenPickers()
671+ close(refreshed)
672+ }()
673+ <-rebuildReached
674+
675+ d.closePicker(ac)
676+ reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
677+ d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
678+ close(allowPublication)
679+ <-refreshed
680+
681+ ac.overlays.pickerMu.Lock()
682+ require.Same(t, reopened, ac.overlays.picker, "a rebuild for the old picker must not publish into its replacement")
683+ ac.overlays.pickerMu.Unlock()
684+}
685+
686+func TestRemoteRowsRemainNonSelectableDuringRefreshPhase(t *testing.T) {
687+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
688+ for _, availability := range []picker.RemoteAvailability{picker.RemoteCached, picker.RemoteFresh, picker.RemoteStale, picker.RemoteVersionMismatch} {
689+ model := picker.New([]picker.SessionView{{
690+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: availability,
691+ ConnectOnly: true, RemoteAttachReady: false,
692+ }}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
693+ _, selectable := model.Selected()
694+ require.False(t, selectable, "availability %d became remotely attachable in phase 5", availability)
695+ }
696+}
@@ -0,0 +1,696 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "log/slog"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+ "github.com/bnema/vev/internal/usecase/picker"
18+)
19+
20+func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
21+ return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
22+}
23+
24+func TestRemotePickerMerge(t *testing.T) {
25+ store := portsmocks.NewMockRemoteHostStore(t)
26+ store.EXPECT().Hosts().Return([]string{"pinned"}, []string{"learned"}, nil).Once()
27+ d := newRemotePickerDaemon(store)
28+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{{}}}
29+ local.mruAt.Store(6)
30+ key := domain.RemoteSessionKey{Host: "pinned", Name: "work"}
31+ proxy := &proxySession{
32+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
33+ key: key,
34+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
35+ {Index: 0, Name: "shell"},
36+ }},
37+ }
38+ proxy.mruAt.Store(5)
39+ d.sessions[local.id] = local
40+ d.sessions[proxy.id] = proxy
41+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 2, lastUsedSeq: 9}
42+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
43+ {Host: "learned", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "alpha", State: "running", Tabs: 3}}},
44+ {Host: "pinned", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 9}, {Name: "alpha", State: "running", Tabs: 2}}},
45+ })
46+ d.remoteCatalog.mu.Lock()
47+ d.remoteCatalog.status["pinned"] = remoteHostFresh
48+ d.remoteCatalog.status["learned"] = remoteHostFresh
49+ d.remoteCatalog.mu.Unlock()
50+
51+ views, _ := d.pickerViews(local)
52+
53+ require.Equal(t, []string{"local", "work@pinned", "alpha@pinned", "alpha@learned", "stopped"}, pickerViewNames(views))
54+ require.False(t, views[0].Stopped)
55+ require.Equal(t, key, *views[1].RemoteKey)
56+ require.Equal(t, picker.RemoteFresh, views[1].RemoteAvailability)
57+ require.True(t, views[1].ConnectOnly)
58+ require.Equal(t, "2 tabs", views[2].RemoteDetail)
59+ require.Equal(t, picker.RemoteFresh, views[2].RemoteAvailability)
60+ require.Equal(t, picker.RemoteFresh, views[3].RemoteAvailability)
61+ require.True(t, views[4].Stopped)
62+}
63+
64+func TestRemotePickerDedupeUsesExactKeyAndKeepsLiveID(t *testing.T) {
65+ d := newRemotePickerDaemon(nil)
66+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
67+ proxy := &proxySession{
68+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
69+ key: key,
70+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
71+ {Index: 0, Name: "live tab"},
72+ }},
73+ }
74+ d.sessions[key.ID()] = proxy
75+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
76+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{
77+ {Name: "work", State: "running", Tabs: 7},
78+ },
79+ }})
80+
81+ views, _ := d.pickerViews(nil)
82+
83+ require.Len(t, views, 1)
84+ require.Equal(t, key.ID(), views[0].ID)
85+ require.Equal(t, key, *views[0].RemoteKey)
86+ require.Equal(t, []picker.TabEntry{{Name: "live tab"}}, views[0].Tabs, "live proxy snapshot wins over cached discovery")
87+ require.Equal(t, picker.RemoteFresh, views[0].RemoteAvailability)
88+}
89+
90+func TestRemotePickerSnapshotsDoNotRetainProxyLock(t *testing.T) {
91+ d := newRemotePickerDaemon(nil)
92+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
93+ proxy := &proxySession{
94+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
95+ key: key,
96+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}},
97+ }
98+ d.sessions[key.ID()] = proxy
99+
100+ views, _ := d.pickerViews(nil)
101+
102+ require.Len(t, views, 1)
103+ require.True(t, proxy.mu.TryLock(), "picker construction must release the proxy leaf lock")
104+ proxy.mu.Unlock()
105+ require.True(t, d.remoteCatalog.mu.TryLock(), "picker construction must release the remote catalog lock")
106+ d.remoteCatalog.mu.Unlock()
107+}
108+
109+func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
110+ tests := []struct {
111+ name string
112+ status remoteHostStatus
113+ want picker.RemoteAvailability
114+ }{
115+ {name: "cached", status: remoteHostCached, want: picker.RemoteCached},
116+ {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale},
117+ {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch},
118+ }
119+
120+ for _, test := range tests {
121+ t.Run(test.name, func(t *testing.T) {
122+ d := newRemotePickerDaemon(nil)
123+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
124+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
125+ }})
126+ d.remoteCatalog.mu.Lock()
127+ d.remoteCatalog.status["arch"] = test.status
128+ d.remoteCatalog.mu.Unlock()
129+
130+ views, _ := d.pickerViews(nil)
131+
132+ require.Len(t, views, 1)
133+ require.Equal(t, test.want, views[0].RemoteAvailability)
134+ })
135+ }
136+}
137+
138+func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
139+ d := newRemotePickerDaemon(nil)
140+ fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
141+ failedAt := fetchedAt.Add(6 * time.Hour)
142+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
143+ Host: "arch", FetchedAt: fetchedAt, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
144+ }})
145+ d.remoteCatalog.mu.Lock()
146+ d.remoteCatalog.status["arch"] = remoteHostUnreachable
147+ d.remoteCatalog.failure["arch"] = failedAt
148+ d.remoteCatalog.mu.Unlock()
149+
150+ views, _ := d.pickerViews(nil)
151+
152+ require.Len(t, views, 1)
153+ require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail)
154+ require.NotContains(t, views[0].RemoteDetail, failedAt.Format(time.RFC3339), "failure time remains runtime-only and is not the freshness authority")
155+}
156+
157+func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
158+ for _, test := range []struct {
159+ name string
160+ status remoteHostStatus
161+ want picker.RemoteAvailability
162+ wantDetail string
163+ }{
164+ {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale, wantDetail: "unreachable"},
165+ {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch, wantDetail: "version mismatch"},
166+ } {
167+ t.Run(test.name, func(t *testing.T) {
168+ d := newRemotePickerDaemon(nil)
169+ d.remoteCatalog.mu.Lock()
170+ d.remoteCatalog.status["arch"] = test.status
171+ d.remoteCatalog.failure["arch"] = time.Unix(50, 0)
172+ d.remoteCatalog.mu.Unlock()
173+
174+ views, _ := d.pickerViews(nil)
175+
176+ require.Len(t, views, 1)
177+ require.Equal(t, "arch", views[0].Name)
178+ require.Equal(t, test.want, views[0].RemoteAvailability)
179+ require.Equal(t, test.wantDetail, views[0].RemoteDetail)
180+ model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
181+ _, selectable := model.Selected()
182+ require.False(t, selectable)
183+ })
184+ }
185+}
186+
187+func TestRemotePickerGroupedSortTreatsProxiesAsLiveSessions(t *testing.T) {
188+ d := newRemotePickerDaemon(nil)
189+ d.pickerSort.Store(uint32(pickerSortGrouped))
190+ ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
191+ ephemeral.mruAt.Store(9)
192+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
193+ proxy := &proxySession{sessionCore: sessionCore{id: key.ID(), name: key.Display()}, key: key}
194+ proxy.mruAt.Store(1)
195+ named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
196+ named.mruAt.Store(2)
197+ d.sessions[ephemeral.id] = ephemeral
198+ d.sessions[proxy.id] = proxy
199+ d.sessions[named.id] = named
200+
201+ views, _ := d.pickerViews(nil)
202+
203+ require.Equal(t, []string{"named", "work@arch", "1"}, pickerViewNames(views))
204+}
205+
206+func pickerViewNames(views []picker.SessionView) []string {
207+ names := make([]string, len(views))
208+ for i, view := range views {
209+ names[i] = view.Name
210+ }
211+ return names
212+}
213+
214+type remoteRefreshHostStore struct {
215+ mu sync.Mutex
216+ hosts []string
217+}
218+
219+func (s *remoteRefreshHostStore) Hosts() ([]string, []string, error) {
220+ s.mu.Lock()
221+ defer s.mu.Unlock()
222+ return append([]string(nil), s.hosts...), nil, nil
223+}
224+
225+func (s *remoteRefreshHostStore) set(hosts ...string) {
226+ s.mu.Lock()
227+ s.hosts = append([]string(nil), hosts...)
228+ s.mu.Unlock()
229+}
230+
231+func (*remoteRefreshHostStore) AddPinned(string) error { return nil }
232+func (*remoteRefreshHostStore) RemovePinned(string) error { return nil }
233+func (*remoteRefreshHostStore) Remember(string) error { return nil }
234+func (*remoteRefreshHostStore) Forget(string) error { return nil }
235+func (*remoteRefreshHostStore) Remove(string) (bool, error) {
236+ return false, nil
237+}
238+
239+type remoteRefreshRequest struct {
240+ ctx context.Context
241+ host string
242+ result chan remoteRefreshResult
243+}
244+
245+type remoteRefreshResult struct {
246+ catalog ports.RemoteCatalog
247+ err error
248+}
249+
250+type channelRemoteCatalog struct {
251+ d *Daemon
252+ requests chan remoteRefreshRequest
253+ probeMu sync.Mutex
254+}
255+
256+func (c *channelRemoteCatalog) List(ctx context.Context, host string) (ports.RemoteCatalog, error) {
257+ c.probeMu.Lock()
258+ if !c.d.mu.TryLock() {
259+ c.probeMu.Unlock()
260+ return ports.RemoteCatalog{}, errors.New("daemon lock held during remote catalog I/O")
261+ }
262+ c.d.mu.Unlock()
263+ if !c.d.remoteCatalog.mu.TryLock() {
264+ c.probeMu.Unlock()
265+ return ports.RemoteCatalog{}, errors.New("remote catalog lock held during remote catalog I/O")
266+ }
267+ c.d.remoteCatalog.mu.Unlock()
268+ c.probeMu.Unlock()
269+ request := remoteRefreshRequest{ctx: ctx, host: host, result: make(chan remoteRefreshResult, 1)}
270+ c.requests <- request
271+ select {
272+ case result := <-request.result:
273+ return result.catalog, result.err
274+ case <-ctx.Done():
275+ return ports.RemoteCatalog{}, ctx.Err()
276+ }
277+}
278+
279+type recordingRemoteCache struct {
280+ d *Daemon
281+ stores chan []ports.RemoteCatalogCacheEntry
282+}
283+
284+func (*recordingRemoteCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil }
285+
286+func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
287+ if !c.d.mu.TryLock() {
288+ return errors.New("daemon lock held during remote cache write")
289+ }
290+ c.d.mu.Unlock()
291+ if !c.d.remoteCatalog.mu.TryLock() {
292+ return errors.New("remote catalog lock held during remote cache write")
293+ }
294+ c.d.remoteCatalog.mu.Unlock()
295+ copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries))
296+ for i, entry := range entries {
297+ copyEntries[i] = cloneRemoteCatalogEntry(entry)
298+ }
299+ c.stores <- copyEntries
300+ return nil
301+}
302+
303+type fixedRemoteRefreshClock struct{ now time.Time }
304+
305+func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
306+func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
307+
308+func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {
309+ t.Helper()
310+ catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}
311+ cache := &recordingRemoteCache{stores: make(chan []ports.RemoteCatalogCacheEntry, 8)}
312+ d := New(nil, fixedRemoteRefreshClock{now: now}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(hosts, catalog, cache, nil, ports.RemoteTransportUDP))
313+ catalog.d, cache.d = d, d
314+ return d, catalog, cache
315+}
316+
317+func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T) {
318+ hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
319+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(100, 0))
320+ ac := &attachedClient{}
321+ ac.initOverlays()
322+ model := picker.New(nil, picker.SelectionConfig{})
323+ ac.overlays.pickerMu.Lock()
324+ ac.overlays.picker = model
325+ ac.overlays.pickerGeneration++
326+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
327+ ac.overlays.pickerMu.Unlock()
328+ require.True(t, d.registerRemotePicker(instance))
329+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
330+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "cached", State: "running"}},
331+ }})
332+
333+ firstGeneration := d.startRemotePickerRefresh(instance)
334+ first := map[string]remoteRefreshRequest{}
335+ for range 2 {
336+ request := <-catalog.requests
337+ first[request.host] = request
338+ }
339+ require.Contains(t, first, "arch")
340+ require.Contains(t, first, "mule")
341+ d.remoteCatalog.mu.Lock()
342+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "cached", State: "running"}}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
343+ require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
344+ d.remoteCatalog.mu.Unlock()
345+
346+ secondGeneration := d.startRemotePickerRefresh(instance)
347+ require.Greater(t, secondGeneration, firstGeneration)
348+ for _, request := range first {
349+ <-request.ctx.Done()
350+ }
351+ for range 2 {
352+ request := <-catalog.requests
353+ request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}
354+ }
355+ for range 2 {
356+ <-cache.stores
357+ }
358+
359+ d.remoteCatalog.mu.Lock()
360+ require.Equal(t, secondGeneration, d.remoteCatalog.refresh)
361+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
362+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["mule"])
363+ d.remoteCatalog.mu.Unlock()
364+}
365+
366+func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *testing.T) {
367+ now := time.Unix(200, 0)
368+ hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
369+ d, _, cache := newRemoteRefreshDaemon(t, hosts, now)
370+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
371+ {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}}},
372+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},
373+ })
374+ d.remoteCatalog.mu.Lock()
375+ d.remoteCatalog.refresh = 7
376+ d.remoteCatalog.mu.Unlock()
377+
378+ require.False(t, d.applyRemoteRefreshResult(7, "mule", ports.RemoteCatalog{}, errors.New("offline")))
379+ d.remoteCatalog.mu.Lock()
380+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}, d.remoteCatalog.cache["mule"].Sessions)
381+ require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)
382+ require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
383+ d.remoteCatalog.mu.Unlock()
384+
385+ require.True(t, d.applyRemoteRefreshResult(7, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}, nil))
386+ stored := <-cache.stores
387+ require.Equal(t, []ports.RemoteCatalogCacheEntry{
388+ {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},
389+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},
390+ }, stored, "every successful write persists the newest full cache atomically")
391+ d.remoteCatalog.mu.Lock()
392+ require.Empty(t, d.remoteCatalog.cache["arch"].Sessions, "a successful omission authoritatively evicts old rows")
393+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
394+ d.remoteCatalog.mu.Unlock()
395+}
396+
397+func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
398+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
399+ d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))
400+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
401+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}},
402+ }})
403+ d.remoteCatalog.mu.Lock()
404+ d.remoteCatalog.refresh = 9
405+ d.remoteCatalog.mu.Unlock()
406+
407+ require.False(t, d.applyRemoteRefreshResult(8, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "late", State: "running"}}}, nil))
408+ hosts.set()
409+ require.True(t, d.applyRemoteRefreshResult(9, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "removed", State: "running"}}}, nil))
410+ require.Empty(t, <-cache.stores)
411+ d.remoteCatalog.mu.Lock()
412+ require.NotContains(t, d.remoteCatalog.cache, "arch")
413+ require.NotContains(t, d.remoteCatalog.status, "arch")
414+ d.remoteCatalog.mu.Unlock()
415+}
416+
417+func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
418+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
419+ d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
420+ old := ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}
421+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{old})
422+ d.remoteCatalog.mu.Lock()
423+ d.remoteCatalog.refresh = 3
424+ d.remoteCatalog.mu.Unlock()
425+
426+ require.False(t, d.applyRemoteRefreshResult(3, "arch", ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{Got: 19, Want: ports.ProtocolVersion}))
427+ d.remoteCatalog.mu.Lock()
428+ require.Equal(t, old, d.remoteCatalog.cache["arch"])
429+ require.Equal(t, remoteHostVersionMismatch, d.remoteCatalog.status["arch"])
430+ d.remoteCatalog.mu.Unlock()
431+}
432+
433+func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
434+ t.Helper()
435+ tr, sends := newCapturingTransport(t)
436+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
437+ ac.initOverlays()
438+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
439+ tb.stableID = string(id) + "-tab"
440+ sess := &session{sessionCore: sessionCore{id: id, name: string(id), client: ac}, tabs: []*tab{tb}, active: 0}
441+ publishTiledPaneOwners(sess, tb)
442+ ac.setSession(sess)
443+ d.sessions[id] = sess
444+ return sess, ac, sends
445+}
446+
447+func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
448+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
449+ d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(450, 0))
450+ firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
451+ _, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
452+ before := make(map[*attachedClient]*picker.Model)
453+
454+ for _, owner := range []*attachedClient{first, second} {
455+ owner.overlays.pickerMu.Lock()
456+ owner.overlays.picker = d.newPickerModel(firstSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: "second", TabID: "second-tab"})
457+ owner.overlays.pickerIntent = pickerNavigate
458+ before[owner] = owner.overlays.picker
459+ owner.overlays.pickerMu.Unlock()
460+ d.remoteCatalog.mu.Lock()
461+ d.remoteCatalog.pickers[owner] = struct{}{}
462+ d.remoteCatalog.mu.Unlock()
463+ }
464+ d.remoteCatalog.mu.Lock()
465+ d.remoteCatalog.refresh = 4
466+ d.remoteCatalog.mu.Unlock()
467+
468+ require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{
469+ ProtocolVersion: ports.ProtocolVersion,
470+ Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 2}},
471+ }, nil))
472+ <-cache.stores
473+
474+ for _, owner := range []*attachedClient{first, second} {
475+ owner.overlays.pickerMu.Lock()
476+ updated := owner.overlays.picker
477+ selected, ok := updated.Selected()
478+ owner.overlays.pickerMu.Unlock()
479+ require.NotSame(t, before[owner], updated, "every open picker must receive the refreshed model")
480+ require.True(t, ok)
481+ require.Equal(t, domain.SessionID("second"), selected.Session)
482+ }
483+}
484+
485+func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.T) {
486+ d := newRemotePickerDaemon(nil)
487+ ownerSession, owner, _ := addRemoteRefreshPickerOwner(t, d, "local")
488+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
489+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
490+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
491+ }})
492+ owner.overlays.pickerMu.Lock()
493+ owner.overlays.picker = d.newPickerModel(ownerSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
494+ owner.overlays.pickerIntent = pickerNavigate
495+ before, ok := owner.overlays.picker.Cursor()
496+ owner.overlays.pickerMu.Unlock()
497+ require.True(t, ok)
498+ require.Equal(t, &key, before.RemoteKey)
499+
500+ proxy := &proxySession{
501+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
502+ key: key,
503+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "live"}}},
504+ }
505+ d.mu.Lock()
506+ d.sessions[key.ID()] = proxy
507+ d.mu.Unlock()
508+
509+ d.refreshPickerOpts(owner, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
510+
511+ owner.overlays.pickerMu.Lock()
512+ after, ok := owner.overlays.picker.Cursor()
513+ _, selectable := owner.overlays.picker.Selected()
514+ owner.overlays.pickerMu.Unlock()
515+ require.True(t, ok)
516+ require.Equal(t, before.Session, after.Session)
517+ require.Equal(t, before.RemoteKey, after.RemoteKey)
518+ require.False(t, selectable, "Phase 5 cache-to-live upgrade must not activate the proxy")
519+}
520+
521+func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
522+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
523+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(500, 0))
524+ newInstance := func() remotePickerInstance {
525+ ac := &attachedClient{}
526+ ac.initOverlays()
527+ model := picker.New(nil, picker.SelectionConfig{})
528+ ac.overlays.pickerMu.Lock()
529+ ac.overlays.picker = model
530+ ac.overlays.pickerGeneration++
531+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
532+ ac.overlays.pickerMu.Unlock()
533+ return instance
534+ }
535+ closeInstance := func(instance remotePickerInstance) {
536+ instance.ac.overlays.pickerMu.Lock()
537+ instance.ac.overlays.picker = nil
538+ instance.ac.overlays.pickerMu.Unlock()
539+ d.remotePickerClosed(instance)
540+ }
541+ first, second := newInstance(), newInstance()
542+ d.remotePickerOpened(first)
543+ request := <-catalog.requests
544+ d.remotePickerOpened(second)
545+ <-request.ctx.Done()
546+ request = <-catalog.requests
547+
548+ closeInstance(first)
549+ select {
550+ case <-request.ctx.Done():
551+ t.Fatal("refresh canceled while another picker remained open")
552+ default:
553+ }
554+ closeInstance(second)
555+ <-request.ctx.Done()
556+
557+ d.remoteCatalog.mu.Lock()
558+ require.Empty(t, d.remoteCatalog.pickers)
559+ require.Nil(t, d.remoteCatalog.cancel)
560+ d.remoteCatalog.mu.Unlock()
561+}
562+
563+func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
564+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
565+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(525, 0))
566+ ac := &attachedClient{}
567+ ac.initOverlays()
568+ model := picker.New(nil, picker.SelectionConfig{})
569+ ac.overlays.pickerMu.Lock()
570+ ac.overlays.picker = model
571+ ac.overlays.pickerGeneration++
572+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
573+ ac.overlays.pickerMu.Unlock()
574+
575+ // Stop at the production seam after exact picker ownership registration but
576+ // before refresh startup, then let the last-picker close linearize first.
577+ require.True(t, d.registerRemotePicker(instance))
578+ ac.overlays.pickerMu.Lock()
579+ ac.overlays.picker = nil
580+ ac.overlays.pickerMu.Unlock()
581+ d.remotePickerClosed(instance)
582+
583+ d.remoteCatalog.mu.Lock()
584+ refreshBefore := d.remoteCatalog.refresh
585+ d.remoteCatalog.mu.Unlock()
586+ require.Zero(t, d.startRemotePickerRefresh(instance))
587+
588+ d.remoteCatalog.mu.Lock()
589+ require.Empty(t, d.remoteCatalog.pickers, "the closing picker must remain unregistered")
590+ require.Equal(t, refreshBefore, d.remoteCatalog.refresh, "a close that wins before startup must prevent a later refresh generation")
591+ require.Nil(t, d.remoteCatalog.cancel, "a close that wins before startup must prevent a later cancel installation")
592+ d.remoteCatalog.mu.Unlock()
593+ select {
594+ case request := <-catalog.requests:
595+ t.Fatalf("catalog call remained running after the last picker closed: %s", request.host)
596+ default:
597+ }
598+}
599+
600+func TestRemotePickerPublishCannotRegisterAfterSnatchClear(t *testing.T) {
601+ d := newRemotePickerDaemon(nil)
602+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
603+ ac.roleGeneration.Store(1)
604+ model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
605+ registrationReached := make(chan struct{})
606+ allowRegistration := make(chan struct{})
607+ ac.overlays.beforeRemotePickerRegistration = func() {
608+ close(registrationReached)
609+ <-allowRegistration
610+ }
611+ published := make(chan struct{})
612+ go func() {
613+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
614+ close(published)
615+ }()
616+ <-registrationReached
617+
618+ token := attachmentRoleToken{
619+ sess: sess, ac: ac, role: attachmentSnatched,
620+ generation: 1, transport: ac.transportSnapshot(),
621+ }
622+ require.True(t, d.clearForSnatch(token))
623+ close(allowRegistration)
624+ <-published
625+
626+ d.remoteCatalog.mu.Lock()
627+ _, registered := d.remoteCatalog.pickers[ac]
628+ d.remoteCatalog.mu.Unlock()
629+ require.False(t, registered, "a picker cleared before delayed registration must not become a refresh owner")
630+}
631+
632+func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
633+ d := newRemotePickerDaemon(nil)
634+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
635+ first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
636+ d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
637+
638+ staleClose := d.pickerListInputState(ac)
639+ ac.overlays.pickerMu.Lock()
640+ staleClose.closeLocked()
641+ ac.overlays.pickerMu.Unlock()
642+
643+ reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
644+ d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
645+ staleClose.afterClose()
646+
647+ d.remoteCatalog.mu.Lock()
648+ _, registered := d.remoteCatalog.pickers[ac]
649+ d.remoteCatalog.mu.Unlock()
650+ require.True(t, registered, "stale close cleanup must retain the reopened picker registration")
651+ ac.overlays.pickerMu.Lock()
652+ require.Same(t, reopened, ac.overlays.picker)
653+ ac.overlays.pickerMu.Unlock()
654+}
655+
656+func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
657+ d := newRemotePickerDaemon(nil)
658+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
659+ first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
660+ d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
661+
662+ rebuildReached := make(chan struct{})
663+ allowPublication := make(chan struct{})
664+ ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
665+ close(rebuildReached)
666+ <-allowPublication
667+ }
668+ refreshed := make(chan struct{})
669+ go func() {
670+ d.refreshRemoteOpenPickers()
671+ close(refreshed)
672+ }()
673+ <-rebuildReached
674+
675+ d.closePicker(ac)
676+ reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
677+ d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
678+ close(allowPublication)
679+ <-refreshed
680+
681+ ac.overlays.pickerMu.Lock()
682+ require.Same(t, reopened, ac.overlays.picker, "a rebuild for the old picker must not publish into its replacement")
683+ ac.overlays.pickerMu.Unlock()
684+}
685+
686+func TestRemoteRowsRemainNonSelectableDuringRefreshPhase(t *testing.T) {
687+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
688+ for _, availability := range []picker.RemoteAvailability{picker.RemoteCached, picker.RemoteFresh, picker.RemoteStale, picker.RemoteVersionMismatch} {
689+ model := picker.New([]picker.SessionView{{
690+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: availability,
691+ ConnectOnly: true, RemoteAttachReady: false,
692+ }}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
693+ _, selectable := model.Selected()
694+ require.False(t, selectable, "availability %d became remotely attachable in phase 5", availability)
695+ }
696+}
M internal/usecase/daemon/status_test.go +6 -3
@@ -581,7 +581,8 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
581581 assertSessionColorScheme(t, sess, true)
582582
583583 tr, _ := newCapturingTransport(t)
584- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
584+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
585+ require.NoError(t, err)
585586
586587 assertSessionColorSchemeUnknown(t, sess)
587588 }
@@ -682,7 +683,8 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
682683 })
683684 tr, _ := newCapturingTransport(t)
684685
685- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
686+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
687+ require.NoError(t, err)
686688
687689 var got []byte
688690 tb := sess.activeTab()
@@ -791,7 +793,8 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
791793 d, sess, old, _ := newManualSessionWithPTYs(t, p)
792794 defer release()
793795 tr, _ := newCapturingTransport(t)
794- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
796+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
797+ require.NoError(t, err)
795798
796799 d.applyTheme(sess, old, ports.Theme{
797800 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
@@ -581,7 +581,8 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
581 assertSessionColorScheme(t, sess, true)581 assertSessionColorScheme(t, sess, true)
582 582
583 tr, _ := newCapturingTransport(t)583 tr, _ := newCapturingTransport(t)
584- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})584+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
585+ require.NoError(t, err)
585 586
586 assertSessionColorSchemeUnknown(t, sess)587 assertSessionColorSchemeUnknown(t, sess)
587 }588 }
@@ -682,7 +683,8 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
682 })683 })
683 tr, _ := newCapturingTransport(t)684 tr, _ := newCapturingTransport(t)
684 685
685- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})686+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
687+ require.NoError(t, err)
686 688
687 var got []byte689 var got []byte
688 tb := sess.activeTab()690 tb := sess.activeTab()
@@ -791,7 +793,8 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
791 d, sess, old, _ := newManualSessionWithPTYs(t, p)793 d, sess, old, _ := newManualSessionWithPTYs(t, p)
792 defer release()794 defer release()
793 tr, _ := newCapturingTransport(t)795 tr, _ := newCapturingTransport(t)
794- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})796+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
797+ require.NoError(t, err)
795 798
796 d.applyTheme(sess, old, ports.Theme{799 d.applyTheme(sess, old, ports.Theme{
797 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},800 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
M internal/usecase/picker/model.go +118 -22
@@ -15,6 +15,16 @@ const (
1515 MinStackHeight = 12
1616 )
1717
18+type RemoteAvailability uint8
19+
20+const (
21+ RemoteNone RemoteAvailability = iota
22+ RemoteCached
23+ RemoteFresh
24+ RemoteStale
25+ RemoteVersionMismatch
26+)
27+
1828 type SessionView struct {
1929 ID domain.SessionID
2030 Incarnation domain.IncarnationID
@@ -24,9 +34,16 @@ type SessionView struct {
2434 Active int
2535 Stopped bool
2636 ExpectedCreatedAt *int64
37+ RemoteKey *domain.RemoteSessionKey
38+ // RemoteHost marks remote host status rows that have no session key.
39+ RemoteHost string
40+ RemoteAvailability RemoteAvailability
41+ RemoteDetail string
42+ ConnectOnly bool
43+ RemoteAttachReady bool
2744 // CannotAcceptMoves reports whether this session cannot receive a moved tab
2845 // or pane. False for ordinary local (and stopped) sessions; true for
29- // restricted proxy sessions in phase 4. Populated, not yet consumed.
46+ // restricted proxy sessions.
3047 CannotAcceptMoves bool
3148 }
3249
@@ -50,6 +67,7 @@ type SourceFilter struct {
5067 Session domain.SessionID
5168 Incarnation domain.IncarnationID
5269 TabID domain.TabStableID
70+ RemoteKey *domain.RemoteSessionKey
5371 }
5472
5573 // SelectionConfig describes which rows are selectable, which stable target
@@ -95,6 +113,7 @@ type Target struct {
95113 Session domain.SessionID
96114 Incarnation domain.IncarnationID
97115 Name string
116+ RemoteKey *domain.RemoteSessionKey
98117 TabID domain.TabStableID
99118 TabIndex int
100119 Stopped bool
@@ -165,6 +184,11 @@ type row struct {
165184 stopped bool
166185 expectedCreatedAt int64
167186 hasExpectedCreatedAt bool
187+ remoteKey domain.RemoteSessionKey
188+ hasRemoteKey bool
189+ remote bool
190+ selectable bool
191+ dim bool
168192 }
169193
170194 func New(sessions []SessionView, config SelectionConfig) *Model {
@@ -175,10 +199,10 @@ func New(sessions []SessionView, config SelectionConfig) *Model {
175199 for _, pickerRow := range sessionRows {
176200 idx := len(m.rows)
177201 m.rows = append(m.rows, pickerRow)
178- if pickerRow.kind.selectable(config.Mode) && selectionMatches(pickerRow, config.Current, config.Mode) {
202+ if selectionMatches(pickerRow, config.Current, config.Mode) {
179203 m.selected = idx
180204 }
181- if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
205+ if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.selectable && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
182206 activeSelection = idx
183207 }
184208 }
@@ -201,7 +225,10 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
201225 if config.Mode != SelectNavigationTab && session.Stopped {
202226 return nil
203227 }
204- if config.Mode == SelectMoveTabSession && (sourceMatchesSession(config.Source, session) || len(session.Tabs) == 0) {
228+ if config.Mode == SelectMoveTabSession && sourceMatchesSession(config.Source, session) {
229+ return nil
230+ }
231+ if config.Mode == SelectMoveTabSession && len(session.Tabs) == 0 && !session.CannotAcceptMoves {
205232 return nil
206233 }
207234
@@ -217,24 +244,61 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
217244 stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,
218245 hasExpectedCreatedAt: hasExpectedCreatedAt,
219246 }
247+ if session.RemoteKey != nil {
248+ common.remoteKey, common.hasRemoteKey = *session.RemoteKey, true
249+ }
250+ common.remote = common.hasRemoteKey || session.RemoteHost != ""
220251 header := common
221252 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
253+ header.selectable = header.kind.selectable(config.Mode)
254+ if common.remote {
255+ header.detail = remoteRowDetail(session)
256+ header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session)
257+ header.dim = !session.RemoteAttachReady || session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch
258+ }
259+ if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
260+ header.selectable, header.dim = false, true
261+ }
222262 rows := []row{header}
223- for i, tab := range session.Tabs {
224- if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
225- continue
263+ if !session.ConnectOnly {
264+ for i, tab := range session.Tabs {
265+ if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
266+ continue
267+ }
268+ tabRow := common
269+ tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
270+ tabRow.tabID, tabRow.tabIndex = tab.TabID, i
271+ tabRow.selectable = tabRow.kind.selectable(config.Mode)
272+ if common.remote {
273+ tabRow.selectable = false
274+ tabRow.dim = !session.RemoteAttachReady || session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch
275+ }
276+ if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
277+ tabRow.selectable, tabRow.dim = false, true
278+ }
279+ rows = append(rows, tabRow)
226280 }
227- tabRow := common
228- tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
229- tabRow.tabID, tabRow.tabIndex = tab.TabID, i
230- rows = append(rows, tabRow)
231281 }
232- if config.Mode == SelectMovePaneTab && len(rows) == 1 {
282+ if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {
233283 return nil
234284 }
235285 return rows
236286 }
237287
288+func remoteSelectable(session SessionView) bool {
289+ return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch
290+}
291+
292+func remoteRowDetail(session SessionView) string {
293+ if session.RemoteDetail != "" && (session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch) {
294+ return session.RemoteDetail
295+ }
296+ if !session.RemoteAttachReady {
297+ return "proxy activation pending"
298+ }
299+ return session.RemoteDetail
300+}
301+
238302 func sourceMatchesSession(source SourceFilter, session SessionView) bool {
239303 if source.Session != session.ID {
240304 return false
@@ -257,6 +321,9 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b
257321 if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) {
258322 return false
259323 }
324+ if pickerRow.hasRemoteKey && pickerRow.kind == rowSession {
325+ return current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey
326+ }
260327 if mode == SelectMoveTabSession {
261328 return pickerRow.kind == rowSession
262329 }
@@ -317,18 +384,25 @@ func (m *Model) Down() {
317384 m.move(1)
318385 }
319386
387+// Cursor reports the identity under the picker cursor independently from
388+// whether that row may be activated. Remote refreshes use it to preserve a
389+// structured remote session key while Selected continues to enforce safety.
390+func (m *Model) Cursor() (Target, bool) {
391+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
392+ return Target{}, false
393+ }
394+ return m.rows[m.selected].target(), true
395+}
396+
320397 func (m *Model) Selected() (Target, bool) {
321398 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
322399 return Target{}, false
323400 }
324401 r := m.rows[m.selected]
325- if !r.kind.selectable(m.mode) {
402+ if !r.selectable {
326403 return Target{}, false
327404 }
328- return Target{
329- Session: r.session, Incarnation: r.incarnation, Name: r.targetName, TabID: r.tabID,
330- TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
331- }, true
405+ return r.target(), true
332406 }
333407
334408 // SelectedIndex reports the raw selected row index. It is -1 only when the
@@ -351,13 +425,13 @@ func (m *Model) SelectNearestRow(idx int) {
351425 }
352426 idx = clamp(idx, 0, len(m.rows)-1)
353427 for i := idx; i < len(m.rows); i++ {
354- if m.rows[i].kind.selectable(m.mode) {
428+ if m.rows[i].selectable {
355429 m.selected = i
356430 return
357431 }
358432 }
359433 for i := idx - 1; i >= 0; i-- {
360- if m.rows[i].kind.selectable(m.mode) {
434+ if m.rows[i].selectable {
361435 m.selected = i
362436 return
363437 }
@@ -396,12 +470,12 @@ func (m *Model) move(delta int) {
396470 if m == nil || len(m.rows) == 0 {
397471 return
398472 }
399- if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].kind.selectable(m.mode) {
473+ if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].selectable {
400474 m.selected = m.firstSelectable()
401475 return
402476 }
403477 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
404- if m.rows[i].kind.selectable(m.mode) {
478+ if m.rows[i].selectable {
405479 m.selected = i
406480 return
407481 }
@@ -410,13 +484,20 @@ func (m *Model) move(delta int) {
410484
411485 func (m *Model) firstSelectable() int {
412486 for i, r := range m.rows {
413- if r.kind.selectable(m.mode) {
487+ if r.selectable {
414488 return i
415489 }
416490 }
417491 return -1
418492 }
419493
494+func (r row) target() Target {
495+ return Target{
496+ Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), TabID: r.tabID,
497+ TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
498+ }
499+}
500+
420501 func (r row) expectedCreatedAtPointer() *int64 {
421502 if !r.hasExpectedCreatedAt {
422503 return nil
@@ -425,6 +506,14 @@ func (r row) expectedCreatedAtPointer() *int64 {
425506 return &value
426507 }
427508
509+func (r row) remoteKeyPointer() *domain.RemoteSessionKey {
510+ if !r.hasRemoteKey {
511+ return nil
512+ }
513+ key := r.remoteKey
514+ return &key
515+}
516+
428517 // renderList draws each visible row as up to three segments: a name segment
429518 // (bold when styles came from a truecolor theme), a base-styled attention
430519 // marker right after the name, and a muted detail segment (tab rows only) —
@@ -450,6 +539,11 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
450539 if r.stopped && idx != m.selected {
451540 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
452541 }
542+ if r.dim {
543+ base.Attrs |= renderer.AttrDim
544+ nameStyle.Attrs |= renderer.AttrDim
545+ detailStyle.Attrs |= renderer.AttrDim
546+ }
453547 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
454548
455549 name := r.dispName
@@ -462,6 +556,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
462556 if r.kind.rendersAsHeader() {
463557 if r.stopped {
464558 ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
559+ } else {
560+ ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)
465561 }
466562 continue
467563 }
@@ -15,6 +15,16 @@ const (
15 MinStackHeight = 1215 MinStackHeight = 12
16 )16 )
17 17
18+type RemoteAvailability uint8
19+
20+const (
21+ RemoteNone RemoteAvailability = iota
22+ RemoteCached
23+ RemoteFresh
24+ RemoteStale
25+ RemoteVersionMismatch
26+)
27+
18 type SessionView struct {28 type SessionView struct {
19 ID domain.SessionID29 ID domain.SessionID
20 Incarnation domain.IncarnationID30 Incarnation domain.IncarnationID
@@ -24,9 +34,16 @@ type SessionView struct {
24 Active int34 Active int
25 Stopped bool35 Stopped bool
26 ExpectedCreatedAt *int6436 ExpectedCreatedAt *int64
37+ RemoteKey *domain.RemoteSessionKey
38+ // RemoteHost marks remote host status rows that have no session key.
39+ RemoteHost string
40+ RemoteAvailability RemoteAvailability
41+ RemoteDetail string
42+ ConnectOnly bool
43+ RemoteAttachReady bool
27 // CannotAcceptMoves reports whether this session cannot receive a moved tab44 // CannotAcceptMoves reports whether this session cannot receive a moved tab
28 // or pane. False for ordinary local (and stopped) sessions; true for45 // or pane. False for ordinary local (and stopped) sessions; true for
29- // restricted proxy sessions in phase 4. Populated, not yet consumed.46+ // restricted proxy sessions.
30 CannotAcceptMoves bool47 CannotAcceptMoves bool
31 }48 }
32 49
@@ -50,6 +67,7 @@ type SourceFilter struct {
50 Session domain.SessionID67 Session domain.SessionID
51 Incarnation domain.IncarnationID68 Incarnation domain.IncarnationID
52 TabID domain.TabStableID69 TabID domain.TabStableID
70+ RemoteKey *domain.RemoteSessionKey
53 }71 }
54 72
55 // SelectionConfig describes which rows are selectable, which stable target73 // SelectionConfig describes which rows are selectable, which stable target
@@ -95,6 +113,7 @@ type Target struct {
95 Session domain.SessionID113 Session domain.SessionID
96 Incarnation domain.IncarnationID114 Incarnation domain.IncarnationID
97 Name string115 Name string
116+ RemoteKey *domain.RemoteSessionKey
98 TabID domain.TabStableID117 TabID domain.TabStableID
99 TabIndex int118 TabIndex int
100 Stopped bool119 Stopped bool
@@ -165,6 +184,11 @@ type row struct {
165 stopped bool184 stopped bool
166 expectedCreatedAt int64185 expectedCreatedAt int64
167 hasExpectedCreatedAt bool186 hasExpectedCreatedAt bool
187+ remoteKey domain.RemoteSessionKey
188+ hasRemoteKey bool
189+ remote bool
190+ selectable bool
191+ dim bool
168 }192 }
169 193
170 func New(sessions []SessionView, config SelectionConfig) *Model {194 func New(sessions []SessionView, config SelectionConfig) *Model {
@@ -175,10 +199,10 @@ func New(sessions []SessionView, config SelectionConfig) *Model {
175 for _, pickerRow := range sessionRows {199 for _, pickerRow := range sessionRows {
176 idx := len(m.rows)200 idx := len(m.rows)
177 m.rows = append(m.rows, pickerRow)201 m.rows = append(m.rows, pickerRow)
178- if pickerRow.kind.selectable(config.Mode) && selectionMatches(pickerRow, config.Current, config.Mode) {202+ if selectionMatches(pickerRow, config.Current, config.Mode) {
179 m.selected = idx203 m.selected = idx
180 }204 }
181- if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {205+ if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.selectable && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
182 activeSelection = idx206 activeSelection = idx
183 }207 }
184 }208 }
@@ -201,7 +225,10 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
201 if config.Mode != SelectNavigationTab && session.Stopped {225 if config.Mode != SelectNavigationTab && session.Stopped {
202 return nil226 return nil
203 }227 }
204- if config.Mode == SelectMoveTabSession && (sourceMatchesSession(config.Source, session) || len(session.Tabs) == 0) {228+ if config.Mode == SelectMoveTabSession && sourceMatchesSession(config.Source, session) {
229+ return nil
230+ }
231+ if config.Mode == SelectMoveTabSession && len(session.Tabs) == 0 && !session.CannotAcceptMoves {
205 return nil232 return nil
206 }233 }
207 234
@@ -217,24 +244,61 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
217 stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,244 stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,
218 hasExpectedCreatedAt: hasExpectedCreatedAt,245 hasExpectedCreatedAt: hasExpectedCreatedAt,
219 }246 }
247+ if session.RemoteKey != nil {
248+ common.remoteKey, common.hasRemoteKey = *session.RemoteKey, true
249+ }
250+ common.remote = common.hasRemoteKey || session.RemoteHost != ""
220 header := common251 header := common
221 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1252 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
253+ header.selectable = header.kind.selectable(config.Mode)
254+ if common.remote {
255+ header.detail = remoteRowDetail(session)
256+ header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session)
257+ header.dim = !session.RemoteAttachReady || session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch
258+ }
259+ if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
260+ header.selectable, header.dim = false, true
261+ }
222 rows := []row{header}262 rows := []row{header}
223- for i, tab := range session.Tabs {263+ if !session.ConnectOnly {
224- if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {264+ for i, tab := range session.Tabs {
225- continue265+ if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
266+ continue
267+ }
268+ tabRow := common
269+ tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
270+ tabRow.tabID, tabRow.tabIndex = tab.TabID, i
271+ tabRow.selectable = tabRow.kind.selectable(config.Mode)
272+ if common.remote {
273+ tabRow.selectable = false
274+ tabRow.dim = !session.RemoteAttachReady || session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch
275+ }
276+ if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
277+ tabRow.selectable, tabRow.dim = false, true
278+ }
279+ rows = append(rows, tabRow)
226 }280 }
227- tabRow := common
228- tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
229- tabRow.tabID, tabRow.tabIndex = tab.TabID, i
230- rows = append(rows, tabRow)
231 }281 }
232- if config.Mode == SelectMovePaneTab && len(rows) == 1 {282+ if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {
233 return nil283 return nil
234 }284 }
235 return rows285 return rows
236 }286 }
237 287
288+func remoteSelectable(session SessionView) bool {
289+ return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch
290+}
291+
292+func remoteRowDetail(session SessionView) string {
293+ if session.RemoteDetail != "" && (session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch) {
294+ return session.RemoteDetail
295+ }
296+ if !session.RemoteAttachReady {
297+ return "proxy activation pending"
298+ }
299+ return session.RemoteDetail
300+}
301+
238 func sourceMatchesSession(source SourceFilter, session SessionView) bool {302 func sourceMatchesSession(source SourceFilter, session SessionView) bool {
239 if source.Session != session.ID {303 if source.Session != session.ID {
240 return false304 return false
@@ -257,6 +321,9 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b
257 if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) {321 if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) {
258 return false322 return false
259 }323 }
324+ if pickerRow.hasRemoteKey && pickerRow.kind == rowSession {
325+ return current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey
326+ }
260 if mode == SelectMoveTabSession {327 if mode == SelectMoveTabSession {
261 return pickerRow.kind == rowSession328 return pickerRow.kind == rowSession
262 }329 }
@@ -317,18 +384,25 @@ func (m *Model) Down() {
317 m.move(1)384 m.move(1)
318 }385 }
319 386
387+// Cursor reports the identity under the picker cursor independently from
388+// whether that row may be activated. Remote refreshes use it to preserve a
389+// structured remote session key while Selected continues to enforce safety.
390+func (m *Model) Cursor() (Target, bool) {
391+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
392+ return Target{}, false
393+ }
394+ return m.rows[m.selected].target(), true
395+}
396+
320 func (m *Model) Selected() (Target, bool) {397 func (m *Model) Selected() (Target, bool) {
321 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {398 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
322 return Target{}, false399 return Target{}, false
323 }400 }
324 r := m.rows[m.selected]401 r := m.rows[m.selected]
325- if !r.kind.selectable(m.mode) {402+ if !r.selectable {
326 return Target{}, false403 return Target{}, false
327 }404 }
328- return Target{405+ return r.target(), true
329- Session: r.session, Incarnation: r.incarnation, Name: r.targetName, TabID: r.tabID,
330- TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
331- }, true
332 }406 }
333 407
334 // SelectedIndex reports the raw selected row index. It is -1 only when the408 // SelectedIndex reports the raw selected row index. It is -1 only when the
@@ -351,13 +425,13 @@ func (m *Model) SelectNearestRow(idx int) {
351 }425 }
352 idx = clamp(idx, 0, len(m.rows)-1)426 idx = clamp(idx, 0, len(m.rows)-1)
353 for i := idx; i < len(m.rows); i++ {427 for i := idx; i < len(m.rows); i++ {
354- if m.rows[i].kind.selectable(m.mode) {428+ if m.rows[i].selectable {
355 m.selected = i429 m.selected = i
356 return430 return
357 }431 }
358 }432 }
359 for i := idx - 1; i >= 0; i-- {433 for i := idx - 1; i >= 0; i-- {
360- if m.rows[i].kind.selectable(m.mode) {434+ if m.rows[i].selectable {
361 m.selected = i435 m.selected = i
362 return436 return
363 }437 }
@@ -396,12 +470,12 @@ func (m *Model) move(delta int) {
396 if m == nil || len(m.rows) == 0 {470 if m == nil || len(m.rows) == 0 {
397 return471 return
398 }472 }
399- if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].kind.selectable(m.mode) {473+ if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].selectable {
400 m.selected = m.firstSelectable()474 m.selected = m.firstSelectable()
401 return475 return
402 }476 }
403 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {477 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
404- if m.rows[i].kind.selectable(m.mode) {478+ if m.rows[i].selectable {
405 m.selected = i479 m.selected = i
406 return480 return
407 }481 }
@@ -410,13 +484,20 @@ func (m *Model) move(delta int) {
410 484
411 func (m *Model) firstSelectable() int {485 func (m *Model) firstSelectable() int {
412 for i, r := range m.rows {486 for i, r := range m.rows {
413- if r.kind.selectable(m.mode) {487+ if r.selectable {
414 return i488 return i
415 }489 }
416 }490 }
417 return -1491 return -1
418 }492 }
419 493
494+func (r row) target() Target {
495+ return Target{
496+ Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), TabID: r.tabID,
497+ TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
498+ }
499+}
500+
420 func (r row) expectedCreatedAtPointer() *int64 {501 func (r row) expectedCreatedAtPointer() *int64 {
421 if !r.hasExpectedCreatedAt {502 if !r.hasExpectedCreatedAt {
422 return nil503 return nil
@@ -425,6 +506,14 @@ func (r row) expectedCreatedAtPointer() *int64 {
425 return &value506 return &value
426 }507 }
427 508
509+func (r row) remoteKeyPointer() *domain.RemoteSessionKey {
510+ if !r.hasRemoteKey {
511+ return nil
512+ }
513+ key := r.remoteKey
514+ return &key
515+}
516+
428 // renderList draws each visible row as up to three segments: a name segment517 // renderList draws each visible row as up to three segments: a name segment
429 // (bold when styles came from a truecolor theme), a base-styled attention518 // (bold when styles came from a truecolor theme), a base-styled attention
430 // marker right after the name, and a muted detail segment (tab rows only) —519 // marker right after the name, and a muted detail segment (tab rows only) —
@@ -450,6 +539,11 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
450 if r.stopped && idx != m.selected {539 if r.stopped && idx != m.selected {
451 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped540 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
452 }541 }
542+ if r.dim {
543+ base.Attrs |= renderer.AttrDim
544+ nameStyle.Attrs |= renderer.AttrDim
545+ detailStyle.Attrs |= renderer.AttrDim
546+ }
453 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})547 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
454 548
455 name := r.dispName549 name := r.dispName
@@ -462,6 +556,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
462 if r.kind.rendersAsHeader() {556 if r.kind.rendersAsHeader() {
463 if r.stopped {557 if r.stopped {
464 ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)558 ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
559+ } else {
560+ ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)
465 }561 }
466 continue562 continue
467 }563 }
M internal/usecase/picker/model_test.go +156 -0
@@ -722,3 +722,159 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
722722 })
723723 }
724724 }
725+
726+func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) {
727+ key := domain.RemoteSessionKey{Host: "build@mule", Name: "work"}
728+ availability := []RemoteAvailability{RemoteCached, RemoteFresh, RemoteStale, RemoteVersionMismatch}
729+ sessions := make([]SessionView, 0, len(availability))
730+ for _, state := range availability {
731+ sessions = append(sessions, SessionView{
732+ ID: key.ID(),
733+ Name: "presentation text is not routing data",
734+ Tabs: []TabEntry{{Name: "relayed metadata is not a picker target"}},
735+ RemoteKey: &key,
736+ RemoteAvailability: state,
737+ RemoteDetail: "remote state detail",
738+ ConnectOnly: true,
739+ RemoteAttachReady: false,
740+ })
741+ }
742+
743+ model := New(sessions, SelectionConfig{Mode: SelectNavigationTab})
744+ require.Len(t, model.rows, len(availability), "connect-only remote rows do not synthesize tab destinations")
745+ for i, pickerRow := range model.rows {
746+ require.Equal(t, rowSession, pickerRow.kind)
747+ require.Equal(t, key, pickerRow.remoteKey)
748+ require.True(t, pickerRow.hasRemoteKey)
749+ require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])
750+ require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i])
751+ wantDetail := "proxy activation pending"
752+ if availability[i] == RemoteStale || availability[i] == RemoteVersionMismatch {
753+ wantDetail = "remote state detail"
754+ }
755+ require.Equal(t, wantDetail, pickerRow.detail)
756+ }
757+ _, ok := model.Selected()
758+ require.False(t, ok)
759+ frame := model.Render(domain.Size{Cols: 80, Rows: len(availability)}, Preview{})
760+ for y := range availability {
761+ require.NotZero(t, frame.At(0, y).Style.Attrs&renderer.AttrDim)
762+ }
763+}
764+
765+func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *testing.T) {
766+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
767+ cached := New([]SessionView{{
768+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached,
769+ ConnectOnly: true, RemoteAttachReady: false,
770+ }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}})
771+
772+ cursor, ok := cached.Cursor()
773+ require.True(t, ok)
774+ require.Equal(t, key.ID(), cursor.Session)
775+ require.Equal(t, &key, cursor.RemoteKey)
776+ _, selectable := cached.Selected()
777+ require.False(t, selectable, "cursor identity must not bypass the phase 5 activation gate")
778+
779+ live := New([]SessionView{{
780+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh,
781+ ConnectOnly: true, RemoteAttachReady: false,
782+ }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{
783+ Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
784+ }})
785+
786+ upgraded, ok := live.Cursor()
787+ require.True(t, ok)
788+ require.Equal(t, cursor, upgraded)
789+ _, selectable = live.Selected()
790+ require.False(t, selectable, "cache-to-live cursor preservation must not enable attach")
791+}
792+
793+func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) {
794+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
795+ model := New([]SessionView{{
796+ ID: key.ID(),
797+ Name: key.Display(),
798+ Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
799+ RemoteKey: &key,
800+ RemoteAvailability: RemoteFresh,
801+ RemoteAttachReady: false,
802+ }}, SelectionConfig{Mode: SelectNavigationTab})
803+
804+ require.Len(t, model.rows, 2)
805+ for _, pickerRow := range model.rows {
806+ require.False(t, pickerRow.selectable)
807+ require.True(t, pickerRow.dim)
808+ }
809+}
810+
811+func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) {
812+ key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"}
813+ wantKey := key
814+ model := New([]SessionView{{
815+ ID: key.ID(),
816+ Name: "not a parseable remote display label",
817+ RemoteKey: &key,
818+ RemoteAvailability: RemoteFresh,
819+ ConnectOnly: true,
820+ RemoteAttachReady: true,
821+ }}, SelectionConfig{Mode: SelectNavigationTab})
822+ key.Host = "mutated-after-model-construction"
823+
824+ got, ok := model.Selected()
825+ require.True(t, ok)
826+ require.Equal(t, wantKey.ID(), got.Session)
827+ require.Equal(t, &wantKey, got.RemoteKey)
828+ require.Equal(t, "", got.Name, "connect-only remote routing never derives a name from display text")
829+}
830+
831+func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
832+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
833+ local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}
834+ availability := []struct {
835+ name string
836+ state RemoteAvailability
837+ }{
838+ {name: "cached", state: RemoteCached},
839+ {name: "fresh", state: RemoteFresh},
840+ {name: "stale", state: RemoteStale},
841+ {name: "version mismatch", state: RemoteVersionMismatch},
842+ }
843+ modes := []struct {
844+ name string
845+ mode SelectionMode
846+ }{
847+ {name: "move pane", mode: SelectMovePaneTab},
848+ {name: "move tab", mode: SelectMoveTabSession},
849+ }
850+
851+ for _, availability := range availability {
852+ t.Run(availability.name, func(t *testing.T) {
853+ remote := SessionView{
854+ ID: key.ID(),
855+ Name: key.Display(),
856+ Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
857+ RemoteKey: &key,
858+ RemoteAvailability: availability.state,
859+ ConnectOnly: true,
860+ RemoteAttachReady: true,
861+ CannotAcceptMoves: true,
862+ }
863+ for _, mode := range modes {
864+ t.Run(mode.name, func(t *testing.T) {
865+ model := New([]SessionView{local, remote}, SelectionConfig{Mode: mode.mode, Source: SourceFilter{Session: "source"}})
866+ var remoteRow *row
867+ for i := range model.rows {
868+ if model.rows[i].session == key.ID() {
869+ remoteRow = &model.rows[i]
870+ break
871+ }
872+ }
873+ require.NotNil(t, remoteRow)
874+ require.True(t, remoteRow.dim)
875+ require.False(t, remoteRow.selectable)
876+ })
877+ }
878+ })
879+ }
880+}
@@ -722,3 +722,159 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
722 })722 })
723 }723 }
724 }724 }
725+
726+func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) {
727+ key := domain.RemoteSessionKey{Host: "build@mule", Name: "work"}
728+ availability := []RemoteAvailability{RemoteCached, RemoteFresh, RemoteStale, RemoteVersionMismatch}
729+ sessions := make([]SessionView, 0, len(availability))
730+ for _, state := range availability {
731+ sessions = append(sessions, SessionView{
732+ ID: key.ID(),
733+ Name: "presentation text is not routing data",
734+ Tabs: []TabEntry{{Name: "relayed metadata is not a picker target"}},
735+ RemoteKey: &key,
736+ RemoteAvailability: state,
737+ RemoteDetail: "remote state detail",
738+ ConnectOnly: true,
739+ RemoteAttachReady: false,
740+ })
741+ }
742+
743+ model := New(sessions, SelectionConfig{Mode: SelectNavigationTab})
744+ require.Len(t, model.rows, len(availability), "connect-only remote rows do not synthesize tab destinations")
745+ for i, pickerRow := range model.rows {
746+ require.Equal(t, rowSession, pickerRow.kind)
747+ require.Equal(t, key, pickerRow.remoteKey)
748+ require.True(t, pickerRow.hasRemoteKey)
749+ require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])
750+ require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i])
751+ wantDetail := "proxy activation pending"
752+ if availability[i] == RemoteStale || availability[i] == RemoteVersionMismatch {
753+ wantDetail = "remote state detail"
754+ }
755+ require.Equal(t, wantDetail, pickerRow.detail)
756+ }
757+ _, ok := model.Selected()
758+ require.False(t, ok)
759+ frame := model.Render(domain.Size{Cols: 80, Rows: len(availability)}, Preview{})
760+ for y := range availability {
761+ require.NotZero(t, frame.At(0, y).Style.Attrs&renderer.AttrDim)
762+ }
763+}
764+
765+func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *testing.T) {
766+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
767+ cached := New([]SessionView{{
768+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached,
769+ ConnectOnly: true, RemoteAttachReady: false,
770+ }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}})
771+
772+ cursor, ok := cached.Cursor()
773+ require.True(t, ok)
774+ require.Equal(t, key.ID(), cursor.Session)
775+ require.Equal(t, &key, cursor.RemoteKey)
776+ _, selectable := cached.Selected()
777+ require.False(t, selectable, "cursor identity must not bypass the phase 5 activation gate")
778+
779+ live := New([]SessionView{{
780+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh,
781+ ConnectOnly: true, RemoteAttachReady: false,
782+ }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{
783+ Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
784+ }})
785+
786+ upgraded, ok := live.Cursor()
787+ require.True(t, ok)
788+ require.Equal(t, cursor, upgraded)
789+ _, selectable = live.Selected()
790+ require.False(t, selectable, "cache-to-live cursor preservation must not enable attach")
791+}
792+
793+func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) {
794+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
795+ model := New([]SessionView{{
796+ ID: key.ID(),
797+ Name: key.Display(),
798+ Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
799+ RemoteKey: &key,
800+ RemoteAvailability: RemoteFresh,
801+ RemoteAttachReady: false,
802+ }}, SelectionConfig{Mode: SelectNavigationTab})
803+
804+ require.Len(t, model.rows, 2)
805+ for _, pickerRow := range model.rows {
806+ require.False(t, pickerRow.selectable)
807+ require.True(t, pickerRow.dim)
808+ }
809+}
810+
811+func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) {
812+ key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"}
813+ wantKey := key
814+ model := New([]SessionView{{
815+ ID: key.ID(),
816+ Name: "not a parseable remote display label",
817+ RemoteKey: &key,
818+ RemoteAvailability: RemoteFresh,
819+ ConnectOnly: true,
820+ RemoteAttachReady: true,
821+ }}, SelectionConfig{Mode: SelectNavigationTab})
822+ key.Host = "mutated-after-model-construction"
823+
824+ got, ok := model.Selected()
825+ require.True(t, ok)
826+ require.Equal(t, wantKey.ID(), got.Session)
827+ require.Equal(t, &wantKey, got.RemoteKey)
828+ require.Equal(t, "", got.Name, "connect-only remote routing never derives a name from display text")
829+}
830+
831+func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
832+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
833+ local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}
834+ availability := []struct {
835+ name string
836+ state RemoteAvailability
837+ }{
838+ {name: "cached", state: RemoteCached},
839+ {name: "fresh", state: RemoteFresh},
840+ {name: "stale", state: RemoteStale},
841+ {name: "version mismatch", state: RemoteVersionMismatch},
842+ }
843+ modes := []struct {
844+ name string
845+ mode SelectionMode
846+ }{
847+ {name: "move pane", mode: SelectMovePaneTab},
848+ {name: "move tab", mode: SelectMoveTabSession},
849+ }
850+
851+ for _, availability := range availability {
852+ t.Run(availability.name, func(t *testing.T) {
853+ remote := SessionView{
854+ ID: key.ID(),
855+ Name: key.Display(),
856+ Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
857+ RemoteKey: &key,
858+ RemoteAvailability: availability.state,
859+ ConnectOnly: true,
860+ RemoteAttachReady: true,
861+ CannotAcceptMoves: true,
862+ }
863+ for _, mode := range modes {
864+ t.Run(mode.name, func(t *testing.T) {
865+ model := New([]SessionView{local, remote}, SelectionConfig{Mode: mode.mode, Source: SourceFilter{Session: "source"}})
866+ var remoteRow *row
867+ for i := range model.rows {
868+ if model.rows[i].session == key.ID() {
869+ remoteRow = &model.rows[i]
870+ break
871+ }
872+ }
873+ require.NotNil(t, remoteRow)
874+ require.True(t, remoteRow.dim)
875+ require.False(t, remoteRow.selectable)
876+ })
877+ }
878+ })
879+ }
880+}

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