detached 3894536

feat(daemon): rearrange panes transactionally

commit
38945368387146740dda3815a477346aa6cdf9ae
author
brice · · 2026-07-27 13:45 UTC
parent
1e03a92
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +657 -58
M internal/usecase/daemon/control.go +11 -2
@@ -206,6 +206,7 @@ const (
206206 daemonActionRenameTab
207207 daemonActionResizePane
208208 daemonActionEqualizePanes
209+ daemonActionConsumeOrExpelPane
209210 )
210211
211212 type daemonActionTarget struct {
@@ -289,6 +290,8 @@ func (a daemonActions) Run(request daemonActionRequest) error {
289290 return a.d.resizePane(target, request.axis, request.delta)
290291 case daemonActionEqualizePanes:
291292 return a.d.equalizePanes(target)
293+ case daemonActionConsumeOrExpelPane:
294+ return a.d.consumeOrExpelPane(target, request.direction)
292295 default:
293296 return errors.New("daemon: unknown action")
294297 }
@@ -345,13 +348,19 @@ func (e controlExec) runAction(request daemonActionRequest) error {
345348 runner = daemonActions{d: e.d}
346349 }
347350 err := runner.Run(request)
348- if err == nil && e.actions == nil {
351+ if errors.Is(err, errDaemonActionNoChange) {
352+ return nil
353+ }
354+ if err != nil {
355+ return err
356+ }
357+ if e.actions == nil {
349358 e.sess.mu.Lock()
350359 ac := e.sess.client
351360 e.sess.mu.Unlock()
352361 finishDaemonActionForClient(e.d, request, ac, "control.go")
353362 }
354- return err
363+ return nil
355364 }
356365
357366 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
@@ -206,6 +206,7 @@ const (
206 daemonActionRenameTab206 daemonActionRenameTab
207 daemonActionResizePane207 daemonActionResizePane
208 daemonActionEqualizePanes208 daemonActionEqualizePanes
209+ daemonActionConsumeOrExpelPane
209 )210 )
210 211
211 type daemonActionTarget struct {212 type daemonActionTarget struct {
@@ -289,6 +290,8 @@ func (a daemonActions) Run(request daemonActionRequest) error {
289 return a.d.resizePane(target, request.axis, request.delta)290 return a.d.resizePane(target, request.axis, request.delta)
290 case daemonActionEqualizePanes:291 case daemonActionEqualizePanes:
291 return a.d.equalizePanes(target)292 return a.d.equalizePanes(target)
293+ case daemonActionConsumeOrExpelPane:
294+ return a.d.consumeOrExpelPane(target, request.direction)
292 default:295 default:
293 return errors.New("daemon: unknown action")296 return errors.New("daemon: unknown action")
294 }297 }
@@ -345,13 +348,19 @@ func (e controlExec) runAction(request daemonActionRequest) error {
345 runner = daemonActions{d: e.d}348 runner = daemonActions{d: e.d}
346 }349 }
347 err := runner.Run(request)350 err := runner.Run(request)
348- if err == nil && e.actions == nil {351+ if errors.Is(err, errDaemonActionNoChange) {
352+ return nil
353+ }
354+ if err != nil {
355+ return err
356+ }
357+ if e.actions == nil {
349 e.sess.mu.Lock()358 e.sess.mu.Lock()
350 ac := e.sess.client359 ac := e.sess.client
351 e.sess.mu.Unlock()360 e.sess.mu.Unlock()
352 finishDaemonActionForClient(e.d, request, ac, "control.go")361 finishDaemonActionForClient(e.d, request, ac, "control.go")
353 }362 }
354- return err363+ return nil
355 }364 }
356 365
357 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {366 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
M internal/usecase/daemon/input.go +4 -0
@@ -3,6 +3,7 @@ package daemon
33
44 import (
55 "bytes"
6+ "errors"
67 "strconv"
78
89 "github.com/bnema/vev/internal/domain"
@@ -298,6 +299,9 @@ func (h daemonKeyHandler) Action(action keys.Action) {
298299 runner = daemonActions{d: h.d}
299300 }
300301 if err := runner.Run(request); err != nil {
302+ if errors.Is(err, errDaemonActionNoChange) {
303+ return
304+ }
301305 h.d.reportError(sess, resizeUserError(err))
302306 return
303307 }
@@ -3,6 +3,7 @@ package daemon
3 3
4 import (4 import (
5 "bytes"5 "bytes"
6+ "errors"
6 "strconv"7 "strconv"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
@@ -298,6 +299,9 @@ func (h daemonKeyHandler) Action(action keys.Action) {
298 runner = daemonActions{d: h.d}299 runner = daemonActions{d: h.d}
299 }300 }
300 if err := runner.Run(request); err != nil {301 if err := runner.Run(request); err != nil {
302+ if errors.Is(err, errDaemonActionNoChange) {
303+ return
304+ }
301 h.d.reportError(sess, resizeUserError(err))305 h.d.reportError(sess, resizeUserError(err))
302 return306 return
303 }307 }
A internal/usecase/daemon/layout_mutation.go +80 -0
@@ -0,0 +1,80 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// mutateTargetLayout preserves the publication semantics used by resize and
9+// equalize mutations, which always publish a successful candidate.
10+func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error {
11+ _, err := d.mutateTargetLayoutChanged(target, requirePane, func(candidate *layout.Tree, area domain.Rect) (bool, error) {
12+ if err := mutate(candidate, area); err != nil {
13+ return false, err
14+ }
15+ return true, nil
16+ })
17+ return err
18+}
19+
20+// mutateTargetLayoutChanged validates an explicit target and publishes the
21+// candidate only when mutate reports a change. State locks are released before
22+// applying pane geometry so PTY.Resize never runs under daemon state locks.
23+func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) (bool, error)) (bool, error) {
24+ if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) {
25+ return false, layout.ErrNotFound
26+ }
27+
28+ d.mu.Lock()
29+ if d.sessions[target.session.id] != target.session {
30+ d.mu.Unlock()
31+ return false, layout.ErrNotFound
32+ }
33+ target.session.mu.Lock()
34+ foundTab := false
35+ for _, tb := range target.session.tabs {
36+ if tb == target.tab {
37+ foundTab = true
38+ break
39+ }
40+ }
41+ if !foundTab {
42+ target.session.mu.Unlock()
43+ d.mu.Unlock()
44+ return false, layout.ErrNotFound
45+ }
46+ target.tab.mu.Lock()
47+ if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) {
48+ target.tab.mu.Unlock()
49+ target.session.mu.Unlock()
50+ d.mu.Unlock()
51+ return false, layout.ErrNotFound
52+ }
53+
54+ candidate := target.tab.tree.Clone()
55+ area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows}
56+ changed, err := mutate(candidate, area)
57+ if err != nil {
58+ target.tab.mu.Unlock()
59+ target.session.mu.Unlock()
60+ d.mu.Unlock()
61+ return false, err
62+ }
63+ if !changed {
64+ target.tab.mu.Unlock()
65+ target.session.mu.Unlock()
66+ d.mu.Unlock()
67+ return false, nil
68+ }
69+
70+ target.tab.tree = candidate
71+ target.tab.bumpLayoutGenerationLocked()
72+ target.tab.mu.Unlock()
73+ target.session.mu.Unlock()
74+ d.mu.Unlock()
75+
76+ if !d.applyTabLayout(target.session, target.tab) {
77+ return false, layout.ErrNotFound
78+ }
79+ return true, nil
80+}
@@ -0,0 +1,80 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// mutateTargetLayout preserves the publication semantics used by resize and
9+// equalize mutations, which always publish a successful candidate.
10+func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error {
11+ _, err := d.mutateTargetLayoutChanged(target, requirePane, func(candidate *layout.Tree, area domain.Rect) (bool, error) {
12+ if err := mutate(candidate, area); err != nil {
13+ return false, err
14+ }
15+ return true, nil
16+ })
17+ return err
18+}
19+
20+// mutateTargetLayoutChanged validates an explicit target and publishes the
21+// candidate only when mutate reports a change. State locks are released before
22+// applying pane geometry so PTY.Resize never runs under daemon state locks.
23+func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) (bool, error)) (bool, error) {
24+ if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) {
25+ return false, layout.ErrNotFound
26+ }
27+
28+ d.mu.Lock()
29+ if d.sessions[target.session.id] != target.session {
30+ d.mu.Unlock()
31+ return false, layout.ErrNotFound
32+ }
33+ target.session.mu.Lock()
34+ foundTab := false
35+ for _, tb := range target.session.tabs {
36+ if tb == target.tab {
37+ foundTab = true
38+ break
39+ }
40+ }
41+ if !foundTab {
42+ target.session.mu.Unlock()
43+ d.mu.Unlock()
44+ return false, layout.ErrNotFound
45+ }
46+ target.tab.mu.Lock()
47+ if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) {
48+ target.tab.mu.Unlock()
49+ target.session.mu.Unlock()
50+ d.mu.Unlock()
51+ return false, layout.ErrNotFound
52+ }
53+
54+ candidate := target.tab.tree.Clone()
55+ area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows}
56+ changed, err := mutate(candidate, area)
57+ if err != nil {
58+ target.tab.mu.Unlock()
59+ target.session.mu.Unlock()
60+ d.mu.Unlock()
61+ return false, err
62+ }
63+ if !changed {
64+ target.tab.mu.Unlock()
65+ target.session.mu.Unlock()
66+ d.mu.Unlock()
67+ return false, nil
68+ }
69+
70+ target.tab.tree = candidate
71+ target.tab.bumpLayoutGenerationLocked()
72+ target.tab.mu.Unlock()
73+ target.session.mu.Unlock()
74+ d.mu.Unlock()
75+
76+ if !d.applyTabLayout(target.session, target.tab) {
77+ return false, layout.ErrNotFound
78+ }
79+ return true, nil
80+}
M internal/usecase/daemon/palette.go +19 -8
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "errors"
45 "sort"
56 "time"
67
@@ -304,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
304305 return
305306 }
306307 sess.dispatchMu.Lock()
307- err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args)
308+ err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, redrawClosedPalette: true}, args)
308309 sess.dispatchMu.Unlock()
309310 if err != nil {
310311 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
@@ -351,11 +352,12 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
351352 }
352353
353354 type paletteExec struct {
354- d *Daemon
355- sess *session
356- ac *attachedClient
357- recent []recentSession
358- actions daemonActionRunner
355+ d *Daemon
356+ sess *session
357+ ac *attachedClient
358+ recent []recentSession
359+ actions daemonActionRunner
360+ redrawClosedPalette bool
359361 }
360362
361363 func (e paletteExec) runAction(request daemonActionRequest) error {
@@ -367,10 +369,19 @@ func (e paletteExec) runAction(request daemonActionRequest) error {
367369 runner = daemonActions{d: e.d}
368370 }
369371 err := runner.Run(request)
370- if err == nil && e.actions == nil {
372+ if errors.Is(err, errDaemonActionNoChange) {
373+ if e.redrawClosedPalette {
374+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
375+ }
376+ return nil
377+ }
378+ if err != nil {
379+ return err
380+ }
381+ if e.actions == nil {
371382 finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
372383 }
373- return err
384+ return nil
374385 }
375386
376387 func (e paletteExec) CreateTab() error {
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "errors"
4 "sort"5 "sort"
5 "time"6 "time"
6 7
@@ -304,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
304 return305 return
305 }306 }
306 sess.dispatchMu.Lock()307 sess.dispatchMu.Lock()
307- err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args)308+ err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, redrawClosedPalette: true}, args)
308 sess.dispatchMu.Unlock()309 sess.dispatchMu.Unlock()
309 if err != nil {310 if err != nil {
310 d.log.Error("palette command failed", "err", err, "command", cmd.Code)311 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
@@ -351,11 +352,12 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
351 }352 }
352 353
353 type paletteExec struct {354 type paletteExec struct {
354- d *Daemon355+ d *Daemon
355- sess *session356+ sess *session
356- ac *attachedClient357+ ac *attachedClient
357- recent []recentSession358+ recent []recentSession
358- actions daemonActionRunner359+ actions daemonActionRunner
360+ redrawClosedPalette bool
359 }361 }
360 362
361 func (e paletteExec) runAction(request daemonActionRequest) error {363 func (e paletteExec) runAction(request daemonActionRequest) error {
@@ -367,10 +369,19 @@ func (e paletteExec) runAction(request daemonActionRequest) error {
367 runner = daemonActions{d: e.d}369 runner = daemonActions{d: e.d}
368 }370 }
369 err := runner.Run(request)371 err := runner.Run(request)
370- if err == nil && e.actions == nil {372+ if errors.Is(err, errDaemonActionNoChange) {
373+ if e.redrawClosedPalette {
374+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
375+ }
376+ return nil
377+ }
378+ if err != nil {
379+ return err
380+ }
381+ if e.actions == nil {
371 finishDaemonActionForClient(e.d, request, e.ac, "palette.go")382 finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
372 }383 }
373- return err384+ return nil
374 }385 }
375 386
376 func (e paletteExec) CreateTab() error {387 func (e paletteExec) CreateTab() error {
A internal/usecase/daemon/pane_rearrange.go +38 -0
@@ -0,0 +1,38 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+)
9+
10+var errDaemonActionNoChange = errors.New("daemon action made no change")
11+
12+// errPaneRearrangeNoop names the pane-rearrange outcome for package-level
13+// callers while adapters normalize the shared daemon no-change sentinel.
14+var errPaneRearrangeNoop = errDaemonActionNoChange
15+
16+func (d *Daemon) consumeOrExpelPane(target daemonActionTarget, direction layout.Direction) error {
17+ changed, err := d.mutateTargetLayoutChanged(target, true, func(candidate *layout.Tree, area domain.Rect) (bool, error) {
18+ return candidate.ConsumeOrExpelPane(target.pane.id, direction, area)
19+ })
20+ if err != nil {
21+ return paneRearrangeUserError(err)
22+ }
23+ if !changed {
24+ return errDaemonActionNoChange
25+ }
26+ return nil
27+}
28+
29+func paneRearrangeUserError(err error) error {
30+ switch {
31+ case errors.Is(err, layout.ErrUnsupportedColumnLayout):
32+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane cannot be rearranged in this layout", err)
33+ case errors.Is(err, layout.ErrTooSmall):
34+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane cannot be rearranged at this size", err)
35+ default:
36+ return err
37+ }
38+}
@@ -0,0 +1,38 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+)
9+
10+var errDaemonActionNoChange = errors.New("daemon action made no change")
11+
12+// errPaneRearrangeNoop names the pane-rearrange outcome for package-level
13+// callers while adapters normalize the shared daemon no-change sentinel.
14+var errPaneRearrangeNoop = errDaemonActionNoChange
15+
16+func (d *Daemon) consumeOrExpelPane(target daemonActionTarget, direction layout.Direction) error {
17+ changed, err := d.mutateTargetLayoutChanged(target, true, func(candidate *layout.Tree, area domain.Rect) (bool, error) {
18+ return candidate.ConsumeOrExpelPane(target.pane.id, direction, area)
19+ })
20+ if err != nil {
21+ return paneRearrangeUserError(err)
22+ }
23+ if !changed {
24+ return errDaemonActionNoChange
25+ }
26+ return nil
27+}
28+
29+func paneRearrangeUserError(err error) error {
30+ switch {
31+ case errors.Is(err, layout.ErrUnsupportedColumnLayout):
32+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane cannot be rearranged in this layout", err)
33+ case errors.Is(err, layout.ErrTooSmall):
34+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane cannot be rearranged at this size", err)
35+ default:
36+ return err
37+ }
38+}
A internal/usecase/daemon/pane_rearrange_test.go +505 -0
@@ -0,0 +1,505 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/layout"
18+)
19+
20+func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) {
21+ tests := []struct {
22+ name string
23+ mutate func(*layout.Tree, domain.Rect) (bool, error)
24+ wantChanged bool
25+ wantGeneration uint64
26+ wantSnapshotGen uint64
27+ wantResizes bool
28+ }{
29+ {
30+ name: "changed candidate publishes once",
31+ mutate: func(candidate *layout.Tree, area domain.Rect) (bool, error) {
32+ return candidate.ConsumeOrExpelPane("pane-1", layout.Right, area)
33+ },
34+ wantChanged: true,
35+ wantGeneration: 1,
36+ wantSnapshotGen: 1,
37+ wantResizes: true,
38+ },
39+ {
40+ name: "unchanged candidate is discarded",
41+ mutate: func(candidate *layout.Tree, _ domain.Rect) (bool, error) {
42+ candidate.Focus = "pane-2"
43+ return false, nil
44+ },
45+ },
46+ }
47+
48+ for _, tt := range tests {
49+ t.Run(tt.name, func(t *testing.T) {
50+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
51+ before := h.tab.tree.Clone()
52+
53+ changed, err := h.daemon.mutateTargetLayoutChanged(h.target("pane-1"), true, tt.mutate)
54+
55+ require.NoError(t, err)
56+ require.Equal(t, tt.wantChanged, changed)
57+ h.tab.mu.Lock()
58+ require.Equal(t, tt.wantGeneration, h.tab.layoutGeneration)
59+ if tt.wantChanged {
60+ require.NotEqual(t, before, h.tab.tree)
61+ } else {
62+ require.Equal(t, before, h.tab.tree, "changed=false must not publish a mutated candidate")
63+ }
64+ h.tab.mu.Unlock()
65+ h.session.snapshotMu.Lock()
66+ require.Equal(t, tt.wantSnapshotGen, h.session.snapshotGeneration)
67+ h.session.snapshotMu.Unlock()
68+ if tt.wantResizes {
69+ require.NotZero(t, h.totalResizes())
70+ } else {
71+ require.Zero(t, h.totalResizes())
72+ }
73+ })
74+ }
75+}
76+
77+func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ stale func(*paneRearrangeHarness) daemonActionTarget
81+ }{
82+ {
83+ name: "session",
84+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
85+ target := h.target("pane-1")
86+ h.daemon.sessions[h.session.id] = &session{id: h.session.id}
87+ return target
88+ },
89+ },
90+ {
91+ name: "tab",
92+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
93+ target := h.target("pane-1")
94+ h.session.tabs = []*tab{newTab(nil, h.tab.size)}
95+ return target
96+ },
97+ },
98+ {
99+ name: "pane identity",
100+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
101+ target := h.target("pane-1")
102+ h.tab.panes[target.pane.id] = newPane(target.pane.id, nil, h.tab.size)
103+ return target
104+ },
105+ },
106+ }
107+
108+ for _, tt := range tests {
109+ t.Run(tt.name, func(t *testing.T) {
110+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
111+ before := h.tab.tree.Clone()
112+ called := false
113+
114+ changed, err := h.daemon.mutateTargetLayoutChanged(tt.stale(h), true, func(*layout.Tree, domain.Rect) (bool, error) {
115+ called = true
116+ return true, nil
117+ })
118+
119+ require.False(t, changed)
120+ require.ErrorIs(t, err, layout.ErrNotFound)
121+ require.False(t, called, "stale identity must be rejected before invoking the mutation")
122+ require.Equal(t, before, h.tab.tree)
123+ require.Zero(t, h.tab.layoutGeneration)
124+ require.Zero(t, h.totalResizes())
125+ })
126+ }
127+}
128+
129+func TestPaneRearrangeResizeRunsWithoutDaemonStateLocks(t *testing.T) {
130+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
131+ locksAvailable := make(chan bool, 8)
132+ for _, tracked := range h.ptys {
133+ tracked.onResize = func() { locksAvailable <- h.stateLocksAvailable() }
134+ }
135+
136+ done := make(chan error, 1)
137+ go func() {
138+ done <- (daemonActions{d: h.daemon}).Run(daemonActionRequest{
139+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
140+ })
141+ }()
142+
143+ select {
144+ case err := <-done:
145+ require.NoError(t, err)
146+ case <-time.After(2 * time.Second):
147+ t.Fatal("pane rearrange blocked while PTY.Resize attempted to re-enter daemon state locks")
148+ }
149+ require.NotZero(t, h.totalResizes())
150+ close(locksAvailable)
151+ for available := range locksAvailable {
152+ require.True(t, available, "PTY.Resize ran while a daemon, session, tab, or pane state lock was held")
153+ }
154+}
155+
156+func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) {
157+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
158+ h.tab.mu.Lock()
159+ h.tab.tree.Focus = "pane-3"
160+ before := make(map[layout.PaneID]*pane, len(h.tab.panes))
161+ for id, p := range h.tab.panes {
162+ before[id] = p
163+ }
164+ h.tab.mu.Unlock()
165+
166+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
167+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
168+ })
169+
170+ require.NoError(t, err)
171+ h.tab.mu.Lock()
172+ require.Equal(t, layout.PaneID("pane-1"), h.tab.tree.Focus, "the explicit non-focused target becomes focused")
173+ require.Len(t, h.tab.panes, len(before))
174+ for id, p := range before {
175+ require.Same(t, p, h.tab.panes[id], "pane %s identity changed", id)
176+ }
177+ h.tab.mu.Unlock()
178+ h.factory.AssertNotCalled(t, "Open")
179+ for _, tracked := range h.ptys {
180+ require.Zero(t, tracked.closeCount())
181+ }
182+}
183+
184+func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) {
185+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
186+ ac := &attachedClient{}
187+ ac.setSession(h.session)
188+ h.session.mu.Lock()
189+ h.session.client = ac
190+ h.session.mu.Unlock()
191+ invalidations := make(chan renderInvalidation, 2)
192+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
193+ rc.attach(ac)
194+ h.session.installRenderCoordinator(rc)
195+ before := h.snapshot()
196+ request := daemonActionRequest{
197+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Left,
198+ }
199+
200+ err := (daemonActions{d: h.daemon}).Run(request)
201+ require.ErrorIs(t, err, errPaneRearrangeNoop, "the daemon action must retain an exact no-change sentinel")
202+ require.NoError(t, (paletteExec{d: h.daemon, sess: h.session, ac: ac}).runAction(request), "palette normalizes the silent sentinel")
203+
204+ require.Equal(t, before, h.snapshot(), "edge no-op changed tree, generations, snapshot state, pane identity, geometry, or VT size")
205+ require.Zero(t, h.totalResizes())
206+ h.factory.AssertNotCalled(t, "Open")
207+ for _, tracked := range h.ptys {
208+ require.Zero(t, tracked.closeCount())
209+ }
210+ require.Empty(t, h.daemon.notices.history())
211+ requireNoInvalidation(t, invalidations)
212+}
213+
214+func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testing.T) {
215+ tests := []struct {
216+ name string
217+ size domain.Size
218+ tree *layout.Tree
219+ pane layout.PaneID
220+ dir layout.Direction
221+ cause error
222+ }{
223+ {
224+ name: "unsupported layout",
225+ size: domain.Size{Cols: 80, Rows: 22},
226+ tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
227+ layout.NewLeaf("pane-1"),
228+ {Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}},
229+ }}, Focus: "pane-1"},
230+ pane: "pane-1", dir: layout.Right, cause: layout.ErrUnsupportedColumnLayout,
231+ },
232+ {
233+ name: "too small",
234+ size: domain.Size{Cols: 40, Rows: 5},
235+ tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
236+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
237+ }}, Focus: "pane-1"},
238+ pane: "pane-1", dir: layout.Right, cause: layout.ErrTooSmall,
239+ },
240+ }
241+
242+ for _, tt := range tests {
243+ t.Run(tt.name, func(t *testing.T) {
244+ h := newPaneRearrangeHarness(t, tt.size, tt.tree)
245+ before := h.snapshot()
246+
247+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
248+ kind: daemonActionConsumeOrExpelPane, target: h.target(tt.pane), direction: tt.dir,
249+ })
250+
251+ require.ErrorIs(t, err, tt.cause)
252+ var userErr *domain.UserError
253+ require.ErrorAs(t, err, &userErr)
254+ require.Equal(t, domain.NoticeLayoutTooSmall, userErr.Code)
255+ require.Equal(t, domain.NoticeWarn, userErr.Severity)
256+ require.Equal(t, before, h.snapshot(), "failed rearrange must be atomic")
257+ require.Zero(t, h.totalResizes())
258+ h.factory.AssertNotCalled(t, "Open")
259+ for _, tracked := range h.ptys {
260+ require.Zero(t, tracked.closeCount())
261+ }
262+ })
263+ }
264+}
265+
266+func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) {
267+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
268+ ac := &attachedClient{}
269+ ac.setSession(h.session)
270+ h.session.mu.Lock()
271+ h.session.client = ac
272+ h.session.mu.Unlock()
273+ invalidations := make(chan renderInvalidation, 2)
274+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
275+ rc.attach(ac)
276+ h.session.installRenderCoordinator(rc)
277+
278+ err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{
279+ kind: daemonActionConsumeOrExpelPane, direction: layout.Right,
280+ })
281+
282+ require.NoError(t, err)
283+ h.tab.mu.Lock()
284+ require.Equal(t, uint64(1), h.tab.layoutGeneration)
285+ h.tab.mu.Unlock()
286+ h.session.snapshotMu.Lock()
287+ require.Equal(t, uint64(1), h.session.snapshotGeneration)
288+ h.session.snapshotMu.Unlock()
289+ require.NotZero(t, h.totalResizes())
290+ awaitInvalidation(t, invalidations)
291+ requireNoInvalidation(t, invalidations)
292+}
293+
294+func TestDaemonActionAdaptersNormalizeOnlyPaneRearrangeNoop(t *testing.T) {
295+ genuine := errors.New("genuine rearrange failure")
296+ tests := []struct {
297+ name string
298+ err error
299+ wantErr error
300+ wantLog bool
301+ }{
302+ {name: "silent no-op", err: errPaneRearrangeNoop},
303+ {name: "genuine error", err: genuine, wantErr: genuine, wantLog: true},
304+ }
305+
306+ for _, tt := range tests {
307+ t.Run(tt.name, func(t *testing.T) {
308+ d := newTestDaemon(t, nil, stubClock{})
309+ sess := addControlSession(d, "work", "t_work", "p_work")
310+ ac := &attachedClient{}
311+ ac.setSession(sess)
312+ target := resolveDaemonActionTarget(sess)
313+ request := daemonActionRequest{kind: daemonActionConsumeOrExpelPane, target: target, direction: layout.Left}
314+
315+ paletteErr := (paletteExec{d: d, sess: sess, ac: ac, actions: &actionRunnerSpy{err: tt.err}}).runAction(request)
316+ controlErr := (controlExec{d: d, sess: sess, target: target, actions: &actionRunnerSpy{err: tt.err}}).runAction(request)
317+ require.ErrorIs(t, paletteErr, tt.wantErr)
318+ require.ErrorIs(t, controlErr, tt.wantErr)
319+
320+ beforeNotices := len(d.notices.history())
321+ daemonKeyHandler{d: d, ac: ac, actions: &actionRunnerSpy{err: tt.err}}.Action(keys.ActionEqualizePanes)
322+ afterNotices := len(d.notices.history())
323+ if tt.wantLog {
324+ require.Equal(t, beforeNotices+1, afterNotices, "key action must report a genuine error")
325+ } else {
326+ require.Equal(t, beforeNotices, afterNotices, "key action must silently normalize rearrange no-op")
327+ }
328+ })
329+ }
330+}
331+
332+type paneRearrangeHarness struct {
333+ daemon *Daemon
334+ session *session
335+ tab *tab
336+ panes map[layout.PaneID]*pane
337+ ptys map[layout.PaneID]*paneRearrangePTY
338+ factory *portsmocks.MockPTYFactory
339+}
340+
341+type paneRearrangeSnapshot struct {
342+ tree *layout.Tree
343+ layoutGeneration uint64
344+ snapshotGeneration uint64
345+ snapshotDirty bool
346+ panes map[layout.PaneID]*pane
347+ rects map[layout.PaneID]domain.Rect
348+ screens map[layout.PaneID]domain.Size
349+}
350+
351+func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) *paneRearrangeHarness {
352+ t.Helper()
353+ factory := portsmocks.NewMockPTYFactory(t)
354+ d := newTestDaemon(t, factory, stubClock{})
355+ ids := layout.LeafIDs(tree.Root)
356+ require.NotEmpty(t, ids)
357+ ptys := make(map[layout.PaneID]*paneRearrangePTY, len(ids))
358+ panes := make(map[layout.PaneID]*pane, len(ids))
359+ for _, id := range ids {
360+ ptys[id] = &paneRearrangePTY{}
361+ }
362+ tb := newTab(ptys[ids[0]], size)
363+ first := tb.panes["pane-1"]
364+ delete(tb.panes, "pane-1")
365+ first.id = ids[0]
366+ panes[ids[0]] = first
367+ tb.panes[ids[0]] = first
368+ for _, id := range ids[1:] {
369+ p := newPane(id, ptys[id], size)
370+ panes[id] = p
371+ tb.panes[id] = p
372+ }
373+ tb.tree = tree.Clone()
374+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
375+ require.True(t, ok, "test fixture layout must solve")
376+ for _, placement := range placements {
377+ p := tb.panes[placement.ID]
378+ p.rect = placement.Content
379+ p.screen.Resize(placement.Content.Width, placement.Content.Height)
380+ }
381+ tb.ctx, tb.cancel = contextWithTestCleanup(t, d.serveCtx)
382+ for _, p := range tb.panes {
383+ p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx)
384+ }
385+ sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}
386+ sess.snapEligible.Store(true)
387+ d.sessions[sess.id] = sess
388+ return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory}
389+}
390+
391+func contextWithTestCleanup(t *testing.T, parent context.Context) (context.Context, context.CancelFunc) {
392+ t.Helper()
393+ ctx, cancel := context.WithCancel(parent)
394+ t.Cleanup(cancel)
395+ return ctx, cancel
396+}
397+
398+func (h *paneRearrangeHarness) target(id layout.PaneID) daemonActionTarget {
399+ return daemonActionTarget{session: h.session, tab: h.tab, pane: h.panes[id]}
400+}
401+
402+func (h *paneRearrangeHarness) totalResizes() int {
403+ total := 0
404+ for _, tracked := range h.ptys {
405+ total += tracked.resizeCount()
406+ }
407+ return total
408+}
409+
410+func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot {
411+ h.tab.mu.Lock()
412+ snapshot := paneRearrangeSnapshot{
413+ tree: h.tab.tree.Clone(),
414+ layoutGeneration: h.tab.layoutGeneration,
415+ panes: make(map[layout.PaneID]*pane, len(h.tab.panes)),
416+ rects: make(map[layout.PaneID]domain.Rect, len(h.tab.panes)),
417+ screens: make(map[layout.PaneID]domain.Size, len(h.tab.panes)),
418+ }
419+ for id, p := range h.tab.panes {
420+ snapshot.panes[id] = p
421+ p.mu.Lock()
422+ snapshot.rects[id] = p.rect
423+ snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}
424+ p.mu.Unlock()
425+ }
426+ h.tab.mu.Unlock()
427+ h.session.snapshotMu.Lock()
428+ snapshot.snapshotGeneration = h.session.snapshotGeneration
429+ snapshot.snapshotDirty = h.session.snapDirty.Load()
430+ h.session.snapshotMu.Unlock()
431+ return snapshot
432+}
433+
434+func (h *paneRearrangeHarness) stateLocksAvailable() bool {
435+ done := make(chan struct{})
436+ go func() {
437+ h.daemon.mu.Lock()
438+ h.session.mu.Lock()
439+ h.tab.mu.Lock()
440+ for _, p := range h.panes {
441+ p.mu.Lock()
442+ }
443+ for _, p := range h.panes {
444+ p.mu.Unlock()
445+ }
446+ h.tab.mu.Unlock()
447+ h.session.mu.Unlock()
448+ h.daemon.mu.Unlock()
449+ close(done)
450+ }()
451+ select {
452+ case <-done:
453+ return true
454+ case <-time.After(250 * time.Millisecond):
455+ return false
456+ }
457+}
458+
459+func threeColumnTree() *layout.Tree {
460+ return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
461+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
462+ }}, Focus: "pane-1"}
463+}
464+
465+type paneRearrangePTY struct {
466+ mu sync.Mutex
467+ resizes []domain.Size
468+ closes int
469+ onResize func()
470+}
471+
472+func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF }
473+func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil }
474+func (p *paneRearrangePTY) Resize(size domain.Size) error {
475+ p.mu.Lock()
476+ p.resizes = append(p.resizes, size)
477+ hook := p.onResize
478+ p.mu.Unlock()
479+ if hook != nil {
480+ hook()
481+ }
482+ return nil
483+}
484+func (p *paneRearrangePTY) Close() error {
485+ p.mu.Lock()
486+ p.closes++
487+ p.mu.Unlock()
488+ return nil
489+}
490+func (*paneRearrangePTY) Pid() int { return 0 }
491+func (*paneRearrangePTY) ForegroundPgid() (int, error) { return 0, nil }
492+
493+func (p *paneRearrangePTY) resizeCount() int {
494+ p.mu.Lock()
495+ defer p.mu.Unlock()
496+ return len(p.resizes)
497+}
498+
499+func (p *paneRearrangePTY) closeCount() int {
500+ p.mu.Lock()
501+ defer p.mu.Unlock()
502+ return p.closes
503+}
504+
505+var _ ports.PTY = (*paneRearrangePTY)(nil)
@@ -0,0 +1,505 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/layout"
18+)
19+
20+func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) {
21+ tests := []struct {
22+ name string
23+ mutate func(*layout.Tree, domain.Rect) (bool, error)
24+ wantChanged bool
25+ wantGeneration uint64
26+ wantSnapshotGen uint64
27+ wantResizes bool
28+ }{
29+ {
30+ name: "changed candidate publishes once",
31+ mutate: func(candidate *layout.Tree, area domain.Rect) (bool, error) {
32+ return candidate.ConsumeOrExpelPane("pane-1", layout.Right, area)
33+ },
34+ wantChanged: true,
35+ wantGeneration: 1,
36+ wantSnapshotGen: 1,
37+ wantResizes: true,
38+ },
39+ {
40+ name: "unchanged candidate is discarded",
41+ mutate: func(candidate *layout.Tree, _ domain.Rect) (bool, error) {
42+ candidate.Focus = "pane-2"
43+ return false, nil
44+ },
45+ },
46+ }
47+
48+ for _, tt := range tests {
49+ t.Run(tt.name, func(t *testing.T) {
50+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
51+ before := h.tab.tree.Clone()
52+
53+ changed, err := h.daemon.mutateTargetLayoutChanged(h.target("pane-1"), true, tt.mutate)
54+
55+ require.NoError(t, err)
56+ require.Equal(t, tt.wantChanged, changed)
57+ h.tab.mu.Lock()
58+ require.Equal(t, tt.wantGeneration, h.tab.layoutGeneration)
59+ if tt.wantChanged {
60+ require.NotEqual(t, before, h.tab.tree)
61+ } else {
62+ require.Equal(t, before, h.tab.tree, "changed=false must not publish a mutated candidate")
63+ }
64+ h.tab.mu.Unlock()
65+ h.session.snapshotMu.Lock()
66+ require.Equal(t, tt.wantSnapshotGen, h.session.snapshotGeneration)
67+ h.session.snapshotMu.Unlock()
68+ if tt.wantResizes {
69+ require.NotZero(t, h.totalResizes())
70+ } else {
71+ require.Zero(t, h.totalResizes())
72+ }
73+ })
74+ }
75+}
76+
77+func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ stale func(*paneRearrangeHarness) daemonActionTarget
81+ }{
82+ {
83+ name: "session",
84+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
85+ target := h.target("pane-1")
86+ h.daemon.sessions[h.session.id] = &session{id: h.session.id}
87+ return target
88+ },
89+ },
90+ {
91+ name: "tab",
92+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
93+ target := h.target("pane-1")
94+ h.session.tabs = []*tab{newTab(nil, h.tab.size)}
95+ return target
96+ },
97+ },
98+ {
99+ name: "pane identity",
100+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
101+ target := h.target("pane-1")
102+ h.tab.panes[target.pane.id] = newPane(target.pane.id, nil, h.tab.size)
103+ return target
104+ },
105+ },
106+ }
107+
108+ for _, tt := range tests {
109+ t.Run(tt.name, func(t *testing.T) {
110+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
111+ before := h.tab.tree.Clone()
112+ called := false
113+
114+ changed, err := h.daemon.mutateTargetLayoutChanged(tt.stale(h), true, func(*layout.Tree, domain.Rect) (bool, error) {
115+ called = true
116+ return true, nil
117+ })
118+
119+ require.False(t, changed)
120+ require.ErrorIs(t, err, layout.ErrNotFound)
121+ require.False(t, called, "stale identity must be rejected before invoking the mutation")
122+ require.Equal(t, before, h.tab.tree)
123+ require.Zero(t, h.tab.layoutGeneration)
124+ require.Zero(t, h.totalResizes())
125+ })
126+ }
127+}
128+
129+func TestPaneRearrangeResizeRunsWithoutDaemonStateLocks(t *testing.T) {
130+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
131+ locksAvailable := make(chan bool, 8)
132+ for _, tracked := range h.ptys {
133+ tracked.onResize = func() { locksAvailable <- h.stateLocksAvailable() }
134+ }
135+
136+ done := make(chan error, 1)
137+ go func() {
138+ done <- (daemonActions{d: h.daemon}).Run(daemonActionRequest{
139+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
140+ })
141+ }()
142+
143+ select {
144+ case err := <-done:
145+ require.NoError(t, err)
146+ case <-time.After(2 * time.Second):
147+ t.Fatal("pane rearrange blocked while PTY.Resize attempted to re-enter daemon state locks")
148+ }
149+ require.NotZero(t, h.totalResizes())
150+ close(locksAvailable)
151+ for available := range locksAvailable {
152+ require.True(t, available, "PTY.Resize ran while a daemon, session, tab, or pane state lock was held")
153+ }
154+}
155+
156+func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) {
157+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
158+ h.tab.mu.Lock()
159+ h.tab.tree.Focus = "pane-3"
160+ before := make(map[layout.PaneID]*pane, len(h.tab.panes))
161+ for id, p := range h.tab.panes {
162+ before[id] = p
163+ }
164+ h.tab.mu.Unlock()
165+
166+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
167+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
168+ })
169+
170+ require.NoError(t, err)
171+ h.tab.mu.Lock()
172+ require.Equal(t, layout.PaneID("pane-1"), h.tab.tree.Focus, "the explicit non-focused target becomes focused")
173+ require.Len(t, h.tab.panes, len(before))
174+ for id, p := range before {
175+ require.Same(t, p, h.tab.panes[id], "pane %s identity changed", id)
176+ }
177+ h.tab.mu.Unlock()
178+ h.factory.AssertNotCalled(t, "Open")
179+ for _, tracked := range h.ptys {
180+ require.Zero(t, tracked.closeCount())
181+ }
182+}
183+
184+func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) {
185+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
186+ ac := &attachedClient{}
187+ ac.setSession(h.session)
188+ h.session.mu.Lock()
189+ h.session.client = ac
190+ h.session.mu.Unlock()
191+ invalidations := make(chan renderInvalidation, 2)
192+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
193+ rc.attach(ac)
194+ h.session.installRenderCoordinator(rc)
195+ before := h.snapshot()
196+ request := daemonActionRequest{
197+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Left,
198+ }
199+
200+ err := (daemonActions{d: h.daemon}).Run(request)
201+ require.ErrorIs(t, err, errPaneRearrangeNoop, "the daemon action must retain an exact no-change sentinel")
202+ require.NoError(t, (paletteExec{d: h.daemon, sess: h.session, ac: ac}).runAction(request), "palette normalizes the silent sentinel")
203+
204+ require.Equal(t, before, h.snapshot(), "edge no-op changed tree, generations, snapshot state, pane identity, geometry, or VT size")
205+ require.Zero(t, h.totalResizes())
206+ h.factory.AssertNotCalled(t, "Open")
207+ for _, tracked := range h.ptys {
208+ require.Zero(t, tracked.closeCount())
209+ }
210+ require.Empty(t, h.daemon.notices.history())
211+ requireNoInvalidation(t, invalidations)
212+}
213+
214+func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testing.T) {
215+ tests := []struct {
216+ name string
217+ size domain.Size
218+ tree *layout.Tree
219+ pane layout.PaneID
220+ dir layout.Direction
221+ cause error
222+ }{
223+ {
224+ name: "unsupported layout",
225+ size: domain.Size{Cols: 80, Rows: 22},
226+ tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
227+ layout.NewLeaf("pane-1"),
228+ {Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}},
229+ }}, Focus: "pane-1"},
230+ pane: "pane-1", dir: layout.Right, cause: layout.ErrUnsupportedColumnLayout,
231+ },
232+ {
233+ name: "too small",
234+ size: domain.Size{Cols: 40, Rows: 5},
235+ tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
236+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
237+ }}, Focus: "pane-1"},
238+ pane: "pane-1", dir: layout.Right, cause: layout.ErrTooSmall,
239+ },
240+ }
241+
242+ for _, tt := range tests {
243+ t.Run(tt.name, func(t *testing.T) {
244+ h := newPaneRearrangeHarness(t, tt.size, tt.tree)
245+ before := h.snapshot()
246+
247+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
248+ kind: daemonActionConsumeOrExpelPane, target: h.target(tt.pane), direction: tt.dir,
249+ })
250+
251+ require.ErrorIs(t, err, tt.cause)
252+ var userErr *domain.UserError
253+ require.ErrorAs(t, err, &userErr)
254+ require.Equal(t, domain.NoticeLayoutTooSmall, userErr.Code)
255+ require.Equal(t, domain.NoticeWarn, userErr.Severity)
256+ require.Equal(t, before, h.snapshot(), "failed rearrange must be atomic")
257+ require.Zero(t, h.totalResizes())
258+ h.factory.AssertNotCalled(t, "Open")
259+ for _, tracked := range h.ptys {
260+ require.Zero(t, tracked.closeCount())
261+ }
262+ })
263+ }
264+}
265+
266+func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) {
267+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
268+ ac := &attachedClient{}
269+ ac.setSession(h.session)
270+ h.session.mu.Lock()
271+ h.session.client = ac
272+ h.session.mu.Unlock()
273+ invalidations := make(chan renderInvalidation, 2)
274+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
275+ rc.attach(ac)
276+ h.session.installRenderCoordinator(rc)
277+
278+ err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{
279+ kind: daemonActionConsumeOrExpelPane, direction: layout.Right,
280+ })
281+
282+ require.NoError(t, err)
283+ h.tab.mu.Lock()
284+ require.Equal(t, uint64(1), h.tab.layoutGeneration)
285+ h.tab.mu.Unlock()
286+ h.session.snapshotMu.Lock()
287+ require.Equal(t, uint64(1), h.session.snapshotGeneration)
288+ h.session.snapshotMu.Unlock()
289+ require.NotZero(t, h.totalResizes())
290+ awaitInvalidation(t, invalidations)
291+ requireNoInvalidation(t, invalidations)
292+}
293+
294+func TestDaemonActionAdaptersNormalizeOnlyPaneRearrangeNoop(t *testing.T) {
295+ genuine := errors.New("genuine rearrange failure")
296+ tests := []struct {
297+ name string
298+ err error
299+ wantErr error
300+ wantLog bool
301+ }{
302+ {name: "silent no-op", err: errPaneRearrangeNoop},
303+ {name: "genuine error", err: genuine, wantErr: genuine, wantLog: true},
304+ }
305+
306+ for _, tt := range tests {
307+ t.Run(tt.name, func(t *testing.T) {
308+ d := newTestDaemon(t, nil, stubClock{})
309+ sess := addControlSession(d, "work", "t_work", "p_work")
310+ ac := &attachedClient{}
311+ ac.setSession(sess)
312+ target := resolveDaemonActionTarget(sess)
313+ request := daemonActionRequest{kind: daemonActionConsumeOrExpelPane, target: target, direction: layout.Left}
314+
315+ paletteErr := (paletteExec{d: d, sess: sess, ac: ac, actions: &actionRunnerSpy{err: tt.err}}).runAction(request)
316+ controlErr := (controlExec{d: d, sess: sess, target: target, actions: &actionRunnerSpy{err: tt.err}}).runAction(request)
317+ require.ErrorIs(t, paletteErr, tt.wantErr)
318+ require.ErrorIs(t, controlErr, tt.wantErr)
319+
320+ beforeNotices := len(d.notices.history())
321+ daemonKeyHandler{d: d, ac: ac, actions: &actionRunnerSpy{err: tt.err}}.Action(keys.ActionEqualizePanes)
322+ afterNotices := len(d.notices.history())
323+ if tt.wantLog {
324+ require.Equal(t, beforeNotices+1, afterNotices, "key action must report a genuine error")
325+ } else {
326+ require.Equal(t, beforeNotices, afterNotices, "key action must silently normalize rearrange no-op")
327+ }
328+ })
329+ }
330+}
331+
332+type paneRearrangeHarness struct {
333+ daemon *Daemon
334+ session *session
335+ tab *tab
336+ panes map[layout.PaneID]*pane
337+ ptys map[layout.PaneID]*paneRearrangePTY
338+ factory *portsmocks.MockPTYFactory
339+}
340+
341+type paneRearrangeSnapshot struct {
342+ tree *layout.Tree
343+ layoutGeneration uint64
344+ snapshotGeneration uint64
345+ snapshotDirty bool
346+ panes map[layout.PaneID]*pane
347+ rects map[layout.PaneID]domain.Rect
348+ screens map[layout.PaneID]domain.Size
349+}
350+
351+func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) *paneRearrangeHarness {
352+ t.Helper()
353+ factory := portsmocks.NewMockPTYFactory(t)
354+ d := newTestDaemon(t, factory, stubClock{})
355+ ids := layout.LeafIDs(tree.Root)
356+ require.NotEmpty(t, ids)
357+ ptys := make(map[layout.PaneID]*paneRearrangePTY, len(ids))
358+ panes := make(map[layout.PaneID]*pane, len(ids))
359+ for _, id := range ids {
360+ ptys[id] = &paneRearrangePTY{}
361+ }
362+ tb := newTab(ptys[ids[0]], size)
363+ first := tb.panes["pane-1"]
364+ delete(tb.panes, "pane-1")
365+ first.id = ids[0]
366+ panes[ids[0]] = first
367+ tb.panes[ids[0]] = first
368+ for _, id := range ids[1:] {
369+ p := newPane(id, ptys[id], size)
370+ panes[id] = p
371+ tb.panes[id] = p
372+ }
373+ tb.tree = tree.Clone()
374+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
375+ require.True(t, ok, "test fixture layout must solve")
376+ for _, placement := range placements {
377+ p := tb.panes[placement.ID]
378+ p.rect = placement.Content
379+ p.screen.Resize(placement.Content.Width, placement.Content.Height)
380+ }
381+ tb.ctx, tb.cancel = contextWithTestCleanup(t, d.serveCtx)
382+ for _, p := range tb.panes {
383+ p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx)
384+ }
385+ sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}
386+ sess.snapEligible.Store(true)
387+ d.sessions[sess.id] = sess
388+ return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory}
389+}
390+
391+func contextWithTestCleanup(t *testing.T, parent context.Context) (context.Context, context.CancelFunc) {
392+ t.Helper()
393+ ctx, cancel := context.WithCancel(parent)
394+ t.Cleanup(cancel)
395+ return ctx, cancel
396+}
397+
398+func (h *paneRearrangeHarness) target(id layout.PaneID) daemonActionTarget {
399+ return daemonActionTarget{session: h.session, tab: h.tab, pane: h.panes[id]}
400+}
401+
402+func (h *paneRearrangeHarness) totalResizes() int {
403+ total := 0
404+ for _, tracked := range h.ptys {
405+ total += tracked.resizeCount()
406+ }
407+ return total
408+}
409+
410+func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot {
411+ h.tab.mu.Lock()
412+ snapshot := paneRearrangeSnapshot{
413+ tree: h.tab.tree.Clone(),
414+ layoutGeneration: h.tab.layoutGeneration,
415+ panes: make(map[layout.PaneID]*pane, len(h.tab.panes)),
416+ rects: make(map[layout.PaneID]domain.Rect, len(h.tab.panes)),
417+ screens: make(map[layout.PaneID]domain.Size, len(h.tab.panes)),
418+ }
419+ for id, p := range h.tab.panes {
420+ snapshot.panes[id] = p
421+ p.mu.Lock()
422+ snapshot.rects[id] = p.rect
423+ snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}
424+ p.mu.Unlock()
425+ }
426+ h.tab.mu.Unlock()
427+ h.session.snapshotMu.Lock()
428+ snapshot.snapshotGeneration = h.session.snapshotGeneration
429+ snapshot.snapshotDirty = h.session.snapDirty.Load()
430+ h.session.snapshotMu.Unlock()
431+ return snapshot
432+}
433+
434+func (h *paneRearrangeHarness) stateLocksAvailable() bool {
435+ done := make(chan struct{})
436+ go func() {
437+ h.daemon.mu.Lock()
438+ h.session.mu.Lock()
439+ h.tab.mu.Lock()
440+ for _, p := range h.panes {
441+ p.mu.Lock()
442+ }
443+ for _, p := range h.panes {
444+ p.mu.Unlock()
445+ }
446+ h.tab.mu.Unlock()
447+ h.session.mu.Unlock()
448+ h.daemon.mu.Unlock()
449+ close(done)
450+ }()
451+ select {
452+ case <-done:
453+ return true
454+ case <-time.After(250 * time.Millisecond):
455+ return false
456+ }
457+}
458+
459+func threeColumnTree() *layout.Tree {
460+ return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
461+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
462+ }}, Focus: "pane-1"}
463+}
464+
465+type paneRearrangePTY struct {
466+ mu sync.Mutex
467+ resizes []domain.Size
468+ closes int
469+ onResize func()
470+}
471+
472+func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF }
473+func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil }
474+func (p *paneRearrangePTY) Resize(size domain.Size) error {
475+ p.mu.Lock()
476+ p.resizes = append(p.resizes, size)
477+ hook := p.onResize
478+ p.mu.Unlock()
479+ if hook != nil {
480+ hook()
481+ }
482+ return nil
483+}
484+func (p *paneRearrangePTY) Close() error {
485+ p.mu.Lock()
486+ p.closes++
487+ p.mu.Unlock()
488+ return nil
489+}
490+func (*paneRearrangePTY) Pid() int { return 0 }
491+func (*paneRearrangePTY) ForegroundPgid() (int, error) { return 0, nil }
492+
493+func (p *paneRearrangePTY) resizeCount() int {
494+ p.mu.Lock()
495+ defer p.mu.Unlock()
496+ return len(p.resizes)
497+}
498+
499+func (p *paneRearrangePTY) closeCount() int {
500+ p.mu.Lock()
501+ defer p.mu.Unlock()
502+ return p.closes
503+}
504+
505+var _ ports.PTY = (*paneRearrangePTY)(nil)
M internal/usecase/daemon/resize_mode.go +0 -48
@@ -32,54 +32,6 @@ func (d *Daemon) equalizePanes(target daemonActionTarget) error {
3232 })
3333 }
3434
35-func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error {
36- if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) {
37- return layout.ErrNotFound
38- }
39- d.mu.Lock()
40- if d.sessions[target.session.id] != target.session {
41- d.mu.Unlock()
42- return layout.ErrNotFound
43- }
44- target.session.mu.Lock()
45- foundTab := false
46- for _, tb := range target.session.tabs {
47- if tb == target.tab {
48- foundTab = true
49- break
50- }
51- }
52- if !foundTab {
53- target.session.mu.Unlock()
54- d.mu.Unlock()
55- return layout.ErrNotFound
56- }
57- target.tab.mu.Lock()
58- if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) {
59- target.tab.mu.Unlock()
60- target.session.mu.Unlock()
61- d.mu.Unlock()
62- return layout.ErrNotFound
63- }
64- candidate := target.tab.tree.Clone()
65- area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows}
66- if err := mutate(candidate, area); err != nil {
67- target.tab.mu.Unlock()
68- target.session.mu.Unlock()
69- d.mu.Unlock()
70- return err
71- }
72- target.tab.tree = candidate
73- target.tab.bumpLayoutGenerationLocked()
74- target.tab.mu.Unlock()
75- target.session.mu.Unlock()
76- d.mu.Unlock()
77- if !d.applyTabLayout(target.session, target.tab) {
78- return layout.ErrNotFound
79- }
80- return nil
81-}
82-
8335 func resizeUserError(err error) error {
8436 switch {
8537 case errors.Is(err, layout.ErrNotInSplit):
@@ -32,54 +32,6 @@ func (d *Daemon) equalizePanes(target daemonActionTarget) error {
32 })32 })
33 }33 }
34 34
35-func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error {
36- if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) {
37- return layout.ErrNotFound
38- }
39- d.mu.Lock()
40- if d.sessions[target.session.id] != target.session {
41- d.mu.Unlock()
42- return layout.ErrNotFound
43- }
44- target.session.mu.Lock()
45- foundTab := false
46- for _, tb := range target.session.tabs {
47- if tb == target.tab {
48- foundTab = true
49- break
50- }
51- }
52- if !foundTab {
53- target.session.mu.Unlock()
54- d.mu.Unlock()
55- return layout.ErrNotFound
56- }
57- target.tab.mu.Lock()
58- if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) {
59- target.tab.mu.Unlock()
60- target.session.mu.Unlock()
61- d.mu.Unlock()
62- return layout.ErrNotFound
63- }
64- candidate := target.tab.tree.Clone()
65- area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows}
66- if err := mutate(candidate, area); err != nil {
67- target.tab.mu.Unlock()
68- target.session.mu.Unlock()
69- d.mu.Unlock()
70- return err
71- }
72- target.tab.tree = candidate
73- target.tab.bumpLayoutGenerationLocked()
74- target.tab.mu.Unlock()
75- target.session.mu.Unlock()
76- d.mu.Unlock()
77- if !d.applyTabLayout(target.session, target.tab) {
78- return layout.ErrNotFound
79- }
80- return nil
81-}
82-
83 func resizeUserError(err error) error {35 func resizeUserError(err error) error {
84 switch {36 switch {
85 case errors.Is(err, layout.ErrNotInSplit):37 case errors.Is(err, layout.ErrNotInSplit):

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