detached 5090cbb

refactor(daemon): pickerViews reads sessions through snapshotView

commit
5090cbbc0b8d1975947d2fb07c4b27c81fb08717
author
brice · · 2026-07-29 19:36 UTC
parent
6710d10
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +65 -63
M internal/usecase/daemon/picker.go +28 -62
@@ -87,28 +87,30 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
8787 }
8888 }
8989 d.mu.Unlock()
90- // Snapshot mruAt, name, and ephemeral once: comparators must not observe
91- // concurrent touchMRU updates or a renameSession mutation (which mutates
92- // name/ephemeral under sess.mu) mid-sort.
93- type pickerSortSnapshot struct {
94- mruAt uint64
95- name string
96- ephemeral bool
97- }
98- snap := make(map[*session]pickerSortSnapshot, len(sessions))
90+
91+ for _, s := range sessions {
92+ d.refreshSessionFocusedTitles(s)
93+ }
94+ opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
95+
96+ // One snapshot per session: sorting and view building read the same
97+ // capture, so comparators cannot observe a concurrent touchMRU or
98+ // renameSession mid-sort (the invariant previously enforced by the
99+ // pickerSortSnapshot map).
100+ snaps := make([]sessionView, 0, len(sessions))
101+ var current picker.SourceFilter
99102 for _, s := range sessions {
100- mruAt := s.mruAt.Load()
101- s.mu.Lock()
102- name := s.name
103- ephemeral := s.ephemeral
104- s.mu.Unlock()
105- snap[s] = pickerSortSnapshot{mruAt: mruAt, name: name, ephemeral: ephemeral}
106- }
107- sort.Slice(sessions, func(i, j int) bool {
108- if snap[sessions[i]].mruAt != snap[sessions[j]].mruAt {
109- return snap[sessions[i]].mruAt > snap[sessions[j]].mruAt
110- }
111- return snap[sessions[i]].name < snap[sessions[j]].name
103+ snap := s.snapshotView(opts)
104+ if s == cur && snap.active >= 0 && snap.active < len(snap.tabs) {
105+ current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}
106+ }
107+ snaps = append(snaps, snap)
108+ }
109+ sort.Slice(snaps, func(i, j int) bool {
110+ if snaps[i].mruAt != snaps[j].mruAt {
111+ return snaps[i].mruAt > snaps[j].mruAt
112+ }
113+ return snaps[i].name < snaps[j].name
112114 })
113115 sort.Slice(stopped, func(i, j int) bool {
114116 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
@@ -119,50 +121,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
119121 if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
120122 // Stable partition: named sessions first, ephemeral after, each keeping
121123 // its MRU order from the sort above.
122- sort.SliceStable(sessions, func(i, j int) bool {
123- return !snap[sessions[i]].ephemeral && snap[sessions[j]].ephemeral
124+ sort.SliceStable(snaps, func(i, j int) bool {
125+ return !snaps[i].ephemeral && snaps[j].ephemeral
124126 })
125127 }
126128
127- for _, s := range sessions {
128- d.refreshSessionFocusedTitles(s)
129- }
130-
131- includeTerminalTitle := d.currentTabsConfig().TerminalTitle
132- views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
133- var current picker.SourceFilter
134- for _, s := range sessions {
135- s.mu.Lock()
136- view := picker.SessionView{
137- ID: s.id, Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Active: s.active,
138- Tabs: make([]picker.TabEntry, 0, len(s.tabs)),
139- }
140- if !s.ephemeral {
141- createdAt := s.createdAt
142- view.ExpectedCreatedAt = &createdAt
143- }
144- sessionAttention := false
145- for i, tb := range s.tabs {
146- name := tabDisplayName(tb, i)
147- tabID := domain.TabStableID(tb.stableID)
148- view.Tabs = append(view.Tabs, picker.TabEntry{
149- TabID: tabID,
150- Name: name,
151- Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),
152- Attention: tb.attention,
153- })
154- if tb.attention {
155- sessionAttention = true
156- }
157- if s == cur && i == s.active {
158- current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID}
159- }
160- }
161- if sessionAttention {
162- view.Name = attentionSuffix(view.Name)
163- }
164- s.mu.Unlock()
165- views = append(views, view)
129+ views := make([]picker.SessionView, 0, len(snaps)+len(stopped))
130+ for _, snap := range snaps {
131+ views = append(views, snap.pickerView())
166132 }
167133 for _, s := range stopped {
168134 createdAt := s.createdAt
@@ -87,28 +87,30 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
87 }87 }
88 }88 }
89 d.mu.Unlock()89 d.mu.Unlock()
90- // Snapshot mruAt, name, and ephemeral once: comparators must not observe90+
91- // concurrent touchMRU updates or a renameSession mutation (which mutates91+ for _, s := range sessions {
92- // name/ephemeral under sess.mu) mid-sort.92+ d.refreshSessionFocusedTitles(s)
93- type pickerSortSnapshot struct {93+ }
94- mruAt uint6494+ opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
95- name string95+
96- ephemeral bool96+ // One snapshot per session: sorting and view building read the same
97- }97+ // capture, so comparators cannot observe a concurrent touchMRU or
98- snap := make(map[*session]pickerSortSnapshot, len(sessions))98+ // renameSession mid-sort (the invariant previously enforced by the
99+ // pickerSortSnapshot map).
100+ snaps := make([]sessionView, 0, len(sessions))
101+ var current picker.SourceFilter
99 for _, s := range sessions {102 for _, s := range sessions {
100- mruAt := s.mruAt.Load()103+ snap := s.snapshotView(opts)
101- s.mu.Lock()104+ if s == cur && snap.active >= 0 && snap.active < len(snap.tabs) {
102- name := s.name105+ current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}
103- ephemeral := s.ephemeral106+ }
104- s.mu.Unlock()107+ snaps = append(snaps, snap)
105- snap[s] = pickerSortSnapshot{mruAt: mruAt, name: name, ephemeral: ephemeral}108+ }
106- }109+ sort.Slice(snaps, func(i, j int) bool {
107- sort.Slice(sessions, func(i, j int) bool {110+ if snaps[i].mruAt != snaps[j].mruAt {
108- if snap[sessions[i]].mruAt != snap[sessions[j]].mruAt {111+ return snaps[i].mruAt > snaps[j].mruAt
109- return snap[sessions[i]].mruAt > snap[sessions[j]].mruAt112+ }
110- }113+ return snaps[i].name < snaps[j].name
111- return snap[sessions[i]].name < snap[sessions[j]].name
112 })114 })
113 sort.Slice(stopped, func(i, j int) bool {115 sort.Slice(stopped, func(i, j int) bool {
114 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {116 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
@@ -119,50 +121,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
119 if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {121 if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
120 // Stable partition: named sessions first, ephemeral after, each keeping122 // Stable partition: named sessions first, ephemeral after, each keeping
121 // its MRU order from the sort above.123 // its MRU order from the sort above.
122- sort.SliceStable(sessions, func(i, j int) bool {124+ sort.SliceStable(snaps, func(i, j int) bool {
123- return !snap[sessions[i]].ephemeral && snap[sessions[j]].ephemeral125+ return !snaps[i].ephemeral && snaps[j].ephemeral
124 })126 })
125 }127 }
126 128
127- for _, s := range sessions {129+ views := make([]picker.SessionView, 0, len(snaps)+len(stopped))
128- d.refreshSessionFocusedTitles(s)130+ for _, snap := range snaps {
129- }131+ views = append(views, snap.pickerView())
130-
131- includeTerminalTitle := d.currentTabsConfig().TerminalTitle
132- views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
133- var current picker.SourceFilter
134- for _, s := range sessions {
135- s.mu.Lock()
136- view := picker.SessionView{
137- ID: s.id, Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Active: s.active,
138- Tabs: make([]picker.TabEntry, 0, len(s.tabs)),
139- }
140- if !s.ephemeral {
141- createdAt := s.createdAt
142- view.ExpectedCreatedAt = &createdAt
143- }
144- sessionAttention := false
145- for i, tb := range s.tabs {
146- name := tabDisplayName(tb, i)
147- tabID := domain.TabStableID(tb.stableID)
148- view.Tabs = append(view.Tabs, picker.TabEntry{
149- TabID: tabID,
150- Name: name,
151- Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),
152- Attention: tb.attention,
153- })
154- if tb.attention {
155- sessionAttention = true
156- }
157- if s == cur && i == s.active {
158- current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID}
159- }
160- }
161- if sessionAttention {
162- view.Name = attentionSuffix(view.Name)
163- }
164- s.mu.Unlock()
165- views = append(views, view)
166 }132 }
167 for _, s := range stopped {133 for _, s := range stopped {
168 createdAt := s.createdAt134 createdAt := s.createdAt
M internal/usecase/daemon/session_view.go +37 -1
@@ -1,6 +1,9 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/domain"
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
47
58 // viewOptions controls optional (costly) fields captured by snapshotView.
69 type viewOptions struct {
@@ -82,3 +85,36 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
8285 }
8386 return view
8487 }
88+
89+// pickerView renders this snapshot as the picker's value type. Field-for-field
90+// equivalent to the inline construction previously in pickerViews.
91+func (view sessionView) pickerView() picker.SessionView {
92+ out := picker.SessionView{
93+ ID: view.id,
94+ Incarnation: view.incarnation,
95+ Name: view.name,
96+ TargetName: view.name,
97+ Active: view.active,
98+ Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
99+ }
100+ if !view.ephemeral {
101+ createdAt := view.createdAt
102+ out.ExpectedCreatedAt = &createdAt
103+ }
104+ attention := false
105+ for _, tb := range view.tabs {
106+ out.Tabs = append(out.Tabs, picker.TabEntry{
107+ TabID: tb.id,
108+ Name: tb.name,
109+ Detail: tabTitleDetail(tb.name, tb.focusedTitle),
110+ Attention: tb.attention,
111+ })
112+ if tb.attention {
113+ attention = true
114+ }
115+ }
116+ if attention {
117+ out.Name = attentionSuffix(out.Name)
118+ }
119+ return out
120+}
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/domain"3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
4 7
5 // viewOptions controls optional (costly) fields captured by snapshotView.8 // viewOptions controls optional (costly) fields captured by snapshotView.
6 type viewOptions struct {9 type viewOptions struct {
@@ -82,3 +85,36 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
82 }85 }
83 return view86 return view
84 }87 }
88+
89+// pickerView renders this snapshot as the picker's value type. Field-for-field
90+// equivalent to the inline construction previously in pickerViews.
91+func (view sessionView) pickerView() picker.SessionView {
92+ out := picker.SessionView{
93+ ID: view.id,
94+ Incarnation: view.incarnation,
95+ Name: view.name,
96+ TargetName: view.name,
97+ Active: view.active,
98+ Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
99+ }
100+ if !view.ephemeral {
101+ createdAt := view.createdAt
102+ out.ExpectedCreatedAt = &createdAt
103+ }
104+ attention := false
105+ for _, tb := range view.tabs {
106+ out.Tabs = append(out.Tabs, picker.TabEntry{
107+ TabID: tb.id,
108+ Name: tb.name,
109+ Detail: tabTitleDetail(tb.name, tb.focusedTitle),
110+ Attention: tb.attention,
111+ })
112+ if tb.attention {
113+ attention = true
114+ }
115+ }
116+ if attention {
117+ out.Name = attentionSuffix(out.Name)
118+ }
119+ return out
120+}

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