fix(palette): preserve refreshed selection
- commit
- fb6668cadd3e182dc60f43e8b1b0fdf66ba8368a
- author
- brice · · 2026-08-25 10:52 UTC
- parent
- cc86ead
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +56 -2
M internal/usecase/palette/model.go +13 -2
| @@ -57,15 +57,26 @@ func New(results []Result) *Model { | |||
| 57 | 57 | ||
| 58 | 58 | func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) } | |
| 59 | 59 | ||
| 60 | - | // ReplaceResults atomically replaces the model's immutable result source while | |
| 61 | - | // preserving the current query, selection index, and scroll position. | |
| 60 | + | // ReplaceResults replaces the model's immutable result source while preserving | |
| 61 | + | // the current query and exact selected target when it remains available. | |
| 62 | 62 | func (m *Model) ReplaceResults(results []Result) { | |
| 63 | 63 | if m == nil { | |
| 64 | 64 | return | |
| 65 | 65 | } | |
| 66 | + | selected, hadSelection := m.Selected() | |
| 66 | 67 | m.results = append([]Result(nil), results...) | |
| 67 | 68 | m.argumentResults = requiredArgumentResults(m.results) | |
| 68 | 69 | m.refresh() | |
| 70 | + | if !hadSelection { | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | for i, match := range m.matches { | |
| 74 | + | if match.Result.sameTarget(selected) { | |
| 75 | + | m.selected = i | |
| 76 | + | m.clamp() | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | } | |
| 69 | 80 | } | |
| 70 | 81 | ||
| 71 | 82 | func DefaultRenderStyles() RenderStyles { | |
| @@ -57,15 +57,26 @@ func New(results []Result) *Model { | |||||
| 57 | 57 | ||||
| 58 | func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) } | 58 | func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) } | ||
| 59 | 59 | ||||
| 60 | - | // ReplaceResults atomically replaces the model's immutable result source while | 60 | + | // ReplaceResults replaces the model's immutable result source while preserving |
| 61 | - | // preserving the current query, selection index, and scroll position. | 61 | + | // the current query and exact selected target when it remains available. |
| 62 | func (m *Model) ReplaceResults(results []Result) { | 62 | func (m *Model) ReplaceResults(results []Result) { | ||
| 63 | if m == nil { | 63 | if m == nil { | ||
| 64 | return | 64 | return | ||
| 65 | } | 65 | } | ||
| 66 | + | selected, hadSelection := m.Selected() | |||
| 66 | m.results = append([]Result(nil), results...) | 67 | m.results = append([]Result(nil), results...) | ||
| 67 | m.argumentResults = requiredArgumentResults(m.results) | 68 | m.argumentResults = requiredArgumentResults(m.results) | ||
| 68 | m.refresh() | 69 | m.refresh() | ||
| 70 | + | if !hadSelection { | |||
| 71 | + | return | |||
| 72 | + | } | |||
| 73 | + | for i, match := range m.matches { | |||
| 74 | + | if match.Result.sameTarget(selected) { | |||
| 75 | + | m.selected = i | |||
| 76 | + | m.clamp() | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 69 | } | 80 | } | ||
| 70 | 81 | ||||
| 71 | func DefaultRenderStyles() RenderStyles { | 82 | func DefaultRenderStyles() RenderStyles { | ||
M internal/usecase/palette/model_test.go +25 -0
| @@ -12,6 +12,31 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | + | func TestReplaceResultsPreservesExactRemoteSelection(t *testing.T) { | |
| 16 | + | result := func(endpoint string, lifecycle byte) Result { | |
| 17 | + | key := domain.RemoteSessionKey{ | |
| 18 | + | Host: endpoint, Name: "vev", LifecycleID: domain.SessionLifecycleID{lifecycle}, DisplayOrigin: "arch", | |
| 19 | + | } | |
| 20 | + | target := domain.RemoteSessionTarget{ | |
| 21 | + | Endpoint: endpoint, DisplayOrigin: "arch", LifecycleID: key.LifecycleID, | |
| 22 | + | SessionName: "vev", LiveTabID: "tab-vev", | |
| 23 | + | } | |
| 24 | + | return NewRemoteSessionResult(key, target, "") | |
| 25 | + | } | |
| 26 | + | first := result("user@arch", 1) | |
| 27 | + | selected := result("admin@arch", 2) | |
| 28 | + | m := New([]Result{first, selected}) | |
| 29 | + | m.Down() | |
| 30 | + | ||
| 31 | + | m.ReplaceResults([]Result{selected, first}) | |
| 32 | + | ||
| 33 | + | got, ok := m.Selected() | |
| 34 | + | require.True(t, ok) | |
| 35 | + | target, ok := got.RemoteSessionTarget() | |
| 36 | + | require.True(t, ok) | |
| 37 | + | require.Equal(t, "admin@arch", target.Endpoint) | |
| 38 | + | } | |
| 39 | + | ||
| 15 | 40 | func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | |
| 16 | 41 | m := New(CommandResults([]command.Command{ | |
| 17 | 42 | cmd("ABC", "Alpha", "first"), | |
| @@ -12,6 +12,31 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | + | func TestReplaceResultsPreservesExactRemoteSelection(t *testing.T) { | |||
| 16 | + | result := func(endpoint string, lifecycle byte) Result { | |||
| 17 | + | key := domain.RemoteSessionKey{ | |||
| 18 | + | Host: endpoint, Name: "vev", LifecycleID: domain.SessionLifecycleID{lifecycle}, DisplayOrigin: "arch", | |||
| 19 | + | } | |||
| 20 | + | target := domain.RemoteSessionTarget{ | |||
| 21 | + | Endpoint: endpoint, DisplayOrigin: "arch", LifecycleID: key.LifecycleID, | |||
| 22 | + | SessionName: "vev", LiveTabID: "tab-vev", | |||
| 23 | + | } | |||
| 24 | + | return NewRemoteSessionResult(key, target, "") | |||
| 25 | + | } | |||
| 26 | + | first := result("user@arch", 1) | |||
| 27 | + | selected := result("admin@arch", 2) | |||
| 28 | + | m := New([]Result{first, selected}) | |||
| 29 | + | m.Down() | |||
| 30 | + | ||||
| 31 | + | m.ReplaceResults([]Result{selected, first}) | |||
| 32 | + | ||||
| 33 | + | got, ok := m.Selected() | |||
| 34 | + | require.True(t, ok) | |||
| 35 | + | target, ok := got.RemoteSessionTarget() | |||
| 36 | + | require.True(t, ok) | |||
| 37 | + | require.Equal(t, "admin@arch", target.Endpoint) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 15 | func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | 40 | func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | ||
| 16 | m := New(CommandResults([]command.Command{ | 41 | m := New(CommandResults([]command.Command{ | ||
| 17 | cmd("ABC", "Alpha", "first"), | 42 | cmd("ABC", "Alpha", "first"), | ||
M internal/usecase/palette/result.go +18 -0
| @@ -84,6 +84,24 @@ func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Resu | |||
| 84 | 84 | ||
| 85 | 85 | func (r Result) Kind() ResultKind { return r.kind } | |
| 86 | 86 | ||
| 87 | + | func (r Result) sameTarget(other Result) bool { | |
| 88 | + | if r.kind != other.kind { | |
| 89 | + | return false | |
| 90 | + | } | |
| 91 | + | switch r.kind { | |
| 92 | + | case ResultKindCommand: | |
| 93 | + | return r.command.Slug == other.command.Slug | |
| 94 | + | case ResultKindActiveSession, ResultKindStoppedSession: | |
| 95 | + | return r.session.target == other.session.target | |
| 96 | + | case ResultKindRemoteSession: | |
| 97 | + | return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target | |
| 98 | + | case ResultKindRecentRoute: | |
| 99 | + | return r.route.action == other.route.action | |
| 100 | + | default: | |
| 101 | + | return false | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 87 | 105 | func (r Result) DisplayText() string { | |
| 88 | 106 | if r.kind == ResultKindCommand { | |
| 89 | 107 | return r.command.Code | |
| @@ -84,6 +84,24 @@ func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Resu | |||||
| 84 | 84 | ||||
| 85 | func (r Result) Kind() ResultKind { return r.kind } | 85 | func (r Result) Kind() ResultKind { return r.kind } | ||
| 86 | 86 | ||||
| 87 | + | func (r Result) sameTarget(other Result) bool { | |||
| 88 | + | if r.kind != other.kind { | |||
| 89 | + | return false | |||
| 90 | + | } | |||
| 91 | + | switch r.kind { | |||
| 92 | + | case ResultKindCommand: | |||
| 93 | + | return r.command.Slug == other.command.Slug | |||
| 94 | + | case ResultKindActiveSession, ResultKindStoppedSession: | |||
| 95 | + | return r.session.target == other.session.target | |||
| 96 | + | case ResultKindRemoteSession: | |||
| 97 | + | return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target | |||
| 98 | + | case ResultKindRecentRoute: | |||
| 99 | + | return r.route.action == other.route.action | |||
| 100 | + | default: | |||
| 101 | + | return false | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 87 | func (r Result) DisplayText() string { | 105 | func (r Result) DisplayText() string { | ||
| 88 | if r.kind == ResultKindCommand { | 106 | if r.kind == ResultKindCommand { | ||
| 89 | return r.command.Code | 107 | return r.command.Code | ||