detached 386ed92

Merge pull request #149 from bnema/refactor/session-view

refactor(daemon): sessionView snapshot + sessionCapabilities seam (phase 0)
commit
386ed924c580bd8eeb4800a3fa12da22520d510d
author
bnema · · 2026-07-29 20:17 UTC
committer
GitHub · · 2026-07-29 20:17 UTC
merge of
8eb2673 + 114345b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +367 -91
M internal/usecase/daemon/control.go +10 -8
@@ -575,17 +575,19 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
575575 sessions = append(sessions, sess)
576576 }
577577 e.d.mu.Unlock()
578- var active *session
578+ snaps := make([]sessionView, 0, len(sessions))
579579 for _, sess := range sessions {
580- if active == nil || sess.mruAt.Load() > active.mruAt.Load() {
581- active = sess
580+ snaps = append(snaps, sess.snapshotView(viewOptions{}))
581+ }
582+ activeIdx := -1
583+ for i, snap := range snaps {
584+ if activeIdx == -1 || snap.mruAt > snaps[activeIdx].mruAt {
585+ activeIdx = i
582586 }
583587 }
584- rows := make([]row, 0, len(sessions))
585- for _, sess := range sessions {
586- sess.mu.Lock()
587- rows = append(rows, row{Name: sess.name, Ephemeral: sess.ephemeral, Tabs: len(sess.tabs), Attached: sess.client != nil, Active: sess == active})
588- sess.mu.Unlock()
588+ rows := make([]row, 0, len(snaps))
589+ for i, snap := range snaps {
590+ rows = append(rows, row{Name: snap.name, Ephemeral: snap.ephemeral, Tabs: snap.tabCount, Attached: snap.attached, Active: i == activeIdx})
589591 }
590592 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
591593 if asJSON {
@@ -575,17 +575,19 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
575 sessions = append(sessions, sess)575 sessions = append(sessions, sess)
576 }576 }
577 e.d.mu.Unlock()577 e.d.mu.Unlock()
578- var active *session578+ snaps := make([]sessionView, 0, len(sessions))
579 for _, sess := range sessions {579 for _, sess := range sessions {
580- if active == nil || sess.mruAt.Load() > active.mruAt.Load() {580+ snaps = append(snaps, sess.snapshotView(viewOptions{}))
581- active = sess581+ }
582+ activeIdx := -1
583+ for i, snap := range snaps {
584+ if activeIdx == -1 || snap.mruAt > snaps[activeIdx].mruAt {
585+ activeIdx = i
582 }586 }
583 }587 }
584- rows := make([]row, 0, len(sessions))588+ rows := make([]row, 0, len(snaps))
585- for _, sess := range sessions {589+ for i, snap := range snaps {
586- sess.mu.Lock()590+ rows = append(rows, row{Name: snap.name, Ephemeral: snap.ephemeral, Tabs: snap.tabCount, Attached: snap.attached, Active: i == activeIdx})
587- rows = append(rows, row{Name: sess.name, Ephemeral: sess.ephemeral, Tabs: len(sess.tabs), Attached: sess.client != nil, Active: sess == active})
588- sess.mu.Unlock()
589 }591 }
590 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })592 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
591 if asJSON {593 if asJSON {
M internal/usecase/daemon/move_picker.go +3 -0
@@ -57,6 +57,9 @@ type moveSourceLocator struct {
5757
5858 // enterPickerForIntent returns errNoMoveDestination only for move intents.
5959 func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
60+ if intent != pickerNavigate && !sess.capabilities().yieldsMoves() {
61+ return errSessionCannotYieldMoves
62+ }
6063 model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})
6164 if intent != pickerNavigate {
6265 if _, ok := model.Selected(); !ok {
@@ -57,6 +57,9 @@ type moveSourceLocator struct {
57 57
58 // enterPickerForIntent returns errNoMoveDestination only for move intents.58 // enterPickerForIntent returns errNoMoveDestination only for move intents.
59 func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {59 func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
60+ if intent != pickerNavigate && !sess.capabilities().yieldsMoves() {
61+ return errSessionCannotYieldMoves
62+ }
60 model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})63 model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})
61 if intent != pickerNavigate {64 if intent != pickerNavigate {
62 if _, ok := model.Selected(); !ok {65 if _, ok := model.Selected(); !ok {
M internal/usecase/daemon/palette.go +3 -6
@@ -74,12 +74,9 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) []
7474 if candidate == current {
7575 continue
7676 }
77- candidate.mu.Lock()
78- name, createdAt, ephemeral := candidate.name, candidate.createdAt, candidate.ephemeral
79- id := candidate.id
80- candidate.mu.Unlock()
81- if name != "" && !ephemeral {
82- active = append(active, palette.NewActiveSessionResult(name, time.Unix(0, createdAt), id))
77+ snap := candidate.snapshotView(viewOptions{})
78+ if snap.name != "" && !snap.ephemeral {
79+ active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id))
8380 }
8481 }
8582 sort.Slice(active, func(i, j int) bool {
@@ -74,12 +74,9 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) []
74 if candidate == current {74 if candidate == current {
75 continue75 continue
76 }76 }
77- candidate.mu.Lock()77+ snap := candidate.snapshotView(viewOptions{})
78- name, createdAt, ephemeral := candidate.name, candidate.createdAt, candidate.ephemeral78+ if snap.name != "" && !snap.ephemeral {
79- id := candidate.id79+ active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id))
80- candidate.mu.Unlock()
81- if name != "" && !ephemeral {
82- active = append(active, palette.NewActiveSessionResult(name, time.Unix(0, createdAt), id))
83 }80 }
84 }81 }
85 sort.Slice(active, func(i, j int) bool {82 sort.Slice(active, func(i, j int) bool {
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/recent_sessions.go +3 -15
@@ -27,23 +27,11 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
2727 if sess == current {
2828 continue
2929 }
30- sess.mu.Lock()
31- if sess.ephemeral {
32- sess.mu.Unlock()
30+ snap := sess.snapshotView(viewOptions{})
31+ if snap.ephemeral {
3332 continue
3433 }
35- entry := recentSession{
36- id: sess.id,
37- name: sess.name,
38- mruAt: sess.mruAt.Load(),
39- }
40- for _, tb := range sess.tabs {
41- if tb.attention {
42- entry.attention = true
43- break
44- }
45- }
46- sess.mu.Unlock()
34+ entry := recentSession{id: snap.id, name: snap.name, mruAt: snap.mruAt, attention: snap.hasAttention}
4735 recent = append(recent, entry)
4836 }
4937 d.mu.Unlock()
@@ -27,23 +27,11 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
27 if sess == current {27 if sess == current {
28 continue28 continue
29 }29 }
30- sess.mu.Lock()30+ snap := sess.snapshotView(viewOptions{})
31- if sess.ephemeral {31+ if snap.ephemeral {
32- sess.mu.Unlock()
33 continue32 continue
34 }33 }
35- entry := recentSession{34+ entry := recentSession{id: snap.id, name: snap.name, mruAt: snap.mruAt, attention: snap.hasAttention}
36- id: sess.id,
37- name: sess.name,
38- mruAt: sess.mruAt.Load(),
39- }
40- for _, tb := range sess.tabs {
41- if tb.attention {
42- entry.attention = true
43- break
44- }
45- }
46- sess.mu.Unlock()
47 recent = append(recent, entry)35 recent = append(recent, entry)
48 }36 }
49 d.mu.Unlock()37 d.mu.Unlock()
M internal/usecase/daemon/session.go +2 -0
@@ -32,6 +32,8 @@ type session struct {
3232 id domain.SessionID
3333 name string
3434 ephemeral bool
35+ // caps is immutable after construction; see sessionCapabilities.
36+ caps sessionCapabilities
3537
3638 ctx context.Context
3739 cancel context.CancelFunc
@@ -32,6 +32,8 @@ type session struct {
32 id domain.SessionID32 id domain.SessionID
33 name string33 name string
34 ephemeral bool34 ephemeral bool
35+ // caps is immutable after construction; see sessionCapabilities.
36+ caps sessionCapabilities
35 37
36 ctx context.Context38 ctx context.Context
37 cancel context.CancelFunc39 cancel context.CancelFunc
A internal/usecase/daemon/session_capabilities.go +23 -0
@@ -0,0 +1,23 @@
1+package daemon
2+
3+import "errors"
4+
5+// errSessionCannotYieldMoves rejects move-picker entry for sessions whose
6+// tabs and panes cannot leave the session (no local session sets this today;
7+// proxy sessions will).
8+var errSessionCannotYieldMoves = errors.New("session does not support moving tabs or panes")
9+
10+// sessionCapabilities declares what cross-session operations a session
11+// supports. Fields are negated so the zero value describes a fully capable
12+// local session — literal &session{...} constructions (tests, restore paths)
13+// keep today's behavior without opting in.
14+//
15+// Set once at construction, never mutated: reading needs no lock.
16+type sessionCapabilities struct {
17+ cannotAcceptMoves bool
18+ cannotYieldMoves bool
19+}
20+
21+func (c sessionCapabilities) yieldsMoves() bool { return !c.cannotYieldMoves }
22+
23+func (s *session) capabilities() sessionCapabilities { return s.caps }
@@ -0,0 +1,23 @@
1+package daemon
2+
3+import "errors"
4+
5+// errSessionCannotYieldMoves rejects move-picker entry for sessions whose
6+// tabs and panes cannot leave the session (no local session sets this today;
7+// proxy sessions will).
8+var errSessionCannotYieldMoves = errors.New("session does not support moving tabs or panes")
9+
10+// sessionCapabilities declares what cross-session operations a session
11+// supports. Fields are negated so the zero value describes a fully capable
12+// local session — literal &session{...} constructions (tests, restore paths)
13+// keep today's behavior without opting in.
14+//
15+// Set once at construction, never mutated: reading needs no lock.
16+type sessionCapabilities struct {
17+ cannotAcceptMoves bool
18+ cannotYieldMoves bool
19+}
20+
21+func (c sessionCapabilities) yieldsMoves() bool { return !c.cannotYieldMoves }
22+
23+func (s *session) capabilities() sessionCapabilities { return s.caps }
A internal/usecase/daemon/session_capabilities_test.go +57 -0
@@ -0,0 +1,57 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {
11+ s := &session{id: domain.SessionID("s1"), name: "alpha"}
12+ caps := s.capabilities()
13+ if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() {
14+ t.Fatalf("zero-value capabilities = %+v, want fully capable", caps)
15+ }
16+}
17+
18+func TestSnapshotViewCarriesCannotAcceptMoves(t *testing.T) {
19+ tests := []struct {
20+ name string
21+ caps sessionCapabilities
22+ want bool
23+ }{
24+ {name: "default local", caps: sessionCapabilities{}, want: false},
25+ {name: "restricted proxy", caps: sessionCapabilities{cannotAcceptMoves: true}, want: true},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ s := &session{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps}
30+ if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want {
31+ t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want)
32+ }
33+ })
34+ }
35+}
36+
37+func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) {
38+ d, sess, ac, _ := newManualSessionWithPTYs(t)
39+ sess.caps = sessionCapabilities{cannotYieldMoves: true}
40+
41+ tests := []struct {
42+ name string
43+ intent pickerIntent
44+ wantErr error
45+ }{
46+ {name: "pickerMoveTab", intent: pickerMoveTab, wantErr: errSessionCannotYieldMoves},
47+ {name: "pickerNavigate", intent: pickerNavigate, wantErr: nil},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ err := d.enterPickerForIntent(sess, ac, tt.intent, moveSourceLocator{})
52+ if !errors.Is(err, tt.wantErr) {
53+ t.Fatalf("enterPickerForIntent error = %v, want %v", err, tt.wantErr)
54+ }
55+ })
56+ }
57+}
@@ -0,0 +1,57 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {
11+ s := &session{id: domain.SessionID("s1"), name: "alpha"}
12+ caps := s.capabilities()
13+ if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() {
14+ t.Fatalf("zero-value capabilities = %+v, want fully capable", caps)
15+ }
16+}
17+
18+func TestSnapshotViewCarriesCannotAcceptMoves(t *testing.T) {
19+ tests := []struct {
20+ name string
21+ caps sessionCapabilities
22+ want bool
23+ }{
24+ {name: "default local", caps: sessionCapabilities{}, want: false},
25+ {name: "restricted proxy", caps: sessionCapabilities{cannotAcceptMoves: true}, want: true},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ s := &session{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps}
30+ if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want {
31+ t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want)
32+ }
33+ })
34+ }
35+}
36+
37+func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) {
38+ d, sess, ac, _ := newManualSessionWithPTYs(t)
39+ sess.caps = sessionCapabilities{cannotYieldMoves: true}
40+
41+ tests := []struct {
42+ name string
43+ intent pickerIntent
44+ wantErr error
45+ }{
46+ {name: "pickerMoveTab", intent: pickerMoveTab, wantErr: errSessionCannotYieldMoves},
47+ {name: "pickerNavigate", intent: pickerNavigate, wantErr: nil},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ err := d.enterPickerForIntent(sess, ac, tt.intent, moveSourceLocator{})
52+ if !errors.Is(err, tt.wantErr) {
53+ t.Fatalf("enterPickerForIntent error = %v, want %v", err, tt.wantErr)
54+ }
55+ })
56+ }
57+}
A internal/usecase/daemon/session_view.go +123 -0
@@ -0,0 +1,123 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
7+
8+// viewOptions controls optional (costly) fields captured by snapshotView.
9+type viewOptions struct {
10+ tabDetails bool
11+ focusedTitles bool
12+ terminalTitle bool
13+}
14+
15+// tabView is an immutable, value-only description of one tab.
16+type tabView struct {
17+ id domain.TabStableID
18+ name string
19+ focusedTitle string
20+ attention bool
21+}
22+
23+// sessionView is an immutable, value-only description of a session. It
24+// deliberately retains no live pointers, mirroring recentSession
25+// (recent_sessions.go). Listing paths (picker, MRU bar, palette,
26+// list-sessions) read local session state through snapshotView; proxy rows in
27+// later phases still come from registry/discovery, not this type alone.
28+type sessionView struct {
29+ id domain.SessionID
30+ incarnation domain.IncarnationID
31+ name string
32+ ephemeral bool
33+ createdAt int64
34+ active int
35+ mruAt uint64
36+ attached bool
37+ tabCount int
38+ hasAttention bool
39+ tabs []tabView
40+ cannotAcceptMoves bool
41+}
42+
43+// snapshotView reads session fields under s.mu and samples the independently
44+// atomic mruAt while that lock is held. tabCount and hasAttention are always
45+// filled; tabs is allocated only when opts.tabDetails is true (non-nil empty
46+// slice when there are no tabs). Never call while holding a pane lock.
47+func (s *session) snapshotView(opts viewOptions) sessionView {
48+ if opts.focusedTitles && !opts.tabDetails {
49+ opts.tabDetails = true
50+ }
51+ s.mu.Lock()
52+ defer s.mu.Unlock()
53+ view := sessionView{
54+ id: s.id,
55+ incarnation: s.incarnation,
56+ name: s.name,
57+ ephemeral: s.ephemeral,
58+ createdAt: s.createdAt,
59+ active: s.active,
60+ mruAt: s.mruAt.Load(),
61+ attached: s.client != nil,
62+ tabCount: len(s.tabs),
63+ cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
64+ }
65+ if opts.tabDetails {
66+ view.tabs = make([]tabView, 0, len(s.tabs))
67+ }
68+ for i, tb := range s.tabs {
69+ if tb.attention {
70+ view.hasAttention = true
71+ if !opts.tabDetails {
72+ break
73+ }
74+ }
75+ if !opts.tabDetails {
76+ continue
77+ }
78+ entry := tabView{
79+ id: domain.TabStableID(tb.stableID),
80+ name: tabDisplayName(tb, i),
81+ attention: tb.attention,
82+ }
83+ if opts.focusedTitles {
84+ entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle)
85+ }
86+ view.tabs = append(view.tabs, entry)
87+ }
88+ return view
89+}
90+
91+// pickerView renders this snapshot as the picker's value type. Field-for-field
92+// equivalent to the inline construction previously in pickerViews.
93+func (view sessionView) pickerView() picker.SessionView {
94+ out := picker.SessionView{
95+ ID: view.id,
96+ Incarnation: view.incarnation,
97+ Name: view.name,
98+ TargetName: view.name,
99+ Active: view.active,
100+ Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
101+ CannotAcceptMoves: view.cannotAcceptMoves,
102+ }
103+ if !view.ephemeral {
104+ createdAt := view.createdAt
105+ out.ExpectedCreatedAt = &createdAt
106+ }
107+ attention := false
108+ for _, tb := range view.tabs {
109+ out.Tabs = append(out.Tabs, picker.TabEntry{
110+ TabID: tb.id,
111+ Name: tb.name,
112+ Detail: tabTitleDetail(tb.name, tb.focusedTitle),
113+ Attention: tb.attention,
114+ })
115+ if tb.attention {
116+ attention = true
117+ }
118+ }
119+ if attention {
120+ out.Name = attentionSuffix(out.Name)
121+ }
122+ return out
123+}
@@ -0,0 +1,123 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
7+
8+// viewOptions controls optional (costly) fields captured by snapshotView.
9+type viewOptions struct {
10+ tabDetails bool
11+ focusedTitles bool
12+ terminalTitle bool
13+}
14+
15+// tabView is an immutable, value-only description of one tab.
16+type tabView struct {
17+ id domain.TabStableID
18+ name string
19+ focusedTitle string
20+ attention bool
21+}
22+
23+// sessionView is an immutable, value-only description of a session. It
24+// deliberately retains no live pointers, mirroring recentSession
25+// (recent_sessions.go). Listing paths (picker, MRU bar, palette,
26+// list-sessions) read local session state through snapshotView; proxy rows in
27+// later phases still come from registry/discovery, not this type alone.
28+type sessionView struct {
29+ id domain.SessionID
30+ incarnation domain.IncarnationID
31+ name string
32+ ephemeral bool
33+ createdAt int64
34+ active int
35+ mruAt uint64
36+ attached bool
37+ tabCount int
38+ hasAttention bool
39+ tabs []tabView
40+ cannotAcceptMoves bool
41+}
42+
43+// snapshotView reads session fields under s.mu and samples the independently
44+// atomic mruAt while that lock is held. tabCount and hasAttention are always
45+// filled; tabs is allocated only when opts.tabDetails is true (non-nil empty
46+// slice when there are no tabs). Never call while holding a pane lock.
47+func (s *session) snapshotView(opts viewOptions) sessionView {
48+ if opts.focusedTitles && !opts.tabDetails {
49+ opts.tabDetails = true
50+ }
51+ s.mu.Lock()
52+ defer s.mu.Unlock()
53+ view := sessionView{
54+ id: s.id,
55+ incarnation: s.incarnation,
56+ name: s.name,
57+ ephemeral: s.ephemeral,
58+ createdAt: s.createdAt,
59+ active: s.active,
60+ mruAt: s.mruAt.Load(),
61+ attached: s.client != nil,
62+ tabCount: len(s.tabs),
63+ cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
64+ }
65+ if opts.tabDetails {
66+ view.tabs = make([]tabView, 0, len(s.tabs))
67+ }
68+ for i, tb := range s.tabs {
69+ if tb.attention {
70+ view.hasAttention = true
71+ if !opts.tabDetails {
72+ break
73+ }
74+ }
75+ if !opts.tabDetails {
76+ continue
77+ }
78+ entry := tabView{
79+ id: domain.TabStableID(tb.stableID),
80+ name: tabDisplayName(tb, i),
81+ attention: tb.attention,
82+ }
83+ if opts.focusedTitles {
84+ entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle)
85+ }
86+ view.tabs = append(view.tabs, entry)
87+ }
88+ return view
89+}
90+
91+// pickerView renders this snapshot as the picker's value type. Field-for-field
92+// equivalent to the inline construction previously in pickerViews.
93+func (view sessionView) pickerView() picker.SessionView {
94+ out := picker.SessionView{
95+ ID: view.id,
96+ Incarnation: view.incarnation,
97+ Name: view.name,
98+ TargetName: view.name,
99+ Active: view.active,
100+ Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
101+ CannotAcceptMoves: view.cannotAcceptMoves,
102+ }
103+ if !view.ephemeral {
104+ createdAt := view.createdAt
105+ out.ExpectedCreatedAt = &createdAt
106+ }
107+ attention := false
108+ for _, tb := range view.tabs {
109+ out.Tabs = append(out.Tabs, picker.TabEntry{
110+ TabID: tb.id,
111+ Name: tb.name,
112+ Detail: tabTitleDetail(tb.name, tb.focusedTitle),
113+ Attention: tb.attention,
114+ })
115+ if tb.attention {
116+ attention = true
117+ }
118+ }
119+ if attention {
120+ out.Name = attentionSuffix(out.Name)
121+ }
122+ return out
123+}
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+}
M internal/usecase/picker/model.go +4 -0
@@ -24,6 +24,10 @@ type SessionView struct {
2424 Active int
2525 Stopped bool
2626 ExpectedCreatedAt *int64
27+ // CannotAcceptMoves reports whether this session cannot receive a moved tab
28+ // or pane. False for ordinary local (and stopped) sessions; true for
29+ // restricted proxy sessions in phase 4. Populated, not yet consumed.
30+ CannotAcceptMoves bool
2731 }
2832
2933 // TabEntry is one tab row; Name is drawn emphasized, Detail muted.
@@ -24,6 +24,10 @@ type SessionView struct {
24 Active int24 Active int
25 Stopped bool25 Stopped bool
26 ExpectedCreatedAt *int6426 ExpectedCreatedAt *int64
27+ // CannotAcceptMoves reports whether this session cannot receive a moved tab
28+ // or pane. False for ordinary local (and stopped) sessions; true for
29+ // restricted proxy sessions in phase 4. Populated, not yet consumed.
30+ CannotAcceptMoves bool
27 }31 }
28 32
29 // TabEntry is one tab row; Name is drawn emphasized, Detail muted.33 // TabEntry is one tab row; Name is drawn emphasized, Detail muted.

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