detached d75f483

refactor(daemon): add sessionCapabilities seam for cross-session moves

commit
d75f483b6aa22fa74649c399f65e653d2883083d
author
brice · · 2026-07-29 19:38 UTC
parent
f5a831c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +109 -26
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/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 +48 -0
@@ -0,0 +1,48 @@
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+ err := d.enterPickerForIntent(sess, ac, pickerMoveTab, moveSourceLocator{})
41+ if !errors.Is(err, errSessionCannotYieldMoves) {
42+ t.Fatalf("enterPickerForIntent error = %v, want errSessionCannotYieldMoves", err)
43+ }
44+ // Navigation intent must stay unaffected by the yield capability.
45+ if err := d.enterPickerForIntent(sess, ac, pickerNavigate, moveSourceLocator{}); err != nil {
46+ t.Fatalf("navigate intent returned %v, want nil", err)
47+ }
48+}
@@ -0,0 +1,48 @@
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+ err := d.enterPickerForIntent(sess, ac, pickerMoveTab, moveSourceLocator{})
41+ if !errors.Is(err, errSessionCannotYieldMoves) {
42+ t.Fatalf("enterPickerForIntent error = %v, want errSessionCannotYieldMoves", err)
43+ }
44+ // Navigation intent must stay unaffected by the yield capability.
45+ if err := d.enterPickerForIntent(sess, ac, pickerNavigate, moveSourceLocator{}); err != nil {
46+ t.Fatalf("navigate intent returned %v, want nil", err)
47+ }
48+}
M internal/usecase/daemon/session_view.go +29 -26
@@ -26,17 +26,18 @@ type tabView struct {
2626 // list-sessions) read local session state through snapshotView; proxy rows in
2727 // later phases still come from registry/discovery, not this type alone.
2828 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
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
4041 }
4142
4243 // snapshotView reads session fields under s.mu and samples the independently
@@ -50,15 +51,16 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
5051 s.mu.Lock()
5152 defer s.mu.Unlock()
5253 view := sessionView{
53- id: s.id,
54- incarnation: s.incarnation,
55- name: s.name,
56- ephemeral: s.ephemeral,
57- createdAt: s.createdAt,
58- active: s.active,
59- mruAt: s.mruAt.Load(),
60- attached: s.client != nil,
61- tabCount: len(s.tabs),
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,
6264 }
6365 if opts.tabDetails {
6466 view.tabs = make([]tabView, 0, len(s.tabs))
@@ -90,12 +92,13 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
9092 // equivalent to the inline construction previously in pickerViews.
9193 func (view sessionView) pickerView() picker.SessionView {
9294 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)),
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,
99102 }
100103 if !view.ephemeral {
101104 createdAt := view.createdAt
@@ -26,17 +26,18 @@ type tabView struct {
26 // list-sessions) read local session state through snapshotView; proxy rows in26 // list-sessions) read local session state through snapshotView; proxy rows in
27 // later phases still come from registry/discovery, not this type alone.27 // later phases still come from registry/discovery, not this type alone.
28 type sessionView struct {28 type sessionView struct {
29- id domain.SessionID29+ id domain.SessionID
30- incarnation domain.IncarnationID30+ incarnation domain.IncarnationID
31- name string31+ name string
32- ephemeral bool32+ ephemeral bool
33- createdAt int6433+ createdAt int64
34- active int34+ active int
35- mruAt uint6435+ mruAt uint64
36- attached bool36+ attached bool
37- tabCount int37+ tabCount int
38- hasAttention bool38+ hasAttention bool
39- tabs []tabView39+ tabs []tabView
40+ cannotAcceptMoves bool
40 }41 }
41 42
42 // snapshotView reads session fields under s.mu and samples the independently43 // snapshotView reads session fields under s.mu and samples the independently
@@ -50,15 +51,16 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
50 s.mu.Lock()51 s.mu.Lock()
51 defer s.mu.Unlock()52 defer s.mu.Unlock()
52 view := sessionView{53 view := sessionView{
53- id: s.id,54+ id: s.id,
54- incarnation: s.incarnation,55+ incarnation: s.incarnation,
55- name: s.name,56+ name: s.name,
56- ephemeral: s.ephemeral,57+ ephemeral: s.ephemeral,
57- createdAt: s.createdAt,58+ createdAt: s.createdAt,
58- active: s.active,59+ active: s.active,
59- mruAt: s.mruAt.Load(),60+ mruAt: s.mruAt.Load(),
60- attached: s.client != nil,61+ attached: s.client != nil,
61- tabCount: len(s.tabs),62+ tabCount: len(s.tabs),
63+ cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
62 }64 }
63 if opts.tabDetails {65 if opts.tabDetails {
64 view.tabs = make([]tabView, 0, len(s.tabs))66 view.tabs = make([]tabView, 0, len(s.tabs))
@@ -90,12 +92,13 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
90 // equivalent to the inline construction previously in pickerViews.92 // equivalent to the inline construction previously in pickerViews.
91 func (view sessionView) pickerView() picker.SessionView {93 func (view sessionView) pickerView() picker.SessionView {
92 out := picker.SessionView{94 out := picker.SessionView{
93- ID: view.id,95+ ID: view.id,
94- Incarnation: view.incarnation,96+ Incarnation: view.incarnation,
95- Name: view.name,97+ Name: view.name,
96- TargetName: view.name,98+ TargetName: view.name,
97- Active: view.active,99+ Active: view.active,
98- Tabs: make([]picker.TabEntry, 0, len(view.tabs)),100+ Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
101+ CannotAcceptMoves: view.cannotAcceptMoves,
99 }102 }
100 if !view.ephemeral {103 if !view.ephemeral {
101 createdAt := view.createdAt104 createdAt := view.createdAt
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