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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 98 | 98 | renderer *pickerRenderer | |
| 99 | 99 | notices ui.ToastManager | |
| 100 | 100 | 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 | |
| 102 | 105 | opsReady chan struct{} | |
| 103 | 106 | lastOp pickerOp | |
| 104 | 107 | // 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 | 129 | clock: clock, | |
| 127 | 130 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | |
| 128 | 131 | renderer: newPickerRenderer(pickerColorProfile(trueColor)), | |
| 132 | + | sort: defaultPickerSort(), | |
| 129 | 133 | ownsInput: true, | |
| 130 | 134 | opsReady: make(chan struct{}, 1), | |
| 131 | 135 | } | |
| @@ -612,11 +616,20 @@ func (p *pickerController) SetCurrent(current pickerCurrent) { | |||
| 612 | 616 | if p.catalogue.Epoch() == 0 { | |
| 613 | 617 | return | |
| 614 | 618 | } | |
| 619 | + | p.rememberSortLocked() | |
| 615 | 620 | p.loop = nil | |
| 616 | 621 | p.rebuildLocked() | |
| 617 | 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 | 633 | // pickerRefusalNotice is the toast text for one resolution refusal. | |
| 621 | 634 | func pickerRefusalNotice(err error) string { | |
| 622 | 635 | var typed pickerCatalogueError | |
| @@ -700,7 +713,7 @@ func (p *pickerController) rebuildLocked() { | |||
| 700 | 713 | } | |
| 701 | 714 | if p.loop == nil { | |
| 702 | 715 | p.interaction++ | |
| 703 | - | p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, defaultPickerSort()) | |
| 716 | + | p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, p.sort) | |
| 704 | 717 | return | |
| 705 | 718 | } | |
| 706 | 719 | p.loop.replaceLines(snapshot()) | |
| @@ -98,7 +98,10 @@ type pickerController struct { | |||||
| 98 | renderer *pickerRenderer | 98 | renderer *pickerRenderer | ||
| 99 | notices ui.ToastManager | 99 | notices ui.ToastManager | ||
| 100 | interaction uint64 | 100 | 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 | |||
| 102 | opsReady chan struct{} | 105 | opsReady chan struct{} | ||
| 103 | lastOp pickerOp | 106 | lastOp pickerOp | ||
| 104 | // lastCommitKey is the catalogue key captured with the pending commit | 107 | // 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 | return | 617 | return | ||
| 614 | } | 618 | } | ||
| 619 | + | p.rememberSortLocked() | |||
| 615 | p.loop = nil | 620 | 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 pickerCatalogueError | 635 | 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 | return | 717 | 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 { | |||
| 294 | 294 | return ok && line.Actions&protocol.PickerCanKill != 0 | |
| 295 | 295 | } | |
| 296 | 296 | ||
| 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 } | |
| 299 | 300 | ||
| 300 | 301 | // pickerPreviewSize is the viewport the modal can display for one terminal: the | |
| 301 | 302 | // preview pane inside the shared picker geometry. | |
| @@ -294,8 +294,9 @@ func (l *pickerLoop) killAction() bool { | |||||
| 294 | return ok && line.Actions&protocol.PickerCanKill != 0 | 294 | 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: the | 301 | // 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) { | |||
| 377 | 377 | } | |
| 378 | 378 | 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 | 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 | 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 | 393 | // TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's | |
| @@ -377,11 +377,17 @@ func TestPickerControllerSortToggleSwitchesProjection(t *testing.T) { | |||||
| 377 | } | 377 | } | ||
| 378 | return labels | 378 | 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's | 393 | // TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's | ||