detached 6710d10

refactor(daemon): add sessionView value snapshot

commit
6710d109edfb3522ad52b7c2eb45d73bd331f1c6
author
brice · · 2026-07-29 19:32 UTC
parent
8eb2673
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +195 -0
A internal/usecase/daemon/session_view.go +84 -0
@@ -0,0 +1,84 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// viewOptions controls optional (costly) fields captured by snapshotView.
6+type viewOptions struct {
7+ tabDetails bool
8+ focusedTitles bool
9+ terminalTitle bool
10+}
11+
12+// tabView is an immutable, value-only description of one tab.
13+type tabView struct {
14+ id domain.TabStableID
15+ name string
16+ focusedTitle string
17+ attention bool
18+}
19+
20+// sessionView is an immutable, value-only description of a session. It
21+// deliberately retains no live pointers, mirroring recentSession
22+// (recent_sessions.go). Listing paths (picker, MRU bar, palette,
23+// list-sessions) read local session state through snapshotView; proxy rows in
24+// later phases still come from registry/discovery, not this type alone.
25+type sessionView struct {
26+ id domain.SessionID
27+ incarnation domain.IncarnationID
28+ name string
29+ ephemeral bool
30+ createdAt int64
31+ active int
32+ mruAt uint64
33+ attached bool
34+ tabCount int
35+ hasAttention bool
36+ tabs []tabView
37+}
38+
39+// snapshotView reads session fields under s.mu and samples the independently
40+// atomic mruAt while that lock is held. tabCount and hasAttention are always
41+// filled; tabs is allocated only when opts.tabDetails is true (non-nil empty
42+// slice when there are no tabs). Never call while holding a pane lock.
43+func (s *session) snapshotView(opts viewOptions) sessionView {
44+ if opts.focusedTitles && !opts.tabDetails {
45+ opts.tabDetails = true
46+ }
47+ s.mu.Lock()
48+ defer s.mu.Unlock()
49+ view := sessionView{
50+ id: s.id,
51+ incarnation: s.incarnation,
52+ name: s.name,
53+ ephemeral: s.ephemeral,
54+ createdAt: s.createdAt,
55+ active: s.active,
56+ mruAt: s.mruAt.Load(),
57+ attached: s.client != nil,
58+ tabCount: len(s.tabs),
59+ }
60+ if opts.tabDetails {
61+ view.tabs = make([]tabView, 0, len(s.tabs))
62+ }
63+ for i, tb := range s.tabs {
64+ if tb.attention {
65+ view.hasAttention = true
66+ if !opts.tabDetails {
67+ break
68+ }
69+ }
70+ if !opts.tabDetails {
71+ continue
72+ }
73+ entry := tabView{
74+ id: domain.TabStableID(tb.stableID),
75+ name: tabDisplayName(tb, i),
76+ attention: tb.attention,
77+ }
78+ if opts.focusedTitles {
79+ entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle)
80+ }
81+ view.tabs = append(view.tabs, entry)
82+ }
83+ return view
84+}
@@ -0,0 +1,84 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// viewOptions controls optional (costly) fields captured by snapshotView.
6+type viewOptions struct {
7+ tabDetails bool
8+ focusedTitles bool
9+ terminalTitle bool
10+}
11+
12+// tabView is an immutable, value-only description of one tab.
13+type tabView struct {
14+ id domain.TabStableID
15+ name string
16+ focusedTitle string
17+ attention bool
18+}
19+
20+// sessionView is an immutable, value-only description of a session. It
21+// deliberately retains no live pointers, mirroring recentSession
22+// (recent_sessions.go). Listing paths (picker, MRU bar, palette,
23+// list-sessions) read local session state through snapshotView; proxy rows in
24+// later phases still come from registry/discovery, not this type alone.
25+type sessionView struct {
26+ id domain.SessionID
27+ incarnation domain.IncarnationID
28+ name string
29+ ephemeral bool
30+ createdAt int64
31+ active int
32+ mruAt uint64
33+ attached bool
34+ tabCount int
35+ hasAttention bool
36+ tabs []tabView
37+}
38+
39+// snapshotView reads session fields under s.mu and samples the independently
40+// atomic mruAt while that lock is held. tabCount and hasAttention are always
41+// filled; tabs is allocated only when opts.tabDetails is true (non-nil empty
42+// slice when there are no tabs). Never call while holding a pane lock.
43+func (s *session) snapshotView(opts viewOptions) sessionView {
44+ if opts.focusedTitles && !opts.tabDetails {
45+ opts.tabDetails = true
46+ }
47+ s.mu.Lock()
48+ defer s.mu.Unlock()
49+ view := sessionView{
50+ id: s.id,
51+ incarnation: s.incarnation,
52+ name: s.name,
53+ ephemeral: s.ephemeral,
54+ createdAt: s.createdAt,
55+ active: s.active,
56+ mruAt: s.mruAt.Load(),
57+ attached: s.client != nil,
58+ tabCount: len(s.tabs),
59+ }
60+ if opts.tabDetails {
61+ view.tabs = make([]tabView, 0, len(s.tabs))
62+ }
63+ for i, tb := range s.tabs {
64+ if tb.attention {
65+ view.hasAttention = true
66+ if !opts.tabDetails {
67+ break
68+ }
69+ }
70+ if !opts.tabDetails {
71+ continue
72+ }
73+ entry := tabView{
74+ id: domain.TabStableID(tb.stableID),
75+ name: tabDisplayName(tb, i),
76+ attention: tb.attention,
77+ }
78+ if opts.focusedTitles {
79+ entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle)
80+ }
81+ view.tabs = append(view.tabs, entry)
82+ }
83+ return view
84+}
A internal/usecase/daemon/session_view_test.go +111 -0
@@ -0,0 +1,111 @@
1+package daemon
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func TestSnapshotViewCapturesSessionFields(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ build func() *session
14+ opts viewOptions
15+ want sessionView
16+ }{
17+ {
18+ name: "named session with attention tab",
19+ build: func() *session {
20+ s := &session{
21+ id: domain.SessionID("s1"),
22+ incarnation: domain.IncarnationID{1},
23+ name: "alpha",
24+ createdAt: 42,
25+ active: 1,
26+ client: &attachedClient{},
27+ tabs: []*tab{
28+ {stableID: "t1", name: "build"},
29+ {stableID: "t2", name: "logs", attention: true},
30+ },
31+ }
32+ s.mruAt.Store(7)
33+ return s
34+ },
35+ opts: viewOptions{tabDetails: true},
36+ want: sessionView{
37+ id: domain.SessionID("s1"),
38+ incarnation: domain.IncarnationID{1},
39+ name: "alpha",
40+ createdAt: 42,
41+ active: 1,
42+ mruAt: 7,
43+ attached: true,
44+ tabCount: 2,
45+ hasAttention: true,
46+ tabs: []tabView{
47+ {id: domain.TabStableID("t1"), name: "build"},
48+ {id: domain.TabStableID("t2"), name: "logs", attention: true},
49+ },
50+ },
51+ },
52+ {
53+ name: "ephemeral detached session without tabs",
54+ build: func() *session {
55+ return &session{id: domain.SessionID("s2"), name: "9", ephemeral: true}
56+ },
57+ opts: viewOptions{},
58+ want: sessionView{
59+ id: domain.SessionID("s2"),
60+ name: "9",
61+ ephemeral: true,
62+ tabCount: 0,
63+ },
64+ },
65+ }
66+ for _, tt := range tests {
67+ t.Run(tt.name, func(t *testing.T) {
68+ got := tt.build().snapshotView(tt.opts)
69+ if !reflect.DeepEqual(got, tt.want) {
70+ t.Fatalf("snapshotView() = %+v, want %+v", got, tt.want)
71+ }
72+ })
73+ }
74+}
75+
76+func TestSnapshotViewFocusedTitlesOption(t *testing.T) {
77+ s := &session{
78+ id: domain.SessionID("s1"),
79+ name: "alpha",
80+ tabs: []*tab{{stableID: "t1", name: "build"}},
81+ }
82+ off := s.snapshotView(viewOptions{tabDetails: true})
83+ if off.tabs[0].focusedTitle != "" {
84+ t.Fatalf("focusedTitle without focusedTitles option = %q, want empty", off.tabs[0].focusedTitle)
85+ }
86+ // A pane-less tab has no focused pane: the option must be exercised
87+ // without panicking and still yield an empty title.
88+ on := s.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: true})
89+ if on.tabs[0].focusedTitle != "" {
90+ t.Fatalf("focusedTitle for pane-less tab = %q, want empty", on.tabs[0].focusedTitle)
91+ }
92+}
93+
94+func TestSnapshotViewAggregatesWithoutTabDetails(t *testing.T) {
95+ s := &session{tabs: []*tab{
96+ {stableID: "t1", name: "build"},
97+ {stableID: "t2", name: "logs", attention: true},
98+ }}
99+ got := s.snapshotView(viewOptions{})
100+ if got.tabCount != 2 || !got.hasAttention {
101+ t.Fatalf("aggregates = (tabCount %d, hasAttention %t), want (2, true)", got.tabCount, got.hasAttention)
102+ }
103+ if got.tabs != nil {
104+ t.Fatalf("tabs without tabDetails = %#v, want nil", got.tabs)
105+ }
106+
107+ empty := (&session{}).snapshotView(viewOptions{tabDetails: true})
108+ if empty.tabCount != 0 || empty.tabs == nil || len(empty.tabs) != 0 {
109+ t.Fatalf("empty detailed snapshot = (tabCount %d, tabs %#v), want (0, non-nil empty)", empty.tabCount, empty.tabs)
110+ }
111+}
@@ -0,0 +1,111 @@
1+package daemon
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func TestSnapshotViewCapturesSessionFields(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ build func() *session
14+ opts viewOptions
15+ want sessionView
16+ }{
17+ {
18+ name: "named session with attention tab",
19+ build: func() *session {
20+ s := &session{
21+ id: domain.SessionID("s1"),
22+ incarnation: domain.IncarnationID{1},
23+ name: "alpha",
24+ createdAt: 42,
25+ active: 1,
26+ client: &attachedClient{},
27+ tabs: []*tab{
28+ {stableID: "t1", name: "build"},
29+ {stableID: "t2", name: "logs", attention: true},
30+ },
31+ }
32+ s.mruAt.Store(7)
33+ return s
34+ },
35+ opts: viewOptions{tabDetails: true},
36+ want: sessionView{
37+ id: domain.SessionID("s1"),
38+ incarnation: domain.IncarnationID{1},
39+ name: "alpha",
40+ createdAt: 42,
41+ active: 1,
42+ mruAt: 7,
43+ attached: true,
44+ tabCount: 2,
45+ hasAttention: true,
46+ tabs: []tabView{
47+ {id: domain.TabStableID("t1"), name: "build"},
48+ {id: domain.TabStableID("t2"), name: "logs", attention: true},
49+ },
50+ },
51+ },
52+ {
53+ name: "ephemeral detached session without tabs",
54+ build: func() *session {
55+ return &session{id: domain.SessionID("s2"), name: "9", ephemeral: true}
56+ },
57+ opts: viewOptions{},
58+ want: sessionView{
59+ id: domain.SessionID("s2"),
60+ name: "9",
61+ ephemeral: true,
62+ tabCount: 0,
63+ },
64+ },
65+ }
66+ for _, tt := range tests {
67+ t.Run(tt.name, func(t *testing.T) {
68+ got := tt.build().snapshotView(tt.opts)
69+ if !reflect.DeepEqual(got, tt.want) {
70+ t.Fatalf("snapshotView() = %+v, want %+v", got, tt.want)
71+ }
72+ })
73+ }
74+}
75+
76+func TestSnapshotViewFocusedTitlesOption(t *testing.T) {
77+ s := &session{
78+ id: domain.SessionID("s1"),
79+ name: "alpha",
80+ tabs: []*tab{{stableID: "t1", name: "build"}},
81+ }
82+ off := s.snapshotView(viewOptions{tabDetails: true})
83+ if off.tabs[0].focusedTitle != "" {
84+ t.Fatalf("focusedTitle without focusedTitles option = %q, want empty", off.tabs[0].focusedTitle)
85+ }
86+ // A pane-less tab has no focused pane: the option must be exercised
87+ // without panicking and still yield an empty title.
88+ on := s.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: true})
89+ if on.tabs[0].focusedTitle != "" {
90+ t.Fatalf("focusedTitle for pane-less tab = %q, want empty", on.tabs[0].focusedTitle)
91+ }
92+}
93+
94+func TestSnapshotViewAggregatesWithoutTabDetails(t *testing.T) {
95+ s := &session{tabs: []*tab{
96+ {stableID: "t1", name: "build"},
97+ {stableID: "t2", name: "logs", attention: true},
98+ }}
99+ got := s.snapshotView(viewOptions{})
100+ if got.tabCount != 2 || !got.hasAttention {
101+ t.Fatalf("aggregates = (tabCount %d, hasAttention %t), want (2, true)", got.tabCount, got.hasAttention)
102+ }
103+ if got.tabs != nil {
104+ t.Fatalf("tabs without tabDetails = %#v, want nil", got.tabs)
105+ }
106+
107+ empty := (&session{}).snapshotView(viewOptions{tabDetails: true})
108+ if empty.tabCount != 0 || empty.tabs == nil || len(empty.tabs) != 0 {
109+ t.Fatalf("empty detailed snapshot = (tabCount %d, tabs %#v), want (0, non-nil empty)", empty.tabCount, empty.tabs)
110+ }
111+}

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