detached fb6668c

fix(palette): preserve refreshed selection

commit
fb6668cadd3e182dc60f43e8b1b0fdf66ba8368a
author
brice · · 2026-08-25 10:52 UTC
parent
cc86ead
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
5757
5858 func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) }
5959
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.
6262 func (m *Model) ReplaceResults(results []Result) {
6363 if m == nil {
6464 return
6565 }
66+ selected, hadSelection := m.Selected()
6667 m.results = append([]Result(nil), results...)
6768 m.argumentResults = requiredArgumentResults(m.results)
6869 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+ }
6980 }
7081
7182 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 while60+// 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 return64 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 (
1212 "github.com/stretchr/testify/require"
1313 )
1414
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+
1540 func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
1641 m := New(CommandResults([]command.Command{
1742 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
8484
8585 func (r Result) Kind() ResultKind { return r.kind }
8686
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+
87105 func (r Result) DisplayText() string {
88106 if r.kind == ResultKindCommand {
89107 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.Code107 return r.command.Code

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