feat(picker): order sessions by most recently used
- commit
- d415bc02c449009d395bd3265c591ca8b23c27d2
- author
- brice · · 2026-07-29 13:27 UTC
- parent
- 0ba6c09
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +60 -3
M internal/usecase/daemon/picker.go +18 -2
| @@ -54,8 +54,24 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||
| 54 | 54 | } | |
| 55 | 55 | } | |
| 56 | 56 | d.mu.Unlock() | |
| 57 | - | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | |
| 58 | - | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |
| 57 | + | // Snapshot mruAt once: comparators must not observe concurrent touchMRU | |
| 58 | + | // updates mid-sort. | |
| 59 | + | mru := make(map[*session]uint64, len(sessions)) | |
| 60 | + | for _, s := range sessions { | |
| 61 | + | mru[s] = s.mruAt.Load() | |
| 62 | + | } | |
| 63 | + | sort.Slice(sessions, func(i, j int) bool { | |
| 64 | + | if mru[sessions[i]] != mru[sessions[j]] { | |
| 65 | + | return mru[sessions[i]] > mru[sessions[j]] | |
| 66 | + | } | |
| 67 | + | return sessions[i].name < sessions[j].name | |
| 68 | + | }) | |
| 69 | + | sort.Slice(stopped, func(i, j int) bool { | |
| 70 | + | if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq { | |
| 71 | + | return stopped[i].lastUsedSeq > stopped[j].lastUsedSeq | |
| 72 | + | } | |
| 73 | + | return stopped[i].name < stopped[j].name | |
| 74 | + | }) | |
| 59 | 75 | ||
| 60 | 76 | for _, s := range sessions { | |
| 61 | 77 | d.refreshSessionFocusedTitles(s) | |
| @@ -54,8 +54,24 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||||
| 54 | } | 54 | } | ||
| 55 | } | 55 | } | ||
| 56 | d.mu.Unlock() | 56 | d.mu.Unlock() | ||
| 57 | - | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | 57 | + | // Snapshot mruAt once: comparators must not observe concurrent touchMRU |
| 58 | - | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | 58 | + | // updates mid-sort. |
| 59 | + | mru := make(map[*session]uint64, len(sessions)) | |||
| 60 | + | for _, s := range sessions { | |||
| 61 | + | mru[s] = s.mruAt.Load() | |||
| 62 | + | } | |||
| 63 | + | sort.Slice(sessions, func(i, j int) bool { | |||
| 64 | + | if mru[sessions[i]] != mru[sessions[j]] { | |||
| 65 | + | return mru[sessions[i]] > mru[sessions[j]] | |||
| 66 | + | } | |||
| 67 | + | return sessions[i].name < sessions[j].name | |||
| 68 | + | }) | |||
| 69 | + | sort.Slice(stopped, func(i, j int) bool { | |||
| 70 | + | if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq { | |||
| 71 | + | return stopped[i].lastUsedSeq > stopped[j].lastUsedSeq | |||
| 72 | + | } | |||
| 73 | + | return stopped[i].name < stopped[j].name | |||
| 74 | + | }) | |||
| 59 | 75 | ||||
| 60 | for _, s := range sessions { | 76 | for _, s := range sessions { | ||
| 61 | d.refreshSessionFocusedTitles(s) | 77 | d.refreshSessionFocusedTitles(s) | ||
M internal/usecase/daemon/picker_test.go +42 -1
| @@ -118,6 +118,46 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) { | |||
| 118 | 118 | require.Equal(t, "1", ephemeralView.Name) | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | + | func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | |
| 122 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 123 | + | old := &session{id: "old", name: "alpha", tabs: []*tab{{}}} | |
| 124 | + | old.mruAt.Store(1) | |
| 125 | + | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | |
| 126 | + | eph.mruAt.Store(3) | |
| 127 | + | recent := &session{id: "recent", name: "zeta", tabs: []*tab{{}}} | |
| 128 | + | recent.mruAt.Store(2) | |
| 129 | + | d.sessions[old.id] = old | |
| 130 | + | d.sessions[eph.id] = eph | |
| 131 | + | d.sessions[recent.id] = recent | |
| 132 | + | d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1} | |
| 133 | + | d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2} | |
| 134 | + | ||
| 135 | + | views, _ := d.pickerViews(recent) | |
| 136 | + | ||
| 137 | + | names := make([]string, 0, len(views)) | |
| 138 | + | for _, v := range views { | |
| 139 | + | names = append(names, v.Name) | |
| 140 | + | } | |
| 141 | + | // Live MRU-desc (ephemeral "1" is most recent), then stopped block MRU-desc. | |
| 142 | + | require.Equal(t, []string{"1", "zeta", "alpha", "halted-new", "halted-old"}, names) | |
| 143 | + | require.True(t, views[3].Stopped) | |
| 144 | + | require.True(t, views[4].Stopped) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) { | |
| 148 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 149 | + | b := &session{id: "b", name: "bravo", tabs: []*tab{{}}} | |
| 150 | + | a := &session{id: "a", name: "alpha", tabs: []*tab{{}}} | |
| 151 | + | // Equal mruAt (both zero) must yield a stable alphabetical order. | |
| 152 | + | d.sessions[b.id] = b | |
| 153 | + | d.sessions[a.id] = a | |
| 154 | + | ||
| 155 | + | views, _ := d.pickerViews(a) | |
| 156 | + | ||
| 157 | + | require.Equal(t, "alpha", views[0].Name) | |
| 158 | + | require.Equal(t, "bravo", views[1].Name) | |
| 159 | + | } | |
| 160 | + | ||
| 121 | 161 | func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |
| 122 | 162 | d := newTestDaemon(t, nil, stubClock{}) | |
| 123 | 163 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| @@ -1563,7 +1603,8 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||
| 1563 | 1603 | ||
| 1564 | 1604 | d.enterPicker(from, ac) | |
| 1565 | 1605 | awaitFrame(t, sends, ports.MsgOutput) | |
| 1566 | - | d.handleInput(from, ac, []byte("k")) | |
| 1606 | + | // MRU-desc order is from(current), recent, older; "j" moves onto "recent". | |
| 1607 | + | d.handleInput(from, ac, []byte("j")) | |
| 1567 | 1608 | awaitFrame(t, sends, ports.MsgOutput) | |
| 1568 | 1609 | d.handleInput(from, ac, []byte("x")) | |
| 1569 | 1610 | ||
| @@ -118,6 +118,46 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) { | |||||
| 118 | require.Equal(t, "1", ephemeralView.Name) | 118 | require.Equal(t, "1", ephemeralView.Name) | ||
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| 121 | + | func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | |||
| 122 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 123 | + | old := &session{id: "old", name: "alpha", tabs: []*tab{{}}} | |||
| 124 | + | old.mruAt.Store(1) | |||
| 125 | + | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | |||
| 126 | + | eph.mruAt.Store(3) | |||
| 127 | + | recent := &session{id: "recent", name: "zeta", tabs: []*tab{{}}} | |||
| 128 | + | recent.mruAt.Store(2) | |||
| 129 | + | d.sessions[old.id] = old | |||
| 130 | + | d.sessions[eph.id] = eph | |||
| 131 | + | d.sessions[recent.id] = recent | |||
| 132 | + | d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1} | |||
| 133 | + | d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2} | |||
| 134 | + | ||||
| 135 | + | views, _ := d.pickerViews(recent) | |||
| 136 | + | ||||
| 137 | + | names := make([]string, 0, len(views)) | |||
| 138 | + | for _, v := range views { | |||
| 139 | + | names = append(names, v.Name) | |||
| 140 | + | } | |||
| 141 | + | // Live MRU-desc (ephemeral "1" is most recent), then stopped block MRU-desc. | |||
| 142 | + | require.Equal(t, []string{"1", "zeta", "alpha", "halted-new", "halted-old"}, names) | |||
| 143 | + | require.True(t, views[3].Stopped) | |||
| 144 | + | require.True(t, views[4].Stopped) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) { | |||
| 148 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 149 | + | b := &session{id: "b", name: "bravo", tabs: []*tab{{}}} | |||
| 150 | + | a := &session{id: "a", name: "alpha", tabs: []*tab{{}}} | |||
| 151 | + | // Equal mruAt (both zero) must yield a stable alphabetical order. | |||
| 152 | + | d.sessions[b.id] = b | |||
| 153 | + | d.sessions[a.id] = a | |||
| 154 | + | ||||
| 155 | + | views, _ := d.pickerViews(a) | |||
| 156 | + | ||||
| 157 | + | require.Equal(t, "alpha", views[0].Name) | |||
| 158 | + | require.Equal(t, "bravo", views[1].Name) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 121 | func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | 161 | func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | ||
| 122 | d := newTestDaemon(t, nil, stubClock{}) | 162 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 123 | ctx, cancel := context.WithCancel(d.serveCtx) | 163 | ctx, cancel := context.WithCancel(d.serveCtx) | ||
| @@ -1563,7 +1603,8 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||||
| 1563 | 1603 | ||||
| 1564 | d.enterPicker(from, ac) | 1604 | d.enterPicker(from, ac) | ||
| 1565 | awaitFrame(t, sends, ports.MsgOutput) | 1605 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 1566 | - | d.handleInput(from, ac, []byte("k")) | 1606 | + | // MRU-desc order is from(current), recent, older; "j" moves onto "recent". |
| 1607 | + | d.handleInput(from, ac, []byte("j")) | |||
| 1567 | awaitFrame(t, sends, ports.MsgOutput) | 1608 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 1568 | d.handleInput(from, ac, []byte("x")) | 1609 | d.handleInput(from, ac, []byte("x")) | ||
| 1569 | 1610 | ||||