refactor(daemon): add sessionCapabilities seam for cross-session moves
- commit
- d75f483b6aa22fa74649c399f65e653d2883083d
- author
- brice · · 2026-07-29 19:38 UTC
- parent
- f5a831c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 57 | 57 | ||
| 58 | 58 | // enterPickerForIntent returns errNoMoveDestination only for move intents. | |
| 59 | 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 | 63 | model := d.newPickerModel(sess, intent, source, picker.SourceFilter{}) | |
| 61 | 64 | if intent != pickerNavigate { | |
| 62 | 65 | 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 { | |||
| 32 | 32 | id domain.SessionID | |
| 33 | 33 | name string | |
| 34 | 34 | ephemeral bool | |
| 35 | + | // caps is immutable after construction; see sessionCapabilities. | |
| 36 | + | caps sessionCapabilities | |
| 35 | 37 | ||
| 36 | 38 | ctx context.Context | |
| 37 | 39 | cancel context.CancelFunc | |
| @@ -32,6 +32,8 @@ type session struct { | |||||
| 32 | id domain.SessionID | 32 | id domain.SessionID | ||
| 33 | name string | 33 | name string | ||
| 34 | ephemeral bool | 34 | ephemeral bool | ||
| 35 | + | // caps is immutable after construction; see sessionCapabilities. | |||
| 36 | + | caps sessionCapabilities | |||
| 35 | 37 | ||||
| 36 | ctx context.Context | 38 | ctx context.Context | ||
| 37 | cancel context.CancelFunc | 39 | 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 { | |||
| 26 | 26 | // list-sessions) read local session state through snapshotView; proxy rows in | |
| 27 | 27 | // later phases still come from registry/discovery, not this type alone. | |
| 28 | 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 | |
| 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 | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | 43 | // snapshotView reads session fields under s.mu and samples the independently | |
| @@ -50,15 +51,16 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 50 | 51 | s.mu.Lock() | |
| 51 | 52 | defer s.mu.Unlock() | |
| 52 | 53 | 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, | |
| 62 | 64 | } | |
| 63 | 65 | if opts.tabDetails { | |
| 64 | 66 | view.tabs = make([]tabView, 0, len(s.tabs)) | |
| @@ -90,12 +92,13 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 90 | 92 | // equivalent to the inline construction previously in pickerViews. | |
| 91 | 93 | func (view sessionView) pickerView() picker.SessionView { | |
| 92 | 94 | 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, | |
| 99 | 102 | } | |
| 100 | 103 | if !view.ephemeral { | |
| 101 | 104 | createdAt := view.createdAt | |
| @@ -26,17 +26,18 @@ type tabView struct { | |||||
| 26 | // list-sessions) read local session state through snapshotView; proxy rows in | 26 | // 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.SessionID | 29 | + | id domain.SessionID |
| 30 | - | incarnation domain.IncarnationID | 30 | + | incarnation domain.IncarnationID |
| 31 | - | name string | 31 | + | name string |
| 32 | - | ephemeral bool | 32 | + | ephemeral bool |
| 33 | - | createdAt int64 | 33 | + | createdAt int64 |
| 34 | - | active int | 34 | + | active int |
| 35 | - | mruAt uint64 | 35 | + | mruAt uint64 |
| 36 | - | attached bool | 36 | + | attached bool |
| 37 | - | tabCount int | 37 | + | tabCount int |
| 38 | - | hasAttention bool | 38 | + | hasAttention bool |
| 39 | - | tabs []tabView | 39 | + | tabs []tabView |
| 40 | + | cannotAcceptMoves bool | |||
| 40 | } | 41 | } | ||
| 41 | 42 | ||||
| 42 | // snapshotView reads session fields under s.mu and samples the independently | 43 | // 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.createdAt | 104 | createdAt := view.createdAt | ||
M internal/usecase/picker/model.go +4 -0
| @@ -24,6 +24,10 @@ type SessionView struct { | |||
| 24 | 24 | Active int | |
| 25 | 25 | Stopped bool | |
| 26 | 26 | 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 | 33 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | |
| @@ -24,6 +24,10 @@ type SessionView struct { | |||||
| 24 | Active int | 24 | Active int | ||
| 25 | Stopped bool | 25 | Stopped bool | ||
| 26 | ExpectedCreatedAt *int64 | 26 | 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. | ||