detached ba2c6f3

feat(picker): group by host by default and remember the sort per client

commit
ba2c6f3a6c77e870889a95764ed351593099e6c1
author
brice · · 2026-09-23 19:59 UTC
parent
d6feeac
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +27 -7
M internal/usecase/client/picker_controller.go +15 -2
@@ -98,7 +98,10 @@ type pickerController struct {
9898 renderer *pickerRenderer
9999 notices ui.ToastManager
100100 interaction uint64
101- ownsInput bool
101+ // sort is this client's picker ordering. It survives every picker
102+ // reopen for the lifetime of the client process.
103+ sort pickerusecase.SortMode
104+ ownsInput bool
102105 opsReady chan struct{}
103106 lastOp pickerOp
104107 // lastCommitKey is the catalogue key captured with the pending commit
@@ -126,6 +129,7 @@ func newPickerController(clock ports.Clock, freshness time.Duration, trueColor b
126129 clock: clock,
127130 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
128131 renderer: newPickerRenderer(pickerColorProfile(trueColor)),
132+ sort: defaultPickerSort(),
129133 ownsInput: true,
130134 opsReady: make(chan struct{}, 1),
131135 }
@@ -612,11 +616,20 @@ func (p *pickerController) SetCurrent(current pickerCurrent) {
612616 if p.catalogue.Epoch() == 0 {
613617 return
614618 }
619+ p.rememberSortLocked()
615620 p.loop = nil
616621 p.rebuildLocked()
617622 p.renderer.invalidate()
618623 }
619624
625+// rememberSortLocked keeps the sort the user chose in the closing
626+// presentation for the next one.
627+func (p *pickerController) rememberSortLocked() {
628+ if p.loop != nil && p.loop.model != nil {
629+ p.sort = p.loop.model.SortMode()
630+ }
631+}
632+
620633 // pickerRefusalNotice is the toast text for one resolution refusal.
621634 func pickerRefusalNotice(err error) string {
622635 var typed pickerCatalogueError
@@ -700,7 +713,7 @@ func (p *pickerController) rebuildLocked() {
700713 }
701714 if p.loop == nil {
702715 p.interaction++
703- p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, defaultPickerSort())
716+ p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, p.sort)
704717 return
705718 }
706719 p.loop.replaceLines(snapshot())
@@ -98,7 +98,10 @@ type pickerController struct {
98 renderer *pickerRenderer98 renderer *pickerRenderer
99 notices ui.ToastManager99 notices ui.ToastManager
100 interaction uint64100 interaction uint64
101- ownsInput bool101+ // sort is this client's picker ordering. It survives every picker
102+ // reopen for the lifetime of the client process.
103+ sort pickerusecase.SortMode
104+ ownsInput bool
102 opsReady chan struct{}105 opsReady chan struct{}
103 lastOp pickerOp106 lastOp pickerOp
104 // lastCommitKey is the catalogue key captured with the pending commit107 // lastCommitKey is the catalogue key captured with the pending commit
@@ -126,6 +129,7 @@ func newPickerController(clock ports.Clock, freshness time.Duration, trueColor b
126 clock: clock,129 clock: clock,
127 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),130 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
128 renderer: newPickerRenderer(pickerColorProfile(trueColor)),131 renderer: newPickerRenderer(pickerColorProfile(trueColor)),
132+ sort: defaultPickerSort(),
129 ownsInput: true,133 ownsInput: true,
130 opsReady: make(chan struct{}, 1),134 opsReady: make(chan struct{}, 1),
131 }135 }
@@ -612,11 +616,20 @@ func (p *pickerController) SetCurrent(current pickerCurrent) {
612 if p.catalogue.Epoch() == 0 {616 if p.catalogue.Epoch() == 0 {
613 return617 return
614 }618 }
619+ p.rememberSortLocked()
615 p.loop = nil620 p.loop = nil
616 p.rebuildLocked()621 p.rebuildLocked()
617 p.renderer.invalidate()622 p.renderer.invalidate()
618 }623 }
619 624
625+// rememberSortLocked keeps the sort the user chose in the closing
626+// presentation for the next one.
627+func (p *pickerController) rememberSortLocked() {
628+ if p.loop != nil && p.loop.model != nil {
629+ p.sort = p.loop.model.SortMode()
630+ }
631+}
632+
620 // pickerRefusalNotice is the toast text for one resolution refusal.633 // pickerRefusalNotice is the toast text for one resolution refusal.
621 func pickerRefusalNotice(err error) string {634 func pickerRefusalNotice(err error) string {
622 var typed pickerCatalogueError635 var typed pickerCatalogueError
@@ -700,7 +713,7 @@ func (p *pickerController) rebuildLocked() {
700 }713 }
701 if p.loop == nil {714 if p.loop == nil {
702 p.interaction++715 p.interaction++
703- p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, defaultPickerSort())716+ p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, p.sort)
704 return717 return
705 }718 }
706 p.loop.replaceLines(snapshot())719 p.loop.replaceLines(snapshot())
M internal/usecase/client/picker_interaction.go +3 -2
@@ -294,8 +294,9 @@ func (l *pickerLoop) killAction() bool {
294294 return ok && line.Actions&protocol.PickerCanKill != 0
295295 }
296296
297-// defaultPickerSort is the client-local initial ordering mode.
298-func defaultPickerSort() picker.SortMode { return picker.SortRecent }
297+// defaultPickerSort is the client-local initial ordering mode: sessions
298+// grouped under their host, local first.
299+func defaultPickerSort() picker.SortMode { return picker.SortGrouped }
299300
300301 // pickerPreviewSize is the viewport the modal can display for one terminal: the
301302 // preview pane inside the shared picker geometry.
@@ -294,8 +294,9 @@ func (l *pickerLoop) killAction() bool {
294 return ok && line.Actions&protocol.PickerCanKill != 0294 return ok && line.Actions&protocol.PickerCanKill != 0
295 }295 }
296 296
297-// defaultPickerSort is the client-local initial ordering mode.297+// defaultPickerSort is the client-local initial ordering mode: sessions
298-func defaultPickerSort() picker.SortMode { return picker.SortRecent }298+// grouped under their host, local first.
299+func defaultPickerSort() picker.SortMode { return picker.SortGrouped }
299 300
300 // pickerPreviewSize is the viewport the modal can display for one terminal: the301 // pickerPreviewSize is the viewport the modal can display for one terminal: the
301 // preview pane inside the shared picker geometry.302 // preview pane inside the shared picker geometry.
M internal/usecase/client/picker_parity_test.go +9 -3
@@ -377,11 +377,17 @@ func TestPickerControllerSortToggleSwitchesProjection(t *testing.T) {
377377 }
378378 return labels
379379 }
380- require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order(), "recent: live, remote, stopped")
380+ grouped := []string{"alpha", "gamma", "remote-a"}
381+ recent := []string{"alpha", "remote-a", "gamma"}
382+ require.Equal(t, grouped, order(), "default grouped: local section, then remote")
381383 require.True(t, controller.ConsumeTerminalRead([]byte("s")))
382- require.Equal(t, []string{"alpha", "gamma", "remote-a"}, order(), "grouped: local section, then remote")
384+ require.Equal(t, recent, order(), "recent: live, remote, stopped")
385+
386+ // Reopening the picker keeps this client's chosen sort.
387+ controller.SetCurrent(pickerCurrent{})
388+ require.Equal(t, recent, order(), "sort survives a reopen")
383389 require.True(t, controller.ConsumeTerminalRead([]byte("s")))
384- require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order())
390+ require.Equal(t, grouped, order())
385391 }
386392
387393 // TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's
@@ -377,11 +377,17 @@ func TestPickerControllerSortToggleSwitchesProjection(t *testing.T) {
377 }377 }
378 return labels378 return labels
379 }379 }
380- require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order(), "recent: live, remote, stopped")380+ grouped := []string{"alpha", "gamma", "remote-a"}
381+ recent := []string{"alpha", "remote-a", "gamma"}
382+ require.Equal(t, grouped, order(), "default grouped: local section, then remote")
381 require.True(t, controller.ConsumeTerminalRead([]byte("s")))383 require.True(t, controller.ConsumeTerminalRead([]byte("s")))
382- require.Equal(t, []string{"alpha", "gamma", "remote-a"}, order(), "grouped: local section, then remote")384+ require.Equal(t, recent, order(), "recent: live, remote, stopped")
385+
386+ // Reopening the picker keeps this client's chosen sort.
387+ controller.SetCurrent(pickerCurrent{})
388+ require.Equal(t, recent, order(), "sort survives a reopen")
383 require.True(t, controller.ConsumeTerminalRead([]byte("s")))389 require.True(t, controller.ConsumeTerminalRead([]byte("s")))
384- require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order())390+ require.Equal(t, grouped, order())
385 }391 }
386 392
387 // TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's393 // TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's

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