detached b7c5bff

fix(panes): stabilize consume/expel cycling semantics

commit
b7c5bff25e6ee188e1893639ca1d6dacde7fcd97
author
brice · · 2025-12-26 13:21 UTC
parent
a9a926c
signature
unsigned
7 files · +297 -38
M CHANGELOG.md +1 -0
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
66
77 ### Added
88 - **Configurable new-pane defaults**: New `workspace.new_pane_url` (default: `about:blank`, supports `dumb://`) and `omnibox.auto_open_on_new_pane` (default: false).
9+- **Consume-or-expel panes (very alpha)**: Niri-style pane consume/expel operation to merge a leaf into a sibling stack or expel a stacked pane into a split. Default global shortcuts: `Alt+[` / `Alt+]` (and `Alt+Shift+[` / `Alt+Shift+]`).
910 - **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize.
1011 - **Mouse-driven pane resizing**: Drag split dividers with the mouse; split ratios are persisted in session snapshots (rounded to 2 decimals) and restored on resurrection.
1112 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
6 6
7 ### Added7 ### Added
8 - **Configurable new-pane defaults**: New `workspace.new_pane_url` (default: `about:blank`, supports `dumb://`) and `omnibox.auto_open_on_new_pane` (default: false).8 - **Configurable new-pane defaults**: New `workspace.new_pane_url` (default: `about:blank`, supports `dumb://`) and `omnibox.auto_open_on_new_pane` (default: false).
9+- **Consume-or-expel panes (very alpha)**: Niri-style pane consume/expel operation to merge a leaf into a sibling stack or expel a stacked pane into a split. Default global shortcuts: `Alt+[` / `Alt+]` (and `Alt+Shift+[` / `Alt+Shift+]`).
9 - **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize.10 - **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize.
10 - **Mouse-driven pane resizing**: Drag split dividers with the mouse; split ratios are persisted in session snapshots (rounded to 2 decimals) and restored on resurrection.11 - **Mouse-driven pane resizing**: Drag split dividers with the mouse; split ratios are persisted in session snapshots (rounded to 2 decimals) and restored on resurrection.
11 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.12 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
M README.md +4 -0
@@ -32,6 +32,8 @@ The project is now structured around clean architecture, which makes it much eas
3232
3333 Early development with regular releases. Core features work well for daily use but expect some rough edges.
3434
35+Some features are very alpha/experimental (notably consume-or-expel panes) and may change behavior between releases.
36+
3537 ## Controls & Shortcuts
3638
3739 ### Keyboard Shortcuts
@@ -79,6 +81,8 @@ Early development with regular releases. Core features work well for daily use b
7981 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |
8082 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
8183 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
84+| **Alt+[** / **Alt+]** | Consume / Expel Pane | Experimental (alpha): merge into sibling stack, or expel out of stack |
85+| **Alt+Shift+[** / **Alt+Shift+]** | Consume / Expel Pane (Vertical) | Experimental (alpha): up/down variants |
8286
8387 #### Resize Mode
8488 | Shortcut | Action | Notes |
@@ -32,6 +32,8 @@ The project is now structured around clean architecture, which makes it much eas
32 32
33 Early development with regular releases. Core features work well for daily use but expect some rough edges.33 Early development with regular releases. Core features work well for daily use but expect some rough edges.
34 34
35+Some features are very alpha/experimental (notably consume-or-expel panes) and may change behavior between releases.
36+
35 ## Controls & Shortcuts37 ## Controls & Shortcuts
36 38
37 ### Keyboard Shortcuts39 ### Keyboard Shortcuts
@@ -79,6 +81,8 @@ Early development with regular releases. Core features work well for daily use b
79 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |81 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |
80 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |82 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
81 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |83 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
84+| **Alt+[** / **Alt+]** | Consume / Expel Pane | Experimental (alpha): merge into sibling stack, or expel out of stack |
85+| **Alt+Shift+[** / **Alt+Shift+]** | Consume / Expel Pane (Vertical) | Experimental (alpha): up/down variants |
82 86
83 #### Resize Mode87 #### Resize Mode
84 | Shortcut | Action | Notes |88 | Shortcut | Action | Notes |
M docs/CONFIG.md +13 -0
@@ -225,6 +225,13 @@ split-up = ["arrowup", "u"]
225225 split-down = ["arrowdown", "d"]
226226 stack-pane = ["s"]
227227 close-pane = ["x"]
228+
229+# Consume-or-expel (niri-style) - very alpha
230+consume-or-expel-left = ["bracketleft", "["]
231+consume-or-expel-right = ["bracketright", "]"]
232+consume-or-expel-up = ["shift+bracketleft", "braceleft", "{"]
233+consume-or-expel-down = ["shift+bracketright", "braceright", "}"]
234+
228235 focus-right = ["shift+arrowright", "shift+l"]
229236 focus-left = ["shift+arrowleft", "shift+h"]
230237 focus-up = ["shift+arrowup", "shift+k"]
@@ -296,8 +303,14 @@ Notes:
296303 | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) |
297304 | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut |
298305 | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut |
306+| `workspace.shortcuts.consume_or_expel_left` | string | `"alt+bracketleft"` | Consume into left sibling stack, or expel left if stacked (very alpha) |
307+| `workspace.shortcuts.consume_or_expel_right` | string | `"alt+bracketright"` | Consume into right sibling stack, or expel right if stacked (very alpha) |
308+| `workspace.shortcuts.consume_or_expel_up` | string | `"alt+shift+bracketleft"` | Consume into upper sibling stack, or expel up if stacked (very alpha) |
309+| `workspace.shortcuts.consume_or_expel_down` | string | `"alt+shift+bracketright"` | Consume into lower sibling stack, or expel down if stacked (very alpha) |
299310
300311 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.
312+>
313+> **Note:** Consume-or-expel is experimental and may change behavior between releases.
301314
302315 ### Popup Behavior
303316
@@ -225,6 +225,13 @@ split-up = ["arrowup", "u"]
225 split-down = ["arrowdown", "d"]225 split-down = ["arrowdown", "d"]
226 stack-pane = ["s"]226 stack-pane = ["s"]
227 close-pane = ["x"]227 close-pane = ["x"]
228+
229+# Consume-or-expel (niri-style) - very alpha
230+consume-or-expel-left = ["bracketleft", "["]
231+consume-or-expel-right = ["bracketright", "]"]
232+consume-or-expel-up = ["shift+bracketleft", "braceleft", "{"]
233+consume-or-expel-down = ["shift+bracketright", "braceright", "}"]
234+
228 focus-right = ["shift+arrowright", "shift+l"]235 focus-right = ["shift+arrowright", "shift+l"]
229 focus-left = ["shift+arrowleft", "shift+h"]236 focus-left = ["shift+arrowleft", "shift+h"]
230 focus-up = ["shift+arrowup", "shift+k"]237 focus-up = ["shift+arrowup", "shift+k"]
@@ -296,8 +303,14 @@ Notes:
296 | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) |303 | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) |
297 | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut |304 | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut |
298 | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut |305 | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut |
306+| `workspace.shortcuts.consume_or_expel_left` | string | `"alt+bracketleft"` | Consume into left sibling stack, or expel left if stacked (very alpha) |
307+| `workspace.shortcuts.consume_or_expel_right` | string | `"alt+bracketright"` | Consume into right sibling stack, or expel right if stacked (very alpha) |
308+| `workspace.shortcuts.consume_or_expel_up` | string | `"alt+shift+bracketleft"` | Consume into upper sibling stack, or expel up if stacked (very alpha) |
309+| `workspace.shortcuts.consume_or_expel_down` | string | `"alt+shift+bracketright"` | Consume into lower sibling stack, or expel down if stacked (very alpha) |
299 310
300 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.311 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.
312+>
313+> **Note:** Consume-or-expel is experimental and may change behavior between releases.
301 314
302 ### Popup Behavior315 ### Popup Behavior
303 316
M internal/application/usecase/manage_panes.go +132 -8
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "math"
88 "sort"
9+ "strings"
910
1011 "github.com/bnema/dumber/internal/domain/entity"
1112 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -66,6 +67,8 @@ type ConsumeOrExpelResult struct {
6667 ErrorMessage string
6768 }
6869
70+const consumeOrExpelExpelCycleMarker = "_expel_cycle"
71+
6972 // ManagePanesUseCase handles pane tree operations.
7073 type ManagePanesUseCase struct {
7174 idGenerator IDGenerator
@@ -1143,7 +1146,7 @@ func (*ManagePanesUseCase) consumeIntoSiblingStack(
11431146 // 1) horizontal split -> vertical split (target on top)
11441147 // 2) vertical split -> stack (top then bottom)
11451148 if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1146- cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling)
1149+ cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction)
11471150 if err != nil {
11481151 return nil, err
11491152 }
@@ -1218,8 +1221,33 @@ func (uc *ManagePanesUseCase) expelFromStack(
12181221 }
12191222
12201223 // Split around the remaining container (stackNode) with expelled pane.
1221- if err := splitExistingNode(ws, stackNode, expelled, direction, uc.idGenerator); err != nil {
1222- return nil, err
1224+ // For left/right, expel cycles stack -> vertical split first:
1225+ // - left: expelled pane above
1226+ // - right: expelled pane below
1227+ splitDirection := direction
1228+ switch direction {
1229+ case ConsumeOrExpelLeft:
1230+ splitDirection = ConsumeOrExpelUp
1231+ case ConsumeOrExpelRight:
1232+ splitDirection = ConsumeOrExpelDown
1233+ }
1234+
1235+ if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1236+ err := splitExistingNodeWithMarker(
1237+ ws,
1238+ stackNode,
1239+ expelled,
1240+ splitDirection,
1241+ uc.idGenerator,
1242+ consumeOrExpelExpelCycleMarker,
1243+ )
1244+ if err != nil {
1245+ return nil, err
1246+ }
1247+ } else {
1248+ if err := splitExistingNode(ws, stackNode, expelled, splitDirection, uc.idGenerator); err != nil {
1249+ return nil, err
1250+ }
12231251 }
12241252 ws.ActivePaneID = expelled.Pane.ID
12251253
@@ -1300,20 +1328,29 @@ func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOr
13001328 }
13011329 }
13021330
1331+ // Cross-axis fallback is only used to enable the leaf cycling behavior
1332+ // (H -> V -> stack, and after expel: V -> H). It should never cause a leaf to
1333+ // consume into a stacked/split sibling when pressing an unrelated axis key.
1334+ var sibling *entity.PaneNode
13031335 if parent.Left() == node {
1304- return parent.Right()
1336+ sibling = parent.Right()
1337+ } else if parent.Right() == node {
1338+ sibling = parent.Left()
13051339 }
1306- if parent.Right() == node {
1307- return parent.Left()
1340+ if sibling == nil {
1341+ return nil
13081342 }
1309-
1310- return nil
1343+ if !node.IsLeaf() || !sibling.IsLeaf() {
1344+ return nil
1345+ }
1346+ return sibling
13111347 }
13121348
13131349 func cycleHorizontalToVerticalThenStack(
13141350 ws *entity.Workspace,
13151351 activeNode *entity.PaneNode,
13161352 sibling *entity.PaneNode,
1353+ direction ConsumeOrExpelDirection,
13171354 ) (cycled bool, cycleAction string, err error) {
13181355 if ws == nil {
13191356 return false, "", fmt.Errorf("workspace is required")
@@ -1332,6 +1369,7 @@ func cycleHorizontalToVerticalThenStack(
13321369
13331370 switch parent.SplitDir {
13341371 case entity.SplitHorizontal:
1372+ // Horizontal -> Vertical: target sibling should be on top.
13351373 parent.SplitDir = entity.SplitVertical
13361374 if parent.Children[0] != sibling {
13371375 parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
@@ -1339,6 +1377,26 @@ func cycleHorizontalToVerticalThenStack(
13391377 ws.ActivePaneID = activeNode.Pane.ID
13401378 return true, "vertical_split", nil
13411379 case entity.SplitVertical:
1380+ // Vertical -> Horizontal when pressing left/right (reverse of first step).
1381+ // This is used after expel: stack -> vertical; then Ctrl+[ / Ctrl+] should turn it into left/right.
1382+ if direction == ConsumeOrExpelRight && strings.Contains(parent.ID, consumeOrExpelExpelCycleMarker) {
1383+ parent.SplitDir = entity.SplitHorizontal
1384+ if direction == ConsumeOrExpelRight {
1385+ // Active should become the right child.
1386+ if parent.Children[1] != activeNode {
1387+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1388+ }
1389+ } else {
1390+ // Active should become the left child.
1391+ if parent.Children[0] != activeNode {
1392+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1393+ }
1394+ }
1395+ ws.ActivePaneID = activeNode.Pane.ID
1396+ return true, "horizontal_split", nil
1397+ }
1398+
1399+ // Otherwise, vertical -> stack.
13421400 parent.SplitDir = entity.SplitNone
13431401 parent.SplitRatio = 0
13441402 parent.IsStacked = true
@@ -1536,6 +1594,72 @@ func splitExistingNode(
15361594 return nil
15371595 }
15381596
1597+func splitExistingNodeWithMarker(
1598+ ws *entity.Workspace,
1599+ targetNode *entity.PaneNode,
1600+ newSibling *entity.PaneNode,
1601+ direction ConsumeOrExpelDirection,
1602+ idGen IDGenerator,
1603+ marker string,
1604+) error {
1605+ if ws == nil {
1606+ return fmt.Errorf("workspace is required")
1607+ }
1608+ if targetNode == nil {
1609+ return fmt.Errorf("target node is required")
1610+ }
1611+ if newSibling == nil {
1612+ return fmt.Errorf("new sibling node is required")
1613+ }
1614+ if idGen == nil {
1615+ return fmt.Errorf("id generator is required")
1616+ }
1617+
1618+ var splitDir entity.SplitDirection
1619+ switch direction {
1620+ case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1621+ splitDir = entity.SplitHorizontal
1622+ case ConsumeOrExpelUp, ConsumeOrExpelDown:
1623+ splitDir = entity.SplitVertical
1624+ default:
1625+ return fmt.Errorf("invalid direction: %s", direction)
1626+ }
1627+
1628+ parentNode := &entity.PaneNode{
1629+ ID: idGen() + marker,
1630+ SplitDir: splitDir,
1631+ SplitRatio: 0.5,
1632+ Children: make([]*entity.PaneNode, 2),
1633+ }
1634+
1635+ switch direction {
1636+ case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1637+ parentNode.Children[0] = newSibling
1638+ parentNode.Children[1] = targetNode
1639+ case ConsumeOrExpelRight, ConsumeOrExpelDown:
1640+ parentNode.Children[0] = targetNode
1641+ parentNode.Children[1] = newSibling
1642+ }
1643+
1644+ newSibling.Parent = parentNode
1645+ oldParent := targetNode.Parent
1646+ targetNode.Parent = parentNode
1647+
1648+ if oldParent == nil {
1649+ ws.Root = parentNode
1650+ } else {
1651+ for i, child := range oldParent.Children {
1652+ if child == targetNode {
1653+ oldParent.Children[i] = parentNode
1654+ break
1655+ }
1656+ }
1657+ parentNode.Parent = oldParent
1658+ }
1659+
1660+ return nil
1661+}
1662+
15391663 // GetAllPanes returns all leaf panes in a workspace.
15401664 //
15411665 //nolint:revive // receiver required for interface consistency
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "math"7 "math"
8 "sort"8 "sort"
9+ "strings"
9 10
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
11 domainurl "github.com/bnema/dumber/internal/domain/url"12 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -66,6 +67,8 @@ type ConsumeOrExpelResult struct {
66 ErrorMessage string67 ErrorMessage string
67 }68 }
68 69
70+const consumeOrExpelExpelCycleMarker = "_expel_cycle"
71+
69 // ManagePanesUseCase handles pane tree operations.72 // ManagePanesUseCase handles pane tree operations.
70 type ManagePanesUseCase struct {73 type ManagePanesUseCase struct {
71 idGenerator IDGenerator74 idGenerator IDGenerator
@@ -1143,7 +1146,7 @@ func (*ManagePanesUseCase) consumeIntoSiblingStack(
1143 // 1) horizontal split -> vertical split (target on top)1146 // 1) horizontal split -> vertical split (target on top)
1144 // 2) vertical split -> stack (top then bottom)1147 // 2) vertical split -> stack (top then bottom)
1145 if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {1148 if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1146- cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling)1149+ cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction)
1147 if err != nil {1150 if err != nil {
1148 return nil, err1151 return nil, err
1149 }1152 }
@@ -1218,8 +1221,33 @@ func (uc *ManagePanesUseCase) expelFromStack(
1218 }1221 }
1219 1222
1220 // Split around the remaining container (stackNode) with expelled pane.1223 // Split around the remaining container (stackNode) with expelled pane.
1221- if err := splitExistingNode(ws, stackNode, expelled, direction, uc.idGenerator); err != nil {1224+ // For left/right, expel cycles stack -> vertical split first:
1222- return nil, err1225+ // - left: expelled pane above
1226+ // - right: expelled pane below
1227+ splitDirection := direction
1228+ switch direction {
1229+ case ConsumeOrExpelLeft:
1230+ splitDirection = ConsumeOrExpelUp
1231+ case ConsumeOrExpelRight:
1232+ splitDirection = ConsumeOrExpelDown
1233+ }
1234+
1235+ if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1236+ err := splitExistingNodeWithMarker(
1237+ ws,
1238+ stackNode,
1239+ expelled,
1240+ splitDirection,
1241+ uc.idGenerator,
1242+ consumeOrExpelExpelCycleMarker,
1243+ )
1244+ if err != nil {
1245+ return nil, err
1246+ }
1247+ } else {
1248+ if err := splitExistingNode(ws, stackNode, expelled, splitDirection, uc.idGenerator); err != nil {
1249+ return nil, err
1250+ }
1223 }1251 }
1224 ws.ActivePaneID = expelled.Pane.ID1252 ws.ActivePaneID = expelled.Pane.ID
1225 1253
@@ -1300,20 +1328,29 @@ func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOr
1300 }1328 }
1301 }1329 }
1302 1330
1331+ // Cross-axis fallback is only used to enable the leaf cycling behavior
1332+ // (H -> V -> stack, and after expel: V -> H). It should never cause a leaf to
1333+ // consume into a stacked/split sibling when pressing an unrelated axis key.
1334+ var sibling *entity.PaneNode
1303 if parent.Left() == node {1335 if parent.Left() == node {
1304- return parent.Right()1336+ sibling = parent.Right()
1337+ } else if parent.Right() == node {
1338+ sibling = parent.Left()
1305 }1339 }
1306- if parent.Right() == node {1340+ if sibling == nil {
1307- return parent.Left()1341+ return nil
1308 }1342 }
1309-1343+ if !node.IsLeaf() || !sibling.IsLeaf() {
1310- return nil1344+ return nil
1345+ }
1346+ return sibling
1311 }1347 }
1312 1348
1313 func cycleHorizontalToVerticalThenStack(1349 func cycleHorizontalToVerticalThenStack(
1314 ws *entity.Workspace,1350 ws *entity.Workspace,
1315 activeNode *entity.PaneNode,1351 activeNode *entity.PaneNode,
1316 sibling *entity.PaneNode,1352 sibling *entity.PaneNode,
1353+ direction ConsumeOrExpelDirection,
1317 ) (cycled bool, cycleAction string, err error) {1354 ) (cycled bool, cycleAction string, err error) {
1318 if ws == nil {1355 if ws == nil {
1319 return false, "", fmt.Errorf("workspace is required")1356 return false, "", fmt.Errorf("workspace is required")
@@ -1332,6 +1369,7 @@ func cycleHorizontalToVerticalThenStack(
1332 1369
1333 switch parent.SplitDir {1370 switch parent.SplitDir {
1334 case entity.SplitHorizontal:1371 case entity.SplitHorizontal:
1372+ // Horizontal -> Vertical: target sibling should be on top.
1335 parent.SplitDir = entity.SplitVertical1373 parent.SplitDir = entity.SplitVertical
1336 if parent.Children[0] != sibling {1374 if parent.Children[0] != sibling {
1337 parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]1375 parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
@@ -1339,6 +1377,26 @@ func cycleHorizontalToVerticalThenStack(
1339 ws.ActivePaneID = activeNode.Pane.ID1377 ws.ActivePaneID = activeNode.Pane.ID
1340 return true, "vertical_split", nil1378 return true, "vertical_split", nil
1341 case entity.SplitVertical:1379 case entity.SplitVertical:
1380+ // Vertical -> Horizontal when pressing left/right (reverse of first step).
1381+ // This is used after expel: stack -> vertical; then Ctrl+[ / Ctrl+] should turn it into left/right.
1382+ if direction == ConsumeOrExpelRight && strings.Contains(parent.ID, consumeOrExpelExpelCycleMarker) {
1383+ parent.SplitDir = entity.SplitHorizontal
1384+ if direction == ConsumeOrExpelRight {
1385+ // Active should become the right child.
1386+ if parent.Children[1] != activeNode {
1387+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1388+ }
1389+ } else {
1390+ // Active should become the left child.
1391+ if parent.Children[0] != activeNode {
1392+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1393+ }
1394+ }
1395+ ws.ActivePaneID = activeNode.Pane.ID
1396+ return true, "horizontal_split", nil
1397+ }
1398+
1399+ // Otherwise, vertical -> stack.
1342 parent.SplitDir = entity.SplitNone1400 parent.SplitDir = entity.SplitNone
1343 parent.SplitRatio = 01401 parent.SplitRatio = 0
1344 parent.IsStacked = true1402 parent.IsStacked = true
@@ -1536,6 +1594,72 @@ func splitExistingNode(
1536 return nil1594 return nil
1537 }1595 }
1538 1596
1597+func splitExistingNodeWithMarker(
1598+ ws *entity.Workspace,
1599+ targetNode *entity.PaneNode,
1600+ newSibling *entity.PaneNode,
1601+ direction ConsumeOrExpelDirection,
1602+ idGen IDGenerator,
1603+ marker string,
1604+) error {
1605+ if ws == nil {
1606+ return fmt.Errorf("workspace is required")
1607+ }
1608+ if targetNode == nil {
1609+ return fmt.Errorf("target node is required")
1610+ }
1611+ if newSibling == nil {
1612+ return fmt.Errorf("new sibling node is required")
1613+ }
1614+ if idGen == nil {
1615+ return fmt.Errorf("id generator is required")
1616+ }
1617+
1618+ var splitDir entity.SplitDirection
1619+ switch direction {
1620+ case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1621+ splitDir = entity.SplitHorizontal
1622+ case ConsumeOrExpelUp, ConsumeOrExpelDown:
1623+ splitDir = entity.SplitVertical
1624+ default:
1625+ return fmt.Errorf("invalid direction: %s", direction)
1626+ }
1627+
1628+ parentNode := &entity.PaneNode{
1629+ ID: idGen() + marker,
1630+ SplitDir: splitDir,
1631+ SplitRatio: 0.5,
1632+ Children: make([]*entity.PaneNode, 2),
1633+ }
1634+
1635+ switch direction {
1636+ case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1637+ parentNode.Children[0] = newSibling
1638+ parentNode.Children[1] = targetNode
1639+ case ConsumeOrExpelRight, ConsumeOrExpelDown:
1640+ parentNode.Children[0] = targetNode
1641+ parentNode.Children[1] = newSibling
1642+ }
1643+
1644+ newSibling.Parent = parentNode
1645+ oldParent := targetNode.Parent
1646+ targetNode.Parent = parentNode
1647+
1648+ if oldParent == nil {
1649+ ws.Root = parentNode
1650+ } else {
1651+ for i, child := range oldParent.Children {
1652+ if child == targetNode {
1653+ oldParent.Children[i] = parentNode
1654+ break
1655+ }
1656+ }
1657+ parentNode.Parent = oldParent
1658+ }
1659+
1660+ return nil
1661+}
1662+
1539 // GetAllPanes returns all leaf panes in a workspace.1663 // GetAllPanes returns all leaf panes in a workspace.
1540 //1664 //
1541 //nolint:revive // receiver required for interface consistency1665 //nolint:revive // receiver required for interface consistency
M internal/application/usecase/manage_panes_consume_expel_test.go +141 -24
@@ -117,7 +117,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd
117117 uc := NewManagePanesUseCase(func() string { return "id" })
118118 ctx := context.Background()
119119
120- stackNode := stack("stack", leaf("a"), leaf("b"))
120+ stackNode := stack(leaf("a"), leaf("b"))
121121 c := leaf("c")
122122 root := split(entity.SplitHorizontal, stackNode, c)
123123 ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID}
@@ -140,11 +140,35 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd
140140 }
141141 }
142142
143+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStackedSibling_Noops(t *testing.T) {
144+ uc := NewManagePanesUseCase(func() string { return "id" })
145+ ctx := context.Background()
146+
147+ top := leaf("top")
148+ bottomStack := stack(leaf("a"), leaf("b"))
149+ root := split(entity.SplitVertical, top, bottomStack)
150+ ws := &entity.Workspace{Root: root, ActivePaneID: top.Pane.ID}
151+
152+ res, err := uc.ConsumeOrExpel(ctx, ws, top, ConsumeOrExpelLeft)
153+ if err != nil {
154+ t.Fatalf("unexpected error: %v", err)
155+ }
156+ if res == nil || res.Action != "none" {
157+ t.Fatalf("result action=%v, want none", res)
158+ }
159+ if res.ErrorMessage != "No pane to the left" {
160+ t.Fatalf("error=%q, want %q", res.ErrorMessage, "No pane to the left")
161+ }
162+ if ws.Root != root {
163+ t.Fatalf("tree should be unchanged")
164+ }
165+}
166+
143167 func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) {
144168 uc := NewManagePanesUseCase(func() string { return "id" })
145169 ctx := context.Background()
146170
147- stackNode := stack("stack", leaf("a"), leaf("b"))
171+ stackNode := stack(leaf("a"), leaf("b"))
148172 b := stackNode.Children[1]
149173 ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
150174
@@ -155,31 +179,90 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testin
155179 if res == nil || res.Action != "expelled" {
156180 t.Fatalf("result action=%v, want expelled", res)
157181 }
158- if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitHorizontal {
159- t.Fatalf("root should be a horizontal split")
182+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
183+ t.Fatalf("root should be a vertical split")
160184 }
161185
162- left := ws.Root.Left()
163- right := ws.Root.Right()
164- if left == nil || right == nil {
186+ top := ws.Root.Left()
187+ bottom := ws.Root.Right()
188+ if top == nil || bottom == nil {
165189 t.Fatalf("split children should exist")
166190 }
167- if !left.IsLeaf() || left.Pane == nil || left.Pane.ID != "a" {
168- t.Fatalf("left child should be leaf a")
191+ if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" {
192+ t.Fatalf("top child should be leaf a")
169193 }
170- if !right.IsLeaf() || right.Pane == nil || right.Pane.ID != "b" {
171- t.Fatalf("right child should be leaf b")
194+ if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" {
195+ t.Fatalf("bottom child should be leaf b")
172196 }
173197 if ws.ActivePaneID != "b" {
174198 t.Fatalf("active=%s, want b", ws.ActivePaneID)
175199 }
176200 }
177201
202+func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalToHorizontal(t *testing.T) {
203+ uc := NewManagePanesUseCase(func() string { return "id" })
204+ ctx := context.Background()
205+
206+ stackNode := stack(leaf("a"), leaf("b"))
207+ b := stackNode.Children[1]
208+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
209+
210+ // First press (on stacked pane): stack -> vertical split with active on bottom.
211+ mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled")
212+ mustSplitRoot(t, ws, entity.SplitVertical)
213+ if string(ws.ActivePaneID) != "b" {
214+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
215+ }
216+
217+ active := ws.ActivePane()
218+ mustLeafPaneID(t, active, "b")
219+
220+ // Second press (on bottom pane): vertical -> horizontal split with active on right.
221+ mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelRight, "consumed")
222+ root := mustSplitRoot(t, ws, entity.SplitHorizontal)
223+ mustLeafPaneID(t, root.Left(), "a")
224+ mustLeafPaneID(t, root.Right(), "b")
225+ if string(ws.ActivePaneID) != "b" {
226+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
227+ }
228+}
229+
230+func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t *testing.T) {
231+ uc := NewManagePanesUseCase(func() string { return "id" })
232+ ctx := context.Background()
233+
234+ stackNode := stack(leaf("a"), leaf("b"))
235+ b := stackNode.Children[1]
236+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
237+
238+ // First press (on stacked pane): stack -> vertical split with active on bottom.
239+ mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled")
240+ mustSplitRoot(t, ws, entity.SplitVertical)
241+ if string(ws.ActivePaneID) != "b" {
242+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
243+ }
244+
245+ active := ws.ActivePane()
246+ mustLeafPaneID(t, active, "b")
247+
248+ // Second press (on bottom pane): vertical -> stack (return to original).
249+ mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelLeft, "consumed")
250+ if ws.Root == nil || !ws.Root.IsStacked {
251+ t.Fatalf("root should be a stack")
252+ }
253+ if got := panesInOrder(ws.Root); got != "a,b" {
254+ t.Fatalf("stack panes=%s, want a,b", got)
255+ }
256+ if string(ws.ActivePaneID) != "b" {
257+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
258+ }
259+}
260+
178261 func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {
179262 uc := NewManagePanesUseCase(func() string { return "id" })
180263 ctx := context.Background()
181264
182- stackNode := stack("stack", leaf("a"), leaf("b"), leaf("c"))
265+ stackNode := stack(leaf("a"), leaf("b"), leaf("c"))
183266 b := stackNode.Children[1]
184267 ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
185268
@@ -190,19 +273,19 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *
190273 if res == nil || res.Action != "expelled" {
191274 t.Fatalf("result action=%v, want expelled", res)
192275 }
193- if ws.Root == nil || !ws.Root.IsSplit() {
194- t.Fatalf("root should be a split")
276+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
277+ t.Fatalf("root should be a vertical split")
195278 }
196- left := ws.Root.Left()
197- right := ws.Root.Right()
198- if left == nil || !left.IsStacked {
199- t.Fatalf("left child should be stack")
279+ top := ws.Root.Left()
280+ bottom := ws.Root.Right()
281+ if top == nil || !top.IsStacked {
282+ t.Fatalf("top child should be stack")
200283 }
201- if panesInOrder(left) != "a,c" {
202- t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(left))
284+ if panesInOrder(top) != "a,c" {
285+ t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top))
203286 }
204- if right == nil || !right.IsLeaf() || right.Pane.ID != "b" {
205- t.Fatalf("right child should be expelled leaf b")
287+ if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" {
288+ t.Fatalf("bottom child should be expelled leaf b")
206289 }
207290 if ws.ActivePaneID != "b" {
208291 t.Fatalf("active=%s, want b", ws.ActivePaneID)
@@ -281,6 +364,40 @@ func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {
281364 }
282365 }
283366
367+func mustConsumeOrExpelAction(
368+ t *testing.T,
369+ uc *ManagePanesUseCase,
370+ ctx context.Context,
371+ ws *entity.Workspace,
372+ node *entity.PaneNode,
373+ direction ConsumeOrExpelDirection,
374+ wantAction string,
375+) {
376+ t.Helper()
377+ res, err := uc.ConsumeOrExpel(ctx, ws, node, direction)
378+ if err != nil {
379+ t.Fatalf("unexpected error: %v", err)
380+ }
381+ if res == nil || res.Action != wantAction {
382+ t.Fatalf("result action=%v, want %s", res, wantAction)
383+ }
384+}
385+
386+func mustSplitRoot(t *testing.T, ws *entity.Workspace, dir entity.SplitDirection) *entity.PaneNode {
387+ t.Helper()
388+ if ws == nil || ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != dir {
389+ t.Fatalf("root should be a %v split", dir)
390+ }
391+ return ws.Root
392+}
393+
394+func mustLeafPaneID(t *testing.T, node *entity.PaneNode, want string) {
395+ t.Helper()
396+ if node == nil || !node.IsLeaf() || node.Pane == nil || string(node.Pane.ID) != want {
397+ t.Fatalf("leaf pane=%v, want %s", node, want)
398+ }
399+}
400+
284401 func leaf(id string) *entity.PaneNode {
285402 return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}}
286403 }
@@ -292,8 +409,8 @@ func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.Pane
292409 return root
293410 }
294411
295-func stack(id string, panes ...*entity.PaneNode) *entity.PaneNode {
296- n := &entity.PaneNode{ID: id, IsStacked: true, ActiveStackIndex: 0}
412+func stack(panes ...*entity.PaneNode) *entity.PaneNode {
413+ n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}
297414 children := make([]*entity.PaneNode, 0, len(panes))
298415 for _, p := range panes {
299416 p.Parent = n
@@ -117,7 +117,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd
117 uc := NewManagePanesUseCase(func() string { return "id" })117 uc := NewManagePanesUseCase(func() string { return "id" })
118 ctx := context.Background()118 ctx := context.Background()
119 119
120- stackNode := stack("stack", leaf("a"), leaf("b"))120+ stackNode := stack(leaf("a"), leaf("b"))
121 c := leaf("c")121 c := leaf("c")
122 root := split(entity.SplitHorizontal, stackNode, c)122 root := split(entity.SplitHorizontal, stackNode, c)
123 ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID}123 ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID}
@@ -140,11 +140,35 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd
140 }140 }
141 }141 }
142 142
143+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStackedSibling_Noops(t *testing.T) {
144+ uc := NewManagePanesUseCase(func() string { return "id" })
145+ ctx := context.Background()
146+
147+ top := leaf("top")
148+ bottomStack := stack(leaf("a"), leaf("b"))
149+ root := split(entity.SplitVertical, top, bottomStack)
150+ ws := &entity.Workspace{Root: root, ActivePaneID: top.Pane.ID}
151+
152+ res, err := uc.ConsumeOrExpel(ctx, ws, top, ConsumeOrExpelLeft)
153+ if err != nil {
154+ t.Fatalf("unexpected error: %v", err)
155+ }
156+ if res == nil || res.Action != "none" {
157+ t.Fatalf("result action=%v, want none", res)
158+ }
159+ if res.ErrorMessage != "No pane to the left" {
160+ t.Fatalf("error=%q, want %q", res.ErrorMessage, "No pane to the left")
161+ }
162+ if ws.Root != root {
163+ t.Fatalf("tree should be unchanged")
164+ }
165+}
166+
143 func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) {167 func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) {
144 uc := NewManagePanesUseCase(func() string { return "id" })168 uc := NewManagePanesUseCase(func() string { return "id" })
145 ctx := context.Background()169 ctx := context.Background()
146 170
147- stackNode := stack("stack", leaf("a"), leaf("b"))171+ stackNode := stack(leaf("a"), leaf("b"))
148 b := stackNode.Children[1]172 b := stackNode.Children[1]
149 ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}173 ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
150 174
@@ -155,31 +179,90 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testin
155 if res == nil || res.Action != "expelled" {179 if res == nil || res.Action != "expelled" {
156 t.Fatalf("result action=%v, want expelled", res)180 t.Fatalf("result action=%v, want expelled", res)
157 }181 }
158- if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitHorizontal {182+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
159- t.Fatalf("root should be a horizontal split")183+ t.Fatalf("root should be a vertical split")
160 }184 }
161 185
162- left := ws.Root.Left()186+ top := ws.Root.Left()
163- right := ws.Root.Right()187+ bottom := ws.Root.Right()
164- if left == nil || right == nil {188+ if top == nil || bottom == nil {
165 t.Fatalf("split children should exist")189 t.Fatalf("split children should exist")
166 }190 }
167- if !left.IsLeaf() || left.Pane == nil || left.Pane.ID != "a" {191+ if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" {
168- t.Fatalf("left child should be leaf a")192+ t.Fatalf("top child should be leaf a")
169 }193 }
170- if !right.IsLeaf() || right.Pane == nil || right.Pane.ID != "b" {194+ if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" {
171- t.Fatalf("right child should be leaf b")195+ t.Fatalf("bottom child should be leaf b")
172 }196 }
173 if ws.ActivePaneID != "b" {197 if ws.ActivePaneID != "b" {
174 t.Fatalf("active=%s, want b", ws.ActivePaneID)198 t.Fatalf("active=%s, want b", ws.ActivePaneID)
175 }199 }
176 }200 }
177 201
202+func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalToHorizontal(t *testing.T) {
203+ uc := NewManagePanesUseCase(func() string { return "id" })
204+ ctx := context.Background()
205+
206+ stackNode := stack(leaf("a"), leaf("b"))
207+ b := stackNode.Children[1]
208+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
209+
210+ // First press (on stacked pane): stack -> vertical split with active on bottom.
211+ mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled")
212+ mustSplitRoot(t, ws, entity.SplitVertical)
213+ if string(ws.ActivePaneID) != "b" {
214+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
215+ }
216+
217+ active := ws.ActivePane()
218+ mustLeafPaneID(t, active, "b")
219+
220+ // Second press (on bottom pane): vertical -> horizontal split with active on right.
221+ mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelRight, "consumed")
222+ root := mustSplitRoot(t, ws, entity.SplitHorizontal)
223+ mustLeafPaneID(t, root.Left(), "a")
224+ mustLeafPaneID(t, root.Right(), "b")
225+ if string(ws.ActivePaneID) != "b" {
226+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
227+ }
228+}
229+
230+func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t *testing.T) {
231+ uc := NewManagePanesUseCase(func() string { return "id" })
232+ ctx := context.Background()
233+
234+ stackNode := stack(leaf("a"), leaf("b"))
235+ b := stackNode.Children[1]
236+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
237+
238+ // First press (on stacked pane): stack -> vertical split with active on bottom.
239+ mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled")
240+ mustSplitRoot(t, ws, entity.SplitVertical)
241+ if string(ws.ActivePaneID) != "b" {
242+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
243+ }
244+
245+ active := ws.ActivePane()
246+ mustLeafPaneID(t, active, "b")
247+
248+ // Second press (on bottom pane): vertical -> stack (return to original).
249+ mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelLeft, "consumed")
250+ if ws.Root == nil || !ws.Root.IsStacked {
251+ t.Fatalf("root should be a stack")
252+ }
253+ if got := panesInOrder(ws.Root); got != "a,b" {
254+ t.Fatalf("stack panes=%s, want a,b", got)
255+ }
256+ if string(ws.ActivePaneID) != "b" {
257+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
258+ }
259+}
260+
178 func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {261 func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {
179 uc := NewManagePanesUseCase(func() string { return "id" })262 uc := NewManagePanesUseCase(func() string { return "id" })
180 ctx := context.Background()263 ctx := context.Background()
181 264
182- stackNode := stack("stack", leaf("a"), leaf("b"), leaf("c"))265+ stackNode := stack(leaf("a"), leaf("b"), leaf("c"))
183 b := stackNode.Children[1]266 b := stackNode.Children[1]
184 ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}267 ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
185 268
@@ -190,19 +273,19 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *
190 if res == nil || res.Action != "expelled" {273 if res == nil || res.Action != "expelled" {
191 t.Fatalf("result action=%v, want expelled", res)274 t.Fatalf("result action=%v, want expelled", res)
192 }275 }
193- if ws.Root == nil || !ws.Root.IsSplit() {276+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
194- t.Fatalf("root should be a split")277+ t.Fatalf("root should be a vertical split")
195 }278 }
196- left := ws.Root.Left()279+ top := ws.Root.Left()
197- right := ws.Root.Right()280+ bottom := ws.Root.Right()
198- if left == nil || !left.IsStacked {281+ if top == nil || !top.IsStacked {
199- t.Fatalf("left child should be stack")282+ t.Fatalf("top child should be stack")
200 }283 }
201- if panesInOrder(left) != "a,c" {284+ if panesInOrder(top) != "a,c" {
202- t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(left))285+ t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top))
203 }286 }
204- if right == nil || !right.IsLeaf() || right.Pane.ID != "b" {287+ if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" {
205- t.Fatalf("right child should be expelled leaf b")288+ t.Fatalf("bottom child should be expelled leaf b")
206 }289 }
207 if ws.ActivePaneID != "b" {290 if ws.ActivePaneID != "b" {
208 t.Fatalf("active=%s, want b", ws.ActivePaneID)291 t.Fatalf("active=%s, want b", ws.ActivePaneID)
@@ -281,6 +364,40 @@ func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {
281 }364 }
282 }365 }
283 366
367+func mustConsumeOrExpelAction(
368+ t *testing.T,
369+ uc *ManagePanesUseCase,
370+ ctx context.Context,
371+ ws *entity.Workspace,
372+ node *entity.PaneNode,
373+ direction ConsumeOrExpelDirection,
374+ wantAction string,
375+) {
376+ t.Helper()
377+ res, err := uc.ConsumeOrExpel(ctx, ws, node, direction)
378+ if err != nil {
379+ t.Fatalf("unexpected error: %v", err)
380+ }
381+ if res == nil || res.Action != wantAction {
382+ t.Fatalf("result action=%v, want %s", res, wantAction)
383+ }
384+}
385+
386+func mustSplitRoot(t *testing.T, ws *entity.Workspace, dir entity.SplitDirection) *entity.PaneNode {
387+ t.Helper()
388+ if ws == nil || ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != dir {
389+ t.Fatalf("root should be a %v split", dir)
390+ }
391+ return ws.Root
392+}
393+
394+func mustLeafPaneID(t *testing.T, node *entity.PaneNode, want string) {
395+ t.Helper()
396+ if node == nil || !node.IsLeaf() || node.Pane == nil || string(node.Pane.ID) != want {
397+ t.Fatalf("leaf pane=%v, want %s", node, want)
398+ }
399+}
400+
284 func leaf(id string) *entity.PaneNode {401 func leaf(id string) *entity.PaneNode {
285 return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}}402 return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}}
286 }403 }
@@ -292,8 +409,8 @@ func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.Pane
292 return root409 return root
293 }410 }
294 411
295-func stack(id string, panes ...*entity.PaneNode) *entity.PaneNode {412+func stack(panes ...*entity.PaneNode) *entity.PaneNode {
296- n := &entity.PaneNode{ID: id, IsStacked: true, ActiveStackIndex: 0}413+ n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}
297 children := make([]*entity.PaneNode, 0, len(panes))414 children := make([]*entity.PaneNode, 0, len(panes))
298 for _, p := range panes {415 for _, p := range panes {
299 p.Parent = n416 p.Parent = n
M internal/infrastructure/config/defaults.go +4 -4
@@ -218,10 +218,10 @@ func DefaultConfig() *Config {
218218 NextTab: "ctrl+tab",
219219 PreviousTab: "ctrl+shift+tab",
220220
221- ConsumeOrExpelLeft: "ctrl+bracketleft",
222- ConsumeOrExpelRight: "ctrl+bracketright",
223- ConsumeOrExpelUp: "ctrl+shift+bracketleft",
224- ConsumeOrExpelDown: "ctrl+shift+bracketright",
221+ ConsumeOrExpelLeft: "alt+bracketleft",
222+ ConsumeOrExpelRight: "alt+bracketright",
223+ ConsumeOrExpelUp: "alt+shift+bracketleft",
224+ ConsumeOrExpelDown: "alt+shift+bracketright",
225225 },
226226 TabBarPosition: defaultTabBarPosition,
227227 HideTabBarWhenSingleTab: true,
@@ -218,10 +218,10 @@ func DefaultConfig() *Config {
218 NextTab: "ctrl+tab",218 NextTab: "ctrl+tab",
219 PreviousTab: "ctrl+shift+tab",219 PreviousTab: "ctrl+shift+tab",
220 220
221- ConsumeOrExpelLeft: "ctrl+bracketleft",221+ ConsumeOrExpelLeft: "alt+bracketleft",
222- ConsumeOrExpelRight: "ctrl+bracketright",222+ ConsumeOrExpelRight: "alt+bracketright",
223- ConsumeOrExpelUp: "ctrl+shift+bracketleft",223+ ConsumeOrExpelUp: "alt+shift+bracketleft",
224- ConsumeOrExpelDown: "ctrl+shift+bracketright",224+ ConsumeOrExpelDown: "alt+shift+bracketright",
225 },225 },
226 TabBarPosition: defaultTabBarPosition,226 TabBarPosition: defaultTabBarPosition,
227 HideTabBarWhenSingleTab: true,227 HideTabBarWhenSingleTab: true,
M internal/ui/input/keyboard.go +2 -2
@@ -212,9 +212,9 @@ func (h *KeyboardHandler) lookupAction(
212212 }
213213 }
214214
215- if !found && mode == ModeNormal && (modifiers == ModCtrl || modifiers == (ModCtrl|ModShift)) {
215+ if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
216216 if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
217- if modifiers == (ModCtrl | ModShift) {
217+ if modifiers == (ModAlt | ModShift) {
218218 action = bracketActions.WithShift
219219 } else {
220220 action = bracketActions.NoShift
@@ -212,9 +212,9 @@ func (h *KeyboardHandler) lookupAction(
212 }212 }
213 }213 }
214 214
215- if !found && mode == ModeNormal && (modifiers == ModCtrl || modifiers == (ModCtrl|ModShift)) {215+ if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
216 if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {216 if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
217- if modifiers == (ModCtrl | ModShift) {217+ if modifiers == (ModAlt | ModShift) {
218 action = bracketActions.WithShift218 action = bracketActions.WithShift
219 } else {219 } else {
220 action = bracketActions.NoShift220 action = bracketActions.NoShift

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 8

Tags 42

↑↓ moveenter openesc close