detached 713ff3a

Merge pull request #39 from bnema/feature/consume-or-expel-pane

Consume/expel pane: cycling fixes + Alt defaults
commit
713ff3abf0e18175485d010e29139ab545eacd25
author
bnema · · 2025-12-26 13:47 UTC
committer
GitHub · · 2025-12-26 13:47 UTC
merge of
8b8ac5d + 5ceb7d6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
15 files · +1198 -0
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 +579 -0
@@ -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"
@@ -52,6 +53,22 @@ const (
5253
5354 var ErrNothingToResize = errors.New("nothing to resize")
5455
56+type ConsumeOrExpelDirection string
57+
58+const (
59+ ConsumeOrExpelLeft ConsumeOrExpelDirection = "left"
60+ ConsumeOrExpelRight ConsumeOrExpelDirection = "right"
61+ ConsumeOrExpelUp ConsumeOrExpelDirection = "up"
62+ ConsumeOrExpelDown ConsumeOrExpelDirection = "down"
63+)
64+
65+type ConsumeOrExpelResult struct {
66+ Action string // "consumed", "expelled", or "none"
67+ ErrorMessage string
68+}
69+
70+const consumeOrExpelExpelCycleMarker = "_expel_cycle"
71+
5572 // ManagePanesUseCase handles pane tree operations.
5673 type ManagePanesUseCase struct {
5774 idGenerator IDGenerator
@@ -1074,6 +1091,568 @@ func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *en
10741091 return nil
10751092 }
10761093
1094+func (uc *ManagePanesUseCase) ConsumeOrExpel(
1095+ ctx context.Context,
1096+ ws *entity.Workspace,
1097+ activeNode *entity.PaneNode,
1098+ direction ConsumeOrExpelDirection,
1099+) (*ConsumeOrExpelResult, error) {
1100+ if uc == nil {
1101+ return nil, fmt.Errorf("manage panes use case is nil")
1102+ }
1103+ if ws == nil {
1104+ return nil, fmt.Errorf("workspace is required")
1105+ }
1106+ if ws.Root == nil {
1107+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1108+ }
1109+ if activeNode == nil {
1110+ return nil, fmt.Errorf("active pane node is required")
1111+ }
1112+ if activeNode.Pane == nil {
1113+ return nil, fmt.Errorf("active pane node has no pane")
1114+ }
1115+
1116+ if ws.PaneCount() <= 1 {
1117+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1118+ }
1119+
1120+ // Expel if active pane is inside a stack.
1121+ if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1122+ return uc.expelFromStack(ctx, ws, activeNode, direction)
1123+ }
1124+
1125+ // Consume if active pane is not inside a stack.
1126+ return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction)
1127+}
1128+
1129+func (*ManagePanesUseCase) consumeIntoSiblingStack(
1130+ ctx context.Context,
1131+ ws *entity.Workspace,
1132+ activeNode *entity.PaneNode,
1133+ direction ConsumeOrExpelDirection,
1134+) (*ConsumeOrExpelResult, error) {
1135+ log := logging.FromContext(ctx)
1136+
1137+ sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction)
1138+ if sibling == nil {
1139+ sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction)
1140+ }
1141+ if sibling == nil {
1142+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil
1143+ }
1144+
1145+ // Left/right consume cycles layout for immediate split siblings:
1146+ // 1) horizontal split -> vertical split (target on top)
1147+ // 2) vertical split -> stack (top then bottom)
1148+ if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1149+ cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction)
1150+ if err != nil {
1151+ return nil, err
1152+ }
1153+ if cycled {
1154+ switch cycleAction {
1155+ case "vertical_split":
1156+ log.Info().
1157+ Str("pane_id", activeNode.ID).
1158+ Str("sibling_id", sibling.ID).
1159+ Msg("pane moved below sibling")
1160+ case "stack":
1161+ log.Info().
1162+ Str("pane_id", activeNode.ID).
1163+ Str("sibling_id", sibling.ID).
1164+ Msg("split converted to stack")
1165+ }
1166+ return &ConsumeOrExpelResult{Action: "consumed"}, nil
1167+ }
1168+ }
1169+
1170+ moved, err := detachLeafByPromotingSibling(ws, activeNode)
1171+ if err != nil {
1172+ return nil, err
1173+ }
1174+ moved.Parent = nil
1175+
1176+ stackNode := sibling
1177+ if stackNode.IsLeaf() {
1178+ stackNode = convertLeafToStackContainer(stackNode)
1179+ }
1180+ if !stackNode.IsStacked {
1181+ return nil, fmt.Errorf("target sibling is not stackable")
1182+ }
1183+
1184+ // Insert consumed pane at the bottom/end.
1185+ moved.Parent = stackNode
1186+ stackNode.Children = append(stackNode.Children, moved)
1187+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1188+ ws.ActivePaneID = moved.Pane.ID
1189+
1190+ log.Info().
1191+ Str("pane_id", moved.ID).
1192+ Str("stack_id", stackNode.ID).
1193+ Int("stack_size", len(stackNode.Children)).
1194+ Msg("pane consumed into sibling stack")
1195+
1196+ return &ConsumeOrExpelResult{Action: "consumed"}, nil
1197+}
1198+
1199+func (uc *ManagePanesUseCase) expelFromStack(
1200+ ctx context.Context,
1201+ ws *entity.Workspace,
1202+ activeNode *entity.PaneNode,
1203+ direction ConsumeOrExpelDirection,
1204+) (*ConsumeOrExpelResult, error) {
1205+ log := logging.FromContext(ctx)
1206+
1207+ stackNode := activeNode.Parent
1208+ if stackNode == nil || !stackNode.IsStacked {
1209+ return &ConsumeOrExpelResult{Action: "none"}, nil
1210+ }
1211+
1212+ expelled, err := removeLeafFromStack(stackNode, activeNode)
1213+ if err != nil {
1214+ return nil, err
1215+ }
1216+ expelled.Parent = nil
1217+
1218+ // If only one pane remains, dissolve stack into a leaf node.
1219+ if len(stackNode.Children) == 1 {
1220+ dissolveStackIntoLeaf(stackNode)
1221+ }
1222+
1223+ // Split around the remaining container (stackNode) with expelled pane.
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+ }
1251+ }
1252+ ws.ActivePaneID = expelled.Pane.ID
1253+
1254+ log.Info().
1255+ Str("pane_id", expelled.ID).
1256+ Str("direction", string(direction)).
1257+ Msg("pane expelled from stack")
1258+
1259+ return &ConsumeOrExpelResult{Action: "expelled"}, nil
1260+}
1261+
1262+func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string {
1263+ switch direction {
1264+ case ConsumeOrExpelLeft:
1265+ return "No pane to the left"
1266+ case ConsumeOrExpelRight:
1267+ return "No pane to the right"
1268+ case ConsumeOrExpelUp:
1269+ return "No pane above"
1270+ case ConsumeOrExpelDown:
1271+ return "No pane below"
1272+ default:
1273+ return "No pane in that direction"
1274+ }
1275+}
1276+
1277+func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) {
1278+ if node == nil {
1279+ return nil, ""
1280+ }
1281+
1282+ // Determine if we need to go horizontal or vertical.
1283+ isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1284+ isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown
1285+
1286+ // Walk up the tree to find an ancestor split matching our direction axis.
1287+ current := node
1288+ for current.Parent != nil {
1289+ parent := current.Parent
1290+ if parent.IsSplit() {
1291+ parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
1292+ if parentIsHorizontal == isHorizontal {
1293+ if isForward {
1294+ if parent.Right() != nil && parent.Left() == current {
1295+ return parent.Right(), ""
1296+ }
1297+ } else {
1298+ if parent.Left() != nil && parent.Right() == current {
1299+ return parent.Left(), ""
1300+ }
1301+ }
1302+ }
1303+ }
1304+ current = parent
1305+ }
1306+
1307+ return nil, directionNoSiblingMessage(direction)
1308+}
1309+
1310+func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode {
1311+ if node == nil || node.Parent == nil {
1312+ return nil
1313+ }
1314+
1315+ parent := node.Parent
1316+ if !parent.IsSplit() {
1317+ return nil
1318+ }
1319+
1320+ axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1321+ if axisHorizontal {
1322+ if parent.SplitDir == entity.SplitHorizontal {
1323+ return nil
1324+ }
1325+ } else {
1326+ if parent.SplitDir == entity.SplitVertical {
1327+ return nil
1328+ }
1329+ }
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
1335+ if parent.Left() == node {
1336+ sibling = parent.Right()
1337+ } else if parent.Right() == node {
1338+ sibling = parent.Left()
1339+ }
1340+ if sibling == nil {
1341+ return nil
1342+ }
1343+ if !node.IsLeaf() || !sibling.IsLeaf() {
1344+ return nil
1345+ }
1346+ return sibling
1347+}
1348+
1349+func cycleHorizontalToVerticalThenStack(
1350+ ws *entity.Workspace,
1351+ activeNode *entity.PaneNode,
1352+ sibling *entity.PaneNode,
1353+ direction ConsumeOrExpelDirection,
1354+) (cycled bool, cycleAction string, err error) {
1355+ if ws == nil {
1356+ return false, "", fmt.Errorf("workspace is required")
1357+ }
1358+ if activeNode == nil || sibling == nil {
1359+ return false, "", nil
1360+ }
1361+ if !activeNode.IsLeaf() || !sibling.IsLeaf() {
1362+ return false, "", nil
1363+ }
1364+
1365+ parent := activeNode.Parent
1366+ if parent == nil || sibling.Parent != parent || !parent.IsSplit() {
1367+ return false, "", nil
1368+ }
1369+
1370+ switch parent.SplitDir {
1371+ case entity.SplitHorizontal:
1372+ // Horizontal -> Vertical: target sibling should be on top.
1373+ parent.SplitDir = entity.SplitVertical
1374+ if parent.Children[0] != sibling {
1375+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1376+ }
1377+ ws.ActivePaneID = activeNode.Pane.ID
1378+ return true, "vertical_split", nil
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+ // Active should become the right child.
1385+ if parent.Children[1] != activeNode {
1386+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1387+ }
1388+ ws.ActivePaneID = activeNode.Pane.ID
1389+ return true, "horizontal_split", nil
1390+ }
1391+
1392+ // Otherwise, vertical -> stack.
1393+ parent.SplitDir = entity.SplitNone
1394+ parent.SplitRatio = 0
1395+ parent.IsStacked = true
1396+ parent.Pane = nil
1397+ if parent.Children[0] == activeNode {
1398+ parent.ActiveStackIndex = 0
1399+ } else if parent.Children[1] == activeNode {
1400+ parent.ActiveStackIndex = 1
1401+ } else {
1402+ parent.ActiveStackIndex = 0
1403+ }
1404+ ws.ActivePaneID = activeNode.Pane.ID
1405+ return true, "stack", nil
1406+ default:
1407+ return false, "", nil
1408+ }
1409+}
1410+
1411+func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1412+ if ws == nil {
1413+ return nil, fmt.Errorf("workspace is required")
1414+ }
1415+ if leaf == nil {
1416+ return nil, fmt.Errorf("leaf is required")
1417+ }
1418+ if !leaf.IsLeaf() {
1419+ return nil, fmt.Errorf("can only detach leaf node")
1420+ }
1421+
1422+ parent := leaf.Parent
1423+ if parent == nil {
1424+ return nil, fmt.Errorf("cannot detach root leaf")
1425+ }
1426+ if !parent.IsSplit() {
1427+ return nil, fmt.Errorf("leaf parent is not a split")
1428+ }
1429+
1430+ var sibling *entity.PaneNode
1431+ for _, child := range parent.Children {
1432+ if child != leaf {
1433+ sibling = child
1434+ break
1435+ }
1436+ }
1437+ if sibling == nil {
1438+ return nil, fmt.Errorf("no sibling found for leaf")
1439+ }
1440+
1441+ grandparent := parent.Parent
1442+ if grandparent == nil {
1443+ ws.Root = sibling
1444+ sibling.Parent = nil
1445+ } else {
1446+ for i, child := range grandparent.Children {
1447+ if child == parent {
1448+ grandparent.Children[i] = sibling
1449+ break
1450+ }
1451+ }
1452+ sibling.Parent = grandparent
1453+ }
1454+
1455+ leaf.Parent = nil
1456+ return leaf, nil
1457+}
1458+
1459+func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode {
1460+ if leaf == nil || !leaf.IsLeaf() {
1461+ return leaf
1462+ }
1463+
1464+ originalPane := leaf.Pane
1465+ originalChildNode := &entity.PaneNode{
1466+ ID: leaf.ID + "_0",
1467+ Pane: originalPane,
1468+ Parent: leaf,
1469+ }
1470+
1471+ leaf.Pane = nil
1472+ leaf.IsStacked = true
1473+ leaf.ActiveStackIndex = 0
1474+ leaf.Children = []*entity.PaneNode{originalChildNode}
1475+
1476+ return leaf
1477+}
1478+
1479+func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1480+ if stackNode == nil {
1481+ return nil, fmt.Errorf("stack node is required")
1482+ }
1483+ if !stackNode.IsStacked {
1484+ return nil, fmt.Errorf("node is not a stack")
1485+ }
1486+ if leaf == nil || leaf.Pane == nil {
1487+ return nil, fmt.Errorf("leaf is required")
1488+ }
1489+
1490+ idx := -1
1491+ for i, child := range stackNode.Children {
1492+ if child == leaf {
1493+ idx = i
1494+ break
1495+ }
1496+ }
1497+ if idx < 0 {
1498+ return nil, fmt.Errorf("pane not found in stack")
1499+ }
1500+
1501+ stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
1502+ if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1503+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1504+ }
1505+ if stackNode.ActiveStackIndex < 0 {
1506+ stackNode.ActiveStackIndex = 0
1507+ }
1508+
1509+ leaf.Parent = nil
1510+ return leaf, nil
1511+}
1512+
1513+func dissolveStackIntoLeaf(stackNode *entity.PaneNode) {
1514+ if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 {
1515+ return
1516+ }
1517+
1518+ remaining := stackNode.Children[0]
1519+ stackNode.Pane = remaining.Pane
1520+ stackNode.Children = nil
1521+ stackNode.IsStacked = false
1522+ stackNode.ActiveStackIndex = 0
1523+}
1524+
1525+func splitExistingNode(
1526+ ws *entity.Workspace,
1527+ targetNode *entity.PaneNode,
1528+ newSibling *entity.PaneNode,
1529+ direction ConsumeOrExpelDirection,
1530+ idGen IDGenerator,
1531+) error {
1532+ if ws == nil {
1533+ return fmt.Errorf("workspace is required")
1534+ }
1535+ if targetNode == nil {
1536+ return fmt.Errorf("target node is required")
1537+ }
1538+ if newSibling == nil {
1539+ return fmt.Errorf("new sibling node is required")
1540+ }
1541+ if idGen == nil {
1542+ return fmt.Errorf("id generator is required")
1543+ }
1544+
1545+ var splitDir entity.SplitDirection
1546+ switch direction {
1547+ case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1548+ splitDir = entity.SplitHorizontal
1549+ case ConsumeOrExpelUp, ConsumeOrExpelDown:
1550+ splitDir = entity.SplitVertical
1551+ default:
1552+ return fmt.Errorf("invalid direction: %s", direction)
1553+ }
1554+
1555+ parentNode := &entity.PaneNode{
1556+ ID: idGen(),
1557+ SplitDir: splitDir,
1558+ SplitRatio: 0.5,
1559+ Children: make([]*entity.PaneNode, 2),
1560+ }
1561+
1562+ switch direction {
1563+ case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1564+ parentNode.Children[0] = newSibling
1565+ parentNode.Children[1] = targetNode
1566+ case ConsumeOrExpelRight, ConsumeOrExpelDown:
1567+ parentNode.Children[0] = targetNode
1568+ parentNode.Children[1] = newSibling
1569+ }
1570+
1571+ newSibling.Parent = parentNode
1572+ oldParent := targetNode.Parent
1573+ targetNode.Parent = parentNode
1574+
1575+ if oldParent == nil {
1576+ ws.Root = parentNode
1577+ } else {
1578+ for i, child := range oldParent.Children {
1579+ if child == targetNode {
1580+ oldParent.Children[i] = parentNode
1581+ break
1582+ }
1583+ }
1584+ parentNode.Parent = oldParent
1585+ }
1586+
1587+ return nil
1588+}
1589+
1590+func splitExistingNodeWithMarker(
1591+ ws *entity.Workspace,
1592+ targetNode *entity.PaneNode,
1593+ newSibling *entity.PaneNode,
1594+ direction ConsumeOrExpelDirection,
1595+ idGen IDGenerator,
1596+ marker string,
1597+) error {
1598+ if ws == nil {
1599+ return fmt.Errorf("workspace is required")
1600+ }
1601+ if targetNode == nil {
1602+ return fmt.Errorf("target node is required")
1603+ }
1604+ if newSibling == nil {
1605+ return fmt.Errorf("new sibling node is required")
1606+ }
1607+ if idGen == nil {
1608+ return fmt.Errorf("id generator is required")
1609+ }
1610+
1611+ var splitDir entity.SplitDirection
1612+ switch direction {
1613+ case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1614+ splitDir = entity.SplitHorizontal
1615+ case ConsumeOrExpelUp, ConsumeOrExpelDown:
1616+ splitDir = entity.SplitVertical
1617+ default:
1618+ return fmt.Errorf("invalid direction: %s", direction)
1619+ }
1620+
1621+ parentNode := &entity.PaneNode{
1622+ ID: idGen() + marker,
1623+ SplitDir: splitDir,
1624+ SplitRatio: 0.5,
1625+ Children: make([]*entity.PaneNode, 2),
1626+ }
1627+
1628+ switch direction {
1629+ case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1630+ parentNode.Children[0] = newSibling
1631+ parentNode.Children[1] = targetNode
1632+ case ConsumeOrExpelRight, ConsumeOrExpelDown:
1633+ parentNode.Children[0] = targetNode
1634+ parentNode.Children[1] = newSibling
1635+ }
1636+
1637+ newSibling.Parent = parentNode
1638+ oldParent := targetNode.Parent
1639+ targetNode.Parent = parentNode
1640+
1641+ if oldParent == nil {
1642+ ws.Root = parentNode
1643+ } else {
1644+ for i, child := range oldParent.Children {
1645+ if child == targetNode {
1646+ oldParent.Children[i] = parentNode
1647+ break
1648+ }
1649+ }
1650+ parentNode.Parent = oldParent
1651+ }
1652+
1653+ return nil
1654+}
1655+
10771656 // GetAllPanes returns all leaf panes in a workspace.
10781657 //
10791658 //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"
@@ -52,6 +53,22 @@ const (
52 53
53 var ErrNothingToResize = errors.New("nothing to resize")54 var ErrNothingToResize = errors.New("nothing to resize")
54 55
56+type ConsumeOrExpelDirection string
57+
58+const (
59+ ConsumeOrExpelLeft ConsumeOrExpelDirection = "left"
60+ ConsumeOrExpelRight ConsumeOrExpelDirection = "right"
61+ ConsumeOrExpelUp ConsumeOrExpelDirection = "up"
62+ ConsumeOrExpelDown ConsumeOrExpelDirection = "down"
63+)
64+
65+type ConsumeOrExpelResult struct {
66+ Action string // "consumed", "expelled", or "none"
67+ ErrorMessage string
68+}
69+
70+const consumeOrExpelExpelCycleMarker = "_expel_cycle"
71+
55 // ManagePanesUseCase handles pane tree operations.72 // ManagePanesUseCase handles pane tree operations.
56 type ManagePanesUseCase struct {73 type ManagePanesUseCase struct {
57 idGenerator IDGenerator74 idGenerator IDGenerator
@@ -1074,6 +1091,568 @@ func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *en
1074 return nil1091 return nil
1075 }1092 }
1076 1093
1094+func (uc *ManagePanesUseCase) ConsumeOrExpel(
1095+ ctx context.Context,
1096+ ws *entity.Workspace,
1097+ activeNode *entity.PaneNode,
1098+ direction ConsumeOrExpelDirection,
1099+) (*ConsumeOrExpelResult, error) {
1100+ if uc == nil {
1101+ return nil, fmt.Errorf("manage panes use case is nil")
1102+ }
1103+ if ws == nil {
1104+ return nil, fmt.Errorf("workspace is required")
1105+ }
1106+ if ws.Root == nil {
1107+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1108+ }
1109+ if activeNode == nil {
1110+ return nil, fmt.Errorf("active pane node is required")
1111+ }
1112+ if activeNode.Pane == nil {
1113+ return nil, fmt.Errorf("active pane node has no pane")
1114+ }
1115+
1116+ if ws.PaneCount() <= 1 {
1117+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1118+ }
1119+
1120+ // Expel if active pane is inside a stack.
1121+ if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1122+ return uc.expelFromStack(ctx, ws, activeNode, direction)
1123+ }
1124+
1125+ // Consume if active pane is not inside a stack.
1126+ return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction)
1127+}
1128+
1129+func (*ManagePanesUseCase) consumeIntoSiblingStack(
1130+ ctx context.Context,
1131+ ws *entity.Workspace,
1132+ activeNode *entity.PaneNode,
1133+ direction ConsumeOrExpelDirection,
1134+) (*ConsumeOrExpelResult, error) {
1135+ log := logging.FromContext(ctx)
1136+
1137+ sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction)
1138+ if sibling == nil {
1139+ sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction)
1140+ }
1141+ if sibling == nil {
1142+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil
1143+ }
1144+
1145+ // Left/right consume cycles layout for immediate split siblings:
1146+ // 1) horizontal split -> vertical split (target on top)
1147+ // 2) vertical split -> stack (top then bottom)
1148+ if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1149+ cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction)
1150+ if err != nil {
1151+ return nil, err
1152+ }
1153+ if cycled {
1154+ switch cycleAction {
1155+ case "vertical_split":
1156+ log.Info().
1157+ Str("pane_id", activeNode.ID).
1158+ Str("sibling_id", sibling.ID).
1159+ Msg("pane moved below sibling")
1160+ case "stack":
1161+ log.Info().
1162+ Str("pane_id", activeNode.ID).
1163+ Str("sibling_id", sibling.ID).
1164+ Msg("split converted to stack")
1165+ }
1166+ return &ConsumeOrExpelResult{Action: "consumed"}, nil
1167+ }
1168+ }
1169+
1170+ moved, err := detachLeafByPromotingSibling(ws, activeNode)
1171+ if err != nil {
1172+ return nil, err
1173+ }
1174+ moved.Parent = nil
1175+
1176+ stackNode := sibling
1177+ if stackNode.IsLeaf() {
1178+ stackNode = convertLeafToStackContainer(stackNode)
1179+ }
1180+ if !stackNode.IsStacked {
1181+ return nil, fmt.Errorf("target sibling is not stackable")
1182+ }
1183+
1184+ // Insert consumed pane at the bottom/end.
1185+ moved.Parent = stackNode
1186+ stackNode.Children = append(stackNode.Children, moved)
1187+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1188+ ws.ActivePaneID = moved.Pane.ID
1189+
1190+ log.Info().
1191+ Str("pane_id", moved.ID).
1192+ Str("stack_id", stackNode.ID).
1193+ Int("stack_size", len(stackNode.Children)).
1194+ Msg("pane consumed into sibling stack")
1195+
1196+ return &ConsumeOrExpelResult{Action: "consumed"}, nil
1197+}
1198+
1199+func (uc *ManagePanesUseCase) expelFromStack(
1200+ ctx context.Context,
1201+ ws *entity.Workspace,
1202+ activeNode *entity.PaneNode,
1203+ direction ConsumeOrExpelDirection,
1204+) (*ConsumeOrExpelResult, error) {
1205+ log := logging.FromContext(ctx)
1206+
1207+ stackNode := activeNode.Parent
1208+ if stackNode == nil || !stackNode.IsStacked {
1209+ return &ConsumeOrExpelResult{Action: "none"}, nil
1210+ }
1211+
1212+ expelled, err := removeLeafFromStack(stackNode, activeNode)
1213+ if err != nil {
1214+ return nil, err
1215+ }
1216+ expelled.Parent = nil
1217+
1218+ // If only one pane remains, dissolve stack into a leaf node.
1219+ if len(stackNode.Children) == 1 {
1220+ dissolveStackIntoLeaf(stackNode)
1221+ }
1222+
1223+ // Split around the remaining container (stackNode) with expelled pane.
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+ }
1251+ }
1252+ ws.ActivePaneID = expelled.Pane.ID
1253+
1254+ log.Info().
1255+ Str("pane_id", expelled.ID).
1256+ Str("direction", string(direction)).
1257+ Msg("pane expelled from stack")
1258+
1259+ return &ConsumeOrExpelResult{Action: "expelled"}, nil
1260+}
1261+
1262+func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string {
1263+ switch direction {
1264+ case ConsumeOrExpelLeft:
1265+ return "No pane to the left"
1266+ case ConsumeOrExpelRight:
1267+ return "No pane to the right"
1268+ case ConsumeOrExpelUp:
1269+ return "No pane above"
1270+ case ConsumeOrExpelDown:
1271+ return "No pane below"
1272+ default:
1273+ return "No pane in that direction"
1274+ }
1275+}
1276+
1277+func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) {
1278+ if node == nil {
1279+ return nil, ""
1280+ }
1281+
1282+ // Determine if we need to go horizontal or vertical.
1283+ isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1284+ isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown
1285+
1286+ // Walk up the tree to find an ancestor split matching our direction axis.
1287+ current := node
1288+ for current.Parent != nil {
1289+ parent := current.Parent
1290+ if parent.IsSplit() {
1291+ parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
1292+ if parentIsHorizontal == isHorizontal {
1293+ if isForward {
1294+ if parent.Right() != nil && parent.Left() == current {
1295+ return parent.Right(), ""
1296+ }
1297+ } else {
1298+ if parent.Left() != nil && parent.Right() == current {
1299+ return parent.Left(), ""
1300+ }
1301+ }
1302+ }
1303+ }
1304+ current = parent
1305+ }
1306+
1307+ return nil, directionNoSiblingMessage(direction)
1308+}
1309+
1310+func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode {
1311+ if node == nil || node.Parent == nil {
1312+ return nil
1313+ }
1314+
1315+ parent := node.Parent
1316+ if !parent.IsSplit() {
1317+ return nil
1318+ }
1319+
1320+ axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1321+ if axisHorizontal {
1322+ if parent.SplitDir == entity.SplitHorizontal {
1323+ return nil
1324+ }
1325+ } else {
1326+ if parent.SplitDir == entity.SplitVertical {
1327+ return nil
1328+ }
1329+ }
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
1335+ if parent.Left() == node {
1336+ sibling = parent.Right()
1337+ } else if parent.Right() == node {
1338+ sibling = parent.Left()
1339+ }
1340+ if sibling == nil {
1341+ return nil
1342+ }
1343+ if !node.IsLeaf() || !sibling.IsLeaf() {
1344+ return nil
1345+ }
1346+ return sibling
1347+}
1348+
1349+func cycleHorizontalToVerticalThenStack(
1350+ ws *entity.Workspace,
1351+ activeNode *entity.PaneNode,
1352+ sibling *entity.PaneNode,
1353+ direction ConsumeOrExpelDirection,
1354+) (cycled bool, cycleAction string, err error) {
1355+ if ws == nil {
1356+ return false, "", fmt.Errorf("workspace is required")
1357+ }
1358+ if activeNode == nil || sibling == nil {
1359+ return false, "", nil
1360+ }
1361+ if !activeNode.IsLeaf() || !sibling.IsLeaf() {
1362+ return false, "", nil
1363+ }
1364+
1365+ parent := activeNode.Parent
1366+ if parent == nil || sibling.Parent != parent || !parent.IsSplit() {
1367+ return false, "", nil
1368+ }
1369+
1370+ switch parent.SplitDir {
1371+ case entity.SplitHorizontal:
1372+ // Horizontal -> Vertical: target sibling should be on top.
1373+ parent.SplitDir = entity.SplitVertical
1374+ if parent.Children[0] != sibling {
1375+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1376+ }
1377+ ws.ActivePaneID = activeNode.Pane.ID
1378+ return true, "vertical_split", nil
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+ // Active should become the right child.
1385+ if parent.Children[1] != activeNode {
1386+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1387+ }
1388+ ws.ActivePaneID = activeNode.Pane.ID
1389+ return true, "horizontal_split", nil
1390+ }
1391+
1392+ // Otherwise, vertical -> stack.
1393+ parent.SplitDir = entity.SplitNone
1394+ parent.SplitRatio = 0
1395+ parent.IsStacked = true
1396+ parent.Pane = nil
1397+ if parent.Children[0] == activeNode {
1398+ parent.ActiveStackIndex = 0
1399+ } else if parent.Children[1] == activeNode {
1400+ parent.ActiveStackIndex = 1
1401+ } else {
1402+ parent.ActiveStackIndex = 0
1403+ }
1404+ ws.ActivePaneID = activeNode.Pane.ID
1405+ return true, "stack", nil
1406+ default:
1407+ return false, "", nil
1408+ }
1409+}
1410+
1411+func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1412+ if ws == nil {
1413+ return nil, fmt.Errorf("workspace is required")
1414+ }
1415+ if leaf == nil {
1416+ return nil, fmt.Errorf("leaf is required")
1417+ }
1418+ if !leaf.IsLeaf() {
1419+ return nil, fmt.Errorf("can only detach leaf node")
1420+ }
1421+
1422+ parent := leaf.Parent
1423+ if parent == nil {
1424+ return nil, fmt.Errorf("cannot detach root leaf")
1425+ }
1426+ if !parent.IsSplit() {
1427+ return nil, fmt.Errorf("leaf parent is not a split")
1428+ }
1429+
1430+ var sibling *entity.PaneNode
1431+ for _, child := range parent.Children {
1432+ if child != leaf {
1433+ sibling = child
1434+ break
1435+ }
1436+ }
1437+ if sibling == nil {
1438+ return nil, fmt.Errorf("no sibling found for leaf")
1439+ }
1440+
1441+ grandparent := parent.Parent
1442+ if grandparent == nil {
1443+ ws.Root = sibling
1444+ sibling.Parent = nil
1445+ } else {
1446+ for i, child := range grandparent.Children {
1447+ if child == parent {
1448+ grandparent.Children[i] = sibling
1449+ break
1450+ }
1451+ }
1452+ sibling.Parent = grandparent
1453+ }
1454+
1455+ leaf.Parent = nil
1456+ return leaf, nil
1457+}
1458+
1459+func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode {
1460+ if leaf == nil || !leaf.IsLeaf() {
1461+ return leaf
1462+ }
1463+
1464+ originalPane := leaf.Pane
1465+ originalChildNode := &entity.PaneNode{
1466+ ID: leaf.ID + "_0",
1467+ Pane: originalPane,
1468+ Parent: leaf,
1469+ }
1470+
1471+ leaf.Pane = nil
1472+ leaf.IsStacked = true
1473+ leaf.ActiveStackIndex = 0
1474+ leaf.Children = []*entity.PaneNode{originalChildNode}
1475+
1476+ return leaf
1477+}
1478+
1479+func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1480+ if stackNode == nil {
1481+ return nil, fmt.Errorf("stack node is required")
1482+ }
1483+ if !stackNode.IsStacked {
1484+ return nil, fmt.Errorf("node is not a stack")
1485+ }
1486+ if leaf == nil || leaf.Pane == nil {
1487+ return nil, fmt.Errorf("leaf is required")
1488+ }
1489+
1490+ idx := -1
1491+ for i, child := range stackNode.Children {
1492+ if child == leaf {
1493+ idx = i
1494+ break
1495+ }
1496+ }
1497+ if idx < 0 {
1498+ return nil, fmt.Errorf("pane not found in stack")
1499+ }
1500+
1501+ stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
1502+ if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1503+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1504+ }
1505+ if stackNode.ActiveStackIndex < 0 {
1506+ stackNode.ActiveStackIndex = 0
1507+ }
1508+
1509+ leaf.Parent = nil
1510+ return leaf, nil
1511+}
1512+
1513+func dissolveStackIntoLeaf(stackNode *entity.PaneNode) {
1514+ if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 {
1515+ return
1516+ }
1517+
1518+ remaining := stackNode.Children[0]
1519+ stackNode.Pane = remaining.Pane
1520+ stackNode.Children = nil
1521+ stackNode.IsStacked = false
1522+ stackNode.ActiveStackIndex = 0
1523+}
1524+
1525+func splitExistingNode(
1526+ ws *entity.Workspace,
1527+ targetNode *entity.PaneNode,
1528+ newSibling *entity.PaneNode,
1529+ direction ConsumeOrExpelDirection,
1530+ idGen IDGenerator,
1531+) error {
1532+ if ws == nil {
1533+ return fmt.Errorf("workspace is required")
1534+ }
1535+ if targetNode == nil {
1536+ return fmt.Errorf("target node is required")
1537+ }
1538+ if newSibling == nil {
1539+ return fmt.Errorf("new sibling node is required")
1540+ }
1541+ if idGen == nil {
1542+ return fmt.Errorf("id generator is required")
1543+ }
1544+
1545+ var splitDir entity.SplitDirection
1546+ switch direction {
1547+ case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1548+ splitDir = entity.SplitHorizontal
1549+ case ConsumeOrExpelUp, ConsumeOrExpelDown:
1550+ splitDir = entity.SplitVertical
1551+ default:
1552+ return fmt.Errorf("invalid direction: %s", direction)
1553+ }
1554+
1555+ parentNode := &entity.PaneNode{
1556+ ID: idGen(),
1557+ SplitDir: splitDir,
1558+ SplitRatio: 0.5,
1559+ Children: make([]*entity.PaneNode, 2),
1560+ }
1561+
1562+ switch direction {
1563+ case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1564+ parentNode.Children[0] = newSibling
1565+ parentNode.Children[1] = targetNode
1566+ case ConsumeOrExpelRight, ConsumeOrExpelDown:
1567+ parentNode.Children[0] = targetNode
1568+ parentNode.Children[1] = newSibling
1569+ }
1570+
1571+ newSibling.Parent = parentNode
1572+ oldParent := targetNode.Parent
1573+ targetNode.Parent = parentNode
1574+
1575+ if oldParent == nil {
1576+ ws.Root = parentNode
1577+ } else {
1578+ for i, child := range oldParent.Children {
1579+ if child == targetNode {
1580+ oldParent.Children[i] = parentNode
1581+ break
1582+ }
1583+ }
1584+ parentNode.Parent = oldParent
1585+ }
1586+
1587+ return nil
1588+}
1589+
1590+func splitExistingNodeWithMarker(
1591+ ws *entity.Workspace,
1592+ targetNode *entity.PaneNode,
1593+ newSibling *entity.PaneNode,
1594+ direction ConsumeOrExpelDirection,
1595+ idGen IDGenerator,
1596+ marker string,
1597+) error {
1598+ if ws == nil {
1599+ return fmt.Errorf("workspace is required")
1600+ }
1601+ if targetNode == nil {
1602+ return fmt.Errorf("target node is required")
1603+ }
1604+ if newSibling == nil {
1605+ return fmt.Errorf("new sibling node is required")
1606+ }
1607+ if idGen == nil {
1608+ return fmt.Errorf("id generator is required")
1609+ }
1610+
1611+ var splitDir entity.SplitDirection
1612+ switch direction {
1613+ case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1614+ splitDir = entity.SplitHorizontal
1615+ case ConsumeOrExpelUp, ConsumeOrExpelDown:
1616+ splitDir = entity.SplitVertical
1617+ default:
1618+ return fmt.Errorf("invalid direction: %s", direction)
1619+ }
1620+
1621+ parentNode := &entity.PaneNode{
1622+ ID: idGen() + marker,
1623+ SplitDir: splitDir,
1624+ SplitRatio: 0.5,
1625+ Children: make([]*entity.PaneNode, 2),
1626+ }
1627+
1628+ switch direction {
1629+ case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1630+ parentNode.Children[0] = newSibling
1631+ parentNode.Children[1] = targetNode
1632+ case ConsumeOrExpelRight, ConsumeOrExpelDown:
1633+ parentNode.Children[0] = targetNode
1634+ parentNode.Children[1] = newSibling
1635+ }
1636+
1637+ newSibling.Parent = parentNode
1638+ oldParent := targetNode.Parent
1639+ targetNode.Parent = parentNode
1640+
1641+ if oldParent == nil {
1642+ ws.Root = parentNode
1643+ } else {
1644+ for i, child := range oldParent.Children {
1645+ if child == targetNode {
1646+ oldParent.Children[i] = parentNode
1647+ break
1648+ }
1649+ }
1650+ parentNode.Parent = oldParent
1651+ }
1652+
1653+ return nil
1654+}
1655+
1077 // GetAllPanes returns all leaf panes in a workspace.1656 // GetAllPanes returns all leaf panes in a workspace.
1078 //1657 //
1079 //nolint:revive // receiver required for interface consistency1658 //nolint:revive // receiver required for interface consistency
A internal/application/usecase/manage_panes_consume_expel_test.go +438 -0
@@ -0,0 +1,438 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVerticalThenStack(t *testing.T) {
11+ uc := NewManagePanesUseCase(func() string { return "id" })
12+ ctx := context.Background()
13+
14+ left := leaf("left")
15+ right := leaf("right")
16+ root := split(entity.SplitHorizontal, left, right)
17+ ws := &entity.Workspace{Root: root, ActivePaneID: right.Pane.ID}
18+
19+ // First press: horizontal -> vertical split (left on top, right on bottom).
20+ res, err := uc.ConsumeOrExpel(ctx, ws, right, ConsumeOrExpelLeft)
21+ if err != nil {
22+ t.Fatalf("unexpected error: %v", err)
23+ }
24+ if res == nil || res.Action != "consumed" {
25+ t.Fatalf("result action=%v, want consumed", res)
26+ }
27+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
28+ t.Fatalf("root should be a vertical split")
29+ }
30+ if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "left" {
31+ t.Fatalf("top pane=%v, want left", ws.Root.Left())
32+ }
33+ if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "right" {
34+ t.Fatalf("bottom pane=%v, want right", ws.Root.Right())
35+ }
36+ if ws.ActivePaneID != "right" {
37+ t.Fatalf("active=%s, want right", ws.ActivePaneID)
38+ }
39+
40+ // Second press: vertical split -> stack.
41+ active := ws.ActivePane()
42+ if active == nil {
43+ t.Fatalf("expected active pane")
44+ }
45+ res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelLeft)
46+ if err != nil {
47+ t.Fatalf("unexpected error: %v", err)
48+ }
49+ if res == nil || res.Action != "consumed" {
50+ t.Fatalf("result action=%v, want consumed", res)
51+ }
52+ if ws.Root == nil || !ws.Root.IsStacked {
53+ t.Fatalf("root should be a stack")
54+ }
55+ if got := panesInOrder(ws.Root); got != "left,right" {
56+ t.Fatalf("stack panes=%s, want left,right", got)
57+ }
58+ if ws.ActivePaneID != "right" {
59+ t.Fatalf("active=%s, want right", ws.ActivePaneID)
60+ }
61+}
62+
63+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVerticalThenStack(t *testing.T) {
64+ uc := NewManagePanesUseCase(func() string { return "id" })
65+ ctx := context.Background()
66+
67+ left := leaf("left")
68+ right := leaf("right")
69+ root := split(entity.SplitHorizontal, left, right)
70+ ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID}
71+
72+ // First press: horizontal -> vertical split (right on top, left on bottom).
73+ res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelRight)
74+ if err != nil {
75+ t.Fatalf("unexpected error: %v", err)
76+ }
77+ if res == nil || res.Action != "consumed" {
78+ t.Fatalf("result action=%v, want consumed", res)
79+ }
80+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
81+ t.Fatalf("root should be a vertical split")
82+ }
83+ if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "right" {
84+ t.Fatalf("top pane=%v, want right", ws.Root.Left())
85+ }
86+ if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "left" {
87+ t.Fatalf("bottom pane=%v, want left", ws.Root.Right())
88+ }
89+ if ws.ActivePaneID != "left" {
90+ t.Fatalf("active=%s, want left", ws.ActivePaneID)
91+ }
92+
93+ // Second press: vertical split -> stack.
94+ active := ws.ActivePane()
95+ if active == nil {
96+ t.Fatalf("expected active pane")
97+ }
98+ res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelRight)
99+ if err != nil {
100+ t.Fatalf("unexpected error: %v", err)
101+ }
102+ if res == nil || res.Action != "consumed" {
103+ t.Fatalf("result action=%v, want consumed", res)
104+ }
105+ if ws.Root == nil || !ws.Root.IsStacked {
106+ t.Fatalf("root should be a stack")
107+ }
108+ if got := panesInOrder(ws.Root); got != "right,left" {
109+ t.Fatalf("stack panes=%s, want right,left", got)
110+ }
111+ if ws.ActivePaneID != "left" {
112+ t.Fatalf("active=%s, want left", ws.ActivePaneID)
113+ }
114+}
115+
116+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd(t *testing.T) {
117+ uc := NewManagePanesUseCase(func() string { return "id" })
118+ ctx := context.Background()
119+
120+ stackNode := stack(leaf("a"), leaf("b"))
121+ c := leaf("c")
122+ root := split(entity.SplitHorizontal, stackNode, c)
123+ ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID}
124+
125+ res, err := uc.ConsumeOrExpel(ctx, ws, c, ConsumeOrExpelLeft)
126+ if err != nil {
127+ t.Fatalf("unexpected error: %v", err)
128+ }
129+ if res == nil || res.Action != "consumed" {
130+ t.Fatalf("result action=%v, want consumed", res)
131+ }
132+ if ws.Root == nil || !ws.Root.IsStacked {
133+ t.Fatalf("root should be a stack")
134+ }
135+ if got := panesInOrder(ws.Root); got != "a,b,c" {
136+ t.Fatalf("stack panes=%s, want a,b,c", got)
137+ }
138+ if ws.ActivePaneID != "c" {
139+ t.Fatalf("active=%s, want c", ws.ActivePaneID)
140+ }
141+}
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+
167+func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) {
168+ uc := NewManagePanesUseCase(func() string { return "id" })
169+ ctx := context.Background()
170+
171+ stackNode := stack(leaf("a"), leaf("b"))
172+ b := stackNode.Children[1]
173+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
174+
175+ res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
176+ if err != nil {
177+ t.Fatalf("unexpected error: %v", err)
178+ }
179+ if res == nil || res.Action != "expelled" {
180+ t.Fatalf("result action=%v, want expelled", res)
181+ }
182+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
183+ t.Fatalf("root should be a vertical split")
184+ }
185+
186+ top := ws.Root.Left()
187+ bottom := ws.Root.Right()
188+ if top == nil || bottom == nil {
189+ t.Fatalf("split children should exist")
190+ }
191+ if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" {
192+ t.Fatalf("top child should be leaf a")
193+ }
194+ if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" {
195+ t.Fatalf("bottom child should be leaf b")
196+ }
197+ if ws.ActivePaneID != "b" {
198+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
199+ }
200+}
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+
261+func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {
262+ uc := NewManagePanesUseCase(func() string { return "id" })
263+ ctx := context.Background()
264+
265+ stackNode := stack(leaf("a"), leaf("b"), leaf("c"))
266+ b := stackNode.Children[1]
267+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
268+
269+ res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
270+ if err != nil {
271+ t.Fatalf("unexpected error: %v", err)
272+ }
273+ if res == nil || res.Action != "expelled" {
274+ t.Fatalf("result action=%v, want expelled", res)
275+ }
276+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
277+ t.Fatalf("root should be a vertical split")
278+ }
279+ top := ws.Root.Left()
280+ bottom := ws.Root.Right()
281+ if top == nil || !top.IsStacked {
282+ t.Fatalf("top child should be stack")
283+ }
284+ if panesInOrder(top) != "a,c" {
285+ t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top))
286+ }
287+ if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" {
288+ t.Fatalf("bottom child should be expelled leaf b")
289+ }
290+ if ws.ActivePaneID != "b" {
291+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
292+ }
293+}
294+
295+func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) {
296+ uc := NewManagePanesUseCase(func() string { return "id" })
297+ ctx := context.Background()
298+
299+ left := leaf("left")
300+ right := leaf("right")
301+ root := split(entity.SplitHorizontal, left, right)
302+ ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID}
303+
304+ res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelLeft)
305+ if err != nil {
306+ t.Fatalf("unexpected error: %v", err)
307+ }
308+ if res == nil || res.Action != "none" {
309+ t.Fatalf("result action=%v, want none", res)
310+ }
311+ if res.ErrorMessage == "" {
312+ t.Fatalf("expected warning message")
313+ }
314+ if ws.Root != root {
315+ t.Fatalf("tree should be unchanged")
316+ }
317+}
318+
319+func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) {
320+ uc := NewManagePanesUseCase(func() string { return "id" })
321+ ctx := context.Background()
322+
323+ a := leaf("a")
324+ b := leaf("b")
325+ top := split(entity.SplitHorizontal, a, b)
326+ c := leaf("c")
327+ root := split(entity.SplitVertical, top, c)
328+ ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID}
329+
330+ res, err := uc.ConsumeOrExpel(ctx, ws, a, ConsumeOrExpelDown)
331+ if err != nil {
332+ t.Fatalf("unexpected error: %v", err)
333+ }
334+ if res == nil || res.Action != "consumed" {
335+ t.Fatalf("result action=%v, want consumed", res)
336+ }
337+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
338+ t.Fatalf("root should remain a vertical split")
339+ }
340+ if ws.Root.Right() == nil || !ws.Root.Right().IsStacked {
341+ t.Fatalf("expected bottom pane to become a stack")
342+ }
343+ if panesInOrder(ws.Root.Right()) != "c,a" {
344+ t.Fatalf("bottom stack panes=%s, want c,a", panesInOrder(ws.Root.Right()))
345+ }
346+}
347+
348+func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {
349+ uc := NewManagePanesUseCase(func() string { return "id" })
350+ ctx := context.Background()
351+
352+ only := leaf("only")
353+ ws := &entity.Workspace{Root: only, ActivePaneID: only.Pane.ID}
354+
355+ res, err := uc.ConsumeOrExpel(ctx, ws, only, ConsumeOrExpelLeft)
356+ if err != nil {
357+ t.Fatalf("unexpected error: %v", err)
358+ }
359+ if res == nil || res.Action != "none" {
360+ t.Fatalf("result action=%v, want none", res)
361+ }
362+ if res.ErrorMessage == "" {
363+ t.Fatalf("expected warning message")
364+ }
365+}
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+
401+func leaf(id string) *entity.PaneNode {
402+ return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}}
403+}
404+
405+func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.PaneNode {
406+ root := &entity.PaneNode{ID: "split", SplitDir: dir, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}}
407+ left.Parent = root
408+ right.Parent = root
409+ return root
410+}
411+
412+func stack(panes ...*entity.PaneNode) *entity.PaneNode {
413+ n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}
414+ children := make([]*entity.PaneNode, 0, len(panes))
415+ for _, p := range panes {
416+ p.Parent = n
417+ children = append(children, p)
418+ }
419+ n.Children = children
420+ return n
421+}
422+
423+func panesInOrder(stackNode *entity.PaneNode) string {
424+ if stackNode == nil || len(stackNode.Children) == 0 {
425+ return ""
426+ }
427+ out := ""
428+ for i, child := range stackNode.Children {
429+ if child == nil || child.Pane == nil {
430+ continue
431+ }
432+ if i > 0 {
433+ out += ","
434+ }
435+ out += string(child.Pane.ID)
436+ }
437+ return out
438+}
@@ -0,0 +1,438 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVerticalThenStack(t *testing.T) {
11+ uc := NewManagePanesUseCase(func() string { return "id" })
12+ ctx := context.Background()
13+
14+ left := leaf("left")
15+ right := leaf("right")
16+ root := split(entity.SplitHorizontal, left, right)
17+ ws := &entity.Workspace{Root: root, ActivePaneID: right.Pane.ID}
18+
19+ // First press: horizontal -> vertical split (left on top, right on bottom).
20+ res, err := uc.ConsumeOrExpel(ctx, ws, right, ConsumeOrExpelLeft)
21+ if err != nil {
22+ t.Fatalf("unexpected error: %v", err)
23+ }
24+ if res == nil || res.Action != "consumed" {
25+ t.Fatalf("result action=%v, want consumed", res)
26+ }
27+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
28+ t.Fatalf("root should be a vertical split")
29+ }
30+ if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "left" {
31+ t.Fatalf("top pane=%v, want left", ws.Root.Left())
32+ }
33+ if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "right" {
34+ t.Fatalf("bottom pane=%v, want right", ws.Root.Right())
35+ }
36+ if ws.ActivePaneID != "right" {
37+ t.Fatalf("active=%s, want right", ws.ActivePaneID)
38+ }
39+
40+ // Second press: vertical split -> stack.
41+ active := ws.ActivePane()
42+ if active == nil {
43+ t.Fatalf("expected active pane")
44+ }
45+ res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelLeft)
46+ if err != nil {
47+ t.Fatalf("unexpected error: %v", err)
48+ }
49+ if res == nil || res.Action != "consumed" {
50+ t.Fatalf("result action=%v, want consumed", res)
51+ }
52+ if ws.Root == nil || !ws.Root.IsStacked {
53+ t.Fatalf("root should be a stack")
54+ }
55+ if got := panesInOrder(ws.Root); got != "left,right" {
56+ t.Fatalf("stack panes=%s, want left,right", got)
57+ }
58+ if ws.ActivePaneID != "right" {
59+ t.Fatalf("active=%s, want right", ws.ActivePaneID)
60+ }
61+}
62+
63+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVerticalThenStack(t *testing.T) {
64+ uc := NewManagePanesUseCase(func() string { return "id" })
65+ ctx := context.Background()
66+
67+ left := leaf("left")
68+ right := leaf("right")
69+ root := split(entity.SplitHorizontal, left, right)
70+ ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID}
71+
72+ // First press: horizontal -> vertical split (right on top, left on bottom).
73+ res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelRight)
74+ if err != nil {
75+ t.Fatalf("unexpected error: %v", err)
76+ }
77+ if res == nil || res.Action != "consumed" {
78+ t.Fatalf("result action=%v, want consumed", res)
79+ }
80+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
81+ t.Fatalf("root should be a vertical split")
82+ }
83+ if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "right" {
84+ t.Fatalf("top pane=%v, want right", ws.Root.Left())
85+ }
86+ if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "left" {
87+ t.Fatalf("bottom pane=%v, want left", ws.Root.Right())
88+ }
89+ if ws.ActivePaneID != "left" {
90+ t.Fatalf("active=%s, want left", ws.ActivePaneID)
91+ }
92+
93+ // Second press: vertical split -> stack.
94+ active := ws.ActivePane()
95+ if active == nil {
96+ t.Fatalf("expected active pane")
97+ }
98+ res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelRight)
99+ if err != nil {
100+ t.Fatalf("unexpected error: %v", err)
101+ }
102+ if res == nil || res.Action != "consumed" {
103+ t.Fatalf("result action=%v, want consumed", res)
104+ }
105+ if ws.Root == nil || !ws.Root.IsStacked {
106+ t.Fatalf("root should be a stack")
107+ }
108+ if got := panesInOrder(ws.Root); got != "right,left" {
109+ t.Fatalf("stack panes=%s, want right,left", got)
110+ }
111+ if ws.ActivePaneID != "left" {
112+ t.Fatalf("active=%s, want left", ws.ActivePaneID)
113+ }
114+}
115+
116+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd(t *testing.T) {
117+ uc := NewManagePanesUseCase(func() string { return "id" })
118+ ctx := context.Background()
119+
120+ stackNode := stack(leaf("a"), leaf("b"))
121+ c := leaf("c")
122+ root := split(entity.SplitHorizontal, stackNode, c)
123+ ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID}
124+
125+ res, err := uc.ConsumeOrExpel(ctx, ws, c, ConsumeOrExpelLeft)
126+ if err != nil {
127+ t.Fatalf("unexpected error: %v", err)
128+ }
129+ if res == nil || res.Action != "consumed" {
130+ t.Fatalf("result action=%v, want consumed", res)
131+ }
132+ if ws.Root == nil || !ws.Root.IsStacked {
133+ t.Fatalf("root should be a stack")
134+ }
135+ if got := panesInOrder(ws.Root); got != "a,b,c" {
136+ t.Fatalf("stack panes=%s, want a,b,c", got)
137+ }
138+ if ws.ActivePaneID != "c" {
139+ t.Fatalf("active=%s, want c", ws.ActivePaneID)
140+ }
141+}
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+
167+func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) {
168+ uc := NewManagePanesUseCase(func() string { return "id" })
169+ ctx := context.Background()
170+
171+ stackNode := stack(leaf("a"), leaf("b"))
172+ b := stackNode.Children[1]
173+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
174+
175+ res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
176+ if err != nil {
177+ t.Fatalf("unexpected error: %v", err)
178+ }
179+ if res == nil || res.Action != "expelled" {
180+ t.Fatalf("result action=%v, want expelled", res)
181+ }
182+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
183+ t.Fatalf("root should be a vertical split")
184+ }
185+
186+ top := ws.Root.Left()
187+ bottom := ws.Root.Right()
188+ if top == nil || bottom == nil {
189+ t.Fatalf("split children should exist")
190+ }
191+ if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" {
192+ t.Fatalf("top child should be leaf a")
193+ }
194+ if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" {
195+ t.Fatalf("bottom child should be leaf b")
196+ }
197+ if ws.ActivePaneID != "b" {
198+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
199+ }
200+}
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+
261+func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {
262+ uc := NewManagePanesUseCase(func() string { return "id" })
263+ ctx := context.Background()
264+
265+ stackNode := stack(leaf("a"), leaf("b"), leaf("c"))
266+ b := stackNode.Children[1]
267+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
268+
269+ res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
270+ if err != nil {
271+ t.Fatalf("unexpected error: %v", err)
272+ }
273+ if res == nil || res.Action != "expelled" {
274+ t.Fatalf("result action=%v, want expelled", res)
275+ }
276+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
277+ t.Fatalf("root should be a vertical split")
278+ }
279+ top := ws.Root.Left()
280+ bottom := ws.Root.Right()
281+ if top == nil || !top.IsStacked {
282+ t.Fatalf("top child should be stack")
283+ }
284+ if panesInOrder(top) != "a,c" {
285+ t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top))
286+ }
287+ if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" {
288+ t.Fatalf("bottom child should be expelled leaf b")
289+ }
290+ if ws.ActivePaneID != "b" {
291+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
292+ }
293+}
294+
295+func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) {
296+ uc := NewManagePanesUseCase(func() string { return "id" })
297+ ctx := context.Background()
298+
299+ left := leaf("left")
300+ right := leaf("right")
301+ root := split(entity.SplitHorizontal, left, right)
302+ ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID}
303+
304+ res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelLeft)
305+ if err != nil {
306+ t.Fatalf("unexpected error: %v", err)
307+ }
308+ if res == nil || res.Action != "none" {
309+ t.Fatalf("result action=%v, want none", res)
310+ }
311+ if res.ErrorMessage == "" {
312+ t.Fatalf("expected warning message")
313+ }
314+ if ws.Root != root {
315+ t.Fatalf("tree should be unchanged")
316+ }
317+}
318+
319+func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) {
320+ uc := NewManagePanesUseCase(func() string { return "id" })
321+ ctx := context.Background()
322+
323+ a := leaf("a")
324+ b := leaf("b")
325+ top := split(entity.SplitHorizontal, a, b)
326+ c := leaf("c")
327+ root := split(entity.SplitVertical, top, c)
328+ ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID}
329+
330+ res, err := uc.ConsumeOrExpel(ctx, ws, a, ConsumeOrExpelDown)
331+ if err != nil {
332+ t.Fatalf("unexpected error: %v", err)
333+ }
334+ if res == nil || res.Action != "consumed" {
335+ t.Fatalf("result action=%v, want consumed", res)
336+ }
337+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
338+ t.Fatalf("root should remain a vertical split")
339+ }
340+ if ws.Root.Right() == nil || !ws.Root.Right().IsStacked {
341+ t.Fatalf("expected bottom pane to become a stack")
342+ }
343+ if panesInOrder(ws.Root.Right()) != "c,a" {
344+ t.Fatalf("bottom stack panes=%s, want c,a", panesInOrder(ws.Root.Right()))
345+ }
346+}
347+
348+func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {
349+ uc := NewManagePanesUseCase(func() string { return "id" })
350+ ctx := context.Background()
351+
352+ only := leaf("only")
353+ ws := &entity.Workspace{Root: only, ActivePaneID: only.Pane.ID}
354+
355+ res, err := uc.ConsumeOrExpel(ctx, ws, only, ConsumeOrExpelLeft)
356+ if err != nil {
357+ t.Fatalf("unexpected error: %v", err)
358+ }
359+ if res == nil || res.Action != "none" {
360+ t.Fatalf("result action=%v, want none", res)
361+ }
362+ if res.ErrorMessage == "" {
363+ t.Fatalf("expected warning message")
364+ }
365+}
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+
401+func leaf(id string) *entity.PaneNode {
402+ return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}}
403+}
404+
405+func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.PaneNode {
406+ root := &entity.PaneNode{ID: "split", SplitDir: dir, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}}
407+ left.Parent = root
408+ right.Parent = root
409+ return root
410+}
411+
412+func stack(panes ...*entity.PaneNode) *entity.PaneNode {
413+ n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}
414+ children := make([]*entity.PaneNode, 0, len(panes))
415+ for _, p := range panes {
416+ p.Parent = n
417+ children = append(children, p)
418+ }
419+ n.Children = children
420+ return n
421+}
422+
423+func panesInOrder(stackNode *entity.PaneNode) string {
424+ if stackNode == nil || len(stackNode.Children) == 0 {
425+ return ""
426+ }
427+ out := ""
428+ for i, child := range stackNode.Children {
429+ if child == nil || child.Pane == nil {
430+ continue
431+ }
432+ if i > 0 {
433+ out += ","
434+ }
435+ out += string(child.Pane.ID)
436+ }
437+ return out
438+}
M internal/infrastructure/config/defaults.go +11 -0
@@ -166,6 +166,12 @@ func DefaultConfig() *Config {
166166 "split-down": {"arrowdown", "d"},
167167 "stack-pane": {"s"},
168168 "close-pane": {"x"},
169+
170+ "consume-or-expel-left": {"bracketleft", "["},
171+ "consume-or-expel-right": {"bracketright", "]"},
172+ "consume-or-expel-up": {"shift+bracketleft", "braceleft", "{"},
173+ "consume-or-expel-down": {"shift+bracketright", "braceright", "}"},
174+
169175 "focus-right": {"shift+arrowright", "shift+l"},
170176 "focus-left": {"shift+arrowleft", "shift+h"},
171177 "focus-up": {"shift+arrowup", "shift+k"},
@@ -211,6 +217,11 @@ func DefaultConfig() *Config {
211217 ClosePane: "ctrl+w",
212218 NextTab: "ctrl+tab",
213219 PreviousTab: "ctrl+shift+tab",
220+
221+ ConsumeOrExpelLeft: "alt+bracketleft",
222+ ConsumeOrExpelRight: "alt+bracketright",
223+ ConsumeOrExpelUp: "alt+shift+bracketleft",
224+ ConsumeOrExpelDown: "alt+shift+bracketright",
214225 },
215226 TabBarPosition: defaultTabBarPosition,
216227 HideTabBarWhenSingleTab: true,
@@ -166,6 +166,12 @@ func DefaultConfig() *Config {
166 "split-down": {"arrowdown", "d"},166 "split-down": {"arrowdown", "d"},
167 "stack-pane": {"s"},167 "stack-pane": {"s"},
168 "close-pane": {"x"},168 "close-pane": {"x"},
169+
170+ "consume-or-expel-left": {"bracketleft", "["},
171+ "consume-or-expel-right": {"bracketright", "]"},
172+ "consume-or-expel-up": {"shift+bracketleft", "braceleft", "{"},
173+ "consume-or-expel-down": {"shift+bracketright", "braceright", "}"},
174+
169 "focus-right": {"shift+arrowright", "shift+l"},175 "focus-right": {"shift+arrowright", "shift+l"},
170 "focus-left": {"shift+arrowleft", "shift+h"},176 "focus-left": {"shift+arrowleft", "shift+h"},
171 "focus-up": {"shift+arrowup", "shift+k"},177 "focus-up": {"shift+arrowup", "shift+k"},
@@ -211,6 +217,11 @@ func DefaultConfig() *Config {
211 ClosePane: "ctrl+w",217 ClosePane: "ctrl+w",
212 NextTab: "ctrl+tab",218 NextTab: "ctrl+tab",
213 PreviousTab: "ctrl+shift+tab",219 PreviousTab: "ctrl+shift+tab",
220+
221+ ConsumeOrExpelLeft: "alt+bracketleft",
222+ ConsumeOrExpelRight: "alt+bracketright",
223+ ConsumeOrExpelUp: "alt+shift+bracketleft",
224+ ConsumeOrExpelDown: "alt+shift+bracketright",
214 },225 },
215 TabBarPosition: defaultTabBarPosition,226 TabBarPosition: defaultTabBarPosition,
216 HideTabBarWhenSingleTab: true,227 HideTabBarWhenSingleTab: true,
M internal/infrastructure/config/loader.go +4 -0
@@ -395,6 +395,10 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
395395 m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)
396396 m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)
397397 m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab)
398+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_left", defaults.Workspace.Shortcuts.ConsumeOrExpelLeft)
399+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_right", defaults.Workspace.Shortcuts.ConsumeOrExpelRight)
400+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_up", defaults.Workspace.Shortcuts.ConsumeOrExpelUp)
401+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)
398402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
399403 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
400404 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
@@ -395,6 +395,10 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
395 m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)395 m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)
396 m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)396 m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)
397 m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab)397 m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab)
398+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_left", defaults.Workspace.Shortcuts.ConsumeOrExpelLeft)
399+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_right", defaults.Workspace.Shortcuts.ConsumeOrExpelRight)
400+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_up", defaults.Workspace.Shortcuts.ConsumeOrExpelUp)
401+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)
398 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
399 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)403 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
400 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))404 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
M internal/infrastructure/config/schema.go +5 -0
@@ -383,6 +383,11 @@ type GlobalShortcutsConfig struct {
383383 ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"`
384384 NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"`
385385 PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"`
386+
387+ ConsumeOrExpelLeft string `mapstructure:"consume_or_expel_left" yaml:"consume_or_expel_left" toml:"consume_or_expel_left" json:"consume_or_expel_left"` //nolint:lll // struct tags must stay on one line
388+ ConsumeOrExpelRight string `mapstructure:"consume_or_expel_right" yaml:"consume_or_expel_right" toml:"consume_or_expel_right" json:"consume_or_expel_right"` //nolint:lll // struct tags must stay on one line
389+ ConsumeOrExpelUp string `mapstructure:"consume_or_expel_up" yaml:"consume_or_expel_up" toml:"consume_or_expel_up" json:"consume_or_expel_up"` //nolint:lll // struct tags must stay on one line
390+ ConsumeOrExpelDown string `mapstructure:"consume_or_expel_down" yaml:"consume_or_expel_down" toml:"consume_or_expel_down" json:"consume_or_expel_down"` //nolint:lll // struct tags must stay on one line
386391 }
387392
388393 // PopupBehavior defines how popup windows should be opened
@@ -383,6 +383,11 @@ type GlobalShortcutsConfig struct {
383 ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"`383 ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"`
384 NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"`384 NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"`
385 PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"`385 PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"`
386+
387+ ConsumeOrExpelLeft string `mapstructure:"consume_or_expel_left" yaml:"consume_or_expel_left" toml:"consume_or_expel_left" json:"consume_or_expel_left"` //nolint:lll // struct tags must stay on one line
388+ ConsumeOrExpelRight string `mapstructure:"consume_or_expel_right" yaml:"consume_or_expel_right" toml:"consume_or_expel_right" json:"consume_or_expel_right"` //nolint:lll // struct tags must stay on one line
389+ ConsumeOrExpelUp string `mapstructure:"consume_or_expel_up" yaml:"consume_or_expel_up" toml:"consume_or_expel_up" json:"consume_or_expel_up"` //nolint:lll // struct tags must stay on one line
390+ ConsumeOrExpelDown string `mapstructure:"consume_or_expel_down" yaml:"consume_or_expel_down" toml:"consume_or_expel_down" json:"consume_or_expel_down"` //nolint:lll // struct tags must stay on one line
386 }391 }
387 392
388 // PopupBehavior defines how popup windows should be opened393 // PopupBehavior defines how popup windows should be opened
M internal/ui/app.go +1 -0
@@ -335,6 +335,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
335335 a.globalShortcutHandler = input.NewGlobalShortcutHandler(
336336 ctx,
337337 a.mainWindow.Window(),
338+ a.deps.Config,
338339 func(ctx context.Context, action input.Action) error {
339340 return a.kbDispatcher.Dispatch(ctx, action)
340341 },
@@ -335,6 +335,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
335 a.globalShortcutHandler = input.NewGlobalShortcutHandler(335 a.globalShortcutHandler = input.NewGlobalShortcutHandler(
336 ctx,336 ctx,
337 a.mainWindow.Window(),337 a.mainWindow.Window(),
338+ a.deps.Config,
338 func(ctx context.Context, action input.Action) error {339 func(ctx context.Context, action input.Action) error {
339 return a.kbDispatcher.Dispatch(ctx, action)340 return a.kbDispatcher.Dispatch(ctx, action)
340 },341 },
M internal/ui/coordinator/workspace.go +44 -0
@@ -1862,6 +1862,50 @@ func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input Inse
18621862 return nil
18631863 }
18641864
1865+func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction usecase.ConsumeOrExpelDirection) error {
1866+ log := logging.FromContext(ctx)
1867+
1868+ if c.panesUC == nil {
1869+ log.Warn().Msg("panes use case not available")
1870+ return nil
1871+ }
1872+
1873+ ws, wsView := c.getActiveWS()
1874+ if ws == nil {
1875+ log.Warn().Msg("no active workspace")
1876+ return nil
1877+ }
1878+
1879+ activeNode := ws.ActivePane()
1880+ if activeNode == nil {
1881+ log.Warn().Msg("no active pane")
1882+ return nil
1883+ }
1884+
1885+ result, err := c.panesUC.ConsumeOrExpel(ctx, ws, activeNode, direction)
1886+ if err != nil {
1887+ return err
1888+ }
1889+ if result != nil && result.ErrorMessage != "" {
1890+ c.ShowToastOnActivePane(ctx, result.ErrorMessage, component.ToastWarning)
1891+ return nil
1892+ }
1893+
1894+ if wsView != nil {
1895+ if err := wsView.Rebuild(ctx); err != nil {
1896+ log.Warn().Err(err).Msg("failed to rebuild workspace view")
1897+ }
1898+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1899+ if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
1900+ log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1901+ }
1902+ wsView.FocusPane(ws.ActivePaneID)
1903+ }
1904+
1905+ c.notifyStateChanged()
1906+ return nil
1907+}
1908+
18651909 // ShowZoomToast displays a zoom level toast on the active pane.
18661910 func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) {
18671911 _, wsView := c.getActiveWS()
@@ -1862,6 +1862,50 @@ func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input Inse
1862 return nil1862 return nil
1863 }1863 }
1864 1864
1865+func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction usecase.ConsumeOrExpelDirection) error {
1866+ log := logging.FromContext(ctx)
1867+
1868+ if c.panesUC == nil {
1869+ log.Warn().Msg("panes use case not available")
1870+ return nil
1871+ }
1872+
1873+ ws, wsView := c.getActiveWS()
1874+ if ws == nil {
1875+ log.Warn().Msg("no active workspace")
1876+ return nil
1877+ }
1878+
1879+ activeNode := ws.ActivePane()
1880+ if activeNode == nil {
1881+ log.Warn().Msg("no active pane")
1882+ return nil
1883+ }
1884+
1885+ result, err := c.panesUC.ConsumeOrExpel(ctx, ws, activeNode, direction)
1886+ if err != nil {
1887+ return err
1888+ }
1889+ if result != nil && result.ErrorMessage != "" {
1890+ c.ShowToastOnActivePane(ctx, result.ErrorMessage, component.ToastWarning)
1891+ return nil
1892+ }
1893+
1894+ if wsView != nil {
1895+ if err := wsView.Rebuild(ctx); err != nil {
1896+ log.Warn().Err(err).Msg("failed to rebuild workspace view")
1897+ }
1898+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1899+ if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
1900+ log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1901+ }
1902+ wsView.FocusPane(ws.ActivePaneID)
1903+ }
1904+
1905+ c.notifyStateChanged()
1906+ return nil
1907+}
1908+
1865 // ShowZoomToast displays a zoom level toast on the active pane.1909 // ShowZoomToast displays a zoom level toast on the active pane.
1866 func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) {1910 func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) {
1867 _, wsView := c.getActiveWS()1911 _, wsView := c.getActiveWS()
M internal/ui/dispatcher/keyboard.go +12 -0
@@ -128,6 +128,18 @@ func (d *KeyboardDispatcher) initActionHandlers() {
128128 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
129129 input.ActionClosePane: d.wsCoord.ClosePane,
130130 input.ActionStackPane: d.wsCoord.StackPane,
131+ input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
132+ return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
133+ },
134+ input.ActionConsumeOrExpelRight: func(ctx context.Context) error {
135+ return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight)
136+ },
137+ input.ActionConsumeOrExpelUp: func(ctx context.Context) error {
138+ return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp)
139+ },
140+ input.ActionConsumeOrExpelDown: func(ctx context.Context) error {
141+ return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown)
142+ },
131143 input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) },
132144 input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
133145 input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
@@ -128,6 +128,18 @@ func (d *KeyboardDispatcher) initActionHandlers() {
128 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },128 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
129 input.ActionClosePane: d.wsCoord.ClosePane,129 input.ActionClosePane: d.wsCoord.ClosePane,
130 input.ActionStackPane: d.wsCoord.StackPane,130 input.ActionStackPane: d.wsCoord.StackPane,
131+ input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
132+ return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
133+ },
134+ input.ActionConsumeOrExpelRight: func(ctx context.Context) error {
135+ return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight)
136+ },
137+ input.ActionConsumeOrExpelUp: func(ctx context.Context) error {
138+ return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp)
139+ },
140+ input.ActionConsumeOrExpelDown: func(ctx context.Context) error {
141+ return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown)
142+ },
131 input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) },143 input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) },
132 input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },144 input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
133 input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },145 input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
M internal/ui/input/global_shortcuts.go +19 -0
@@ -4,6 +4,7 @@ package input
44 import (
55 "context"
66
7+ "github.com/bnema/dumber/internal/infrastructure/config"
78 "github.com/bnema/dumber/internal/logging"
89 "github.com/jwijenbergh/puregotk/v4/gdk"
910 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -27,6 +28,7 @@ type GlobalShortcutHandler struct {
2728 func NewGlobalShortcutHandler(
2829 ctx context.Context,
2930 window *gtk.ApplicationWindow,
31+ cfg *config.Config,
3032 onAction ActionHandler,
3133 ) *GlobalShortcutHandler {
3234 log := logging.FromContext(ctx)
@@ -91,6 +93,23 @@ func NewGlobalShortcutHandler(
9193 Str("action", string(ActionOpenSessionManager)).
9294 Msg("registered global shortcut")
9395
96+ if cfg != nil {
97+ registerFromConfig := func(key string, action Action) {
98+ binding, ok := ParseKeyString(key)
99+ if !ok {
100+ log.Warn().Str("shortcut", key).Str("action", string(action)).Msg("failed to parse global shortcut")
101+ return
102+ }
103+ h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
104+ log.Trace().Str("shortcut", key).Str("action", string(action)).Msg("registered global shortcut")
105+ }
106+
107+ registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelLeft, ActionConsumeOrExpelLeft)
108+ registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelRight, ActionConsumeOrExpelRight)
109+ registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelUp, ActionConsumeOrExpelUp)
110+ registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelDown, ActionConsumeOrExpelDown)
111+ }
112+
94113 // Attach to window
95114 window.AddController(&h.controller.EventController)
96115
@@ -4,6 +4,7 @@ package input
4 import (4 import (
5 "context"5 "context"
6 6
7+ "github.com/bnema/dumber/internal/infrastructure/config"
7 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
8 "github.com/jwijenbergh/puregotk/v4/gdk"9 "github.com/jwijenbergh/puregotk/v4/gdk"
9 "github.com/jwijenbergh/puregotk/v4/glib"10 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -27,6 +28,7 @@ type GlobalShortcutHandler struct {
27 func NewGlobalShortcutHandler(28 func NewGlobalShortcutHandler(
28 ctx context.Context,29 ctx context.Context,
29 window *gtk.ApplicationWindow,30 window *gtk.ApplicationWindow,
31+ cfg *config.Config,
30 onAction ActionHandler,32 onAction ActionHandler,
31 ) *GlobalShortcutHandler {33 ) *GlobalShortcutHandler {
32 log := logging.FromContext(ctx)34 log := logging.FromContext(ctx)
@@ -91,6 +93,23 @@ func NewGlobalShortcutHandler(
91 Str("action", string(ActionOpenSessionManager)).93 Str("action", string(ActionOpenSessionManager)).
92 Msg("registered global shortcut")94 Msg("registered global shortcut")
93 95
96+ if cfg != nil {
97+ registerFromConfig := func(key string, action Action) {
98+ binding, ok := ParseKeyString(key)
99+ if !ok {
100+ log.Warn().Str("shortcut", key).Str("action", string(action)).Msg("failed to parse global shortcut")
101+ return
102+ }
103+ h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
104+ log.Trace().Str("shortcut", key).Str("action", string(action)).Msg("registered global shortcut")
105+ }
106+
107+ registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelLeft, ActionConsumeOrExpelLeft)
108+ registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelRight, ActionConsumeOrExpelRight)
109+ registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelUp, ActionConsumeOrExpelUp)
110+ registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelDown, ActionConsumeOrExpelDown)
111+ }
112+
94 // Attach to window113 // Attach to window
95 window.AddController(&h.controller.EventController)114 window.AddController(&h.controller.EventController)
96 115
M internal/ui/input/keyboard.go +17 -0
@@ -212,6 +212,23 @@ func (h *KeyboardHandler) lookupAction(
212212 }
213213 }
214214
215+ if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
216+ if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
217+ if modifiers == (ModAlt | ModShift) {
218+ action = bracketActions.WithShift
219+ } else {
220+ action = bracketActions.NoShift
221+ }
222+ found = action != ""
223+ if found && log != nil {
224+ log.Debug().
225+ Uint("keycode", keycode).
226+ Str("action", string(action)).
227+ Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
228+ }
229+ }
230+ }
231+
215232 return action, found
216233 }
217234
@@ -212,6 +212,23 @@ func (h *KeyboardHandler) lookupAction(
212 }212 }
213 }213 }
214 214
215+ if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
216+ if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
217+ if modifiers == (ModAlt | ModShift) {
218+ action = bracketActions.WithShift
219+ } else {
220+ action = bracketActions.NoShift
221+ }
222+ found = action != ""
223+ if found && log != nil {
224+ log.Debug().
225+ Uint("keycode", keycode).
226+ Str("action", string(action)).
227+ Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
228+ }
229+ }
230+ }
231+
215 return action, found232 return action, found
216 }233 }
217 234
M internal/ui/input/keycodes.go +17 -0
@@ -22,6 +22,9 @@ const (
2222 KeycodeDigit8 uint = 17 // Physical '8' key position
2323 KeycodeDigit9 uint = 18 // Physical '9' key position
2424 KeycodeDigit0 uint = 19 // Physical '0' key position
25+
26+ KeycodeBracketLeft uint = 34 // Physical '[' key position (US layout)
27+ KeycodeBracketRight uint = 35 // Physical ']' key position (US layout)
2528 )
2629
2730 // KeycodeToTabAction maps hardware keycodes for the number row to tab switch actions.
@@ -40,6 +43,20 @@ var KeycodeToTabAction = map[uint]Action{
4043 KeycodeDigit0: ActionSwitchTabIndex10,
4144 }
4245
46+var KeycodeToBracketAction = map[uint]struct {
47+ NoShift Action
48+ WithShift Action
49+}{
50+ KeycodeBracketLeft: {
51+ NoShift: ActionConsumeOrExpelLeft,
52+ WithShift: ActionConsumeOrExpelUp,
53+ },
54+ KeycodeBracketRight: {
55+ NoShift: ActionConsumeOrExpelRight,
56+ WithShift: ActionConsumeOrExpelDown,
57+ },
58+}
59+
4360 // KeycodeToDigitIndex converts a hardware keycode to a digit index (0-9).
4461 // Returns the index and true if the keycode is a number row key, or -1 and false otherwise.
4562 // This is useful for components like omnibox that need Ctrl+1-9/0 shortcuts.
@@ -22,6 +22,9 @@ const (
22 KeycodeDigit8 uint = 17 // Physical '8' key position22 KeycodeDigit8 uint = 17 // Physical '8' key position
23 KeycodeDigit9 uint = 18 // Physical '9' key position23 KeycodeDigit9 uint = 18 // Physical '9' key position
24 KeycodeDigit0 uint = 19 // Physical '0' key position24 KeycodeDigit0 uint = 19 // Physical '0' key position
25+
26+ KeycodeBracketLeft uint = 34 // Physical '[' key position (US layout)
27+ KeycodeBracketRight uint = 35 // Physical ']' key position (US layout)
25 )28 )
26 29
27 // KeycodeToTabAction maps hardware keycodes for the number row to tab switch actions.30 // KeycodeToTabAction maps hardware keycodes for the number row to tab switch actions.
@@ -40,6 +43,20 @@ var KeycodeToTabAction = map[uint]Action{
40 KeycodeDigit0: ActionSwitchTabIndex10,43 KeycodeDigit0: ActionSwitchTabIndex10,
41 }44 }
42 45
46+var KeycodeToBracketAction = map[uint]struct {
47+ NoShift Action
48+ WithShift Action
49+}{
50+ KeycodeBracketLeft: {
51+ NoShift: ActionConsumeOrExpelLeft,
52+ WithShift: ActionConsumeOrExpelUp,
53+ },
54+ KeycodeBracketRight: {
55+ NoShift: ActionConsumeOrExpelRight,
56+ WithShift: ActionConsumeOrExpelDown,
57+ },
58+}
59+
43 // KeycodeToDigitIndex converts a hardware keycode to a digit index (0-9).60 // KeycodeToDigitIndex converts a hardware keycode to a digit index (0-9).
44 // Returns the index and true if the keycode is a number row key, or -1 and false otherwise.61 // Returns the index and true if the keycode is a number row key, or -1 and false otherwise.
45 // This is useful for components like omnibox that need Ctrl+1-9/0 shortcuts.62 // This is useful for components like omnibox that need Ctrl+1-9/0 shortcuts.
M internal/ui/input/shortcuts.go +33 -0
@@ -79,6 +79,15 @@ var keyvalByName = map[string]uint{
7979 "7": uint(gdk.KEY_7),
8080 "8": uint(gdk.KEY_8),
8181 "9": uint(gdk.KEY_9),
82+
83+ "bracketleft": uint(gdk.KEY_bracketleft),
84+ "bracketright": uint(gdk.KEY_bracketright),
85+ "[": uint(gdk.KEY_bracketleft),
86+ "]": uint(gdk.KEY_bracketright),
87+ "braceleft": uint(gdk.KEY_braceleft),
88+ "braceright": uint(gdk.KEY_braceright),
89+ "{": uint(gdk.KEY_braceleft),
90+ "}": uint(gdk.KEY_braceright),
8291 }
8392
8493 // KeyBinding represents a single key combination.
@@ -125,6 +134,11 @@ const (
125134 ActionClosePane Action = "close_pane"
126135 ActionStackPane Action = "stack_pane"
127136
137+ ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
138+ ActionConsumeOrExpelRight Action = "consume_or_expel_right"
139+ ActionConsumeOrExpelUp Action = "consume_or_expel_up"
140+ ActionConsumeOrExpelDown Action = "consume_or_expel_down"
141+
128142 // Pane focus navigation
129143 ActionFocusRight Action = "focus_right"
130144 ActionFocusLeft Action = "focus_left"
@@ -316,6 +330,19 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
316330 if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok {
317331 s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab
318332 }
333+
334+ if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelLeft); ok {
335+ s.Global[binding] = ActionConsumeOrExpelLeft
336+ }
337+ if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelRight); ok {
338+ s.Global[binding] = ActionConsumeOrExpelRight
339+ }
340+ if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelUp); ok {
341+ s.Global[binding] = ActionConsumeOrExpelUp
342+ }
343+ if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelDown); ok {
344+ s.Global[binding] = ActionConsumeOrExpelDown
345+ }
319346 }
320347
321348 func (s *ShortcutSet) registerStandardShortcuts() {
@@ -409,6 +436,11 @@ var configActionToAction = map[string]Action{
409436 "close-pane": ActionClosePane,
410437 "stack-pane": ActionStackPane,
411438
439+ "consume-or-expel-left": ActionConsumeOrExpelLeft,
440+ "consume-or-expel-right": ActionConsumeOrExpelRight,
441+ "consume-or-expel-up": ActionConsumeOrExpelUp,
442+ "consume-or-expel-down": ActionConsumeOrExpelDown,
443+
412444 // Focus navigation
413445 "focus-right": ActionFocusRight,
414446 "focus-left": ActionFocusLeft,
@@ -556,6 +588,7 @@ func ShouldAutoExitMode(action Action) bool {
556588 case ActionNewTab, ActionCloseTab, ActionRenameTab,
557589 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
558590 ActionClosePane, ActionStackPane,
591+ ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
559592 ActionOpenSessionManager:
560593 return true
561594 default:
@@ -79,6 +79,15 @@ var keyvalByName = map[string]uint{
79 "7": uint(gdk.KEY_7),79 "7": uint(gdk.KEY_7),
80 "8": uint(gdk.KEY_8),80 "8": uint(gdk.KEY_8),
81 "9": uint(gdk.KEY_9),81 "9": uint(gdk.KEY_9),
82+
83+ "bracketleft": uint(gdk.KEY_bracketleft),
84+ "bracketright": uint(gdk.KEY_bracketright),
85+ "[": uint(gdk.KEY_bracketleft),
86+ "]": uint(gdk.KEY_bracketright),
87+ "braceleft": uint(gdk.KEY_braceleft),
88+ "braceright": uint(gdk.KEY_braceright),
89+ "{": uint(gdk.KEY_braceleft),
90+ "}": uint(gdk.KEY_braceright),
82 }91 }
83 92
84 // KeyBinding represents a single key combination.93 // KeyBinding represents a single key combination.
@@ -125,6 +134,11 @@ const (
125 ActionClosePane Action = "close_pane"134 ActionClosePane Action = "close_pane"
126 ActionStackPane Action = "stack_pane"135 ActionStackPane Action = "stack_pane"
127 136
137+ ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
138+ ActionConsumeOrExpelRight Action = "consume_or_expel_right"
139+ ActionConsumeOrExpelUp Action = "consume_or_expel_up"
140+ ActionConsumeOrExpelDown Action = "consume_or_expel_down"
141+
128 // Pane focus navigation142 // Pane focus navigation
129 ActionFocusRight Action = "focus_right"143 ActionFocusRight Action = "focus_right"
130 ActionFocusLeft Action = "focus_left"144 ActionFocusLeft Action = "focus_left"
@@ -316,6 +330,19 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
316 if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok {330 if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok {
317 s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab331 s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab
318 }332 }
333+
334+ if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelLeft); ok {
335+ s.Global[binding] = ActionConsumeOrExpelLeft
336+ }
337+ if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelRight); ok {
338+ s.Global[binding] = ActionConsumeOrExpelRight
339+ }
340+ if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelUp); ok {
341+ s.Global[binding] = ActionConsumeOrExpelUp
342+ }
343+ if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelDown); ok {
344+ s.Global[binding] = ActionConsumeOrExpelDown
345+ }
319 }346 }
320 347
321 func (s *ShortcutSet) registerStandardShortcuts() {348 func (s *ShortcutSet) registerStandardShortcuts() {
@@ -409,6 +436,11 @@ var configActionToAction = map[string]Action{
409 "close-pane": ActionClosePane,436 "close-pane": ActionClosePane,
410 "stack-pane": ActionStackPane,437 "stack-pane": ActionStackPane,
411 438
439+ "consume-or-expel-left": ActionConsumeOrExpelLeft,
440+ "consume-or-expel-right": ActionConsumeOrExpelRight,
441+ "consume-or-expel-up": ActionConsumeOrExpelUp,
442+ "consume-or-expel-down": ActionConsumeOrExpelDown,
443+
412 // Focus navigation444 // Focus navigation
413 "focus-right": ActionFocusRight,445 "focus-right": ActionFocusRight,
414 "focus-left": ActionFocusLeft,446 "focus-left": ActionFocusLeft,
@@ -556,6 +588,7 @@ func ShouldAutoExitMode(action Action) bool {
556 case ActionNewTab, ActionCloseTab, ActionRenameTab,588 case ActionNewTab, ActionCloseTab, ActionRenameTab,
557 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,589 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
558 ActionClosePane, ActionStackPane,590 ActionClosePane, ActionStackPane,
591+ ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
559 ActionOpenSessionManager:592 ActionOpenSessionManager:
560 return true593 return true
561 default:594 default:

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