fix(panes): support vim focus keys and stack splitting
- commit
- 23380f820c89227326af036e43e81c29c6eac76b
- author
- brice · · 2026-07-04 08:26 UTC
- parent
- 05cf628
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +82 -1
M internal/usecase/daemon/pane_actions_test.go +25 -0
| @@ -57,6 +57,31 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | + | func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |
| 61 | + | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4}) | |
| 62 | + | tb := sess.activeTab() | |
| 63 | + | stackPTY := portsmocks.NewMockPTY(t) | |
| 64 | + | stackPane := newPane("pane-2", stackPTY, domain.Size{Cols: 41, Rows: 2}) | |
| 65 | + | tb.panes[stackPane.id] = stackPane | |
| 66 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-2"}, Focus: "pane-2"} | |
| 67 | + | tb.nextPaneID = 3 | |
| 68 | + | newPTY := portsmocks.NewMockPTY(t) | |
| 69 | + | stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |
| 70 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 71 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once() | |
| 72 | + | ||
| 73 | + | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 74 | + | ||
| 75 | + | tb.mu.Lock() | |
| 76 | + | defer tb.mu.Unlock() | |
| 77 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 78 | + | require.True(t, ok) | |
| 79 | + | require.Equal(t, layout.PaneID("pane-3"), tb.tree.Focus) | |
| 80 | + | require.Len(t, tb.panes, 3) | |
| 81 | + | require.Equal(t, domain.Rect{Y: 2, Width: 20, Height: 2}, placementContent(placements, "pane-2")) | |
| 82 | + | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, placementContent(placements, "pane-3")) | |
| 83 | + | } | |
| 84 | + | ||
| 60 | 85 | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | |
| 61 | 86 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 62 | 87 | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once() | |
| @@ -57,6 +57,31 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||||
| 57 | } | 57 | } | ||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | + | func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||
| 61 | + | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4}) | |||
| 62 | + | tb := sess.activeTab() | |||
| 63 | + | stackPTY := portsmocks.NewMockPTY(t) | |||
| 64 | + | stackPane := newPane("pane-2", stackPTY, domain.Size{Cols: 41, Rows: 2}) | |||
| 65 | + | tb.panes[stackPane.id] = stackPane | |||
| 66 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-2"}, Focus: "pane-2"} | |||
| 67 | + | tb.nextPaneID = 3 | |||
| 68 | + | newPTY := portsmocks.NewMockPTY(t) | |||
| 69 | + | stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |||
| 70 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |||
| 71 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once() | |||
| 72 | + | ||||
| 73 | + | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |||
| 74 | + | ||||
| 75 | + | tb.mu.Lock() | |||
| 76 | + | defer tb.mu.Unlock() | |||
| 77 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 78 | + | require.True(t, ok) | |||
| 79 | + | require.Equal(t, layout.PaneID("pane-3"), tb.tree.Focus) | |||
| 80 | + | require.Len(t, tb.panes, 3) | |||
| 81 | + | require.Equal(t, domain.Rect{Y: 2, Width: 20, Height: 2}, placementContent(placements, "pane-2")) | |||
| 82 | + | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, placementContent(placements, "pane-3")) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 60 | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | 85 | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | ||
| 61 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | 86 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | ||
| 62 | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once() | 87 | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once() | ||
M internal/usecase/daemon/render.go +1 -1
| @@ -595,7 +595,7 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse | |||
| 595 | 595 | styles := newThemeStyles(theme) | |
| 596 | 596 | style := styles.border | |
| 597 | 597 | if focused { | |
| 598 | - | style = styles.accent | |
| 598 | + | style = styles.statusBar | |
| 599 | 599 | } else { | |
| 600 | 600 | style = themeui.DimStyle(style, theme) | |
| 601 | 601 | } | |
| @@ -595,7 +595,7 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse | |||||
| 595 | styles := newThemeStyles(theme) | 595 | styles := newThemeStyles(theme) | ||
| 596 | style := styles.border | 596 | style := styles.border | ||
| 597 | if focused { | 597 | if focused { | ||
| 598 | - | style = styles.accent | 598 | + | style = styles.statusBar |
| 599 | } else { | 599 | } else { | ||
| 600 | style = themeui.DimStyle(style, theme) | 600 | style = themeui.DimStyle(style, theme) | ||
| 601 | } | 601 | } | ||
M internal/usecase/keys/router.go +8 -0
| @@ -273,6 +273,14 @@ func binding(data []byte) (Action, int, bool) { | |||
| 273 | 273 | return ActionOpenPalette, 1, true | |
| 274 | 274 | case 'a': | |
| 275 | 275 | return ActionJumpAttention, 1, true | |
| 276 | + | case 'h': | |
| 277 | + | return ActionFocusPaneLeft, 1, true | |
| 278 | + | case 'j': | |
| 279 | + | return ActionFocusPaneDown, 1, true | |
| 280 | + | case 'k': | |
| 281 | + | return ActionFocusPaneUp, 1, true | |
| 282 | + | case 'l': | |
| 283 | + | return ActionFocusPaneRight, 1, true | |
| 276 | 284 | } | |
| 277 | 285 | ||
| 278 | 286 | key, size := utf8.DecodeRune(data) | |
| @@ -273,6 +273,14 @@ func binding(data []byte) (Action, int, bool) { | |||||
| 273 | return ActionOpenPalette, 1, true | 273 | return ActionOpenPalette, 1, true | ||
| 274 | case 'a': | 274 | case 'a': | ||
| 275 | return ActionJumpAttention, 1, true | 275 | return ActionJumpAttention, 1, true | ||
| 276 | + | case 'h': | |||
| 277 | + | return ActionFocusPaneLeft, 1, true | |||
| 278 | + | case 'j': | |||
| 279 | + | return ActionFocusPaneDown, 1, true | |||
| 280 | + | case 'k': | |||
| 281 | + | return ActionFocusPaneUp, 1, true | |||
| 282 | + | case 'l': | |||
| 283 | + | return ActionFocusPaneRight, 1, true | |||
| 276 | } | 284 | } | ||
| 277 | 285 | ||||
| 278 | key, size := utf8.DecodeRune(data) | 286 | key, size := utf8.DecodeRune(data) | ||
M internal/usecase/keys/router_test.go +23 -0
| @@ -212,6 +212,29 @@ func TestRouterInterceptsAltAForJumpAttention(t *testing.T) { | |||
| 212 | 212 | require.Empty(t, clk.timers) | |
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | + | func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) { | |
| 216 | + | cases := []struct { | |
| 217 | + | name string | |
| 218 | + | key byte | |
| 219 | + | want Action | |
| 220 | + | }{ | |
| 221 | + | {name: "left", key: 'h', want: ActionFocusPaneLeft}, | |
| 222 | + | {name: "down", key: 'j', want: ActionFocusPaneDown}, | |
| 223 | + | {name: "up", key: 'k', want: ActionFocusPaneUp}, | |
| 224 | + | {name: "right", key: 'l', want: ActionFocusPaneRight}, | |
| 225 | + | } | |
| 226 | + | for _, tc := range cases { | |
| 227 | + | t.Run(tc.name, func(t *testing.T) { | |
| 228 | + | clk := &fakeClock{} | |
| 229 | + | h := &captureHandler{} | |
| 230 | + | NewRouter(clk, h).Route([]byte{ESC, tc.key}) | |
| 231 | + | require.Equal(t, []Action{tc.want}, h.actions) | |
| 232 | + | require.Empty(t, h.forwards) | |
| 233 | + | require.Empty(t, clk.timers) | |
| 234 | + | }) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 215 | 238 | func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) { | |
| 216 | 239 | for _, b := range []byte("cnpdxurt") { | |
| 217 | 240 | t.Run(string(b), func(t *testing.T) { | |
| @@ -212,6 +212,29 @@ func TestRouterInterceptsAltAForJumpAttention(t *testing.T) { | |||||
| 212 | require.Empty(t, clk.timers) | 212 | require.Empty(t, clk.timers) | ||
| 213 | } | 213 | } | ||
| 214 | 214 | ||||
| 215 | + | func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) { | |||
| 216 | + | cases := []struct { | |||
| 217 | + | name string | |||
| 218 | + | key byte | |||
| 219 | + | want Action | |||
| 220 | + | }{ | |||
| 221 | + | {name: "left", key: 'h', want: ActionFocusPaneLeft}, | |||
| 222 | + | {name: "down", key: 'j', want: ActionFocusPaneDown}, | |||
| 223 | + | {name: "up", key: 'k', want: ActionFocusPaneUp}, | |||
| 224 | + | {name: "right", key: 'l', want: ActionFocusPaneRight}, | |||
| 225 | + | } | |||
| 226 | + | for _, tc := range cases { | |||
| 227 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 228 | + | clk := &fakeClock{} | |||
| 229 | + | h := &captureHandler{} | |||
| 230 | + | NewRouter(clk, h).Route([]byte{ESC, tc.key}) | |||
| 231 | + | require.Equal(t, []Action{tc.want}, h.actions) | |||
| 232 | + | require.Empty(t, h.forwards) | |||
| 233 | + | require.Empty(t, clk.timers) | |||
| 234 | + | }) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 215 | func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) { | 238 | func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) { | ||
| 216 | for _, b := range []byte("cnpdxurt") { | 239 | for _, b := range []byte("cnpdxurt") { | ||
| 217 | t.Run(string(b), func(t *testing.T) { | 240 | t.Run(string(b), func(t *testing.T) { | ||
M internal/usecase/layout/layout_test.go +16 -0
| @@ -73,6 +73,22 @@ func TestSolveStackFitAndOverflow(t *testing.T) { | |||
| 73 | 73 | require.False(t, ok) | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | func TestSplitStackedLeafSplitsWholeStack(t *testing.T) { | |
| 77 | + | t.Parallel() | |
| 78 | + | tr := &Tree{ | |
| 79 | + | Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("one"), NewLeaf("two")}, Expanded: "two"}, | |
| 80 | + | Focus: "two", | |
| 81 | + | } | |
| 82 | + | area := domain.Rect{Width: 41, Height: 4} | |
| 83 | + | ||
| 84 | + | require.NoError(t, tr.Split("two", Right, true, "three", area)) | |
| 85 | + | placements, ok := Solve(tr.Root, area) | |
| 86 | + | require.True(t, ok) | |
| 87 | + | require.Equal(t, PaneID("three"), tr.Focus) | |
| 88 | + | require.Equal(t, domain.Rect{Y: 2, Width: 20, Height: 2}, contents(placements)["two"], "existing stack should remain visible on the left") | |
| 89 | + | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, contents(placements)["three"], "new split pane should appear to the right of the stack") | |
| 90 | + | } | |
| 91 | + | ||
| 76 | 92 | func TestStackNewEmptyTreeReturnsError(t *testing.T) { | |
| 77 | 93 | t.Parallel() | |
| 78 | 94 | tr := &Tree{} | |
| @@ -73,6 +73,22 @@ func TestSolveStackFitAndOverflow(t *testing.T) { | |||||
| 73 | require.False(t, ok) | 73 | require.False(t, ok) | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | func TestSplitStackedLeafSplitsWholeStack(t *testing.T) { | |||
| 77 | + | t.Parallel() | |||
| 78 | + | tr := &Tree{ | |||
| 79 | + | Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("one"), NewLeaf("two")}, Expanded: "two"}, | |||
| 80 | + | Focus: "two", | |||
| 81 | + | } | |||
| 82 | + | area := domain.Rect{Width: 41, Height: 4} | |||
| 83 | + | ||||
| 84 | + | require.NoError(t, tr.Split("two", Right, true, "three", area)) | |||
| 85 | + | placements, ok := Solve(tr.Root, area) | |||
| 86 | + | require.True(t, ok) | |||
| 87 | + | require.Equal(t, PaneID("three"), tr.Focus) | |||
| 88 | + | require.Equal(t, domain.Rect{Y: 2, Width: 20, Height: 2}, contents(placements)["two"], "existing stack should remain visible on the left") | |||
| 89 | + | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, contents(placements)["three"], "new split pane should appear to the right of the stack") | |||
| 90 | + | } | |||
| 91 | + | ||||
| 76 | func TestStackNewEmptyTreeReturnsError(t *testing.T) { | 92 | func TestStackNewEmptyTreeReturnsError(t *testing.T) { | ||
| 77 | t.Parallel() | 93 | t.Parallel() | ||
| 78 | tr := &Tree{} | 94 | tr := &Tree{} | ||
M internal/usecase/layout/ops.go +9 -0
| @@ -43,6 +43,15 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID | |||
| 43 | 43 | n.Children[at] = newLeaf | |
| 44 | 44 | return true | |
| 45 | 45 | } | |
| 46 | + | if n.Kind == Stack { | |
| 47 | + | stack := n.clone() | |
| 48 | + | children := []*Node{stack, newLeaf} | |
| 49 | + | if !after { | |
| 50 | + | children = []*Node{newLeaf, stack} | |
| 51 | + | } | |
| 52 | + | *n = Node{Kind: Split, Dir: axis, Children: children} | |
| 53 | + | return true | |
| 54 | + | } | |
| 46 | 55 | children := []*Node{child, newLeaf} | |
| 47 | 56 | if !after { | |
| 48 | 57 | children = []*Node{newLeaf, child} | |
| @@ -43,6 +43,15 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID | |||||
| 43 | n.Children[at] = newLeaf | 43 | n.Children[at] = newLeaf | ||
| 44 | return true | 44 | return true | ||
| 45 | } | 45 | } | ||
| 46 | + | if n.Kind == Stack { | |||
| 47 | + | stack := n.clone() | |||
| 48 | + | children := []*Node{stack, newLeaf} | |||
| 49 | + | if !after { | |||
| 50 | + | children = []*Node{newLeaf, stack} | |||
| 51 | + | } | |||
| 52 | + | *n = Node{Kind: Split, Dir: axis, Children: children} | |||
| 53 | + | return true | |||
| 54 | + | } | |||
| 46 | children := []*Node{child, newLeaf} | 55 | children := []*Node{child, newLeaf} | ||
| 47 | if !after { | 56 | if !after { | ||
| 48 | children = []*Node{newLeaf, child} | 57 | children = []*Node{newLeaf, child} | ||