detached de8bb89

fix(picker): refresh remote-owned picker views

commit
de8bb89e0f5d1482eafb3af906b75dd073bb999b
author
brice · · 2026-08-07 10:13 UTC
parent
01fd03b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +55 -4
M internal/usecase/daemon/move_picker.go +4 -1
@@ -161,7 +161,10 @@ type pickerRefreshOptions struct {
161161 }
162162
163163 func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
164- sess := ac.currentSession()
164+ // A remote view retains its local navigation predecessor for picker state.
165+ // Rebuild against that session so remote catalog completions update the
166+ // picker while its attachment remains remotely owned.
167+ sess := ac.navigationSession()
165168 if sess == nil {
166169 return
167170 }
@@ -161,7 +161,10 @@ type pickerRefreshOptions struct {
161 }161 }
162 162
163 func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {163 func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
164- sess := ac.currentSession()164+ // A remote view retains its local navigation predecessor for picker state.
165+ // Rebuild against that session so remote catalog completions update the
166+ // picker while its attachment remains remotely owned.
167+ sess := ac.navigationSession()
165 if sess == nil {168 if sess == nil {
166 return169 return
167 }170 }
M internal/usecase/daemon/remote_picker.go +3 -3
@@ -570,8 +570,8 @@ func (d *Daemon) refreshRemoteOpenPickers() {
570570 continue
571571 }
572572 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
573- if entry := ac.currentAttachmentSession(); entry != nil {
574- d.invalidateRender(entry, ac, true, "remote_picker.go")
575- }
573+ // The picker is attachment-local: catalog changes repaint the actual
574+ // owner, which can be either the retained local session or a remote view.
575+ d.invalidateAttachedOwner(ac, true, "remote_picker.go")
576576 }
577577 }
@@ -570,8 +570,8 @@ func (d *Daemon) refreshRemoteOpenPickers() {
570 continue570 continue
571 }571 }
572 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})572 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
573- if entry := ac.currentAttachmentSession(); entry != nil {573+ // The picker is attachment-local: catalog changes repaint the actual
574- d.invalidateRender(entry, ac, true, "remote_picker.go")574+ // owner, which can be either the retained local session or a remote view.
575- }575+ d.invalidateAttachedOwner(ac, true, "remote_picker.go")
576 }576 }
577 }577 }
M internal/usecase/daemon/remote_picker_test.go +48 -0
@@ -635,6 +635,54 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
635635 d.remoteCatalog.mu.Unlock()
636636 }
637637
638+func TestRemoteCatalogRefreshUpdatesRemoteOwnerPicker(t *testing.T) {
639+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
640+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
641+ source, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
642+
643+ source.mu.Lock()
644+ require.True(t, source.unregisterAttachmentLocked(ac))
645+ source.mu.Unlock()
646+ view.mu.Lock()
647+ require.True(t, view.registerAttachmentLocked(ac))
648+ view.mu.Unlock()
649+ ac.setAttachmentOwner(view)
650+ ac.recordPreviousOwner(source)
651+
652+ setCatalog := func(name string) {
653+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
654+ Host: view.key.endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
655+ LifecycleID: view.key.lifecycleID, Name: name, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
656+ }},
657+ }})
658+ d.remoteCatalog.mu.Lock()
659+ d.remoteCatalog.status[view.key.endpoint] = remoteHostFresh
660+ d.remoteCatalog.mu.Unlock()
661+ }
662+ setCatalog("cached")
663+
664+ d.enterPicker(source, ac)
665+ awaitFrame(t, sends, ports.MsgOutput)
666+
667+ setCatalog("updated")
668+ d.refreshRemoteOpenPickers()
669+
670+ ac.overlays.pickerMu.Lock()
671+ model := ac.overlays.picker.Clone()
672+ ac.overlays.pickerMu.Unlock()
673+ frame := model.Render(domain.Size{Cols: 80, Rows: 20}, picker.Preview{})
674+ var text strings.Builder
675+ for y := range frame.Height {
676+ text.WriteString(rowText(frame.Row(y)))
677+ }
678+ require.Contains(t, text.String(), "updated", "remote-owner picker must use refreshed catalog rows")
679+
680+ output := awaitFrame(t, sends, ports.MsgOutput)
681+ decoded, err := ports.UnmarshalOutput(output.Payload)
682+ require.NoError(t, err)
683+ require.Contains(t, string(decoded.Data), "updated", "remote-owner picker must repaint after refresh")
684+}
685+
638686 func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *testing.T) {
639687 d := newRemotePickerDaemon(nil)
640688 target := remoteLinkTestTarget()
@@ -635,6 +635,54 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
635 d.remoteCatalog.mu.Unlock()635 d.remoteCatalog.mu.Unlock()
636 }636 }
637 637
638+func TestRemoteCatalogRefreshUpdatesRemoteOwnerPicker(t *testing.T) {
639+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
640+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
641+ source, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
642+
643+ source.mu.Lock()
644+ require.True(t, source.unregisterAttachmentLocked(ac))
645+ source.mu.Unlock()
646+ view.mu.Lock()
647+ require.True(t, view.registerAttachmentLocked(ac))
648+ view.mu.Unlock()
649+ ac.setAttachmentOwner(view)
650+ ac.recordPreviousOwner(source)
651+
652+ setCatalog := func(name string) {
653+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
654+ Host: view.key.endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
655+ LifecycleID: view.key.lifecycleID, Name: name, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
656+ }},
657+ }})
658+ d.remoteCatalog.mu.Lock()
659+ d.remoteCatalog.status[view.key.endpoint] = remoteHostFresh
660+ d.remoteCatalog.mu.Unlock()
661+ }
662+ setCatalog("cached")
663+
664+ d.enterPicker(source, ac)
665+ awaitFrame(t, sends, ports.MsgOutput)
666+
667+ setCatalog("updated")
668+ d.refreshRemoteOpenPickers()
669+
670+ ac.overlays.pickerMu.Lock()
671+ model := ac.overlays.picker.Clone()
672+ ac.overlays.pickerMu.Unlock()
673+ frame := model.Render(domain.Size{Cols: 80, Rows: 20}, picker.Preview{})
674+ var text strings.Builder
675+ for y := range frame.Height {
676+ text.WriteString(rowText(frame.Row(y)))
677+ }
678+ require.Contains(t, text.String(), "updated", "remote-owner picker must use refreshed catalog rows")
679+
680+ output := awaitFrame(t, sends, ports.MsgOutput)
681+ decoded, err := ports.UnmarshalOutput(output.Payload)
682+ require.NoError(t, err)
683+ require.Contains(t, string(decoded.Data), "updated", "remote-owner picker must repaint after refresh")
684+}
685+
638 func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *testing.T) {686 func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *testing.T) {
639 d := newRemotePickerDaemon(nil)687 d := newRemotePickerDaemon(nil)
640 target := remoteLinkTestTarget()688 target := remoteLinkTestTarget()

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 2

Tags 13

↑↓ moveenter openesc close