detached 563179c

feat(keys): add configurable pane resize actions

commit
563179c7542b1227ffe93496c283fd6641528a50
author
brice · · 2026-07-25 04:58 UTC
parent
c54ef32
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +193 -2
M internal/usecase/daemon/input.go +27 -2
@@ -266,8 +266,9 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
266266 }
267267
268268 type daemonKeyHandler struct {
269- d *Daemon
270- ac *attachedClient
269+ d *Daemon
270+ ac *attachedClient
271+ actions daemonActionRunner
271272 }
272273
273274 func (h daemonKeyHandler) Forward(data []byte) {
@@ -290,6 +291,20 @@ func (h daemonKeyHandler) Action(action keys.Action) {
290291 if sess == nil {
291292 return
292293 }
294+ runResizeAction := func(request daemonActionRequest) {
295+ request.target = resolveDaemonActionTarget(sess)
296+ runner := h.actions
297+ if runner == nil {
298+ runner = daemonActions{d: h.d}
299+ }
300+ if err := runner.Run(request); err != nil {
301+ h.d.reportError(sess, resizeUserError(err))
302+ return
303+ }
304+ if h.actions == nil {
305+ finishDaemonActionForClient(h.d, request, h.ac, "input.go")
306+ }
307+ }
293308 switch action {
294309 case keys.ActionOpenPalette:
295310 h.d.enterPalette(sess, h.ac)
@@ -318,6 +333,16 @@ func (h daemonKeyHandler) Action(action keys.Action) {
318333 if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil {
319334 h.d.reportError(sess, err)
320335 }
336+ case keys.ActionGrowPaneWidth:
337+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
338+ case keys.ActionShrinkPaneWidth:
339+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
340+ case keys.ActionGrowPaneHeight:
341+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
342+ case keys.ActionShrinkPaneHeight:
343+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
344+ case keys.ActionEqualizePanes:
345+ runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes})
321346 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
322347 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
323348 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
@@ -266,8 +266,9 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
266 }266 }
267 267
268 type daemonKeyHandler struct {268 type daemonKeyHandler struct {
269- d *Daemon269+ d *Daemon
270- ac *attachedClient270+ ac *attachedClient
271+ actions daemonActionRunner
271 }272 }
272 273
273 func (h daemonKeyHandler) Forward(data []byte) {274 func (h daemonKeyHandler) Forward(data []byte) {
@@ -290,6 +291,20 @@ func (h daemonKeyHandler) Action(action keys.Action) {
290 if sess == nil {291 if sess == nil {
291 return292 return
292 }293 }
294+ runResizeAction := func(request daemonActionRequest) {
295+ request.target = resolveDaemonActionTarget(sess)
296+ runner := h.actions
297+ if runner == nil {
298+ runner = daemonActions{d: h.d}
299+ }
300+ if err := runner.Run(request); err != nil {
301+ h.d.reportError(sess, resizeUserError(err))
302+ return
303+ }
304+ if h.actions == nil {
305+ finishDaemonActionForClient(h.d, request, h.ac, "input.go")
306+ }
307+ }
293 switch action {308 switch action {
294 case keys.ActionOpenPalette:309 case keys.ActionOpenPalette:
295 h.d.enterPalette(sess, h.ac)310 h.d.enterPalette(sess, h.ac)
@@ -318,6 +333,16 @@ func (h daemonKeyHandler) Action(action keys.Action) {
318 if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil {333 if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil {
319 h.d.reportError(sess, err)334 h.d.reportError(sess, err)
320 }335 }
336+ case keys.ActionGrowPaneWidth:
337+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
338+ case keys.ActionShrinkPaneWidth:
339+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
340+ case keys.ActionGrowPaneHeight:
341+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
342+ case keys.ActionShrinkPaneHeight:
343+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
344+ case keys.ActionEqualizePanes:
345+ runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes})
321 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,346 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
322 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,347 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
323 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:348 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +121 -0
@@ -26,6 +26,127 @@ import (
2626
2727 // --- test doubles -----------------------------------------------------------
2828
29+func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
30+ tests := []struct {
31+ name string
32+ action keys.Action
33+ palette func(paletteExec) error
34+ control func(controlExec) error
35+ }{
36+ {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
37+ {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
38+ {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
39+ {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
40+ {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
41+ }
42+
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ d := newTestDaemon(t, nil, stubClock{})
46+ sess := addControlSession(d, "work", "t_work", "p_work")
47+ ac := &attachedClient{}
48+ ac.setSession(sess)
49+ target := resolveDaemonActionTarget(sess)
50+ keySpy := &actionRunnerSpy{}
51+ paletteSpy := &actionRunnerSpy{}
52+ controlSpy := &actionRunnerSpy{}
53+
54+ daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action)
55+ require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy}))
56+ require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy}))
57+
58+ require.Len(t, keySpy.requests, 1)
59+ require.Equal(t, keySpy.requests, paletteSpy.requests)
60+ require.Equal(t, keySpy.requests, controlSpy.requests)
61+ require.Same(t, sess, keySpy.requests[0].target.session)
62+ require.Same(t, target.tab, keySpy.requests[0].target.tab)
63+ require.Same(t, target.pane, keySpy.requests[0].target.pane)
64+ })
65+ }
66+}
67+
68+func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) {
69+ type adapterState struct {
70+ tree *layout.Tree
71+ placements []layout.Placement
72+ generationDelta uint64
73+ snapshotDirtyDelta uint64
74+ }
75+ tests := []struct {
76+ name string
77+ action keys.Action
78+ palette func(paletteExec) error
79+ control func(controlExec) error
80+ }{
81+ {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
82+ {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
83+ {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
84+ {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
85+ {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ run := func(t *testing.T, adapter string) adapterState {
91+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
92+ tb := sess.activeTab()
93+ p2 := newPane("pane-2", nil, tb.size)
94+ p3 := newPane("pane-3", nil, tb.size)
95+ tb.mu.Lock()
96+ tb.panes[p2.id] = p2
97+ tb.panes[p3.id] = p3
98+ tb.tree = &layout.Tree{
99+ Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
100+ layout.NewLeaf("pane-1"),
101+ {Kind: layout.Split, Dir: layout.Vertical, Weight: 2, Children: []*layout.Node{
102+ layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
103+ }},
104+ }},
105+ Focus: "pane-2",
106+ }
107+ tb.tree.Root.Children[0].Weight = 1
108+ tb.tree.Root.Children[1].Children[0].Weight = 1
109+ tb.tree.Root.Children[1].Children[1].Weight = 2
110+ generation := tb.layoutGeneration
111+ tb.mu.Unlock()
112+ sess.snapEligible.Store(true)
113+ sess.snapshotMu.Lock()
114+ dirty := sess.snapshotGeneration
115+ sess.snapshotMu.Unlock()
116+
117+ switch adapter {
118+ case "key":
119+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action)
120+ case "palette":
121+ require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac}))
122+ case "control":
123+ target := resolveDaemonActionTarget(sess)
124+ require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target}))
125+ default:
126+ t.Fatalf("unknown adapter %q", adapter)
127+ }
128+
129+ tb.mu.Lock()
130+ tree := tb.tree.Clone()
131+ gotGeneration := tb.layoutGeneration
132+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
133+ tb.mu.Unlock()
134+ require.True(t, ok)
135+ sess.snapshotMu.Lock()
136+ gotDirty := sess.snapshotGeneration
137+ sess.snapshotMu.Unlock()
138+ return adapterState{tree: tree, placements: placements, generationDelta: gotGeneration - generation, snapshotDirtyDelta: gotDirty - dirty}
139+ }
140+
141+ keyState := run(t, "key")
142+ require.Equal(t, uint64(1), keyState.generationDelta)
143+ require.Equal(t, uint64(1), keyState.snapshotDirtyDelta)
144+ require.Equal(t, keyState, run(t, "palette"))
145+ require.Equal(t, keyState, run(t, "control"))
146+ })
147+ }
148+}
149+
29150 // stubClock returns timers whose channel never fires, so a scheduler under it
30151 // blocks in its debounce loop until the session context is cancelled. Used by
31152
@@ -26,6 +26,127 @@ import (
26 26
27 // --- test doubles -----------------------------------------------------------27 // --- test doubles -----------------------------------------------------------
28 28
29+func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
30+ tests := []struct {
31+ name string
32+ action keys.Action
33+ palette func(paletteExec) error
34+ control func(controlExec) error
35+ }{
36+ {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
37+ {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
38+ {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
39+ {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
40+ {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
41+ }
42+
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ d := newTestDaemon(t, nil, stubClock{})
46+ sess := addControlSession(d, "work", "t_work", "p_work")
47+ ac := &attachedClient{}
48+ ac.setSession(sess)
49+ target := resolveDaemonActionTarget(sess)
50+ keySpy := &actionRunnerSpy{}
51+ paletteSpy := &actionRunnerSpy{}
52+ controlSpy := &actionRunnerSpy{}
53+
54+ daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action)
55+ require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy}))
56+ require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy}))
57+
58+ require.Len(t, keySpy.requests, 1)
59+ require.Equal(t, keySpy.requests, paletteSpy.requests)
60+ require.Equal(t, keySpy.requests, controlSpy.requests)
61+ require.Same(t, sess, keySpy.requests[0].target.session)
62+ require.Same(t, target.tab, keySpy.requests[0].target.tab)
63+ require.Same(t, target.pane, keySpy.requests[0].target.pane)
64+ })
65+ }
66+}
67+
68+func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) {
69+ type adapterState struct {
70+ tree *layout.Tree
71+ placements []layout.Placement
72+ generationDelta uint64
73+ snapshotDirtyDelta uint64
74+ }
75+ tests := []struct {
76+ name string
77+ action keys.Action
78+ palette func(paletteExec) error
79+ control func(controlExec) error
80+ }{
81+ {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
82+ {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
83+ {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
84+ {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
85+ {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ run := func(t *testing.T, adapter string) adapterState {
91+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
92+ tb := sess.activeTab()
93+ p2 := newPane("pane-2", nil, tb.size)
94+ p3 := newPane("pane-3", nil, tb.size)
95+ tb.mu.Lock()
96+ tb.panes[p2.id] = p2
97+ tb.panes[p3.id] = p3
98+ tb.tree = &layout.Tree{
99+ Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
100+ layout.NewLeaf("pane-1"),
101+ {Kind: layout.Split, Dir: layout.Vertical, Weight: 2, Children: []*layout.Node{
102+ layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
103+ }},
104+ }},
105+ Focus: "pane-2",
106+ }
107+ tb.tree.Root.Children[0].Weight = 1
108+ tb.tree.Root.Children[1].Children[0].Weight = 1
109+ tb.tree.Root.Children[1].Children[1].Weight = 2
110+ generation := tb.layoutGeneration
111+ tb.mu.Unlock()
112+ sess.snapEligible.Store(true)
113+ sess.snapshotMu.Lock()
114+ dirty := sess.snapshotGeneration
115+ sess.snapshotMu.Unlock()
116+
117+ switch adapter {
118+ case "key":
119+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action)
120+ case "palette":
121+ require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac}))
122+ case "control":
123+ target := resolveDaemonActionTarget(sess)
124+ require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target}))
125+ default:
126+ t.Fatalf("unknown adapter %q", adapter)
127+ }
128+
129+ tb.mu.Lock()
130+ tree := tb.tree.Clone()
131+ gotGeneration := tb.layoutGeneration
132+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
133+ tb.mu.Unlock()
134+ require.True(t, ok)
135+ sess.snapshotMu.Lock()
136+ gotDirty := sess.snapshotGeneration
137+ sess.snapshotMu.Unlock()
138+ return adapterState{tree: tree, placements: placements, generationDelta: gotGeneration - generation, snapshotDirtyDelta: gotDirty - dirty}
139+ }
140+
141+ keyState := run(t, "key")
142+ require.Equal(t, uint64(1), keyState.generationDelta)
143+ require.Equal(t, uint64(1), keyState.snapshotDirtyDelta)
144+ require.Equal(t, keyState, run(t, "palette"))
145+ require.Equal(t, keyState, run(t, "control"))
146+ })
147+ }
148+}
149+
29 // stubClock returns timers whose channel never fires, so a scheduler under it150 // stubClock returns timers whose channel never fires, so a scheduler under it
30 // blocks in its debounce loop until the session context is cancelled. Used by151 // blocks in its debounce loop until the session context is cancelled. Used by
31 152
M internal/usecase/keys/keyspec.go +5 -0
@@ -65,6 +65,11 @@ var actionNames = map[Action]string{
6565 ActionSwitchTab7: "switch-tab-7",
6666 ActionSwitchTab8: "switch-tab-8",
6767 ActionSwitchTab9: "switch-tab-9",
68+ ActionGrowPaneWidth: "grow-pane-width",
69+ ActionShrinkPaneWidth: "shrink-pane-width",
70+ ActionGrowPaneHeight: "grow-pane-height",
71+ ActionShrinkPaneHeight: "shrink-pane-height",
72+ ActionEqualizePanes: "equalize-panes",
6873 }
6974
7075 var actionsByName = func() map[string]Action {
@@ -65,6 +65,11 @@ var actionNames = map[Action]string{
65 ActionSwitchTab7: "switch-tab-7",65 ActionSwitchTab7: "switch-tab-7",
66 ActionSwitchTab8: "switch-tab-8",66 ActionSwitchTab8: "switch-tab-8",
67 ActionSwitchTab9: "switch-tab-9",67 ActionSwitchTab9: "switch-tab-9",
68+ ActionGrowPaneWidth: "grow-pane-width",
69+ ActionShrinkPaneWidth: "shrink-pane-width",
70+ ActionGrowPaneHeight: "grow-pane-height",
71+ ActionShrinkPaneHeight: "shrink-pane-height",
72+ ActionEqualizePanes: "equalize-panes",
68 }73 }
69 74
70 var actionsByName = func() map[string]Action {75 var actionsByName = func() map[string]Action {
M internal/usecase/keys/keyspec_test.go +35 -0
@@ -79,6 +79,11 @@ func TestActionNamesAreCanonical(t *testing.T) {
7979 {ActionSwitchTab7, "switch-tab-7"},
8080 {ActionSwitchTab8, "switch-tab-8"},
8181 {ActionSwitchTab9, "switch-tab-9"},
82+ {ActionGrowPaneWidth, "grow-pane-width"},
83+ {ActionShrinkPaneWidth, "shrink-pane-width"},
84+ {ActionGrowPaneHeight, "grow-pane-height"},
85+ {ActionShrinkPaneHeight, "shrink-pane-height"},
86+ {ActionEqualizePanes, "equalize-panes"},
8287 }
8388
8489 for _, tc := range cases {
@@ -114,6 +119,36 @@ func TestBuildBindingsAppliesOverrides(t *testing.T) {
114119 assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2)
115120 }
116121
122+func TestResizeActionsBindWithoutDefaults(t *testing.T) {
123+ tests := []struct {
124+ name string
125+ action Action
126+ key rune
127+ }{
128+ {"grow-pane-width", ActionGrowPaneWidth, 'w'},
129+ {"shrink-pane-width", ActionShrinkPaneWidth, 's'},
130+ {"grow-pane-height", ActionGrowPaneHeight, 'g'},
131+ {"shrink-pane-height", ActionShrinkPaneHeight, 'r'},
132+ {"equalize-panes", ActionEqualizePanes, 'e'},
133+ }
134+
135+ defaults := DefaultBindings()
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ for _, bound := range defaults.altRunes {
139+ require.NotEqual(t, tt.action, bound)
140+ }
141+ for _, bound := range defaults.altArrows {
142+ require.NotEqual(t, tt.action, bound)
143+ }
144+
145+ bindings, warnings := BuildBindings(map[string]string{tt.name: "alt+" + string(tt.key)})
146+ require.Empty(t, warnings)
147+ assertAltRuneAction(t, bindings, tt.key, tt.action)
148+ })
149+ }
150+}
151+
117152 func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) {
118153 bindings, warnings := BuildBindings(map[string]string{
119154 "missing": "alt+m",
@@ -79,6 +79,11 @@ func TestActionNamesAreCanonical(t *testing.T) {
79 {ActionSwitchTab7, "switch-tab-7"},79 {ActionSwitchTab7, "switch-tab-7"},
80 {ActionSwitchTab8, "switch-tab-8"},80 {ActionSwitchTab8, "switch-tab-8"},
81 {ActionSwitchTab9, "switch-tab-9"},81 {ActionSwitchTab9, "switch-tab-9"},
82+ {ActionGrowPaneWidth, "grow-pane-width"},
83+ {ActionShrinkPaneWidth, "shrink-pane-width"},
84+ {ActionGrowPaneHeight, "grow-pane-height"},
85+ {ActionShrinkPaneHeight, "shrink-pane-height"},
86+ {ActionEqualizePanes, "equalize-panes"},
82 }87 }
83 88
84 for _, tc := range cases {89 for _, tc := range cases {
@@ -114,6 +119,36 @@ func TestBuildBindingsAppliesOverrides(t *testing.T) {
114 assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2)119 assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2)
115 }120 }
116 121
122+func TestResizeActionsBindWithoutDefaults(t *testing.T) {
123+ tests := []struct {
124+ name string
125+ action Action
126+ key rune
127+ }{
128+ {"grow-pane-width", ActionGrowPaneWidth, 'w'},
129+ {"shrink-pane-width", ActionShrinkPaneWidth, 's'},
130+ {"grow-pane-height", ActionGrowPaneHeight, 'g'},
131+ {"shrink-pane-height", ActionShrinkPaneHeight, 'r'},
132+ {"equalize-panes", ActionEqualizePanes, 'e'},
133+ }
134+
135+ defaults := DefaultBindings()
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ for _, bound := range defaults.altRunes {
139+ require.NotEqual(t, tt.action, bound)
140+ }
141+ for _, bound := range defaults.altArrows {
142+ require.NotEqual(t, tt.action, bound)
143+ }
144+
145+ bindings, warnings := BuildBindings(map[string]string{tt.name: "alt+" + string(tt.key)})
146+ require.Empty(t, warnings)
147+ assertAltRuneAction(t, bindings, tt.key, tt.action)
148+ })
149+ }
150+}
151+
117 func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) {152 func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) {
118 bindings, warnings := BuildBindings(map[string]string{153 bindings, warnings := BuildBindings(map[string]string{
119 "missing": "alt+m",154 "missing": "alt+m",
M internal/usecase/keys/router.go +5 -0
@@ -38,6 +38,11 @@ const (
3838 ActionFocusPaneUp
3939 ActionFocusPaneDown
4040 ActionToggleFloatingPane
41+ ActionGrowPaneWidth
42+ ActionShrinkPaneWidth
43+ ActionGrowPaneHeight
44+ ActionShrinkPaneHeight
45+ ActionEqualizePanes
4146 )
4247
4348 // Handler receives router outputs. Forward is called only for bytes that should
@@ -38,6 +38,11 @@ const (
38 ActionFocusPaneUp38 ActionFocusPaneUp
39 ActionFocusPaneDown39 ActionFocusPaneDown
40 ActionToggleFloatingPane40 ActionToggleFloatingPane
41+ ActionGrowPaneWidth
42+ ActionShrinkPaneWidth
43+ ActionGrowPaneHeight
44+ ActionShrinkPaneHeight
45+ ActionEqualizePanes
41 )46 )
42 47
43 // Handler receives router outputs. Forward is called only for bytes that should48 // Handler receives router outputs. Forward is called only for bytes that should

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