detached 9683889

feat(picker): grouped sort mode toggled with s

commit
9683889c770b4094dc9f9227bbbec69533e790e2
author
brice · · 2026-07-29 13:38 UTC
parent
d415bc0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +125 -2
M internal/usecase/daemon/daemon.go +3 -0
@@ -79,6 +79,9 @@ type Daemon struct {
7979 // reusing a lifecycle identity.
8080 lastAllocatedCreatedAt int64
8181 mruSeq atomic.Uint64
82+ // pickerSort is the picker ordering mode for this daemon's lifetime
83+ // (pickerSortMode); not persisted across restarts.
84+ pickerSort atomic.Uint32
8285 // closing marks that shutdown has irreversibly begun. It is set under mu,
8386 // atomically with the event that makes shutdown inevitable (the registry
8487 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -79,6 +79,9 @@ type Daemon struct {
79 // reusing a lifecycle identity.79 // reusing a lifecycle identity.
80 lastAllocatedCreatedAt int6480 lastAllocatedCreatedAt int64
81 mruSeq atomic.Uint6481 mruSeq atomic.Uint64
82+ // pickerSort is the picker ordering mode for this daemon's lifetime
83+ // (pickerSortMode); not persisted across restarts.
84+ pickerSort atomic.Uint32
82 // closing marks that shutdown has irreversibly begun. It is set under mu,85 // closing marks that shutdown has irreversibly begun. It is set under mu,
83 // atomically with the event that makes shutdown inevitable (the registry86 // atomically with the event that makes shutdown inevitable (the registry
84 // emptying in killSession, or shutdownAll starting), and checked by route87 // emptying in killSession, or shutdownAll starting), and checked by route
M internal/usecase/daemon/move_picker.go +14 -1
@@ -137,7 +137,20 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
137137 return sess, sess.tabs[target.TabIndex]
138138 }
139139
140+type pickerRefreshOptions struct {
141+ // preserveSelection keeps the currently selected target selected across the
142+ // rebuild even for navigate intent (used by the sort toggle).
143+ preserveSelection bool
144+ // nearestRow, when >= 0, selects the row occupying this index after the
145+ // rebuild (used after deletes). -1 disables it.
146+ nearestRow int
147+}
148+
140149 func (d *Daemon) refreshPicker(ac *attachedClient) {
150+ d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1})
151+}
152+
153+func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
141154 sess := ac.currentSession()
142155 if sess == nil {
143156 return
@@ -150,7 +163,7 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
150163 }
151164 intent, source := rt.pickerIntent, rt.pickerSource
152165 current := picker.SourceFilter{}
153- if intent != pickerNavigate {
166+ if intent != pickerNavigate || opts.preserveSelection {
154167 selected, _ := rt.picker.Selected()
155168 current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
156169 }
@@ -137,7 +137,20 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
137 return sess, sess.tabs[target.TabIndex]137 return sess, sess.tabs[target.TabIndex]
138 }138 }
139 139
140+type pickerRefreshOptions struct {
141+ // preserveSelection keeps the currently selected target selected across the
142+ // rebuild even for navigate intent (used by the sort toggle).
143+ preserveSelection bool
144+ // nearestRow, when >= 0, selects the row occupying this index after the
145+ // rebuild (used after deletes). -1 disables it.
146+ nearestRow int
147+}
148+
140 func (d *Daemon) refreshPicker(ac *attachedClient) {149 func (d *Daemon) refreshPicker(ac *attachedClient) {
150+ d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1})
151+}
152+
153+func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
141 sess := ac.currentSession()154 sess := ac.currentSession()
142 if sess == nil {155 if sess == nil {
143 return156 return
@@ -150,7 +163,7 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
150 }163 }
151 intent, source := rt.pickerIntent, rt.pickerSource164 intent, source := rt.pickerIntent, rt.pickerSource
152 current := picker.SourceFilter{}165 current := picker.SourceFilter{}
153- if intent != pickerNavigate {166+ if intent != pickerNavigate || opts.preserveSelection {
154 selected, _ := rt.picker.Selected()167 selected, _ := rt.picker.Selected()
155 current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}168 current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
156 }169 }
M internal/usecase/daemon/picker.go +32 -1
@@ -16,6 +16,22 @@ import (
1616
1717 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
1818
19+// pickerSortMode orders the picker's live sessions. It lives for the daemon's
20+// lifetime only and is never persisted.
21+type pickerSortMode uint32
22+
23+const (
24+ pickerSortRecent pickerSortMode = iota
25+ pickerSortGrouped
26+)
27+
28+func pickerTitle(mode pickerSortMode) string {
29+ if mode == pickerSortGrouped {
30+ return " Sessions · grouped "
31+ }
32+ return " Sessions · recent "
33+}
34+
1935 // enterPicker preserves the existing navigation entry point. Navigation always
2036 // publishes its model, including an empty one; only move entry can fail for a
2137 // missing destination.
@@ -72,6 +88,13 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
7288 }
7389 return stopped[i].name < stopped[j].name
7490 })
91+ if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
92+ // Stable partition: named sessions first, ephemeral after, each keeping
93+ // its MRU order from the sort above.
94+ sort.SliceStable(sessions, func(i, j int) bool {
95+ return !sessions[i].ephemeral && sessions[j].ephemeral
96+ })
97+ }
7598
7699 for _, s := range sessions {
77100 d.refreshSessionFocusedTitles(s)
@@ -130,7 +153,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
130153
131154 func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
132155 views, attachedCurrent := d.pickerViews(cur)
133- if intent == pickerNavigate {
156+ if intent == pickerNavigate && current == (picker.SourceFilter{}) {
134157 current = attachedCurrent
135158 }
136159 return picker.New(views, picker.SelectionConfig{
@@ -189,6 +212,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
189212 switch b {
190213 case 'x':
191214 return listInputResult{action: b, stop: true}
215+ case 's':
216+ return listInputResult{action: b, stop: true}
192217 case '\r', '\n':
193218 return listInputResult{action: b, exit: true}
194219 default:
@@ -205,6 +230,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
205230 intent, source = rt.pickerIntent, rt.pickerSource
206231 rt.pickerMu.Unlock()
207232
233+ if result.action == 's' {
234+ d.pickerSort.Store(d.pickerSort.Load() ^ 1)
235+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
236+ d.invalidateRender(sess, ac, true, "picker.go")
237+ return
238+ }
208239 if result.action == 'x' {
209240 var effect *roleEffectTicket
210241 if len(effects) != 0 {
@@ -16,6 +16,22 @@ import (
16 16
17 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}17 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
18 18
19+// pickerSortMode orders the picker's live sessions. It lives for the daemon's
20+// lifetime only and is never persisted.
21+type pickerSortMode uint32
22+
23+const (
24+ pickerSortRecent pickerSortMode = iota
25+ pickerSortGrouped
26+)
27+
28+func pickerTitle(mode pickerSortMode) string {
29+ if mode == pickerSortGrouped {
30+ return " Sessions · grouped "
31+ }
32+ return " Sessions · recent "
33+}
34+
19 // enterPicker preserves the existing navigation entry point. Navigation always35 // enterPicker preserves the existing navigation entry point. Navigation always
20 // publishes its model, including an empty one; only move entry can fail for a36 // publishes its model, including an empty one; only move entry can fail for a
21 // missing destination.37 // missing destination.
@@ -72,6 +88,13 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
72 }88 }
73 return stopped[i].name < stopped[j].name89 return stopped[i].name < stopped[j].name
74 })90 })
91+ if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
92+ // Stable partition: named sessions first, ephemeral after, each keeping
93+ // its MRU order from the sort above.
94+ sort.SliceStable(sessions, func(i, j int) bool {
95+ return !sessions[i].ephemeral && sessions[j].ephemeral
96+ })
97+ }
75 98
76 for _, s := range sessions {99 for _, s := range sessions {
77 d.refreshSessionFocusedTitles(s)100 d.refreshSessionFocusedTitles(s)
@@ -130,7 +153,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
130 153
131 func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {154 func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
132 views, attachedCurrent := d.pickerViews(cur)155 views, attachedCurrent := d.pickerViews(cur)
133- if intent == pickerNavigate {156+ if intent == pickerNavigate && current == (picker.SourceFilter{}) {
134 current = attachedCurrent157 current = attachedCurrent
135 }158 }
136 return picker.New(views, picker.SelectionConfig{159 return picker.New(views, picker.SelectionConfig{
@@ -189,6 +212,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
189 switch b {212 switch b {
190 case 'x':213 case 'x':
191 return listInputResult{action: b, stop: true}214 return listInputResult{action: b, stop: true}
215+ case 's':
216+ return listInputResult{action: b, stop: true}
192 case '\r', '\n':217 case '\r', '\n':
193 return listInputResult{action: b, exit: true}218 return listInputResult{action: b, exit: true}
194 default:219 default:
@@ -205,6 +230,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
205 intent, source = rt.pickerIntent, rt.pickerSource230 intent, source = rt.pickerIntent, rt.pickerSource
206 rt.pickerMu.Unlock()231 rt.pickerMu.Unlock()
207 232
233+ if result.action == 's' {
234+ d.pickerSort.Store(d.pickerSort.Load() ^ 1)
235+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
236+ d.invalidateRender(sess, ac, true, "picker.go")
237+ return
238+ }
208 if result.action == 'x' {239 if result.action == 'x' {
209 var effect *roleEffectTicket240 var effect *roleEffectTicket
210 if len(effects) != 0 {241 if len(effects) != 0 {
M internal/usecase/daemon/picker_test.go +76 -0
@@ -158,6 +158,82 @@ func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
158158 require.Equal(t, "bravo", views[1].Name)
159159 }
160160
161+func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
162+ d := newTestDaemon(t, nil, stubClock{})
163+ d.pickerSort.Store(uint32(pickerSortGrouped))
164+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
165+ eph.mruAt.Store(5)
166+ named := &session{id: "named", name: "work", tabs: []*tab{{}}}
167+ named.mruAt.Store(2)
168+ named2 := &session{id: "named2", name: "notes", tabs: []*tab{{}}}
169+ named2.mruAt.Store(3)
170+ d.sessions[eph.id] = eph
171+ d.sessions[named.id] = named
172+ d.sessions[named2.id] = named2
173+ d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
174+
175+ views, _ := d.pickerViews(named)
176+
177+ names := make([]string, 0, len(views))
178+ for _, v := range views {
179+ names = append(names, v.Name)
180+ }
181+ // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the
182+ // ephemeral session has the highest mruAt.
183+ require.Equal(t, []string{"notes", "work", "1", "halted"}, names)
184+}
185+
186+func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
187+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
188+ defer releaseAll(releases)
189+ sess.mu.Lock()
190+ sess.client = ac
191+ secondTabID := domain.TabStableID(sess.tabs[1].stableID)
192+ sess.mu.Unlock()
193+ sess.mruAt.Store(1)
194+ // A second live session, ephemeral and more recently used: recent mode lists
195+ // it first, grouped mode moves it below the named session.
196+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
197+ eph.mruAt.Store(5)
198+ d.mu.Lock()
199+ d.sessions[eph.id] = eph
200+ d.mu.Unlock()
201+
202+ d.enterPicker(sess, ac)
203+ awaitFrame(t, sends, ports.MsgOutput)
204+ // Move off the attached session's active tab: a navigate rebuild snaps back
205+ // to it unless the toggle preserves the selection.
206+ d.handleInput(sess, ac, []byte("j"))
207+ awaitFrame(t, sends, ports.MsgOutput)
208+ ac.overlays.pickerMu.Lock()
209+ before, ok := ac.overlays.picker.Selected()
210+ ac.overlays.pickerMu.Unlock()
211+ require.True(t, ok)
212+ require.Equal(t, secondTabID, before.TabID)
213+
214+ d.handleInput(sess, ac, []byte("s"))
215+ awaitFrame(t, sends, ports.MsgOutput)
216+
217+ require.Equal(t, uint32(pickerSortGrouped), d.pickerSort.Load())
218+ require.True(t, ac.overlays.pickerActive())
219+ ac.overlays.pickerMu.Lock()
220+ selected, ok := ac.overlays.picker.Selected()
221+ ac.overlays.pickerMu.Unlock()
222+ require.True(t, ok)
223+ require.Equal(t, before, selected)
224+
225+ d.handleInput(sess, ac, []byte("s"))
226+ awaitFrame(t, sends, ports.MsgOutput)
227+
228+ require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load())
229+ require.True(t, ac.overlays.pickerActive())
230+ ac.overlays.pickerMu.Lock()
231+ selected, ok = ac.overlays.picker.Selected()
232+ ac.overlays.pickerMu.Unlock()
233+ require.True(t, ok)
234+ require.Equal(t, before, selected)
235+}
236+
161237 func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
162238 d := newTestDaemon(t, nil, stubClock{})
163239 ctx, cancel := context.WithCancel(d.serveCtx)
@@ -158,6 +158,82 @@ func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
158 require.Equal(t, "bravo", views[1].Name)158 require.Equal(t, "bravo", views[1].Name)
159 }159 }
160 160
161+func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
162+ d := newTestDaemon(t, nil, stubClock{})
163+ d.pickerSort.Store(uint32(pickerSortGrouped))
164+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
165+ eph.mruAt.Store(5)
166+ named := &session{id: "named", name: "work", tabs: []*tab{{}}}
167+ named.mruAt.Store(2)
168+ named2 := &session{id: "named2", name: "notes", tabs: []*tab{{}}}
169+ named2.mruAt.Store(3)
170+ d.sessions[eph.id] = eph
171+ d.sessions[named.id] = named
172+ d.sessions[named2.id] = named2
173+ d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
174+
175+ views, _ := d.pickerViews(named)
176+
177+ names := make([]string, 0, len(views))
178+ for _, v := range views {
179+ names = append(names, v.Name)
180+ }
181+ // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the
182+ // ephemeral session has the highest mruAt.
183+ require.Equal(t, []string{"notes", "work", "1", "halted"}, names)
184+}
185+
186+func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
187+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
188+ defer releaseAll(releases)
189+ sess.mu.Lock()
190+ sess.client = ac
191+ secondTabID := domain.TabStableID(sess.tabs[1].stableID)
192+ sess.mu.Unlock()
193+ sess.mruAt.Store(1)
194+ // A second live session, ephemeral and more recently used: recent mode lists
195+ // it first, grouped mode moves it below the named session.
196+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
197+ eph.mruAt.Store(5)
198+ d.mu.Lock()
199+ d.sessions[eph.id] = eph
200+ d.mu.Unlock()
201+
202+ d.enterPicker(sess, ac)
203+ awaitFrame(t, sends, ports.MsgOutput)
204+ // Move off the attached session's active tab: a navigate rebuild snaps back
205+ // to it unless the toggle preserves the selection.
206+ d.handleInput(sess, ac, []byte("j"))
207+ awaitFrame(t, sends, ports.MsgOutput)
208+ ac.overlays.pickerMu.Lock()
209+ before, ok := ac.overlays.picker.Selected()
210+ ac.overlays.pickerMu.Unlock()
211+ require.True(t, ok)
212+ require.Equal(t, secondTabID, before.TabID)
213+
214+ d.handleInput(sess, ac, []byte("s"))
215+ awaitFrame(t, sends, ports.MsgOutput)
216+
217+ require.Equal(t, uint32(pickerSortGrouped), d.pickerSort.Load())
218+ require.True(t, ac.overlays.pickerActive())
219+ ac.overlays.pickerMu.Lock()
220+ selected, ok := ac.overlays.picker.Selected()
221+ ac.overlays.pickerMu.Unlock()
222+ require.True(t, ok)
223+ require.Equal(t, before, selected)
224+
225+ d.handleInput(sess, ac, []byte("s"))
226+ awaitFrame(t, sends, ports.MsgOutput)
227+
228+ require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load())
229+ require.True(t, ac.overlays.pickerActive())
230+ ac.overlays.pickerMu.Lock()
231+ selected, ok = ac.overlays.picker.Selected()
232+ ac.overlays.pickerMu.Unlock()
233+ require.True(t, ok)
234+ require.Equal(t, before, selected)
235+}
236+
161 func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {237 func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
162 d := newTestDaemon(t, nil, stubClock{})238 d := newTestDaemon(t, nil, stubClock{})
163 ctx, cancel := context.WithCancel(d.serveCtx)239 ctx, cancel := context.WithCancel(d.serveCtx)

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