detached 8b4f298

refactor(daemon): share explicit-target command actions

commit
8b4f29811a1ac865500c0e416a60ffe91a75cef4
author
brice · · 2026-07-23 22:29 UTC
parent
d277208
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +315 -136
M internal/usecase/daemon/control.go +166 -43
@@ -57,7 +57,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu
5757 if code != 0 {
5858 return commandFailure(code, text)
5959 }
60- return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, pane: pane}, request)
60+ return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
6161 }
6262
6363 func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports.CommandRequest) ports.CommandResult {
@@ -165,22 +165,154 @@ func paneByStableIDLocked(tb *tab, stableID string) *pane {
165165 return nil
166166 }
167167
168+type daemonActionKind uint8
169+
170+const (
171+ daemonActionCreateTab daemonActionKind = iota
172+ daemonActionCloseTab
173+ daemonActionSplitPane
174+ daemonActionStackPane
175+ daemonActionToggleStack
176+ daemonActionClosePane
177+ daemonActionFocusPane
178+ daemonActionNextTab
179+ daemonActionPreviousTab
180+ daemonActionRenameSession
181+ daemonActionRenameTab
182+)
183+
184+type daemonActionTarget struct {
185+ session *session
186+ tab *tab
187+ pane *pane
188+}
189+
190+type daemonActionRequest struct {
191+ kind daemonActionKind
192+ target daemonActionTarget
193+ direction layout.Direction
194+ viewport domain.Size
195+ name string
196+}
197+
198+type daemonActionRunner interface {
199+ Run(daemonActionRequest) error
200+}
201+
202+func resolveDaemonActionTarget(sess *session) daemonActionTarget {
203+ if sess == nil {
204+ return daemonActionTarget{}
205+ }
206+ tb := sess.activeTab()
207+ if tb == nil {
208+ return daemonActionTarget{session: sess}
209+ }
210+ tb.mu.Lock()
211+ pane := tb.focusedPane()
212+ tb.mu.Unlock()
213+ return daemonActionTarget{session: sess, tab: tb, pane: pane}
214+}
215+
216+// daemonActions is the daemon-owned mutation seam shared by palette and
217+// control. Requests contain already-resolved targets and no attachment state;
218+// adapters remain responsible for their attached-client UI lifecycle.
219+type daemonActions struct{ d *Daemon }
220+
221+func (a daemonActions) Run(request daemonActionRequest) error {
222+ target := request.target
223+ switch request.kind {
224+ case daemonActionCreateTab:
225+ return a.d.createTab(target.session, request.viewport)
226+ case daemonActionCloseTab:
227+ a.d.closeTab(target.session, target.tab, true)
228+ return nil
229+ case daemonActionSplitPane:
230+ return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction)
231+ case daemonActionStackPane:
232+ return a.d.stackPaneAt(target.session, target.tab, target.pane)
233+ case daemonActionToggleStack:
234+ return a.d.toggleStackAt(target.session, target.tab, target.pane)
235+ case daemonActionClosePane:
236+ if a.d.ptys == nil {
237+ return nil
238+ }
239+ if target.pane == nil {
240+ return layout.ErrNotFound
241+ }
242+ return a.d.closePane(target.session, target.tab, target.pane.id, nil, true)
243+ case daemonActionFocusPane:
244+ return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
245+ case daemonActionNextTab:
246+ return a.switchRelative(target.session, 1)
247+ case daemonActionPreviousTab:
248+ return a.switchRelative(target.session, -1)
249+ case daemonActionRenameSession:
250+ return a.d.renameSession(target.session, request.name)
251+ case daemonActionRenameTab:
252+ return a.d.renameTab(target.session, target.tab, request.name)
253+ default:
254+ return errors.New("daemon: unknown action")
255+ }
256+}
257+
258+func (a daemonActions) switchRelative(sess *session, delta int) error {
259+ if sess.switchRelative(delta) {
260+ a.d.activateTab(sess, sess.activeTab())
261+ }
262+ return nil
263+}
264+
168265 // controlExec implements command.ControlContext against resolved daemon-owned
169266 // targets. It deliberately contains no attached-client state.
170267 type controlExec struct {
171- d *Daemon
172- sess *session
173- tab *tab
174- pane *pane
268+ d *Daemon
269+ sess *session
270+ tab *tab
271+ target daemonActionTarget
272+ actions daemonActionRunner
175273 }
176274
177-func (e controlExec) client() *attachedClient {
178- e.sess.mu.Lock()
179- defer e.sess.mu.Unlock()
180- return e.sess.client
275+func (e controlExec) runAction(request daemonActionRequest) error {
276+ if request.target.session == nil {
277+ request.target = e.target
278+ }
279+ runner := e.actions
280+ if runner == nil {
281+ runner = daemonActions{d: e.d}
282+ }
283+ err := runner.Run(request)
284+ if err == nil && e.actions == nil {
285+ e.sess.mu.Lock()
286+ ac := e.sess.client
287+ e.sess.mu.Unlock()
288+ finishDaemonActionForClient(e.d, request, ac, "control.go")
289+ }
290+ return err
291+}
292+
293+func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
294+ if ac == nil {
295+ return
296+ }
297+ if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
298+ tb := request.target.tab
299+ tb.mu.Lock()
300+ newFocus := tb.tree.Focus
301+ pl, hasPlacement := focusedPlacementLocked(tb)
302+ tb.mu.Unlock()
303+ if newFocus != request.target.pane.id {
304+ d.exitCopyMode(ac)
305+ if hasPlacement && pl.TitleBar.Height > 0 {
306+ d.refreshPaneTitleOnFocus(request.target.session, newFocus)
307+ }
308+ }
309+ }
310+ d.invalidateRender(request.target.session, ac, true, producer)
181311 }
182312
183-func (e controlExec) CreateTab() error { return e.d.createTab(e.sess, e.sess.fullViewportSize()) }
313+func (e controlExec) CreateTab() error {
314+ return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
315+}
184316 func (e controlExec) CreateSessionNamed(name string) error {
185317 if err := domain.ValidateSessionName(name); err != nil {
186318 return command.ErrInvalidArguments
@@ -201,51 +333,42 @@ func (e controlExec) CreateSessionNamed(name string) error {
201333 return err
202334 }
203335 func (e controlExec) CloseTab() error {
204- e.d.closeTab(e.sess, e.tab, true)
205- return nil
336+ return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
206337 }
207338 func (e controlExec) ClosePane() error {
208- return e.d.closeFocusedPane(e.sess, e.client())
209-}
210-func (e controlExec) SplitRight() error {
211- return e.d.splitPane(e.sess, e.client(), layout.Right)
212-}
213-func (e controlExec) SplitLeft() error {
214- return e.d.splitPane(e.sess, e.client(), layout.Left)
339+ return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
215340 }
216-func (e controlExec) SplitUp() error {
217- return e.d.splitPane(e.sess, e.client(), layout.Up)
341+func (e controlExec) split(direction layout.Direction) error {
342+ return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
218343 }
219-func (e controlExec) SplitDown() error {
220- return e.d.splitPane(e.sess, e.client(), layout.Down)
344+func (e controlExec) SplitRight() error { return e.split(layout.Right) }
345+func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
346+func (e controlExec) SplitUp() error { return e.split(layout.Up) }
347+func (e controlExec) SplitDown() error { return e.split(layout.Down) }
348+func (e controlExec) StackPane() error {
349+ return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
221350 }
222-func (e controlExec) StackPane() error { return e.d.stackPane(e.sess, e.client()) }
223351 func (e controlExec) ToggleStack() error {
224- return e.d.toggleStack(e.sess, e.client())
352+ return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
225353 }
226-func (e controlExec) FocusPaneLeft() error {
227- return e.d.focusDir(e.sess, e.client(), layout.Left)
354+func (e controlExec) focus(direction layout.Direction) error {
355+ return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
228356 }
229-func (e controlExec) FocusPaneRight() error {
230- return e.d.focusDir(e.sess, e.client(), layout.Right)
357+func (e controlExec) FocusPaneLeft() error { return e.focus(layout.Left) }
358+func (e controlExec) FocusPaneRight() error { return e.focus(layout.Right) }
359+func (e controlExec) FocusPaneUp() error { return e.focus(layout.Up) }
360+func (e controlExec) FocusPaneDown() error { return e.focus(layout.Down) }
361+func (e controlExec) NextTab() error {
362+ return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
231363 }
232-func (e controlExec) FocusPaneUp() error {
233- return e.d.focusDir(e.sess, e.client(), layout.Up)
364+func (e controlExec) PrevTab() error {
365+ return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
234366 }
235-func (e controlExec) FocusPaneDown() error {
236- return e.d.focusDir(e.sess, e.client(), layout.Down)
237-}
238-func (e controlExec) NextTab() error { return e.switchRelative(1) }
239-func (e controlExec) PrevTab() error { return e.switchRelative(-1) }
240-func (e controlExec) switchRelative(delta int) error {
241- if e.sess.switchRelative(delta) {
242- e.d.activateTab(e.sess, e.sess.activeTab())
243- }
244- return nil
367+func (e controlExec) RenameSessionTo(name string) error {
368+ return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
245369 }
246-func (e controlExec) RenameSessionTo(name string) error { return e.d.renameSession(e.sess, name) }
247370 func (e controlExec) RenameTabTo(name string) error {
248- return e.d.renameTab(e.sess, e.tab, name)
371+ return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, name: name})
249372 }
250373 func (e controlExec) Toast(severity, message string) error {
251374 var level domain.NoticeSeverity
@@ -57,7 +57,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu
57 if code != 0 {57 if code != 0 {
58 return commandFailure(code, text)58 return commandFailure(code, text)
59 }59 }
60- return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, pane: pane}, request)60+ return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
61 }61 }
62 62
63 func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports.CommandRequest) ports.CommandResult {63 func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports.CommandRequest) ports.CommandResult {
@@ -165,22 +165,154 @@ func paneByStableIDLocked(tb *tab, stableID string) *pane {
165 return nil165 return nil
166 }166 }
167 167
168+type daemonActionKind uint8
169+
170+const (
171+ daemonActionCreateTab daemonActionKind = iota
172+ daemonActionCloseTab
173+ daemonActionSplitPane
174+ daemonActionStackPane
175+ daemonActionToggleStack
176+ daemonActionClosePane
177+ daemonActionFocusPane
178+ daemonActionNextTab
179+ daemonActionPreviousTab
180+ daemonActionRenameSession
181+ daemonActionRenameTab
182+)
183+
184+type daemonActionTarget struct {
185+ session *session
186+ tab *tab
187+ pane *pane
188+}
189+
190+type daemonActionRequest struct {
191+ kind daemonActionKind
192+ target daemonActionTarget
193+ direction layout.Direction
194+ viewport domain.Size
195+ name string
196+}
197+
198+type daemonActionRunner interface {
199+ Run(daemonActionRequest) error
200+}
201+
202+func resolveDaemonActionTarget(sess *session) daemonActionTarget {
203+ if sess == nil {
204+ return daemonActionTarget{}
205+ }
206+ tb := sess.activeTab()
207+ if tb == nil {
208+ return daemonActionTarget{session: sess}
209+ }
210+ tb.mu.Lock()
211+ pane := tb.focusedPane()
212+ tb.mu.Unlock()
213+ return daemonActionTarget{session: sess, tab: tb, pane: pane}
214+}
215+
216+// daemonActions is the daemon-owned mutation seam shared by palette and
217+// control. Requests contain already-resolved targets and no attachment state;
218+// adapters remain responsible for their attached-client UI lifecycle.
219+type daemonActions struct{ d *Daemon }
220+
221+func (a daemonActions) Run(request daemonActionRequest) error {
222+ target := request.target
223+ switch request.kind {
224+ case daemonActionCreateTab:
225+ return a.d.createTab(target.session, request.viewport)
226+ case daemonActionCloseTab:
227+ a.d.closeTab(target.session, target.tab, true)
228+ return nil
229+ case daemonActionSplitPane:
230+ return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction)
231+ case daemonActionStackPane:
232+ return a.d.stackPaneAt(target.session, target.tab, target.pane)
233+ case daemonActionToggleStack:
234+ return a.d.toggleStackAt(target.session, target.tab, target.pane)
235+ case daemonActionClosePane:
236+ if a.d.ptys == nil {
237+ return nil
238+ }
239+ if target.pane == nil {
240+ return layout.ErrNotFound
241+ }
242+ return a.d.closePane(target.session, target.tab, target.pane.id, nil, true)
243+ case daemonActionFocusPane:
244+ return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
245+ case daemonActionNextTab:
246+ return a.switchRelative(target.session, 1)
247+ case daemonActionPreviousTab:
248+ return a.switchRelative(target.session, -1)
249+ case daemonActionRenameSession:
250+ return a.d.renameSession(target.session, request.name)
251+ case daemonActionRenameTab:
252+ return a.d.renameTab(target.session, target.tab, request.name)
253+ default:
254+ return errors.New("daemon: unknown action")
255+ }
256+}
257+
258+func (a daemonActions) switchRelative(sess *session, delta int) error {
259+ if sess.switchRelative(delta) {
260+ a.d.activateTab(sess, sess.activeTab())
261+ }
262+ return nil
263+}
264+
168 // controlExec implements command.ControlContext against resolved daemon-owned265 // controlExec implements command.ControlContext against resolved daemon-owned
169 // targets. It deliberately contains no attached-client state.266 // targets. It deliberately contains no attached-client state.
170 type controlExec struct {267 type controlExec struct {
171- d *Daemon268+ d *Daemon
172- sess *session269+ sess *session
173- tab *tab270+ tab *tab
174- pane *pane271+ target daemonActionTarget
272+ actions daemonActionRunner
175 }273 }
176 274
177-func (e controlExec) client() *attachedClient {275+func (e controlExec) runAction(request daemonActionRequest) error {
178- e.sess.mu.Lock()276+ if request.target.session == nil {
179- defer e.sess.mu.Unlock()277+ request.target = e.target
180- return e.sess.client278+ }
279+ runner := e.actions
280+ if runner == nil {
281+ runner = daemonActions{d: e.d}
282+ }
283+ err := runner.Run(request)
284+ if err == nil && e.actions == nil {
285+ e.sess.mu.Lock()
286+ ac := e.sess.client
287+ e.sess.mu.Unlock()
288+ finishDaemonActionForClient(e.d, request, ac, "control.go")
289+ }
290+ return err
291+}
292+
293+func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
294+ if ac == nil {
295+ return
296+ }
297+ if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
298+ tb := request.target.tab
299+ tb.mu.Lock()
300+ newFocus := tb.tree.Focus
301+ pl, hasPlacement := focusedPlacementLocked(tb)
302+ tb.mu.Unlock()
303+ if newFocus != request.target.pane.id {
304+ d.exitCopyMode(ac)
305+ if hasPlacement && pl.TitleBar.Height > 0 {
306+ d.refreshPaneTitleOnFocus(request.target.session, newFocus)
307+ }
308+ }
309+ }
310+ d.invalidateRender(request.target.session, ac, true, producer)
181 }311 }
182 312
183-func (e controlExec) CreateTab() error { return e.d.createTab(e.sess, e.sess.fullViewportSize()) }313+func (e controlExec) CreateTab() error {
314+ return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
315+}
184 func (e controlExec) CreateSessionNamed(name string) error {316 func (e controlExec) CreateSessionNamed(name string) error {
185 if err := domain.ValidateSessionName(name); err != nil {317 if err := domain.ValidateSessionName(name); err != nil {
186 return command.ErrInvalidArguments318 return command.ErrInvalidArguments
@@ -201,51 +333,42 @@ func (e controlExec) CreateSessionNamed(name string) error {
201 return err333 return err
202 }334 }
203 func (e controlExec) CloseTab() error {335 func (e controlExec) CloseTab() error {
204- e.d.closeTab(e.sess, e.tab, true)336+ return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
205- return nil
206 }337 }
207 func (e controlExec) ClosePane() error {338 func (e controlExec) ClosePane() error {
208- return e.d.closeFocusedPane(e.sess, e.client())339+ return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
209-}
210-func (e controlExec) SplitRight() error {
211- return e.d.splitPane(e.sess, e.client(), layout.Right)
212-}
213-func (e controlExec) SplitLeft() error {
214- return e.d.splitPane(e.sess, e.client(), layout.Left)
215 }340 }
216-func (e controlExec) SplitUp() error {341+func (e controlExec) split(direction layout.Direction) error {
217- return e.d.splitPane(e.sess, e.client(), layout.Up)342+ return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
218 }343 }
219-func (e controlExec) SplitDown() error {344+func (e controlExec) SplitRight() error { return e.split(layout.Right) }
220- return e.d.splitPane(e.sess, e.client(), layout.Down)345+func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
346+func (e controlExec) SplitUp() error { return e.split(layout.Up) }
347+func (e controlExec) SplitDown() error { return e.split(layout.Down) }
348+func (e controlExec) StackPane() error {
349+ return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
221 }350 }
222-func (e controlExec) StackPane() error { return e.d.stackPane(e.sess, e.client()) }
223 func (e controlExec) ToggleStack() error {351 func (e controlExec) ToggleStack() error {
224- return e.d.toggleStack(e.sess, e.client())352+ return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
225 }353 }
226-func (e controlExec) FocusPaneLeft() error {354+func (e controlExec) focus(direction layout.Direction) error {
227- return e.d.focusDir(e.sess, e.client(), layout.Left)355+ return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
228 }356 }
229-func (e controlExec) FocusPaneRight() error {357+func (e controlExec) FocusPaneLeft() error { return e.focus(layout.Left) }
230- return e.d.focusDir(e.sess, e.client(), layout.Right)358+func (e controlExec) FocusPaneRight() error { return e.focus(layout.Right) }
359+func (e controlExec) FocusPaneUp() error { return e.focus(layout.Up) }
360+func (e controlExec) FocusPaneDown() error { return e.focus(layout.Down) }
361+func (e controlExec) NextTab() error {
362+ return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
231 }363 }
232-func (e controlExec) FocusPaneUp() error {364+func (e controlExec) PrevTab() error {
233- return e.d.focusDir(e.sess, e.client(), layout.Up)365+ return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
234 }366 }
235-func (e controlExec) FocusPaneDown() error {367+func (e controlExec) RenameSessionTo(name string) error {
236- return e.d.focusDir(e.sess, e.client(), layout.Down)368+ return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
237-}
238-func (e controlExec) NextTab() error { return e.switchRelative(1) }
239-func (e controlExec) PrevTab() error { return e.switchRelative(-1) }
240-func (e controlExec) switchRelative(delta int) error {
241- if e.sess.switchRelative(delta) {
242- e.d.activateTab(e.sess, e.sess.activeTab())
243- }
244- return nil
245 }369 }
246-func (e controlExec) RenameSessionTo(name string) error { return e.d.renameSession(e.sess, name) }
247 func (e controlExec) RenameTabTo(name string) error {370 func (e controlExec) RenameTabTo(name string) error {
248- return e.d.renameTab(e.sess, e.tab, name)371+ return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, name: name})
249 }372 }
250 func (e controlExec) Toast(severity, message string) error {373 func (e controlExec) Toast(severity, message string) error {
251 var level domain.NoticeSeverity374 var level domain.NoticeSeverity
M internal/usecase/daemon/control_test.go +35 -0
@@ -9,9 +9,44 @@ import (
99
1010 "github.com/bnema/vev/internal/domain"
1111 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/usecase/layout"
1213 "github.com/stretchr/testify/require"
1314 )
1415
16+func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
17+ d := newTestDaemon(t, nil, stubClock{})
18+ sess := addControlSession(d, "work", "t_work", "p_work")
19+ tb := sess.activeTab()
20+ tb.mu.Lock()
21+ pane := tb.focusedPane()
22+ tb.mu.Unlock()
23+ spy := &actionRunnerSpy{}
24+
25+ require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight())
26+ require.NoError(t, (controlExec{
27+ d: d, sess: sess, tab: tb, actions: spy,
28+ target: daemonActionTarget{session: sess, tab: tb, pane: pane},
29+ }).SplitRight())
30+
31+ require.Len(t, spy.requests, 2)
32+ for _, request := range spy.requests {
33+ require.Equal(t, daemonActionSplitPane, request.kind)
34+ require.Same(t, sess, request.target.session)
35+ require.Same(t, tb, request.target.tab)
36+ require.Same(t, pane, request.target.pane)
37+ require.Equal(t, layout.Right, request.direction)
38+ }
39+}
40+
41+type actionRunnerSpy struct {
42+ requests []daemonActionRequest
43+}
44+
45+func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
46+ s.requests = append(s.requests, request)
47+ return nil
48+}
49+
1550 func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
1651 // The version prefix is valid and mismatched, while the remainder is not a
1752 // decodable request. The daemon must still return ErrVersionMismatch.
@@ -9,9 +9,44 @@ import (
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
13 )14 )
14 15
16+func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
17+ d := newTestDaemon(t, nil, stubClock{})
18+ sess := addControlSession(d, "work", "t_work", "p_work")
19+ tb := sess.activeTab()
20+ tb.mu.Lock()
21+ pane := tb.focusedPane()
22+ tb.mu.Unlock()
23+ spy := &actionRunnerSpy{}
24+
25+ require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight())
26+ require.NoError(t, (controlExec{
27+ d: d, sess: sess, tab: tb, actions: spy,
28+ target: daemonActionTarget{session: sess, tab: tb, pane: pane},
29+ }).SplitRight())
30+
31+ require.Len(t, spy.requests, 2)
32+ for _, request := range spy.requests {
33+ require.Equal(t, daemonActionSplitPane, request.kind)
34+ require.Same(t, sess, request.target.session)
35+ require.Same(t, tb, request.target.tab)
36+ require.Same(t, pane, request.target.pane)
37+ require.Equal(t, layout.Right, request.direction)
38+ }
39+}
40+
41+type actionRunnerSpy struct {
42+ requests []daemonActionRequest
43+}
44+
45+func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
46+ s.requests = append(s.requests, request)
47+ return nil
48+}
49+
15 func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {50 func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
16 // The version prefix is valid and mismatched, while the remainder is not a51 // The version prefix is valid and mismatched, while the remainder is not a
17 // decodable request. The daemon must still return ErrVersionMismatch.52 // decodable request. The daemon must still return ErrVersionMismatch.
M internal/usecase/daemon/palette.go +41 -51
@@ -351,15 +351,30 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
351351 }
352352
353353 type paletteExec struct {
354- d *Daemon
355- sess *session
356- ac *attachedClient
357- recent []recentSession
354+ d *Daemon
355+ sess *session
356+ ac *attachedClient
357+ recent []recentSession
358+ actions daemonActionRunner
359+}
360+
361+func (e paletteExec) runAction(request daemonActionRequest) error {
362+ if request.target.session == nil {
363+ request.target = resolveDaemonActionTarget(e.sess)
364+ }
365+ runner := e.actions
366+ if runner == nil {
367+ runner = daemonActions{d: e.d}
368+ }
369+ err := runner.Run(request)
370+ if err == nil && e.actions == nil {
371+ finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
372+ }
373+ return err
358374 }
359375
360376 func (e paletteExec) CreateTab() error {
361- defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
362- return e.d.createTab(e.sess, e.ac.size)
377+ return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.ac.size})
363378 }
364379
365380 func (e paletteExec) CreateSession() error {
@@ -370,70 +385,45 @@ func (e paletteExec) CreateSession() error {
370385 }
371386
372387 func (e paletteExec) CloseTab() error {
373- if tb := e.sess.activeTab(); tb != nil {
374- e.d.closeTab(e.sess, tb, true)
375- }
376- return nil
388+ return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
377389 }
378390
379-func (e paletteExec) SplitRight() error {
380- return e.d.splitPane(e.sess, e.ac, layout.Right)
391+func (e paletteExec) split(direction layout.Direction) error {
392+ return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
381393 }
382394
383-func (e paletteExec) SplitLeft() error {
384- return e.d.splitPane(e.sess, e.ac, layout.Left)
385-}
386-
387-func (e paletteExec) SplitUp() error {
388- return e.d.splitPane(e.sess, e.ac, layout.Up)
389-}
390-
391-func (e paletteExec) SplitDown() error {
392- return e.d.splitPane(e.sess, e.ac, layout.Down)
393-}
395+func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
396+func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
397+func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
398+func (e paletteExec) SplitDown() error { return e.split(layout.Down) }
394399
395400 func (e paletteExec) StackPane() error {
396- return e.d.stackPane(e.sess, e.ac)
401+ return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
397402 }
398403
399404 func (e paletteExec) ToggleStack() error {
400- return e.d.toggleStack(e.sess, e.ac)
405+ return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
401406 }
402407
403408 func (e paletteExec) ClosePane() error {
404- return e.d.closeFocusedPane(e.sess, e.ac)
405-}
406-
407-func (e paletteExec) FocusPaneLeft() error {
408- return e.d.focusDir(e.sess, e.ac, layout.Left)
409-}
410-
411-func (e paletteExec) FocusPaneRight() error {
412- return e.d.focusDir(e.sess, e.ac, layout.Right)
409+ return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
413410 }
414411
415-func (e paletteExec) FocusPaneUp() error {
416- return e.d.focusDir(e.sess, e.ac, layout.Up)
412+func (e paletteExec) focus(direction layout.Direction) error {
413+ return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
417414 }
418415
419-func (e paletteExec) FocusPaneDown() error {
420- return e.d.focusDir(e.sess, e.ac, layout.Down)
421-}
416+func (e paletteExec) FocusPaneLeft() error { return e.focus(layout.Left) }
417+func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
418+func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) }
419+func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) }
422420
423421 func (e paletteExec) NextTab() error {
424- if e.sess.switchRelative(1) {
425- e.d.activateTab(e.sess, e.sess.activeTab())
426- }
427- e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
428- return nil
422+ return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
429423 }
430424
431425 func (e paletteExec) PrevTab() error {
432- if e.sess.switchRelative(-1) {
433- e.d.activateTab(e.sess, e.sess.activeTab())
434- }
435- e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
436- return nil
426+ return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
437427 }
438428
439429 func (e paletteExec) ToggleFloatingPane() error {
@@ -460,7 +450,7 @@ func (e paletteExec) RenameSession() error {
460450 currentName := e.sess.name
461451 e.sess.mu.Unlock()
462452 e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
463- return e.d.renameSession(e.sess, name)
453+ return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
464454 })
465455 return nil
466456 }
@@ -474,7 +464,7 @@ func (e paletteExec) RenameTab() error {
474464 currentName := tabDisplayName(tb, e.sess.active)
475465 e.sess.mu.Unlock()
476466 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
477- return e.d.renameTab(e.sess, tb, name)
467+ return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
478468 })
479469 return nil
480470 }
@@ -351,15 +351,30 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
351 }351 }
352 352
353 type paletteExec struct {353 type paletteExec struct {
354- d *Daemon354+ d *Daemon
355- sess *session355+ sess *session
356- ac *attachedClient356+ ac *attachedClient
357- recent []recentSession357+ recent []recentSession
358+ actions daemonActionRunner
359+}
360+
361+func (e paletteExec) runAction(request daemonActionRequest) error {
362+ if request.target.session == nil {
363+ request.target = resolveDaemonActionTarget(e.sess)
364+ }
365+ runner := e.actions
366+ if runner == nil {
367+ runner = daemonActions{d: e.d}
368+ }
369+ err := runner.Run(request)
370+ if err == nil && e.actions == nil {
371+ finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
372+ }
373+ return err
358 }374 }
359 375
360 func (e paletteExec) CreateTab() error {376 func (e paletteExec) CreateTab() error {
361- defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go")377+ return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.ac.size})
362- return e.d.createTab(e.sess, e.ac.size)
363 }378 }
364 379
365 func (e paletteExec) CreateSession() error {380 func (e paletteExec) CreateSession() error {
@@ -370,70 +385,45 @@ func (e paletteExec) CreateSession() error {
370 }385 }
371 386
372 func (e paletteExec) CloseTab() error {387 func (e paletteExec) CloseTab() error {
373- if tb := e.sess.activeTab(); tb != nil {388+ return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
374- e.d.closeTab(e.sess, tb, true)
375- }
376- return nil
377 }389 }
378 390
379-func (e paletteExec) SplitRight() error {391+func (e paletteExec) split(direction layout.Direction) error {
380- return e.d.splitPane(e.sess, e.ac, layout.Right)392+ return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
381 }393 }
382 394
383-func (e paletteExec) SplitLeft() error {395+func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
384- return e.d.splitPane(e.sess, e.ac, layout.Left)396+func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
385-}397+func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
386-398+func (e paletteExec) SplitDown() error { return e.split(layout.Down) }
387-func (e paletteExec) SplitUp() error {
388- return e.d.splitPane(e.sess, e.ac, layout.Up)
389-}
390-
391-func (e paletteExec) SplitDown() error {
392- return e.d.splitPane(e.sess, e.ac, layout.Down)
393-}
394 399
395 func (e paletteExec) StackPane() error {400 func (e paletteExec) StackPane() error {
396- return e.d.stackPane(e.sess, e.ac)401+ return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
397 }402 }
398 403
399 func (e paletteExec) ToggleStack() error {404 func (e paletteExec) ToggleStack() error {
400- return e.d.toggleStack(e.sess, e.ac)405+ return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
401 }406 }
402 407
403 func (e paletteExec) ClosePane() error {408 func (e paletteExec) ClosePane() error {
404- return e.d.closeFocusedPane(e.sess, e.ac)409+ return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
405-}
406-
407-func (e paletteExec) FocusPaneLeft() error {
408- return e.d.focusDir(e.sess, e.ac, layout.Left)
409-}
410-
411-func (e paletteExec) FocusPaneRight() error {
412- return e.d.focusDir(e.sess, e.ac, layout.Right)
413 }410 }
414 411
415-func (e paletteExec) FocusPaneUp() error {412+func (e paletteExec) focus(direction layout.Direction) error {
416- return e.d.focusDir(e.sess, e.ac, layout.Up)413+ return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
417 }414 }
418 415
419-func (e paletteExec) FocusPaneDown() error {416+func (e paletteExec) FocusPaneLeft() error { return e.focus(layout.Left) }
420- return e.d.focusDir(e.sess, e.ac, layout.Down)417+func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
421-}418+func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) }
419+func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) }
422 420
423 func (e paletteExec) NextTab() error {421 func (e paletteExec) NextTab() error {
424- if e.sess.switchRelative(1) {422+ return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
425- e.d.activateTab(e.sess, e.sess.activeTab())
426- }
427- e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
428- return nil
429 }423 }
430 424
431 func (e paletteExec) PrevTab() error {425 func (e paletteExec) PrevTab() error {
432- if e.sess.switchRelative(-1) {426+ return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
433- e.d.activateTab(e.sess, e.sess.activeTab())
434- }
435- e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
436- return nil
437 }427 }
438 428
439 func (e paletteExec) ToggleFloatingPane() error {429 func (e paletteExec) ToggleFloatingPane() error {
@@ -460,7 +450,7 @@ func (e paletteExec) RenameSession() error {
460 currentName := e.sess.name450 currentName := e.sess.name
461 e.sess.mu.Unlock()451 e.sess.mu.Unlock()
462 e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {452 e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
463- return e.d.renameSession(e.sess, name)453+ return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
464 })454 })
465 return nil455 return nil
466 }456 }
@@ -474,7 +464,7 @@ func (e paletteExec) RenameTab() error {
474 currentName := tabDisplayName(tb, e.sess.active)464 currentName := tabDisplayName(tb, e.sess.active)
475 e.sess.mu.Unlock()465 e.sess.mu.Unlock()
476 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {466 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
477- return e.d.renameTab(e.sess, tb, name)467+ return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
478 })468 })
479 return nil469 return nil
480 }470 }
M internal/usecase/daemon/pane_actions.go +73 -42
@@ -10,25 +10,31 @@ import (
1010 )
1111
1212 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
13+ target := resolveDaemonActionTarget(sess)
14+ err := d.splitPaneAt(sess, target.tab, target.pane, dir)
15+ if err == nil && ac != nil {
16+ d.invalidateRender(sess, ac, true, "pane_actions.go")
17+ }
18+ return err
19+}
20+
21+func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {
1322 after := dir == layout.Right || dir == layout.Down
14- return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
23+ return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
1524 return tree.Split(oldFocus, dir, after, newID, area)
1625 })
1726 }
1827
19-func (d *Daemon) spawnPaneOp(
28+func (d *Daemon) spawnPaneOpAt(
2029 sess *session,
21- ac *attachedClient,
30+ tb *tab,
31+ target *pane,
2232 mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error,
2333 ) error {
2434 if d.ptys == nil {
25- if ac != nil {
26- d.invalidateRender(sess, ac, true, "pane_actions.go")
27- }
2835 return nil
2936 }
30- tb := sess.activeTab()
31- if tb == nil {
37+ if tb == nil || target == nil {
3238 return layout.ErrNotFound
3339 }
3440
@@ -43,7 +49,11 @@ func (d *Daemon) spawnPaneOp(
4349 tb.mu.Unlock()
4450 return layout.ErrNotFound
4551 }
46- oldFocus := tb.tree.Focus
52+ oldFocus := target.id
53+ if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {
54+ tb.mu.Unlock()
55+ return layout.ErrNotFound
56+ }
4757 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
4858 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
4959 if err := mutate(tb.tree, oldFocus, newID, area); err != nil {
@@ -103,9 +113,6 @@ func (d *Daemon) spawnPaneOp(
103113
104114 d.startPaneGoroutines(sess, tb, p)
105115 markSnapshotDirty(sess)
106- if ac != nil {
107- d.invalidateRender(sess, ac, true, "pane_actions.go")
108- }
109116 return nil
110117 }
111118
@@ -164,20 +171,34 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re
164171 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
165172
166173 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
167- return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
174+ target := resolveDaemonActionTarget(sess)
175+ err := d.stackPaneAt(sess, target.tab, target.pane)
176+ if err == nil && ac != nil {
177+ d.invalidateRender(sess, ac, true, "pane_actions.go")
178+ }
179+ return err
180+}
181+
182+func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error {
183+ return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
168184 return tree.StackNew(oldFocus, newID, area)
169185 })
170186 }
171187
172188 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
189+ target := resolveDaemonActionTarget(sess)
190+ err := d.toggleStackAt(sess, target.tab, target.pane)
191+ if err == nil && ac != nil {
192+ d.invalidateRender(sess, ac, true, "pane_actions.go")
193+ }
194+ return err
195+}
196+
197+func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
173198 if d.ptys == nil {
174- if ac != nil {
175- d.invalidateRender(sess, ac, true, "pane_actions.go")
176- }
177199 return nil
178200 }
179- tb := sess.activeTab()
180- if tb == nil {
201+ if tb == nil || target == nil {
181202 return layout.ErrNotFound
182203 }
183204 tb.mu.Lock()
@@ -185,16 +206,17 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
185206 tb.mu.Unlock()
186207 return layout.ErrNotFound
187208 }
188- err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
209+ if tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
210+ tb.mu.Unlock()
211+ return layout.ErrNotFound
212+ }
213+ err := tb.tree.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
189214 if err == nil {
190215 d.applyLayoutLocked(tb)
191216 }
192217 tb.mu.Unlock()
193218 if err == nil {
194219 markSnapshotDirty(sess)
195- if ac != nil {
196- d.invalidateRender(sess, ac, true, "pane_actions.go")
197- }
198220 }
199221 return err
200222 }
@@ -283,38 +305,47 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
283305 }
284306
285307 func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error {
286- tb := sess.activeTab()
287- if tb == nil {
308+ target := resolveDaemonActionTarget(sess)
309+ oldFocus := layout.PaneID("")
310+ if target.pane != nil {
311+ oldFocus = target.pane.id
312+ }
313+ err := d.focusDirAt(sess, target.tab, target.pane, dir)
314+ if err == nil && ac != nil {
315+ target.tab.mu.Lock()
316+ newFocus := target.tab.tree.Focus
317+ pl, hasPlacement := focusedPlacementLocked(target.tab)
318+ target.tab.mu.Unlock()
319+ if newFocus != oldFocus {
320+ d.exitCopyMode(ac)
321+ if hasPlacement && pl.TitleBar.Height > 0 {
322+ d.refreshPaneTitleOnFocus(sess, newFocus)
323+ }
324+ }
325+ d.invalidateRender(sess, ac, true, "pane_actions.go")
326+ }
327+ return err
328+}
329+
330+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {
331+ if tb == nil || target == nil {
288332 return layout.ErrNotFound
289333 }
290334 tb.mu.Lock()
291- if tb.tree == nil {
335+ if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
292336 tb.mu.Unlock()
293337 return layout.ErrNotFound
294338 }
295- oldFocus := tb.tree.Focus
339+ oldFocus := target.id
340+ tb.tree.Focus = oldFocus
296341 err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
297342 newFocus := tb.tree.Focus
298343 if err == nil {
299344 d.applyLayoutLocked(tb)
300345 }
301346 tb.mu.Unlock()
302- if err == nil {
303- if newFocus != oldFocus {
304- markSnapshotDirty(sess)
305- }
306- }
307- if err == nil && ac != nil {
308- if newFocus != oldFocus {
309- d.exitCopyMode(ac)
310- tb.mu.Lock()
311- pl, ok := focusedPlacementLocked(tb)
312- tb.mu.Unlock()
313- if ok && pl.TitleBar.Height > 0 {
314- d.refreshPaneTitleOnFocus(sess, newFocus)
315- }
316- }
317- d.invalidateRender(sess, ac, true, "pane_actions.go")
347+ if err == nil && newFocus != oldFocus {
348+ markSnapshotDirty(sess)
318349 }
319350 if errors.Is(err, layout.ErrNoPane) {
320351 return errNoNeighbor
@@ -10,25 +10,31 @@ import (
10 )10 )
11 11
12 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {12 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
13+ target := resolveDaemonActionTarget(sess)
14+ err := d.splitPaneAt(sess, target.tab, target.pane, dir)
15+ if err == nil && ac != nil {
16+ d.invalidateRender(sess, ac, true, "pane_actions.go")
17+ }
18+ return err
19+}
20+
21+func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {
13 after := dir == layout.Right || dir == layout.Down22 after := dir == layout.Right || dir == layout.Down
14- return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {23+ return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
15 return tree.Split(oldFocus, dir, after, newID, area)24 return tree.Split(oldFocus, dir, after, newID, area)
16 })25 })
17 }26 }
18 27
19-func (d *Daemon) spawnPaneOp(28+func (d *Daemon) spawnPaneOpAt(
20 sess *session,29 sess *session,
21- ac *attachedClient,30+ tb *tab,
31+ target *pane,
22 mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error,32 mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error,
23 ) error {33 ) error {
24 if d.ptys == nil {34 if d.ptys == nil {
25- if ac != nil {
26- d.invalidateRender(sess, ac, true, "pane_actions.go")
27- }
28 return nil35 return nil
29 }36 }
30- tb := sess.activeTab()37+ if tb == nil || target == nil {
31- if tb == nil {
32 return layout.ErrNotFound38 return layout.ErrNotFound
33 }39 }
34 40
@@ -43,7 +49,11 @@ func (d *Daemon) spawnPaneOp(
43 tb.mu.Unlock()49 tb.mu.Unlock()
44 return layout.ErrNotFound50 return layout.ErrNotFound
45 }51 }
46- oldFocus := tb.tree.Focus52+ oldFocus := target.id
53+ if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {
54+ tb.mu.Unlock()
55+ return layout.ErrNotFound
56+ }
47 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))57 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
48 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}58 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
49 if err := mutate(tb.tree, oldFocus, newID, area); err != nil {59 if err := mutate(tb.tree, oldFocus, newID, area); err != nil {
@@ -103,9 +113,6 @@ func (d *Daemon) spawnPaneOp(
103 113
104 d.startPaneGoroutines(sess, tb, p)114 d.startPaneGoroutines(sess, tb, p)
105 markSnapshotDirty(sess)115 markSnapshotDirty(sess)
106- if ac != nil {
107- d.invalidateRender(sess, ac, true, "pane_actions.go")
108- }
109 return nil116 return nil
110 }117 }
111 118
@@ -164,20 +171,34 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re
164 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }171 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
165 172
166 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {173 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
167- return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {174+ target := resolveDaemonActionTarget(sess)
175+ err := d.stackPaneAt(sess, target.tab, target.pane)
176+ if err == nil && ac != nil {
177+ d.invalidateRender(sess, ac, true, "pane_actions.go")
178+ }
179+ return err
180+}
181+
182+func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error {
183+ return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
168 return tree.StackNew(oldFocus, newID, area)184 return tree.StackNew(oldFocus, newID, area)
169 })185 })
170 }186 }
171 187
172 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {188 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
189+ target := resolveDaemonActionTarget(sess)
190+ err := d.toggleStackAt(sess, target.tab, target.pane)
191+ if err == nil && ac != nil {
192+ d.invalidateRender(sess, ac, true, "pane_actions.go")
193+ }
194+ return err
195+}
196+
197+func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
173 if d.ptys == nil {198 if d.ptys == nil {
174- if ac != nil {
175- d.invalidateRender(sess, ac, true, "pane_actions.go")
176- }
177 return nil199 return nil
178 }200 }
179- tb := sess.activeTab()201+ if tb == nil || target == nil {
180- if tb == nil {
181 return layout.ErrNotFound202 return layout.ErrNotFound
182 }203 }
183 tb.mu.Lock()204 tb.mu.Lock()
@@ -185,16 +206,17 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
185 tb.mu.Unlock()206 tb.mu.Unlock()
186 return layout.ErrNotFound207 return layout.ErrNotFound
187 }208 }
188- err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})209+ if tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
210+ tb.mu.Unlock()
211+ return layout.ErrNotFound
212+ }
213+ err := tb.tree.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
189 if err == nil {214 if err == nil {
190 d.applyLayoutLocked(tb)215 d.applyLayoutLocked(tb)
191 }216 }
192 tb.mu.Unlock()217 tb.mu.Unlock()
193 if err == nil {218 if err == nil {
194 markSnapshotDirty(sess)219 markSnapshotDirty(sess)
195- if ac != nil {
196- d.invalidateRender(sess, ac, true, "pane_actions.go")
197- }
198 }220 }
199 return err221 return err
200 }222 }
@@ -283,38 +305,47 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
283 }305 }
284 306
285 func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error {307 func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error {
286- tb := sess.activeTab()308+ target := resolveDaemonActionTarget(sess)
287- if tb == nil {309+ oldFocus := layout.PaneID("")
310+ if target.pane != nil {
311+ oldFocus = target.pane.id
312+ }
313+ err := d.focusDirAt(sess, target.tab, target.pane, dir)
314+ if err == nil && ac != nil {
315+ target.tab.mu.Lock()
316+ newFocus := target.tab.tree.Focus
317+ pl, hasPlacement := focusedPlacementLocked(target.tab)
318+ target.tab.mu.Unlock()
319+ if newFocus != oldFocus {
320+ d.exitCopyMode(ac)
321+ if hasPlacement && pl.TitleBar.Height > 0 {
322+ d.refreshPaneTitleOnFocus(sess, newFocus)
323+ }
324+ }
325+ d.invalidateRender(sess, ac, true, "pane_actions.go")
326+ }
327+ return err
328+}
329+
330+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {
331+ if tb == nil || target == nil {
288 return layout.ErrNotFound332 return layout.ErrNotFound
289 }333 }
290 tb.mu.Lock()334 tb.mu.Lock()
291- if tb.tree == nil {335+ if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
292 tb.mu.Unlock()336 tb.mu.Unlock()
293 return layout.ErrNotFound337 return layout.ErrNotFound
294 }338 }
295- oldFocus := tb.tree.Focus339+ oldFocus := target.id
340+ tb.tree.Focus = oldFocus
296 err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})341 err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
297 newFocus := tb.tree.Focus342 newFocus := tb.tree.Focus
298 if err == nil {343 if err == nil {
299 d.applyLayoutLocked(tb)344 d.applyLayoutLocked(tb)
300 }345 }
301 tb.mu.Unlock()346 tb.mu.Unlock()
302- if err == nil {347+ if err == nil && newFocus != oldFocus {
303- if newFocus != oldFocus {348+ markSnapshotDirty(sess)
304- markSnapshotDirty(sess)
305- }
306- }
307- if err == nil && ac != nil {
308- if newFocus != oldFocus {
309- d.exitCopyMode(ac)
310- tb.mu.Lock()
311- pl, ok := focusedPlacementLocked(tb)
312- tb.mu.Unlock()
313- if ok && pl.TitleBar.Height > 0 {
314- d.refreshPaneTitleOnFocus(sess, newFocus)
315- }
316- }
317- d.invalidateRender(sess, ac, true, "pane_actions.go")
318 }349 }
319 if errors.Is(err, layout.ErrNoPane) {350 if errors.Is(err, layout.ErrNoPane) {
320 return errNoNeighbor351 return errNoNeighbor

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