fix(picker): refresh remote-owned picker views
- commit
- de8bb89e0f5d1482eafb3af906b75dd073bb999b
- author
- brice · · 2026-08-07 10:13 UTC
- parent
- 01fd03b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 161 | 161 | } | |
| 162 | 162 | ||
| 163 | 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 | 168 | if sess == nil { | |
| 166 | 169 | return | |
| 167 | 170 | } | |
| @@ -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 | return | 169 | return | ||
| 167 | } | 170 | } | ||
M internal/usecase/daemon/remote_picker.go +3 -3
| @@ -570,8 +570,8 @@ func (d *Daemon) refreshRemoteOpenPickers() { | |||
| 570 | 570 | continue | |
| 571 | 571 | } | |
| 572 | 572 | 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") | |
| 576 | 576 | } | |
| 577 | 577 | } | |
| @@ -570,8 +570,8 @@ func (d *Daemon) refreshRemoteOpenPickers() { | |||||
| 570 | continue | 570 | 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) { | |||
| 635 | 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 | 686 | func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *testing.T) { | |
| 639 | 687 | d := newRemotePickerDaemon(nil) | |
| 640 | 688 | 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() | ||