Merge pull request #191 from bnema/fix/pane-focus-attachment-sync
fix(daemon): synchronize pane focus after splits
5 files · +123 -63
M internal/usecase/daemon/control.go +14 -3
| @@ -346,9 +346,17 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 346 | 346 | case daemonActionCloseTab: | |
| 347 | 347 | return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect) | |
| 348 | 348 | case daemonActionSplitPane: | |
| 349 | - | return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction) | |
| 349 | + | change, err := a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction) | |
| 350 | + | if err == nil { | |
| 351 | + | publishPaneFocusForAttachment(target.session, target.attachment, change) | |
| 352 | + | } | |
| 353 | + | return err | |
| 350 | 354 | case daemonActionStackPane: | |
| 351 | - | return a.d.stackPaneAt(target.session, target.tab, target.pane) | |
| 355 | + | change, err := a.d.stackPaneAt(target.session, target.tab, target.pane) | |
| 356 | + | if err == nil { | |
| 357 | + | publishPaneFocusForAttachment(target.session, target.attachment, change) | |
| 358 | + | } | |
| 359 | + | return err | |
| 352 | 360 | case daemonActionToggleStack: | |
| 353 | 361 | return a.d.toggleStackAt(target.session, target.tab, target.pane) | |
| 354 | 362 | case daemonActionClosePane: | |
| @@ -366,7 +374,10 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 366 | 374 | } | |
| 367 | 375 | return nil | |
| 368 | 376 | case daemonActionFocusPane: | |
| 369 | - | _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction, target.attachment) | |
| 377 | + | change, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction) | |
| 378 | + | if err == nil { | |
| 379 | + | publishPaneFocusForAttachment(target.session, target.attachment, change) | |
| 380 | + | } | |
| 370 | 381 | return err | |
| 371 | 382 | case daemonActionNextTab: | |
| 372 | 383 | return a.switchRelative(target.session, target.attachment, 1) | |
| @@ -346,9 +346,17 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||||
| 346 | case daemonActionCloseTab: | 346 | case daemonActionCloseTab: | ||
| 347 | return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect) | 347 | return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect) | ||
| 348 | case daemonActionSplitPane: | 348 | case daemonActionSplitPane: | ||
| 349 | - | return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction) | 349 | + | change, err := a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction) |
| 350 | + | if err == nil { | |||
| 351 | + | publishPaneFocusForAttachment(target.session, target.attachment, change) | |||
| 352 | + | } | |||
| 353 | + | return err | |||
| 350 | case daemonActionStackPane: | 354 | case daemonActionStackPane: | ||
| 351 | - | return a.d.stackPaneAt(target.session, target.tab, target.pane) | 355 | + | change, err := a.d.stackPaneAt(target.session, target.tab, target.pane) |
| 356 | + | if err == nil { | |||
| 357 | + | publishPaneFocusForAttachment(target.session, target.attachment, change) | |||
| 358 | + | } | |||
| 359 | + | return err | |||
| 352 | case daemonActionToggleStack: | 360 | case daemonActionToggleStack: | ||
| 353 | return a.d.toggleStackAt(target.session, target.tab, target.pane) | 361 | return a.d.toggleStackAt(target.session, target.tab, target.pane) | ||
| 354 | case daemonActionClosePane: | 362 | case daemonActionClosePane: | ||
| @@ -366,7 +374,10 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||||
| 366 | } | 374 | } | ||
| 367 | return nil | 375 | return nil | ||
| 368 | case daemonActionFocusPane: | 376 | case daemonActionFocusPane: | ||
| 369 | - | _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction, target.attachment) | 377 | + | change, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction) |
| 378 | + | if err == nil { | |||
| 379 | + | publishPaneFocusForAttachment(target.session, target.attachment, change) | |||
| 380 | + | } | |||
| 370 | return err | 381 | return err | ||
| 371 | case daemonActionNextTab: | 382 | case daemonActionNextTab: | ||
| 372 | return a.switchRelative(target.session, target.attachment, 1) | 383 | return a.switchRelative(target.session, target.attachment, 1) | ||
M internal/usecase/daemon/multi_attachment_callers_test.go +15 -0
| @@ -70,6 +70,21 @@ func TestAttachmentFocusActionUpdatesOnlyInitiatingPaneView(t *testing.T) { | |||
| 70 | 70 | require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID) | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | + | func TestAttachmentFocusActionCommitsWhenLayoutAlreadyFocusesDestination(t *testing.T) { | |
| 74 | + | sess, _, secondPane := multiPaneAttachmentSession(t) | |
| 75 | + | ac := registryTestAttachment(1) | |
| 76 | + | require.True(t, sess.registerAttachment(ac)) | |
| 77 | + | ||
| 78 | + | tb := sess.tabs[0] | |
| 79 | + | tb.mu.Lock() | |
| 80 | + | tb.tree.Focus = secondPane.id | |
| 81 | + | tb.mu.Unlock() | |
| 82 | + | ||
| 83 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 84 | + | require.NoError(t, d.focusDir(sess, ac, layout.Right, nil)) | |
| 85 | + | require.Equal(t, domain.PaneStableID(secondPane.stableID), ac.viewSnapshot().paneID) | |
| 86 | + | } | |
| 87 | + | ||
| 73 | 88 | func TestPaneRemovalRepairsEachAttachmentViewIndependently(t *testing.T) { | |
| 74 | 89 | sess, firstPane, secondPane := multiPaneAttachmentSession(t) | |
| 75 | 90 | first, second := registryTestAttachment(1), registryTestAttachment(2) | |
| @@ -70,6 +70,21 @@ func TestAttachmentFocusActionUpdatesOnlyInitiatingPaneView(t *testing.T) { | |||||
| 70 | require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID) | 70 | require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID) | ||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
| 73 | + | func TestAttachmentFocusActionCommitsWhenLayoutAlreadyFocusesDestination(t *testing.T) { | |||
| 74 | + | sess, _, secondPane := multiPaneAttachmentSession(t) | |||
| 75 | + | ac := registryTestAttachment(1) | |||
| 76 | + | require.True(t, sess.registerAttachment(ac)) | |||
| 77 | + | ||||
| 78 | + | tb := sess.tabs[0] | |||
| 79 | + | tb.mu.Lock() | |||
| 80 | + | tb.tree.Focus = secondPane.id | |||
| 81 | + | tb.mu.Unlock() | |||
| 82 | + | ||||
| 83 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 84 | + | require.NoError(t, d.focusDir(sess, ac, layout.Right, nil)) | |||
| 85 | + | require.Equal(t, domain.PaneStableID(secondPane.stableID), ac.viewSnapshot().paneID) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 73 | func TestPaneRemovalRepairsEachAttachmentViewIndependently(t *testing.T) { | 88 | func TestPaneRemovalRepairsEachAttachmentViewIndependently(t *testing.T) { | ||
| 74 | sess, firstPane, secondPane := multiPaneAttachmentSession(t) | 89 | sess, firstPane, secondPane := multiPaneAttachmentSession(t) | ||
| 75 | first, second := registryTestAttachment(1), registryTestAttachment(2) | 90 | first, second := registryTestAttachment(1), registryTestAttachment(2) | ||
M internal/usecase/daemon/pane_actions.go +55 -57
| @@ -9,16 +9,24 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | + | type paneFocusChange struct { | |
| 13 | + | tab *tab | |
| 14 | + | focused *pane | |
| 15 | + | span domain.Rect | |
| 16 | + | layoutChanged bool | |
| 17 | + | } | |
| 18 | + | ||
| 12 | 19 | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 13 | 20 | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 14 | - | err := d.splitPaneAt(sess, target.tab, target.pane, dir) | |
| 21 | + | change, err := d.splitPaneAt(sess, target.tab, target.pane, dir) | |
| 15 | 22 | if err == nil && ac != nil { | |
| 23 | + | publishPaneFocusForAttachment(sess, ac, change) | |
| 16 | 24 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 17 | 25 | } | |
| 18 | 26 | return err | |
| 19 | 27 | } | |
| 20 | 28 | ||
| 21 | - | func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) error { | |
| 29 | + | func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) (paneFocusChange, error) { | |
| 22 | 30 | after := dir == layout.Right || dir == layout.Down | |
| 23 | 31 | return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | |
| 24 | 32 | return tree.Split(oldFocus, dir, after, newID, area) | |
| @@ -30,12 +38,12 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 30 | 38 | tb *tab, | |
| 31 | 39 | target *pane, | |
| 32 | 40 | mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error, | |
| 33 | - | ) error { | |
| 41 | + | ) (paneFocusChange, error) { | |
| 34 | 42 | if d.ptys == nil { | |
| 35 | - | return nil | |
| 43 | + | return paneFocusChange{}, nil | |
| 36 | 44 | } | |
| 37 | 45 | if tb == nil || target == nil { | |
| 38 | - | return layout.ErrNotFound | |
| 46 | + | return paneFocusChange{}, layout.ErrNotFound | |
| 39 | 47 | } | |
| 40 | 48 | ||
| 41 | 49 | sess.mu.Lock() | |
| @@ -47,12 +55,12 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 47 | 55 | tb.mu.Lock() | |
| 48 | 56 | if tb.tree == nil || tb.tree.Root == nil { | |
| 49 | 57 | tb.mu.Unlock() | |
| 50 | - | return layout.ErrNotFound | |
| 58 | + | return paneFocusChange{}, layout.ErrNotFound | |
| 51 | 59 | } | |
| 52 | 60 | oldFocus := target.id | |
| 53 | 61 | if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) { | |
| 54 | 62 | tb.mu.Unlock() | |
| 55 | - | return layout.ErrNotFound | |
| 63 | + | return paneFocusChange{}, layout.ErrNotFound | |
| 56 | 64 | } | |
| 57 | 65 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | |
| 58 | 66 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| @@ -60,15 +68,15 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 60 | 68 | if err := mutate(candidate, oldFocus, newID, area); err != nil { | |
| 61 | 69 | tb.mu.Unlock() | |
| 62 | 70 | if errors.Is(err, layout.ErrTooSmall) { | |
| 63 | - | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err) | |
| 71 | + | return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err) | |
| 64 | 72 | } | |
| 65 | - | return err | |
| 73 | + | return paneFocusChange{}, err | |
| 66 | 74 | } | |
| 67 | 75 | tb.nextPaneID++ | |
| 68 | 76 | placements, ok := layout.Solve(candidate.Root, area) | |
| 69 | 77 | if !ok { | |
| 70 | 78 | tb.mu.Unlock() | |
| 71 | - | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall) | |
| 79 | + | return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall) | |
| 72 | 80 | } | |
| 73 | 81 | newRect := placementContent(placements, newID) | |
| 74 | 82 | tabStableID := tb.stableID | |
| @@ -77,7 +85,7 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 77 | 85 | ||
| 78 | 86 | paneStableID, err := newStableID("p") | |
| 79 | 87 | if err != nil { | |
| 80 | - | return fmt.Errorf("daemon: generating pane identity: %w", err) | |
| 88 | + | return paneFocusChange{}, fmt.Errorf("daemon: generating pane identity: %w", err) | |
| 81 | 89 | } | |
| 82 | 90 | command, args := d.ptyCommand(env) | |
| 83 | 91 | lifetime := d.newPaneProcessLifetime(tb.ctx) | |
| @@ -88,7 +96,7 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 88 | 96 | _ = pty.Close() | |
| 89 | 97 | } | |
| 90 | 98 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 91 | - | return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) | |
| 99 | + | return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) | |
| 92 | 100 | } | |
| 93 | 101 | ||
| 94 | 102 | p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect)) | |
| @@ -99,12 +107,12 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 99 | 107 | tb.mu.Unlock() | |
| 100 | 108 | lifetime.abort() | |
| 101 | 109 | _ = pty.Close() | |
| 102 | - | return layout.ErrNotFound | |
| 110 | + | return paneFocusChange{}, layout.ErrNotFound | |
| 103 | 111 | } | |
| 104 | 112 | if !lifetime.publish(p) { | |
| 105 | 113 | tb.mu.Unlock() | |
| 106 | 114 | _ = pty.Close() | |
| 107 | - | return layout.ErrNotFound | |
| 115 | + | return paneFocusChange{}, layout.ErrNotFound | |
| 108 | 116 | } | |
| 109 | 117 | // The tab lock excludes membership observers while pane.mu publishes the | |
| 110 | 118 | // initial owner generation. | |
| @@ -116,7 +124,7 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 116 | 124 | ||
| 117 | 125 | d.applyTabLayout(sess, tb) | |
| 118 | 126 | d.startPaneGoroutines(sess, tb, p) | |
| 119 | - | return nil | |
| 127 | + | return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil | |
| 120 | 128 | } | |
| 121 | 129 | ||
| 122 | 130 | func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect { | |
| @@ -132,14 +140,15 @@ func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Row | |||
| 132 | 140 | ||
| 133 | 141 | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | |
| 134 | 142 | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 135 | - | err := d.stackPaneAt(sess, target.tab, target.pane) | |
| 143 | + | change, err := d.stackPaneAt(sess, target.tab, target.pane) | |
| 136 | 144 | if err == nil && ac != nil { | |
| 145 | + | publishPaneFocusForAttachment(sess, ac, change) | |
| 137 | 146 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 138 | 147 | } | |
| 139 | 148 | return err | |
| 140 | 149 | } | |
| 141 | 150 | ||
| 142 | - | func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error { | |
| 151 | + | func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) (paneFocusChange, error) { | |
| 143 | 152 | return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | |
| 144 | 153 | return tree.StackNew(oldFocus, newID, area) | |
| 145 | 154 | }) | |
| @@ -402,13 +411,15 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 402 | 411 | if target.pane != nil { | |
| 403 | 412 | oldFocus = target.pane.id | |
| 404 | 413 | } | |
| 405 | - | span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac) | |
| 414 | + | change, err := d.focusDirAt(sess, target.tab, target.pane, dir) | |
| 406 | 415 | if err == nil { | |
| 407 | 416 | if ac != nil { | |
| 417 | + | publishPaneFocusForAttachment(sess, ac, change) | |
| 408 | 418 | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go") | |
| 409 | 419 | } | |
| 410 | 420 | return nil | |
| 411 | 421 | } | |
| 422 | + | span := change.span | |
| 412 | 423 | if !errors.Is(err, errNoNeighbor) || target.tab == nil { | |
| 413 | 424 | return err | |
| 414 | 425 | } | |
| @@ -443,6 +454,16 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 443 | 454 | return nil | |
| 444 | 455 | } | |
| 445 | 456 | ||
| 457 | + | func publishPaneFocusForAttachment(sess *session, ac *attachedClient, change paneFocusChange) bool { | |
| 458 | + | if sess == nil || ac == nil || change.tab == nil || change.focused == nil { | |
| 459 | + | return false | |
| 460 | + | } | |
| 461 | + | sess.mu.Lock() | |
| 462 | + | changed := sess.setAttachmentPaneLocked(ac, change.tab, change.focused) | |
| 463 | + | sess.mu.Unlock() | |
| 464 | + | return changed | |
| 465 | + | } | |
| 466 | + | ||
| 446 | 467 | // finishPaneFocusForClient applies the attachment lifecycle shared by every | |
| 447 | 468 | // successful directional focus move. Keep copy-mode exit before the optional | |
| 448 | 469 | // stack title refresh, and both before render invalidation. | |
| @@ -451,10 +472,6 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb | |||
| 451 | 472 | var newPane *pane | |
| 452 | 473 | var pl layout.Placement | |
| 453 | 474 | var hasPlacement bool | |
| 454 | - | beforePaneID := domain.PaneStableID("") | |
| 455 | - | if ac != nil { | |
| 456 | - | beforePaneID = ac.viewSnapshot().paneID | |
| 457 | - | } | |
| 458 | 475 | if tb != nil { | |
| 459 | 476 | tb.mu.Lock() | |
| 460 | 477 | if ac != nil { | |
| @@ -480,15 +497,7 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb | |||
| 480 | 497 | tb.mu.Unlock() | |
| 481 | 498 | } | |
| 482 | 499 | ||
| 483 | - | moved := newFocus != oldFocus | |
| 484 | - | if ac != nil && newPane != nil { | |
| 485 | - | sess.mu.Lock() | |
| 486 | - | sess.setAttachmentPaneLocked(ac, tb, newPane) | |
| 487 | - | sess.mu.Unlock() | |
| 488 | - | after := ac.viewSnapshot() | |
| 489 | - | moved = beforePaneID != after.paneID | |
| 490 | - | } | |
| 491 | - | if moved { | |
| 500 | + | if newFocus != oldFocus { | |
| 492 | 501 | d.exitCopyMode(ac) | |
| 493 | 502 | if hasPlacement && pl.InStack { | |
| 494 | 503 | d.refreshPaneTitleOnFocus(sess, newFocus, tb) | |
| @@ -510,46 +519,35 @@ func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) { | |||
| 510 | 519 | return layout.Placement{}, false | |
| 511 | 520 | } | |
| 512 | 521 | ||
| 513 | - | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction, initiating ...*attachedClient) (domain.Rect, error) { | |
| 522 | + | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (paneFocusChange, error) { | |
| 523 | + | change := paneFocusChange{tab: tb} | |
| 514 | 524 | if tb == nil || target == nil { | |
| 515 | - | return domain.Rect{}, layout.ErrNotFound | |
| 516 | - | } | |
| 517 | - | var ac *attachedClient | |
| 518 | - | if len(initiating) > 0 { | |
| 519 | - | ac = initiating[0] | |
| 525 | + | return change, layout.ErrNotFound | |
| 520 | 526 | } | |
| 521 | 527 | tb.mu.Lock() | |
| 522 | 528 | if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { | |
| 523 | 529 | tb.mu.Unlock() | |
| 524 | - | return domain.Rect{}, layout.ErrNotFound | |
| 530 | + | return change, layout.ErrNotFound | |
| 525 | 531 | } | |
| 526 | 532 | candidate := tb.tree.Clone() | |
| 527 | 533 | candidate.Focus = target.id | |
| 528 | 534 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 529 | - | span, _ := candidate.FocusSpan(area) | |
| 535 | + | change.span, _ = candidate.FocusSpan(area) | |
| 530 | 536 | err := candidate.FocusDir(dir, area) | |
| 531 | - | currentFocus := tb.focusedPane() | |
| 532 | - | committed := err == nil && currentFocus != nil && candidate.Focus != currentFocus.id | |
| 533 | - | var focusedPane *pane | |
| 534 | - | if committed { | |
| 535 | - | focusedPane = tb.panes[candidate.Focus] | |
| 536 | - | tb.tree = candidate | |
| 537 | - | tb.bumpLayoutGenerationLocked() | |
| 537 | + | if err == nil { | |
| 538 | + | change.focused = tb.panes[candidate.Focus] | |
| 539 | + | change.layoutChanged = change.focused != nil && (candidate.Focus != tb.tree.Focus || layoutFingerprint(candidate.Root) != layoutFingerprint(tb.tree.Root)) | |
| 540 | + | if change.layoutChanged { | |
| 541 | + | tb.tree = candidate | |
| 542 | + | tb.bumpLayoutGenerationLocked() | |
| 543 | + | } | |
| 538 | 544 | } | |
| 539 | 545 | tb.mu.Unlock() | |
| 540 | - | if committed { | |
| 541 | - | if ac != nil { | |
| 542 | - | d.exitCopyMode(ac) | |
| 543 | - | } | |
| 544 | - | if sess != nil && ac != nil && focusedPane != nil { | |
| 545 | - | sess.mu.Lock() | |
| 546 | - | sess.setAttachmentPaneLocked(ac, tb, focusedPane) | |
| 547 | - | sess.mu.Unlock() | |
| 548 | - | } | |
| 546 | + | if change.layoutChanged { | |
| 549 | 547 | d.applyTabLayout(sess, tb) | |
| 550 | 548 | } | |
| 551 | 549 | if errors.Is(err, layout.ErrNoPane) { | |
| 552 | - | return span, errNoNeighbor | |
| 550 | + | return change, errNoNeighbor | |
| 553 | 551 | } | |
| 554 | - | return span, err | |
| 552 | + | return change, err | |
| 555 | 553 | } | |
| @@ -9,16 +9,24 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | + | type paneFocusChange struct { | |||
| 13 | + | tab *tab | |||
| 14 | + | focused *pane | |||
| 15 | + | span domain.Rect | |||
| 16 | + | layoutChanged bool | |||
| 17 | + | } | |||
| 18 | + | ||||
| 12 | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | 19 | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | ||
| 13 | target := resolveDaemonActionTargetForAttachment(sess, ac) | 20 | target := resolveDaemonActionTargetForAttachment(sess, ac) | ||
| 14 | - | err := d.splitPaneAt(sess, target.tab, target.pane, dir) | 21 | + | change, err := d.splitPaneAt(sess, target.tab, target.pane, dir) |
| 15 | if err == nil && ac != nil { | 22 | if err == nil && ac != nil { | ||
| 23 | + | publishPaneFocusForAttachment(sess, ac, change) | |||
| 16 | d.invalidateRender(sess, ac, true, "pane_actions.go") | 24 | d.invalidateRender(sess, ac, true, "pane_actions.go") | ||
| 17 | } | 25 | } | ||
| 18 | return err | 26 | return err | ||
| 19 | } | 27 | } | ||
| 20 | 28 | ||||
| 21 | - | func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) error { | 29 | + | func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) (paneFocusChange, error) { |
| 22 | after := dir == layout.Right || dir == layout.Down | 30 | after := dir == layout.Right || dir == layout.Down | ||
| 23 | return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | 31 | return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | ||
| 24 | return tree.Split(oldFocus, dir, after, newID, area) | 32 | return tree.Split(oldFocus, dir, after, newID, area) | ||
| @@ -30,12 +38,12 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 30 | tb *tab, | 38 | tb *tab, | ||
| 31 | target *pane, | 39 | target *pane, | ||
| 32 | mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error, | 40 | mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error, | ||
| 33 | - | ) error { | 41 | + | ) (paneFocusChange, error) { |
| 34 | if d.ptys == nil { | 42 | if d.ptys == nil { | ||
| 35 | - | return nil | 43 | + | return paneFocusChange{}, nil |
| 36 | } | 44 | } | ||
| 37 | if tb == nil || target == nil { | 45 | if tb == nil || target == nil { | ||
| 38 | - | return layout.ErrNotFound | 46 | + | return paneFocusChange{}, layout.ErrNotFound |
| 39 | } | 47 | } | ||
| 40 | 48 | ||||
| 41 | sess.mu.Lock() | 49 | sess.mu.Lock() | ||
| @@ -47,12 +55,12 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 47 | tb.mu.Lock() | 55 | tb.mu.Lock() | ||
| 48 | if tb.tree == nil || tb.tree.Root == nil { | 56 | if tb.tree == nil || tb.tree.Root == nil { | ||
| 49 | tb.mu.Unlock() | 57 | tb.mu.Unlock() | ||
| 50 | - | return layout.ErrNotFound | 58 | + | return paneFocusChange{}, layout.ErrNotFound |
| 51 | } | 59 | } | ||
| 52 | oldFocus := target.id | 60 | oldFocus := target.id | ||
| 53 | if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) { | 61 | if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) { | ||
| 54 | tb.mu.Unlock() | 62 | tb.mu.Unlock() | ||
| 55 | - | return layout.ErrNotFound | 63 | + | return paneFocusChange{}, layout.ErrNotFound |
| 56 | } | 64 | } | ||
| 57 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | 65 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | ||
| 58 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | 66 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | ||
| @@ -60,15 +68,15 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 60 | if err := mutate(candidate, oldFocus, newID, area); err != nil { | 68 | if err := mutate(candidate, oldFocus, newID, area); err != nil { | ||
| 61 | tb.mu.Unlock() | 69 | tb.mu.Unlock() | ||
| 62 | if errors.Is(err, layout.ErrTooSmall) { | 70 | if errors.Is(err, layout.ErrTooSmall) { | ||
| 63 | - | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err) | 71 | + | return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err) |
| 64 | } | 72 | } | ||
| 65 | - | return err | 73 | + | return paneFocusChange{}, err |
| 66 | } | 74 | } | ||
| 67 | tb.nextPaneID++ | 75 | tb.nextPaneID++ | ||
| 68 | placements, ok := layout.Solve(candidate.Root, area) | 76 | placements, ok := layout.Solve(candidate.Root, area) | ||
| 69 | if !ok { | 77 | if !ok { | ||
| 70 | tb.mu.Unlock() | 78 | tb.mu.Unlock() | ||
| 71 | - | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall) | 79 | + | return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall) |
| 72 | } | 80 | } | ||
| 73 | newRect := placementContent(placements, newID) | 81 | newRect := placementContent(placements, newID) | ||
| 74 | tabStableID := tb.stableID | 82 | tabStableID := tb.stableID | ||
| @@ -77,7 +85,7 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 77 | 85 | ||||
| 78 | paneStableID, err := newStableID("p") | 86 | paneStableID, err := newStableID("p") | ||
| 79 | if err != nil { | 87 | if err != nil { | ||
| 80 | - | return fmt.Errorf("daemon: generating pane identity: %w", err) | 88 | + | return paneFocusChange{}, fmt.Errorf("daemon: generating pane identity: %w", err) |
| 81 | } | 89 | } | ||
| 82 | command, args := d.ptyCommand(env) | 90 | command, args := d.ptyCommand(env) | ||
| 83 | lifetime := d.newPaneProcessLifetime(tb.ctx) | 91 | lifetime := d.newPaneProcessLifetime(tb.ctx) | ||
| @@ -88,7 +96,7 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 88 | _ = pty.Close() | 96 | _ = pty.Close() | ||
| 89 | } | 97 | } | ||
| 90 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | 98 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | ||
| 91 | - | return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) | 99 | + | return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) |
| 92 | } | 100 | } | ||
| 93 | 101 | ||||
| 94 | p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect)) | 102 | p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect)) | ||
| @@ -99,12 +107,12 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 99 | tb.mu.Unlock() | 107 | tb.mu.Unlock() | ||
| 100 | lifetime.abort() | 108 | lifetime.abort() | ||
| 101 | _ = pty.Close() | 109 | _ = pty.Close() | ||
| 102 | - | return layout.ErrNotFound | 110 | + | return paneFocusChange{}, layout.ErrNotFound |
| 103 | } | 111 | } | ||
| 104 | if !lifetime.publish(p) { | 112 | if !lifetime.publish(p) { | ||
| 105 | tb.mu.Unlock() | 113 | tb.mu.Unlock() | ||
| 106 | _ = pty.Close() | 114 | _ = pty.Close() | ||
| 107 | - | return layout.ErrNotFound | 115 | + | return paneFocusChange{}, layout.ErrNotFound |
| 108 | } | 116 | } | ||
| 109 | // The tab lock excludes membership observers while pane.mu publishes the | 117 | // The tab lock excludes membership observers while pane.mu publishes the | ||
| 110 | // initial owner generation. | 118 | // initial owner generation. | ||
| @@ -116,7 +124,7 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 116 | 124 | ||||
| 117 | d.applyTabLayout(sess, tb) | 125 | d.applyTabLayout(sess, tb) | ||
| 118 | d.startPaneGoroutines(sess, tb, p) | 126 | d.startPaneGoroutines(sess, tb, p) | ||
| 119 | - | return nil | 127 | + | return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil |
| 120 | } | 128 | } | ||
| 121 | 129 | ||||
| 122 | func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect { | 130 | func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect { | ||
| @@ -132,14 +140,15 @@ func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Row | |||||
| 132 | 140 | ||||
| 133 | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | 141 | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | ||
| 134 | target := resolveDaemonActionTargetForAttachment(sess, ac) | 142 | target := resolveDaemonActionTargetForAttachment(sess, ac) | ||
| 135 | - | err := d.stackPaneAt(sess, target.tab, target.pane) | 143 | + | change, err := d.stackPaneAt(sess, target.tab, target.pane) |
| 136 | if err == nil && ac != nil { | 144 | if err == nil && ac != nil { | ||
| 145 | + | publishPaneFocusForAttachment(sess, ac, change) | |||
| 137 | d.invalidateRender(sess, ac, true, "pane_actions.go") | 146 | d.invalidateRender(sess, ac, true, "pane_actions.go") | ||
| 138 | } | 147 | } | ||
| 139 | return err | 148 | return err | ||
| 140 | } | 149 | } | ||
| 141 | 150 | ||||
| 142 | - | func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error { | 151 | + | func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) (paneFocusChange, error) { |
| 143 | return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | 152 | return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | ||
| 144 | return tree.StackNew(oldFocus, newID, area) | 153 | return tree.StackNew(oldFocus, newID, area) | ||
| 145 | }) | 154 | }) | ||
| @@ -402,13 +411,15 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||||
| 402 | if target.pane != nil { | 411 | if target.pane != nil { | ||
| 403 | oldFocus = target.pane.id | 412 | oldFocus = target.pane.id | ||
| 404 | } | 413 | } | ||
| 405 | - | span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac) | 414 | + | change, err := d.focusDirAt(sess, target.tab, target.pane, dir) |
| 406 | if err == nil { | 415 | if err == nil { | ||
| 407 | if ac != nil { | 416 | if ac != nil { | ||
| 417 | + | publishPaneFocusForAttachment(sess, ac, change) | |||
| 408 | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go") | 418 | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go") | ||
| 409 | } | 419 | } | ||
| 410 | return nil | 420 | return nil | ||
| 411 | } | 421 | } | ||
| 422 | + | span := change.span | |||
| 412 | if !errors.Is(err, errNoNeighbor) || target.tab == nil { | 423 | if !errors.Is(err, errNoNeighbor) || target.tab == nil { | ||
| 413 | return err | 424 | return err | ||
| 414 | } | 425 | } | ||
| @@ -443,6 +454,16 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||||
| 443 | return nil | 454 | return nil | ||
| 444 | } | 455 | } | ||
| 445 | 456 | ||||
| 457 | + | func publishPaneFocusForAttachment(sess *session, ac *attachedClient, change paneFocusChange) bool { | |||
| 458 | + | if sess == nil || ac == nil || change.tab == nil || change.focused == nil { | |||
| 459 | + | return false | |||
| 460 | + | } | |||
| 461 | + | sess.mu.Lock() | |||
| 462 | + | changed := sess.setAttachmentPaneLocked(ac, change.tab, change.focused) | |||
| 463 | + | sess.mu.Unlock() | |||
| 464 | + | return changed | |||
| 465 | + | } | |||
| 466 | + | ||||
| 446 | // finishPaneFocusForClient applies the attachment lifecycle shared by every | 467 | // finishPaneFocusForClient applies the attachment lifecycle shared by every | ||
| 447 | // successful directional focus move. Keep copy-mode exit before the optional | 468 | // successful directional focus move. Keep copy-mode exit before the optional | ||
| 448 | // stack title refresh, and both before render invalidation. | 469 | // stack title refresh, and both before render invalidation. | ||
| @@ -451,10 +472,6 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb | |||||
| 451 | var newPane *pane | 472 | var newPane *pane | ||
| 452 | var pl layout.Placement | 473 | var pl layout.Placement | ||
| 453 | var hasPlacement bool | 474 | var hasPlacement bool | ||
| 454 | - | beforePaneID := domain.PaneStableID("") | |||
| 455 | - | if ac != nil { | |||
| 456 | - | beforePaneID = ac.viewSnapshot().paneID | |||
| 457 | - | } | |||
| 458 | if tb != nil { | 475 | if tb != nil { | ||
| 459 | tb.mu.Lock() | 476 | tb.mu.Lock() | ||
| 460 | if ac != nil { | 477 | if ac != nil { | ||
| @@ -480,15 +497,7 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb | |||||
| 480 | tb.mu.Unlock() | 497 | tb.mu.Unlock() | ||
| 481 | } | 498 | } | ||
| 482 | 499 | ||||
| 483 | - | moved := newFocus != oldFocus | 500 | + | if newFocus != oldFocus { |
| 484 | - | if ac != nil && newPane != nil { | |||
| 485 | - | sess.mu.Lock() | |||
| 486 | - | sess.setAttachmentPaneLocked(ac, tb, newPane) | |||
| 487 | - | sess.mu.Unlock() | |||
| 488 | - | after := ac.viewSnapshot() | |||
| 489 | - | moved = beforePaneID != after.paneID | |||
| 490 | - | } | |||
| 491 | - | if moved { | |||
| 492 | d.exitCopyMode(ac) | 501 | d.exitCopyMode(ac) | ||
| 493 | if hasPlacement && pl.InStack { | 502 | if hasPlacement && pl.InStack { | ||
| 494 | d.refreshPaneTitleOnFocus(sess, newFocus, tb) | 503 | d.refreshPaneTitleOnFocus(sess, newFocus, tb) | ||
| @@ -510,46 +519,35 @@ func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) { | |||||
| 510 | return layout.Placement{}, false | 519 | return layout.Placement{}, false | ||
| 511 | } | 520 | } | ||
| 512 | 521 | ||||
| 513 | - | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction, initiating ...*attachedClient) (domain.Rect, error) { | 522 | + | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (paneFocusChange, error) { |
| 523 | + | change := paneFocusChange{tab: tb} | |||
| 514 | if tb == nil || target == nil { | 524 | if tb == nil || target == nil { | ||
| 515 | - | return domain.Rect{}, layout.ErrNotFound | 525 | + | return change, layout.ErrNotFound |
| 516 | - | } | |||
| 517 | - | var ac *attachedClient | |||
| 518 | - | if len(initiating) > 0 { | |||
| 519 | - | ac = initiating[0] | |||
| 520 | } | 526 | } | ||
| 521 | tb.mu.Lock() | 527 | tb.mu.Lock() | ||
| 522 | if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { | 528 | if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { | ||
| 523 | tb.mu.Unlock() | 529 | tb.mu.Unlock() | ||
| 524 | - | return domain.Rect{}, layout.ErrNotFound | 530 | + | return change, layout.ErrNotFound |
| 525 | } | 531 | } | ||
| 526 | candidate := tb.tree.Clone() | 532 | candidate := tb.tree.Clone() | ||
| 527 | candidate.Focus = target.id | 533 | candidate.Focus = target.id | ||
| 528 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | 534 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | ||
| 529 | - | span, _ := candidate.FocusSpan(area) | 535 | + | change.span, _ = candidate.FocusSpan(area) |
| 530 | err := candidate.FocusDir(dir, area) | 536 | err := candidate.FocusDir(dir, area) | ||
| 531 | - | currentFocus := tb.focusedPane() | 537 | + | if err == nil { |
| 532 | - | committed := err == nil && currentFocus != nil && candidate.Focus != currentFocus.id | 538 | + | change.focused = tb.panes[candidate.Focus] |
| 533 | - | var focusedPane *pane | 539 | + | change.layoutChanged = change.focused != nil && (candidate.Focus != tb.tree.Focus || layoutFingerprint(candidate.Root) != layoutFingerprint(tb.tree.Root)) |
| 534 | - | if committed { | 540 | + | if change.layoutChanged { |
| 535 | - | focusedPane = tb.panes[candidate.Focus] | 541 | + | tb.tree = candidate |
| 536 | - | tb.tree = candidate | 542 | + | tb.bumpLayoutGenerationLocked() |
| 537 | - | tb.bumpLayoutGenerationLocked() | 543 | + | } |
| 538 | } | 544 | } | ||
| 539 | tb.mu.Unlock() | 545 | tb.mu.Unlock() | ||
| 540 | - | if committed { | 546 | + | if change.layoutChanged { |
| 541 | - | if ac != nil { | |||
| 542 | - | d.exitCopyMode(ac) | |||
| 543 | - | } | |||
| 544 | - | if sess != nil && ac != nil && focusedPane != nil { | |||
| 545 | - | sess.mu.Lock() | |||
| 546 | - | sess.setAttachmentPaneLocked(ac, tb, focusedPane) | |||
| 547 | - | sess.mu.Unlock() | |||
| 548 | - | } | |||
| 549 | d.applyTabLayout(sess, tb) | 547 | d.applyTabLayout(sess, tb) | ||
| 550 | } | 548 | } | ||
| 551 | if errors.Is(err, layout.ErrNoPane) { | 549 | if errors.Is(err, layout.ErrNoPane) { | ||
| 552 | - | return span, errNoNeighbor | 550 | + | return change, errNoNeighbor |
| 553 | } | 551 | } | ||
| 554 | - | return span, err | 552 | + | return change, err |
| 555 | } | 553 | } | ||
M internal/usecase/daemon/pane_actions_test.go +38 -2
| @@ -490,6 +490,42 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||
| 490 | 490 | } | |
| 491 | 491 | } | |
| 492 | 492 | ||
| 493 | + | func TestSplitPaneActionFocusesNewPaneForInitiatingAttachment(t *testing.T) { | |
| 494 | + | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 495 | + | ac := registryTestAttachment(1) | |
| 496 | + | other := registryTestAttachment(2) | |
| 497 | + | require.True(t, sess.registerAttachment(ac)) | |
| 498 | + | require.True(t, sess.registerAttachment(other)) | |
| 499 | + | newPTY := portsmocks.NewMockPTY(t) | |
| 500 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 501 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 502 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once() | |
| 503 | + | ||
| 504 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 505 | + | require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{ | |
| 506 | + | kind: daemonActionSplitPane, | |
| 507 | + | target: target, | |
| 508 | + | direction: layout.Right, | |
| 509 | + | })) | |
| 510 | + | ||
| 511 | + | _, focused := sess.paneForAttachment(ac) | |
| 512 | + | require.NotNil(t, focused) | |
| 513 | + | require.Equal(t, layout.PaneID("pane-2"), focused.id) | |
| 514 | + | _, otherFocused := sess.paneForAttachment(other) | |
| 515 | + | require.NotNil(t, otherFocused) | |
| 516 | + | require.Equal(t, layout.PaneID("pane-1"), otherFocused.id) | |
| 517 | + | ||
| 518 | + | target = resolveDaemonActionTargetForAttachment(sess, ac) | |
| 519 | + | require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{ | |
| 520 | + | kind: daemonActionFocusPane, | |
| 521 | + | target: target, | |
| 522 | + | direction: layout.Left, | |
| 523 | + | })) | |
| 524 | + | _, focused = sess.paneForAttachment(ac) | |
| 525 | + | require.NotNil(t, focused) | |
| 526 | + | require.Equal(t, layout.PaneID("pane-1"), focused.id) | |
| 527 | + | } | |
| 528 | + | ||
| 493 | 529 | func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |
| 494 | 530 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4}) | |
| 495 | 531 | tb := testAttachmentTab(sess) | |
| @@ -603,10 +639,10 @@ func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) { | |||
| 603 | 639 | tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 604 | 640 | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |
| 605 | 641 | ||
| 606 | - | span, err := d.focusDirAt(sess, tb, target, tt.direction) | |
| 642 | + | change, err := d.focusDirAt(sess, tb, target, tt.direction) | |
| 607 | 643 | ||
| 608 | 644 | require.ErrorIs(t, err, tt.wantErr) | |
| 609 | - | require.Equal(t, domain.Rect{Width: 20, Height: 10}, span) | |
| 645 | + | require.Equal(t, domain.Rect{Width: 20, Height: 10}, change.span) | |
| 610 | 646 | require.Equal(t, tt.wantFocus, tb.tree.Focus) | |
| 611 | 647 | }) | |
| 612 | 648 | } | |
| @@ -490,6 +490,42 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||||
| 490 | } | 490 | } | ||
| 491 | } | 491 | } | ||
| 492 | 492 | ||||
| 493 | + | func TestSplitPaneActionFocusesNewPaneForInitiatingAttachment(t *testing.T) { | |||
| 494 | + | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 495 | + | ac := registryTestAttachment(1) | |||
| 496 | + | other := registryTestAttachment(2) | |||
| 497 | + | require.True(t, sess.registerAttachment(ac)) | |||
| 498 | + | require.True(t, sess.registerAttachment(other)) | |||
| 499 | + | newPTY := portsmocks.NewMockPTY(t) | |||
| 500 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |||
| 501 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |||
| 502 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once() | |||
| 503 | + | ||||
| 504 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |||
| 505 | + | require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{ | |||
| 506 | + | kind: daemonActionSplitPane, | |||
| 507 | + | target: target, | |||
| 508 | + | direction: layout.Right, | |||
| 509 | + | })) | |||
| 510 | + | ||||
| 511 | + | _, focused := sess.paneForAttachment(ac) | |||
| 512 | + | require.NotNil(t, focused) | |||
| 513 | + | require.Equal(t, layout.PaneID("pane-2"), focused.id) | |||
| 514 | + | _, otherFocused := sess.paneForAttachment(other) | |||
| 515 | + | require.NotNil(t, otherFocused) | |||
| 516 | + | require.Equal(t, layout.PaneID("pane-1"), otherFocused.id) | |||
| 517 | + | ||||
| 518 | + | target = resolveDaemonActionTargetForAttachment(sess, ac) | |||
| 519 | + | require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{ | |||
| 520 | + | kind: daemonActionFocusPane, | |||
| 521 | + | target: target, | |||
| 522 | + | direction: layout.Left, | |||
| 523 | + | })) | |||
| 524 | + | _, focused = sess.paneForAttachment(ac) | |||
| 525 | + | require.NotNil(t, focused) | |||
| 526 | + | require.Equal(t, layout.PaneID("pane-1"), focused.id) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 493 | func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | 529 | func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | ||
| 494 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4}) | 530 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4}) | ||
| 495 | tb := testAttachmentTab(sess) | 531 | tb := testAttachmentTab(sess) | ||
| @@ -603,10 +639,10 @@ func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) { | |||||
| 603 | tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | 639 | tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | ||
| 604 | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | 640 | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | ||
| 605 | 641 | ||||
| 606 | - | span, err := d.focusDirAt(sess, tb, target, tt.direction) | 642 | + | change, err := d.focusDirAt(sess, tb, target, tt.direction) |
| 607 | 643 | ||||
| 608 | require.ErrorIs(t, err, tt.wantErr) | 644 | require.ErrorIs(t, err, tt.wantErr) | ||
| 609 | - | require.Equal(t, domain.Rect{Width: 20, Height: 10}, span) | 645 | + | require.Equal(t, domain.Rect{Width: 20, Height: 10}, change.span) |
| 610 | require.Equal(t, tt.wantFocus, tb.tree.Focus) | 646 | require.Equal(t, tt.wantFocus, tb.tree.Focus) | ||
| 611 | }) | 647 | }) | ||
| 612 | } | 648 | } | ||
M internal/usecase/daemon/pane_lifetime_test.go +1 -1
| @@ -108,7 +108,7 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) { | |||
| 108 | 108 | tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10}) | |
| 109 | 109 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | |
| 110 | 110 | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 111 | - | err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right) | |
| 111 | + | _, err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right) | |
| 112 | 112 | return []*tab{tb}, func() { | |
| 113 | 113 | cancelSession() | |
| 114 | 114 | tb.closeAllPanes() | |
| @@ -108,7 +108,7 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) { | |||||
| 108 | tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10}) | 108 | tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10}) | ||
| 109 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | 109 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | ||
| 110 | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | 110 | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | ||
| 111 | - | err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right) | 111 | + | _, err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right) |
| 112 | return []*tab{tb}, func() { | 112 | return []*tab{tb}, func() { | ||
| 113 | cancelSession() | 113 | cancelSession() | ||
| 114 | tb.closeAllPanes() | 114 | tb.closeAllPanes() | ||