refactor(daemon): share explicit-target command actions
- commit
- 8b4f29811a1ac865500c0e416a60ffe91a75cef4
- author
- brice · · 2026-07-23 22:29 UTC
- parent
- d277208
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 57 | 57 | if code != 0 { | |
| 58 | 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 | 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 | 165 | 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 | 265 | // controlExec implements command.ControlContext against resolved daemon-owned | |
| 169 | 266 | // targets. It deliberately contains no attached-client state. | |
| 170 | 267 | 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 | |
| 175 | 273 | } | |
| 176 | 274 | ||
| 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) | |
| 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 | 316 | func (e controlExec) CreateSessionNamed(name string) error { | |
| 185 | 317 | if err := domain.ValidateSessionName(name); err != nil { | |
| 186 | 318 | return command.ErrInvalidArguments | |
| @@ -201,51 +333,42 @@ func (e controlExec) CreateSessionNamed(name string) error { | |||
| 201 | 333 | return err | |
| 202 | 334 | } | |
| 203 | 335 | func (e controlExec) CloseTab() error { | |
| 204 | - | e.d.closeTab(e.sess, e.tab, true) | |
| 205 | - | return nil | |
| 336 | + | return e.runAction(daemonActionRequest{kind: daemonActionCloseTab}) | |
| 206 | 337 | } | |
| 207 | 338 | 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}) | |
| 215 | 340 | } | |
| 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}) | |
| 218 | 343 | } | |
| 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}) | |
| 221 | 350 | } | |
| 222 | - | func (e controlExec) StackPane() error { return e.d.stackPane(e.sess, e.client()) } | |
| 223 | 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 { | |
| 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}) | |
| 228 | 356 | } | |
| 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}) | |
| 231 | 363 | } | |
| 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}) | |
| 234 | 366 | } | |
| 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}) | |
| 245 | 369 | } | |
| 246 | - | func (e controlExec) RenameSessionTo(name string) error { return e.d.renameSession(e.sess, name) } | |
| 247 | 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 | 373 | func (e controlExec) Toast(severity, message string) error { | |
| 251 | 374 | 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 nil | 165 | 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-owned | 265 | // 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 *Daemon | 268 | + | d *Daemon |
| 172 | - | sess *session | 269 | + | sess *session |
| 173 | - | tab *tab | 270 | + | tab *tab |
| 174 | - | pane *pane | 271 | + | 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.client | 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) | |||
| 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.ErrInvalidArguments | 318 | return command.ErrInvalidArguments | ||
| @@ -201,51 +333,42 @@ func (e controlExec) CreateSessionNamed(name string) error { | |||||
| 201 | return err | 333 | 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.NoticeSeverity | 374 | var level domain.NoticeSeverity | ||
M internal/usecase/daemon/control_test.go +35 -0
| @@ -9,9 +9,44 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | 11 | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 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 | 50 | func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) { | |
| 16 | 51 | // The version prefix is valid and mismatched, while the remainder is not a | |
| 17 | 52 | // 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 a | 51 | // 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 | |||
| 351 | 351 | } | |
| 352 | 352 | ||
| 353 | 353 | 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 | |
| 358 | 374 | } | |
| 359 | 375 | ||
| 360 | 376 | 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}) | |
| 363 | 378 | } | |
| 364 | 379 | ||
| 365 | 380 | func (e paletteExec) CreateSession() error { | |
| @@ -370,70 +385,45 @@ func (e paletteExec) CreateSession() error { | |||
| 370 | 385 | } | |
| 371 | 386 | ||
| 372 | 387 | 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}) | |
| 377 | 389 | } | |
| 378 | 390 | ||
| 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}) | |
| 381 | 393 | } | |
| 382 | 394 | ||
| 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) } | |
| 394 | 399 | ||
| 395 | 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 | 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 | 408 | 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}) | |
| 413 | 410 | } | |
| 414 | 411 | ||
| 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}) | |
| 417 | 414 | } | |
| 418 | 415 | ||
| 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) } | |
| 422 | 420 | ||
| 423 | 421 | 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}) | |
| 429 | 423 | } | |
| 430 | 424 | ||
| 431 | 425 | 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}) | |
| 437 | 427 | } | |
| 438 | 428 | ||
| 439 | 429 | func (e paletteExec) ToggleFloatingPane() error { | |
| @@ -460,7 +450,7 @@ func (e paletteExec) RenameSession() error { | |||
| 460 | 450 | currentName := e.sess.name | |
| 461 | 451 | e.sess.mu.Unlock() | |
| 462 | 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 | 455 | return nil | |
| 466 | 456 | } | |
| @@ -474,7 +464,7 @@ func (e paletteExec) RenameTab() error { | |||
| 474 | 464 | currentName := tabDisplayName(tb, e.sess.active) | |
| 475 | 465 | e.sess.mu.Unlock() | |
| 476 | 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 | 469 | return nil | |
| 480 | 470 | } | |
| @@ -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 *Daemon | 354 | + | d *Daemon |
| 355 | - | sess *session | 355 | + | sess *session |
| 356 | - | ac *attachedClient | 356 | + | ac *attachedClient |
| 357 | - | recent []recentSession | 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 | |||
| 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.name | 450 | 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 nil | 455 | 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 nil | 469 | return nil | ||
| 480 | } | 470 | } | ||
M internal/usecase/daemon/pane_actions.go +73 -42
| @@ -10,25 +10,31 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 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 | 22 | 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 | 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 | 29 | sess *session, | |
| 21 | - | ac *attachedClient, | |
| 30 | + | tb *tab, | |
| 31 | + | target *pane, | |
| 22 | 32 | mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error, | |
| 23 | 33 | ) error { | |
| 24 | 34 | if d.ptys == nil { | |
| 25 | - | if ac != nil { | |
| 26 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 27 | - | } | |
| 28 | 35 | return nil | |
| 29 | 36 | } | |
| 30 | - | tb := sess.activeTab() | |
| 31 | - | if tb == nil { | |
| 37 | + | if tb == nil || target == nil { | |
| 32 | 38 | return layout.ErrNotFound | |
| 33 | 39 | } | |
| 34 | 40 | ||
| @@ -43,7 +49,11 @@ func (d *Daemon) spawnPaneOp( | |||
| 43 | 49 | tb.mu.Unlock() | |
| 44 | 50 | return layout.ErrNotFound | |
| 45 | 51 | } | |
| 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 | + | } | |
| 47 | 57 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | |
| 48 | 58 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 49 | 59 | if err := mutate(tb.tree, oldFocus, newID, area); err != nil { | |
| @@ -103,9 +113,6 @@ func (d *Daemon) spawnPaneOp( | |||
| 103 | 113 | ||
| 104 | 114 | d.startPaneGoroutines(sess, tb, p) | |
| 105 | 115 | markSnapshotDirty(sess) | |
| 106 | - | if ac != nil { | |
| 107 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 108 | - | } | |
| 109 | 116 | return nil | |
| 110 | 117 | } | |
| 111 | 118 | ||
| @@ -164,20 +171,34 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re | |||
| 164 | 171 | func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} } | |
| 165 | 172 | ||
| 166 | 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 | 184 | return tree.StackNew(oldFocus, newID, area) | |
| 169 | 185 | }) | |
| 170 | 186 | } | |
| 171 | 187 | ||
| 172 | 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 | 198 | if d.ptys == nil { | |
| 174 | - | if ac != nil { | |
| 175 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 176 | - | } | |
| 177 | 199 | return nil | |
| 178 | 200 | } | |
| 179 | - | tb := sess.activeTab() | |
| 180 | - | if tb == nil { | |
| 201 | + | if tb == nil || target == nil { | |
| 181 | 202 | return layout.ErrNotFound | |
| 182 | 203 | } | |
| 183 | 204 | tb.mu.Lock() | |
| @@ -185,16 +206,17 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |||
| 185 | 206 | tb.mu.Unlock() | |
| 186 | 207 | 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 | 214 | if err == nil { | |
| 190 | 215 | d.applyLayoutLocked(tb) | |
| 191 | 216 | } | |
| 192 | 217 | tb.mu.Unlock() | |
| 193 | 218 | if err == nil { | |
| 194 | 219 | markSnapshotDirty(sess) | |
| 195 | - | if ac != nil { | |
| 196 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 197 | - | } | |
| 198 | 220 | } | |
| 199 | 221 | 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 | 307 | 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 { | |
| 288 | 332 | return layout.ErrNotFound | |
| 289 | 333 | } | |
| 290 | 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 | 336 | tb.mu.Unlock() | |
| 293 | 337 | return layout.ErrNotFound | |
| 294 | 338 | } | |
| 295 | - | oldFocus := tb.tree.Focus | |
| 339 | + | oldFocus := target.id | |
| 340 | + | tb.tree.Focus = oldFocus | |
| 296 | 341 | err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 297 | 342 | newFocus := tb.tree.Focus | |
| 298 | 343 | if err == nil { | |
| 299 | 344 | d.applyLayoutLocked(tb) | |
| 300 | 345 | } | |
| 301 | 346 | 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) | |
| 318 | 349 | } | |
| 319 | 350 | if errors.Is(err, layout.ErrNoPane) { | |
| 320 | 351 | 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.Down | 22 | 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 nil | 35 | return nil | ||
| 29 | } | 36 | } | ||
| 30 | - | tb := sess.activeTab() | 37 | + | if tb == nil || target == nil { |
| 31 | - | if tb == nil { | |||
| 32 | return layout.ErrNotFound | 38 | 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.ErrNotFound | 50 | return layout.ErrNotFound | ||
| 45 | } | 51 | } | ||
| 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 | + | } | |||
| 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 nil | 116 | 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 nil | 199 | return nil | ||
| 178 | } | 200 | } | ||
| 179 | - | tb := sess.activeTab() | 201 | + | if tb == nil || target == nil { |
| 180 | - | if tb == nil { | |||
| 181 | return layout.ErrNotFound | 202 | 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.ErrNotFound | 207 | 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 err | 221 | 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.ErrNotFound | 332 | 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.ErrNotFound | 337 | return layout.ErrNotFound | ||
| 294 | } | 338 | } | ||
| 295 | - | oldFocus := tb.tree.Focus | 339 | + | 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.Focus | 342 | 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 errNoNeighbor | 351 | return errNoNeighbor | ||