detached 23380f8

fix(panes): support vim focus keys and stack splitting

commit
23380f820c89227326af036e43e81c29c6eac76b
author
brice · · 2026-07-04 08:26 UTC
parent
05cf628
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
5757 }
5858 }
5959
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+
6085 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
6186 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
6287 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
595595 styles := newThemeStyles(theme)
596596 style := styles.border
597597 if focused {
598- style = styles.accent
598+ style = styles.statusBar
599599 } else {
600600 style = themeui.DimStyle(style, theme)
601601 }
@@ -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.border596 style := styles.border
597 if focused {597 if focused {
598- style = styles.accent598+ 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) {
273273 return ActionOpenPalette, 1, true
274274 case 'a':
275275 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
276284 }
277285
278286 key, size := utf8.DecodeRune(data)
@@ -273,6 +273,14 @@ func binding(data []byte) (Action, int, bool) {
273 return ActionOpenPalette, 1, true273 return ActionOpenPalette, 1, true
274 case 'a':274 case 'a':
275 return ActionJumpAttention, 1, true275 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) {
212212 require.Empty(t, clk.timers)
213213 }
214214
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+
215238 func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
216239 for _, b := range []byte("cnpdxurt") {
217240 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) {
7373 require.False(t, ok)
7474 }
7575
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+
7692 func TestStackNewEmptyTreeReturnsError(t *testing.T) {
7793 t.Parallel()
7894 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
4343 n.Children[at] = newLeaf
4444 return true
4545 }
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+ }
4655 children := []*Node{child, newLeaf}
4756 if !after {
4857 children = []*Node{newLeaf, child}
@@ -43,6 +43,15 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID
43 n.Children[at] = newLeaf43 n.Children[at] = newLeaf
44 return true44 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}

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