detached e4a8173

fix(daemon): synchronize pane focus after splits

commit
e4a8173fa69c9c3e2f03df40086ed4c176af7343
author
brice · · 2026-08-10 15:39 UTC
parent
32ba649
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +123 -63
M internal/usecase/daemon/control.go +14 -3
@@ -346,9 +346,17 @@ func (a daemonActions) Run(request daemonActionRequest) error {
346346 case daemonActionCloseTab:
347347 return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect)
348348 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
350354 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
352360 case daemonActionToggleStack:
353361 return a.d.toggleStackAt(target.session, target.tab, target.pane)
354362 case daemonActionClosePane:
@@ -366,7 +374,10 @@ func (a daemonActions) Run(request daemonActionRequest) error {
366374 }
367375 return nil
368376 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+ }
370381 return err
371382 case daemonActionNextTab:
372383 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 nil375 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 err381 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) {
7070 require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID)
7171 }
7272
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+
7388 func TestPaneRemovalRepairsEachAttachmentViewIndependently(t *testing.T) {
7489 sess, firstPane, secondPane := multiPaneAttachmentSession(t)
7590 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 (
99 "github.com/bnema/vev/internal/usecase/layout"
1010 )
1111
12+type paneFocusChange struct {
13+ tab *tab
14+ focused *pane
15+ span domain.Rect
16+ layoutChanged bool
17+}
18+
1219 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
1320 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)
1522 if err == nil && ac != nil {
23+ publishPaneFocusForAttachment(sess, ac, change)
1624 d.invalidateRender(sess, ac, true, "pane_actions.go")
1725 }
1826 return err
1927 }
2028
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) {
2230 after := dir == layout.Right || dir == layout.Down
2331 return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
2432 return tree.Split(oldFocus, dir, after, newID, area)
@@ -30,12 +38,12 @@ func (d *Daemon) spawnPaneOpAt(
3038 tb *tab,
3139 target *pane,
3240 mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error,
33-) error {
41+) (paneFocusChange, error) {
3442 if d.ptys == nil {
35- return nil
43+ return paneFocusChange{}, nil
3644 }
3745 if tb == nil || target == nil {
38- return layout.ErrNotFound
46+ return paneFocusChange{}, layout.ErrNotFound
3947 }
4048
4149 sess.mu.Lock()
@@ -47,12 +55,12 @@ func (d *Daemon) spawnPaneOpAt(
4755 tb.mu.Lock()
4856 if tb.tree == nil || tb.tree.Root == nil {
4957 tb.mu.Unlock()
50- return layout.ErrNotFound
58+ return paneFocusChange{}, layout.ErrNotFound
5159 }
5260 oldFocus := target.id
5361 if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {
5462 tb.mu.Unlock()
55- return layout.ErrNotFound
63+ return paneFocusChange{}, layout.ErrNotFound
5664 }
5765 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
5866 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
@@ -60,15 +68,15 @@ func (d *Daemon) spawnPaneOpAt(
6068 if err := mutate(candidate, oldFocus, newID, area); err != nil {
6169 tb.mu.Unlock()
6270 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)
6472 }
65- return err
73+ return paneFocusChange{}, err
6674 }
6775 tb.nextPaneID++
6876 placements, ok := layout.Solve(candidate.Root, area)
6977 if !ok {
7078 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)
7280 }
7381 newRect := placementContent(placements, newID)
7482 tabStableID := tb.stableID
@@ -77,7 +85,7 @@ func (d *Daemon) spawnPaneOpAt(
7785
7886 paneStableID, err := newStableID("p")
7987 if err != nil {
80- return fmt.Errorf("daemon: generating pane identity: %w", err)
88+ return paneFocusChange{}, fmt.Errorf("daemon: generating pane identity: %w", err)
8189 }
8290 command, args := d.ptyCommand(env)
8391 lifetime := d.newPaneProcessLifetime(tb.ctx)
@@ -88,7 +96,7 @@ func (d *Daemon) spawnPaneOpAt(
8896 _ = pty.Close()
8997 }
9098 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)
92100 }
93101
94102 p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect))
@@ -99,12 +107,12 @@ func (d *Daemon) spawnPaneOpAt(
99107 tb.mu.Unlock()
100108 lifetime.abort()
101109 _ = pty.Close()
102- return layout.ErrNotFound
110+ return paneFocusChange{}, layout.ErrNotFound
103111 }
104112 if !lifetime.publish(p) {
105113 tb.mu.Unlock()
106114 _ = pty.Close()
107- return layout.ErrNotFound
115+ return paneFocusChange{}, layout.ErrNotFound
108116 }
109117 // The tab lock excludes membership observers while pane.mu publishes the
110118 // initial owner generation.
@@ -116,7 +124,7 @@ func (d *Daemon) spawnPaneOpAt(
116124
117125 d.applyTabLayout(sess, tb)
118126 d.startPaneGoroutines(sess, tb, p)
119- return nil
127+ return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil
120128 }
121129
122130 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
132140
133141 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
134142 target := resolveDaemonActionTargetForAttachment(sess, ac)
135- err := d.stackPaneAt(sess, target.tab, target.pane)
143+ change, err := d.stackPaneAt(sess, target.tab, target.pane)
136144 if err == nil && ac != nil {
145+ publishPaneFocusForAttachment(sess, ac, change)
137146 d.invalidateRender(sess, ac, true, "pane_actions.go")
138147 }
139148 return err
140149 }
141150
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) {
143152 return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
144153 return tree.StackNew(oldFocus, newID, area)
145154 })
@@ -402,13 +411,15 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
402411 if target.pane != nil {
403412 oldFocus = target.pane.id
404413 }
405- span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac)
414+ change, err := d.focusDirAt(sess, target.tab, target.pane, dir)
406415 if err == nil {
407416 if ac != nil {
417+ publishPaneFocusForAttachment(sess, ac, change)
408418 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
409419 }
410420 return nil
411421 }
422+ span := change.span
412423 if !errors.Is(err, errNoNeighbor) || target.tab == nil {
413424 return err
414425 }
@@ -443,6 +454,16 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
443454 return nil
444455 }
445456
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+
446467 // finishPaneFocusForClient applies the attachment lifecycle shared by every
447468 // successful directional focus move. Keep copy-mode exit before the optional
448469 // stack title refresh, and both before render invalidation.
@@ -451,10 +472,6 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb
451472 var newPane *pane
452473 var pl layout.Placement
453474 var hasPlacement bool
454- beforePaneID := domain.PaneStableID("")
455- if ac != nil {
456- beforePaneID = ac.viewSnapshot().paneID
457- }
458475 if tb != nil {
459476 tb.mu.Lock()
460477 if ac != nil {
@@ -480,15 +497,7 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb
480497 tb.mu.Unlock()
481498 }
482499
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 {
492501 d.exitCopyMode(ac)
493502 if hasPlacement && pl.InStack {
494503 d.refreshPaneTitleOnFocus(sess, newFocus, tb)
@@ -510,46 +519,35 @@ func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) {
510519 return layout.Placement{}, false
511520 }
512521
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}
514524 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
520526 }
521527 tb.mu.Lock()
522528 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
523529 tb.mu.Unlock()
524- return domain.Rect{}, layout.ErrNotFound
530+ return change, layout.ErrNotFound
525531 }
526532 candidate := tb.tree.Clone()
527533 candidate.Focus = target.id
528534 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
529- span, _ := candidate.FocusSpan(area)
535+ change.span, _ = candidate.FocusSpan(area)
530536 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+ }
538544 }
539545 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 {
549547 d.applyTabLayout(sess, tb)
550548 }
551549 if errors.Is(err, layout.ErrNoPane) {
552- return span, errNoNeighbor
550+ return change, errNoNeighbor
553551 }
554- return span, err
552+ return change, err
555553 }
@@ -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 err26 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.Down30 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 nil43+ return paneFocusChange{}, nil
36 }44 }
37 if tb == nil || target == nil {45 if tb == nil || target == nil {
38- return layout.ErrNotFound46+ 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.ErrNotFound58+ return paneFocusChange{}, layout.ErrNotFound
51 }59 }
52 oldFocus := target.id60 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.ErrNotFound63+ 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 err73+ 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.stableID82 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.ErrNotFound110+ 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.ErrNotFound115+ return paneFocusChange{}, layout.ErrNotFound
108 }116 }
109 // The tab lock excludes membership observers while pane.mu publishes the117 // 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 nil127+ 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 err148 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.id412 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 nil420 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 err424 return err
414 }425 }
@@ -443,6 +454,16 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
443 return nil454 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 every467 // finishPaneFocusForClient applies the attachment lifecycle shared by every
447 // successful directional focus move. Keep copy-mode exit before the optional468 // 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 *pane472 var newPane *pane
452 var pl layout.Placement473 var pl layout.Placement
453 var hasPlacement bool474 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 != oldFocus500+ 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{}, false519 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.ErrNotFound525+ 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.ErrNotFound530+ return change, layout.ErrNotFound
525 }531 }
526 candidate := tb.tree.Clone()532 candidate := tb.tree.Clone()
527 candidate.Focus = target.id533 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.id538+ change.focused = tb.panes[candidate.Focus]
533- var focusedPane *pane539+ 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 = candidate542+ 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, errNoNeighbor550+ return change, errNoNeighbor
553 }551 }
554- return span, err552+ return change, err
555 }553 }
M internal/usecase/daemon/pane_actions_test.go +38 -2
@@ -490,6 +490,42 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
490490 }
491491 }
492492
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+
493529 func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
494530 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4})
495531 tb := testAttachmentTab(sess)
@@ -603,10 +639,10 @@ func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
603639 tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
604640 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
605641
606- span, err := d.focusDirAt(sess, tb, target, tt.direction)
642+ change, err := d.focusDirAt(sess, tb, target, tt.direction)
607643
608644 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)
610646 require.Equal(t, tt.wantFocus, tb.tree.Focus)
611647 })
612648 }
@@ -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) {
108108 tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10})
109109 tb.ctx, tb.cancel = context.WithCancel(sessCtx)
110110 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)
112112 return []*tab{tb}, func() {
113113 cancelSession()
114114 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close