detached a9a926c

feat(ui): implement niri-like consume and expel pane operations

commit
a9a926cbd71c46595b91f7e1f4cd45bb027761e0
author
brice · · 2025-12-26 11:21 UTC
parent
8b8ac5d
signature
unsigned
12 files · +946 -0
M internal/application/usecase/manage_panes.go +462 -0
@@ -52,6 +52,20 @@ const (
5252
5353 var ErrNothingToResize = errors.New("nothing to resize")
5454
55+type ConsumeOrExpelDirection string
56+
57+const (
58+ ConsumeOrExpelLeft ConsumeOrExpelDirection = "left"
59+ ConsumeOrExpelRight ConsumeOrExpelDirection = "right"
60+ ConsumeOrExpelUp ConsumeOrExpelDirection = "up"
61+ ConsumeOrExpelDown ConsumeOrExpelDirection = "down"
62+)
63+
64+type ConsumeOrExpelResult struct {
65+ Action string // "consumed", "expelled", or "none"
66+ ErrorMessage string
67+}
68+
5569 // ManagePanesUseCase handles pane tree operations.
5670 type ManagePanesUseCase struct {
5771 idGenerator IDGenerator
@@ -1074,6 +1088,454 @@ func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *en
10741088 return nil
10751089 }
10761090
1091+func (uc *ManagePanesUseCase) ConsumeOrExpel(
1092+ ctx context.Context,
1093+ ws *entity.Workspace,
1094+ activeNode *entity.PaneNode,
1095+ direction ConsumeOrExpelDirection,
1096+) (*ConsumeOrExpelResult, error) {
1097+ if uc == nil {
1098+ return nil, fmt.Errorf("manage panes use case is nil")
1099+ }
1100+ if ws == nil {
1101+ return nil, fmt.Errorf("workspace is required")
1102+ }
1103+ if ws.Root == nil {
1104+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1105+ }
1106+ if activeNode == nil {
1107+ return nil, fmt.Errorf("active pane node is required")
1108+ }
1109+ if activeNode.Pane == nil {
1110+ return nil, fmt.Errorf("active pane node has no pane")
1111+ }
1112+
1113+ if ws.PaneCount() <= 1 {
1114+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1115+ }
1116+
1117+ // Expel if active pane is inside a stack.
1118+ if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1119+ return uc.expelFromStack(ctx, ws, activeNode, direction)
1120+ }
1121+
1122+ // Consume if active pane is not inside a stack.
1123+ return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction)
1124+}
1125+
1126+func (*ManagePanesUseCase) consumeIntoSiblingStack(
1127+ ctx context.Context,
1128+ ws *entity.Workspace,
1129+ activeNode *entity.PaneNode,
1130+ direction ConsumeOrExpelDirection,
1131+) (*ConsumeOrExpelResult, error) {
1132+ log := logging.FromContext(ctx)
1133+
1134+ sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction)
1135+ if sibling == nil {
1136+ sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction)
1137+ }
1138+ if sibling == nil {
1139+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil
1140+ }
1141+
1142+ // Left/right consume cycles layout for immediate split siblings:
1143+ // 1) horizontal split -> vertical split (target on top)
1144+ // 2) vertical split -> stack (top then bottom)
1145+ if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1146+ cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling)
1147+ if err != nil {
1148+ return nil, err
1149+ }
1150+ if cycled {
1151+ switch cycleAction {
1152+ case "vertical_split":
1153+ log.Info().
1154+ Str("pane_id", activeNode.ID).
1155+ Str("sibling_id", sibling.ID).
1156+ Msg("pane moved below sibling")
1157+ case "stack":
1158+ log.Info().
1159+ Str("pane_id", activeNode.ID).
1160+ Str("sibling_id", sibling.ID).
1161+ Msg("split converted to stack")
1162+ }
1163+ return &ConsumeOrExpelResult{Action: "consumed"}, nil
1164+ }
1165+ }
1166+
1167+ moved, err := detachLeafByPromotingSibling(ws, activeNode)
1168+ if err != nil {
1169+ return nil, err
1170+ }
1171+ moved.Parent = nil
1172+
1173+ stackNode := sibling
1174+ if stackNode.IsLeaf() {
1175+ stackNode = convertLeafToStackContainer(stackNode)
1176+ }
1177+ if !stackNode.IsStacked {
1178+ return nil, fmt.Errorf("target sibling is not stackable")
1179+ }
1180+
1181+ // Insert consumed pane at the bottom/end.
1182+ moved.Parent = stackNode
1183+ stackNode.Children = append(stackNode.Children, moved)
1184+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1185+ ws.ActivePaneID = moved.Pane.ID
1186+
1187+ log.Info().
1188+ Str("pane_id", moved.ID).
1189+ Str("stack_id", stackNode.ID).
1190+ Int("stack_size", len(stackNode.Children)).
1191+ Msg("pane consumed into sibling stack")
1192+
1193+ return &ConsumeOrExpelResult{Action: "consumed"}, nil
1194+}
1195+
1196+func (uc *ManagePanesUseCase) expelFromStack(
1197+ ctx context.Context,
1198+ ws *entity.Workspace,
1199+ activeNode *entity.PaneNode,
1200+ direction ConsumeOrExpelDirection,
1201+) (*ConsumeOrExpelResult, error) {
1202+ log := logging.FromContext(ctx)
1203+
1204+ stackNode := activeNode.Parent
1205+ if stackNode == nil || !stackNode.IsStacked {
1206+ return &ConsumeOrExpelResult{Action: "none"}, nil
1207+ }
1208+
1209+ expelled, err := removeLeafFromStack(stackNode, activeNode)
1210+ if err != nil {
1211+ return nil, err
1212+ }
1213+ expelled.Parent = nil
1214+
1215+ // If only one pane remains, dissolve stack into a leaf node.
1216+ if len(stackNode.Children) == 1 {
1217+ dissolveStackIntoLeaf(stackNode)
1218+ }
1219+
1220+ // Split around the remaining container (stackNode) with expelled pane.
1221+ if err := splitExistingNode(ws, stackNode, expelled, direction, uc.idGenerator); err != nil {
1222+ return nil, err
1223+ }
1224+ ws.ActivePaneID = expelled.Pane.ID
1225+
1226+ log.Info().
1227+ Str("pane_id", expelled.ID).
1228+ Str("direction", string(direction)).
1229+ Msg("pane expelled from stack")
1230+
1231+ return &ConsumeOrExpelResult{Action: "expelled"}, nil
1232+}
1233+
1234+func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string {
1235+ switch direction {
1236+ case ConsumeOrExpelLeft:
1237+ return "No pane to the left"
1238+ case ConsumeOrExpelRight:
1239+ return "No pane to the right"
1240+ case ConsumeOrExpelUp:
1241+ return "No pane above"
1242+ case ConsumeOrExpelDown:
1243+ return "No pane below"
1244+ default:
1245+ return "No pane in that direction"
1246+ }
1247+}
1248+
1249+func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) {
1250+ if node == nil {
1251+ return nil, ""
1252+ }
1253+
1254+ // Determine if we need to go horizontal or vertical.
1255+ isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1256+ isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown
1257+
1258+ // Walk up the tree to find an ancestor split matching our direction axis.
1259+ current := node
1260+ for current.Parent != nil {
1261+ parent := current.Parent
1262+ if parent.IsSplit() {
1263+ parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
1264+ if parentIsHorizontal == isHorizontal {
1265+ if isForward {
1266+ if parent.Right() != nil && parent.Left() == current {
1267+ return parent.Right(), ""
1268+ }
1269+ } else {
1270+ if parent.Left() != nil && parent.Right() == current {
1271+ return parent.Left(), ""
1272+ }
1273+ }
1274+ }
1275+ }
1276+ current = parent
1277+ }
1278+
1279+ return nil, directionNoSiblingMessage(direction)
1280+}
1281+
1282+func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode {
1283+ if node == nil || node.Parent == nil {
1284+ return nil
1285+ }
1286+
1287+ parent := node.Parent
1288+ if !parent.IsSplit() {
1289+ return nil
1290+ }
1291+
1292+ axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1293+ if axisHorizontal {
1294+ if parent.SplitDir == entity.SplitHorizontal {
1295+ return nil
1296+ }
1297+ } else {
1298+ if parent.SplitDir == entity.SplitVertical {
1299+ return nil
1300+ }
1301+ }
1302+
1303+ if parent.Left() == node {
1304+ return parent.Right()
1305+ }
1306+ if parent.Right() == node {
1307+ return parent.Left()
1308+ }
1309+
1310+ return nil
1311+}
1312+
1313+func cycleHorizontalToVerticalThenStack(
1314+ ws *entity.Workspace,
1315+ activeNode *entity.PaneNode,
1316+ sibling *entity.PaneNode,
1317+) (cycled bool, cycleAction string, err error) {
1318+ if ws == nil {
1319+ return false, "", fmt.Errorf("workspace is required")
1320+ }
1321+ if activeNode == nil || sibling == nil {
1322+ return false, "", nil
1323+ }
1324+ if !activeNode.IsLeaf() || !sibling.IsLeaf() {
1325+ return false, "", nil
1326+ }
1327+
1328+ parent := activeNode.Parent
1329+ if parent == nil || sibling.Parent != parent || !parent.IsSplit() {
1330+ return false, "", nil
1331+ }
1332+
1333+ switch parent.SplitDir {
1334+ case entity.SplitHorizontal:
1335+ parent.SplitDir = entity.SplitVertical
1336+ if parent.Children[0] != sibling {
1337+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1338+ }
1339+ ws.ActivePaneID = activeNode.Pane.ID
1340+ return true, "vertical_split", nil
1341+ case entity.SplitVertical:
1342+ parent.SplitDir = entity.SplitNone
1343+ parent.SplitRatio = 0
1344+ parent.IsStacked = true
1345+ parent.Pane = nil
1346+ if parent.Children[0] == activeNode {
1347+ parent.ActiveStackIndex = 0
1348+ } else if parent.Children[1] == activeNode {
1349+ parent.ActiveStackIndex = 1
1350+ } else {
1351+ parent.ActiveStackIndex = 0
1352+ }
1353+ ws.ActivePaneID = activeNode.Pane.ID
1354+ return true, "stack", nil
1355+ default:
1356+ return false, "", nil
1357+ }
1358+}
1359+
1360+func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1361+ if ws == nil {
1362+ return nil, fmt.Errorf("workspace is required")
1363+ }
1364+ if leaf == nil {
1365+ return nil, fmt.Errorf("leaf is required")
1366+ }
1367+ if !leaf.IsLeaf() {
1368+ return nil, fmt.Errorf("can only detach leaf node")
1369+ }
1370+
1371+ parent := leaf.Parent
1372+ if parent == nil {
1373+ return nil, fmt.Errorf("cannot detach root leaf")
1374+ }
1375+ if !parent.IsSplit() {
1376+ return nil, fmt.Errorf("leaf parent is not a split")
1377+ }
1378+
1379+ var sibling *entity.PaneNode
1380+ for _, child := range parent.Children {
1381+ if child != leaf {
1382+ sibling = child
1383+ break
1384+ }
1385+ }
1386+ if sibling == nil {
1387+ return nil, fmt.Errorf("no sibling found for leaf")
1388+ }
1389+
1390+ grandparent := parent.Parent
1391+ if grandparent == nil {
1392+ ws.Root = sibling
1393+ sibling.Parent = nil
1394+ } else {
1395+ for i, child := range grandparent.Children {
1396+ if child == parent {
1397+ grandparent.Children[i] = sibling
1398+ break
1399+ }
1400+ }
1401+ sibling.Parent = grandparent
1402+ }
1403+
1404+ leaf.Parent = nil
1405+ return leaf, nil
1406+}
1407+
1408+func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode {
1409+ if leaf == nil || !leaf.IsLeaf() {
1410+ return leaf
1411+ }
1412+
1413+ originalPane := leaf.Pane
1414+ originalChildNode := &entity.PaneNode{
1415+ ID: leaf.ID + "_0",
1416+ Pane: originalPane,
1417+ Parent: leaf,
1418+ }
1419+
1420+ leaf.Pane = nil
1421+ leaf.IsStacked = true
1422+ leaf.ActiveStackIndex = 0
1423+ leaf.Children = []*entity.PaneNode{originalChildNode}
1424+
1425+ return leaf
1426+}
1427+
1428+func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1429+ if stackNode == nil {
1430+ return nil, fmt.Errorf("stack node is required")
1431+ }
1432+ if !stackNode.IsStacked {
1433+ return nil, fmt.Errorf("node is not a stack")
1434+ }
1435+ if leaf == nil || leaf.Pane == nil {
1436+ return nil, fmt.Errorf("leaf is required")
1437+ }
1438+
1439+ idx := -1
1440+ for i, child := range stackNode.Children {
1441+ if child == leaf {
1442+ idx = i
1443+ break
1444+ }
1445+ }
1446+ if idx < 0 {
1447+ return nil, fmt.Errorf("pane not found in stack")
1448+ }
1449+
1450+ stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
1451+ if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1452+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1453+ }
1454+ if stackNode.ActiveStackIndex < 0 {
1455+ stackNode.ActiveStackIndex = 0
1456+ }
1457+
1458+ leaf.Parent = nil
1459+ return leaf, nil
1460+}
1461+
1462+func dissolveStackIntoLeaf(stackNode *entity.PaneNode) {
1463+ if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 {
1464+ return
1465+ }
1466+
1467+ remaining := stackNode.Children[0]
1468+ stackNode.Pane = remaining.Pane
1469+ stackNode.Children = nil
1470+ stackNode.IsStacked = false
1471+ stackNode.ActiveStackIndex = 0
1472+}
1473+
1474+func splitExistingNode(
1475+ ws *entity.Workspace,
1476+ targetNode *entity.PaneNode,
1477+ newSibling *entity.PaneNode,
1478+ direction ConsumeOrExpelDirection,
1479+ idGen IDGenerator,
1480+) error {
1481+ if ws == nil {
1482+ return fmt.Errorf("workspace is required")
1483+ }
1484+ if targetNode == nil {
1485+ return fmt.Errorf("target node is required")
1486+ }
1487+ if newSibling == nil {
1488+ return fmt.Errorf("new sibling node is required")
1489+ }
1490+ if idGen == nil {
1491+ return fmt.Errorf("id generator is required")
1492+ }
1493+
1494+ var splitDir entity.SplitDirection
1495+ switch direction {
1496+ case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1497+ splitDir = entity.SplitHorizontal
1498+ case ConsumeOrExpelUp, ConsumeOrExpelDown:
1499+ splitDir = entity.SplitVertical
1500+ default:
1501+ return fmt.Errorf("invalid direction: %s", direction)
1502+ }
1503+
1504+ parentNode := &entity.PaneNode{
1505+ ID: idGen(),
1506+ SplitDir: splitDir,
1507+ SplitRatio: 0.5,
1508+ Children: make([]*entity.PaneNode, 2),
1509+ }
1510+
1511+ switch direction {
1512+ case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1513+ parentNode.Children[0] = newSibling
1514+ parentNode.Children[1] = targetNode
1515+ case ConsumeOrExpelRight, ConsumeOrExpelDown:
1516+ parentNode.Children[0] = targetNode
1517+ parentNode.Children[1] = newSibling
1518+ }
1519+
1520+ newSibling.Parent = parentNode
1521+ oldParent := targetNode.Parent
1522+ targetNode.Parent = parentNode
1523+
1524+ if oldParent == nil {
1525+ ws.Root = parentNode
1526+ } else {
1527+ for i, child := range oldParent.Children {
1528+ if child == targetNode {
1529+ oldParent.Children[i] = parentNode
1530+ break
1531+ }
1532+ }
1533+ parentNode.Parent = oldParent
1534+ }
1535+
1536+ return nil
1537+}
1538+
10771539 // GetAllPanes returns all leaf panes in a workspace.
10781540 //
10791541 //nolint:revive // receiver required for interface consistency
@@ -52,6 +52,20 @@ const (
52 52
53 var ErrNothingToResize = errors.New("nothing to resize")53 var ErrNothingToResize = errors.New("nothing to resize")
54 54
55+type ConsumeOrExpelDirection string
56+
57+const (
58+ ConsumeOrExpelLeft ConsumeOrExpelDirection = "left"
59+ ConsumeOrExpelRight ConsumeOrExpelDirection = "right"
60+ ConsumeOrExpelUp ConsumeOrExpelDirection = "up"
61+ ConsumeOrExpelDown ConsumeOrExpelDirection = "down"
62+)
63+
64+type ConsumeOrExpelResult struct {
65+ Action string // "consumed", "expelled", or "none"
66+ ErrorMessage string
67+}
68+
55 // ManagePanesUseCase handles pane tree operations.69 // ManagePanesUseCase handles pane tree operations.
56 type ManagePanesUseCase struct {70 type ManagePanesUseCase struct {
57 idGenerator IDGenerator71 idGenerator IDGenerator
@@ -1074,6 +1088,454 @@ func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *en
1074 return nil1088 return nil
1075 }1089 }
1076 1090
1091+func (uc *ManagePanesUseCase) ConsumeOrExpel(
1092+ ctx context.Context,
1093+ ws *entity.Workspace,
1094+ activeNode *entity.PaneNode,
1095+ direction ConsumeOrExpelDirection,
1096+) (*ConsumeOrExpelResult, error) {
1097+ if uc == nil {
1098+ return nil, fmt.Errorf("manage panes use case is nil")
1099+ }
1100+ if ws == nil {
1101+ return nil, fmt.Errorf("workspace is required")
1102+ }
1103+ if ws.Root == nil {
1104+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1105+ }
1106+ if activeNode == nil {
1107+ return nil, fmt.Errorf("active pane node is required")
1108+ }
1109+ if activeNode.Pane == nil {
1110+ return nil, fmt.Errorf("active pane node has no pane")
1111+ }
1112+
1113+ if ws.PaneCount() <= 1 {
1114+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1115+ }
1116+
1117+ // Expel if active pane is inside a stack.
1118+ if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1119+ return uc.expelFromStack(ctx, ws, activeNode, direction)
1120+ }
1121+
1122+ // Consume if active pane is not inside a stack.
1123+ return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction)
1124+}
1125+
1126+func (*ManagePanesUseCase) consumeIntoSiblingStack(
1127+ ctx context.Context,
1128+ ws *entity.Workspace,
1129+ activeNode *entity.PaneNode,
1130+ direction ConsumeOrExpelDirection,
1131+) (*ConsumeOrExpelResult, error) {
1132+ log := logging.FromContext(ctx)
1133+
1134+ sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction)
1135+ if sibling == nil {
1136+ sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction)
1137+ }
1138+ if sibling == nil {
1139+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil
1140+ }
1141+
1142+ // Left/right consume cycles layout for immediate split siblings:
1143+ // 1) horizontal split -> vertical split (target on top)
1144+ // 2) vertical split -> stack (top then bottom)
1145+ if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1146+ cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling)
1147+ if err != nil {
1148+ return nil, err
1149+ }
1150+ if cycled {
1151+ switch cycleAction {
1152+ case "vertical_split":
1153+ log.Info().
1154+ Str("pane_id", activeNode.ID).
1155+ Str("sibling_id", sibling.ID).
1156+ Msg("pane moved below sibling")
1157+ case "stack":
1158+ log.Info().
1159+ Str("pane_id", activeNode.ID).
1160+ Str("sibling_id", sibling.ID).
1161+ Msg("split converted to stack")
1162+ }
1163+ return &ConsumeOrExpelResult{Action: "consumed"}, nil
1164+ }
1165+ }
1166+
1167+ moved, err := detachLeafByPromotingSibling(ws, activeNode)
1168+ if err != nil {
1169+ return nil, err
1170+ }
1171+ moved.Parent = nil
1172+
1173+ stackNode := sibling
1174+ if stackNode.IsLeaf() {
1175+ stackNode = convertLeafToStackContainer(stackNode)
1176+ }
1177+ if !stackNode.IsStacked {
1178+ return nil, fmt.Errorf("target sibling is not stackable")
1179+ }
1180+
1181+ // Insert consumed pane at the bottom/end.
1182+ moved.Parent = stackNode
1183+ stackNode.Children = append(stackNode.Children, moved)
1184+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1185+ ws.ActivePaneID = moved.Pane.ID
1186+
1187+ log.Info().
1188+ Str("pane_id", moved.ID).
1189+ Str("stack_id", stackNode.ID).
1190+ Int("stack_size", len(stackNode.Children)).
1191+ Msg("pane consumed into sibling stack")
1192+
1193+ return &ConsumeOrExpelResult{Action: "consumed"}, nil
1194+}
1195+
1196+func (uc *ManagePanesUseCase) expelFromStack(
1197+ ctx context.Context,
1198+ ws *entity.Workspace,
1199+ activeNode *entity.PaneNode,
1200+ direction ConsumeOrExpelDirection,
1201+) (*ConsumeOrExpelResult, error) {
1202+ log := logging.FromContext(ctx)
1203+
1204+ stackNode := activeNode.Parent
1205+ if stackNode == nil || !stackNode.IsStacked {
1206+ return &ConsumeOrExpelResult{Action: "none"}, nil
1207+ }
1208+
1209+ expelled, err := removeLeafFromStack(stackNode, activeNode)
1210+ if err != nil {
1211+ return nil, err
1212+ }
1213+ expelled.Parent = nil
1214+
1215+ // If only one pane remains, dissolve stack into a leaf node.
1216+ if len(stackNode.Children) == 1 {
1217+ dissolveStackIntoLeaf(stackNode)
1218+ }
1219+
1220+ // Split around the remaining container (stackNode) with expelled pane.
1221+ if err := splitExistingNode(ws, stackNode, expelled, direction, uc.idGenerator); err != nil {
1222+ return nil, err
1223+ }
1224+ ws.ActivePaneID = expelled.Pane.ID
1225+
1226+ log.Info().
1227+ Str("pane_id", expelled.ID).
1228+ Str("direction", string(direction)).
1229+ Msg("pane expelled from stack")
1230+
1231+ return &ConsumeOrExpelResult{Action: "expelled"}, nil
1232+}
1233+
1234+func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string {
1235+ switch direction {
1236+ case ConsumeOrExpelLeft:
1237+ return "No pane to the left"
1238+ case ConsumeOrExpelRight:
1239+ return "No pane to the right"
1240+ case ConsumeOrExpelUp:
1241+ return "No pane above"
1242+ case ConsumeOrExpelDown:
1243+ return "No pane below"
1244+ default:
1245+ return "No pane in that direction"
1246+ }
1247+}
1248+
1249+func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) {
1250+ if node == nil {
1251+ return nil, ""
1252+ }
1253+
1254+ // Determine if we need to go horizontal or vertical.
1255+ isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1256+ isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown
1257+
1258+ // Walk up the tree to find an ancestor split matching our direction axis.
1259+ current := node
1260+ for current.Parent != nil {
1261+ parent := current.Parent
1262+ if parent.IsSplit() {
1263+ parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
1264+ if parentIsHorizontal == isHorizontal {
1265+ if isForward {
1266+ if parent.Right() != nil && parent.Left() == current {
1267+ return parent.Right(), ""
1268+ }
1269+ } else {
1270+ if parent.Left() != nil && parent.Right() == current {
1271+ return parent.Left(), ""
1272+ }
1273+ }
1274+ }
1275+ }
1276+ current = parent
1277+ }
1278+
1279+ return nil, directionNoSiblingMessage(direction)
1280+}
1281+
1282+func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode {
1283+ if node == nil || node.Parent == nil {
1284+ return nil
1285+ }
1286+
1287+ parent := node.Parent
1288+ if !parent.IsSplit() {
1289+ return nil
1290+ }
1291+
1292+ axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1293+ if axisHorizontal {
1294+ if parent.SplitDir == entity.SplitHorizontal {
1295+ return nil
1296+ }
1297+ } else {
1298+ if parent.SplitDir == entity.SplitVertical {
1299+ return nil
1300+ }
1301+ }
1302+
1303+ if parent.Left() == node {
1304+ return parent.Right()
1305+ }
1306+ if parent.Right() == node {
1307+ return parent.Left()
1308+ }
1309+
1310+ return nil
1311+}
1312+
1313+func cycleHorizontalToVerticalThenStack(
1314+ ws *entity.Workspace,
1315+ activeNode *entity.PaneNode,
1316+ sibling *entity.PaneNode,
1317+) (cycled bool, cycleAction string, err error) {
1318+ if ws == nil {
1319+ return false, "", fmt.Errorf("workspace is required")
1320+ }
1321+ if activeNode == nil || sibling == nil {
1322+ return false, "", nil
1323+ }
1324+ if !activeNode.IsLeaf() || !sibling.IsLeaf() {
1325+ return false, "", nil
1326+ }
1327+
1328+ parent := activeNode.Parent
1329+ if parent == nil || sibling.Parent != parent || !parent.IsSplit() {
1330+ return false, "", nil
1331+ }
1332+
1333+ switch parent.SplitDir {
1334+ case entity.SplitHorizontal:
1335+ parent.SplitDir = entity.SplitVertical
1336+ if parent.Children[0] != sibling {
1337+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1338+ }
1339+ ws.ActivePaneID = activeNode.Pane.ID
1340+ return true, "vertical_split", nil
1341+ case entity.SplitVertical:
1342+ parent.SplitDir = entity.SplitNone
1343+ parent.SplitRatio = 0
1344+ parent.IsStacked = true
1345+ parent.Pane = nil
1346+ if parent.Children[0] == activeNode {
1347+ parent.ActiveStackIndex = 0
1348+ } else if parent.Children[1] == activeNode {
1349+ parent.ActiveStackIndex = 1
1350+ } else {
1351+ parent.ActiveStackIndex = 0
1352+ }
1353+ ws.ActivePaneID = activeNode.Pane.ID
1354+ return true, "stack", nil
1355+ default:
1356+ return false, "", nil
1357+ }
1358+}
1359+
1360+func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1361+ if ws == nil {
1362+ return nil, fmt.Errorf("workspace is required")
1363+ }
1364+ if leaf == nil {
1365+ return nil, fmt.Errorf("leaf is required")
1366+ }
1367+ if !leaf.IsLeaf() {
1368+ return nil, fmt.Errorf("can only detach leaf node")
1369+ }
1370+
1371+ parent := leaf.Parent
1372+ if parent == nil {
1373+ return nil, fmt.Errorf("cannot detach root leaf")
1374+ }
1375+ if !parent.IsSplit() {
1376+ return nil, fmt.Errorf("leaf parent is not a split")
1377+ }
1378+
1379+ var sibling *entity.PaneNode
1380+ for _, child := range parent.Children {
1381+ if child != leaf {
1382+ sibling = child
1383+ break
1384+ }
1385+ }
1386+ if sibling == nil {
1387+ return nil, fmt.Errorf("no sibling found for leaf")
1388+ }
1389+
1390+ grandparent := parent.Parent
1391+ if grandparent == nil {
1392+ ws.Root = sibling
1393+ sibling.Parent = nil
1394+ } else {
1395+ for i, child := range grandparent.Children {
1396+ if child == parent {
1397+ grandparent.Children[i] = sibling
1398+ break
1399+ }
1400+ }
1401+ sibling.Parent = grandparent
1402+ }
1403+
1404+ leaf.Parent = nil
1405+ return leaf, nil
1406+}
1407+
1408+func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode {
1409+ if leaf == nil || !leaf.IsLeaf() {
1410+ return leaf
1411+ }
1412+
1413+ originalPane := leaf.Pane
1414+ originalChildNode := &entity.PaneNode{
1415+ ID: leaf.ID + "_0",
1416+ Pane: originalPane,
1417+ Parent: leaf,
1418+ }
1419+
1420+ leaf.Pane = nil
1421+ leaf.IsStacked = true
1422+ leaf.ActiveStackIndex = 0
1423+ leaf.Children = []*entity.PaneNode{originalChildNode}
1424+
1425+ return leaf
1426+}
1427+
1428+func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1429+ if stackNode == nil {
1430+ return nil, fmt.Errorf("stack node is required")
1431+ }
1432+ if !stackNode.IsStacked {
1433+ return nil, fmt.Errorf("node is not a stack")
1434+ }
1435+ if leaf == nil || leaf.Pane == nil {
1436+ return nil, fmt.Errorf("leaf is required")
1437+ }
1438+
1439+ idx := -1
1440+ for i, child := range stackNode.Children {
1441+ if child == leaf {
1442+ idx = i
1443+ break
1444+ }
1445+ }
1446+ if idx < 0 {
1447+ return nil, fmt.Errorf("pane not found in stack")
1448+ }
1449+
1450+ stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
1451+ if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1452+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1453+ }
1454+ if stackNode.ActiveStackIndex < 0 {
1455+ stackNode.ActiveStackIndex = 0
1456+ }
1457+
1458+ leaf.Parent = nil
1459+ return leaf, nil
1460+}
1461+
1462+func dissolveStackIntoLeaf(stackNode *entity.PaneNode) {
1463+ if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 {
1464+ return
1465+ }
1466+
1467+ remaining := stackNode.Children[0]
1468+ stackNode.Pane = remaining.Pane
1469+ stackNode.Children = nil
1470+ stackNode.IsStacked = false
1471+ stackNode.ActiveStackIndex = 0
1472+}
1473+
1474+func splitExistingNode(
1475+ ws *entity.Workspace,
1476+ targetNode *entity.PaneNode,
1477+ newSibling *entity.PaneNode,
1478+ direction ConsumeOrExpelDirection,
1479+ idGen IDGenerator,
1480+) error {
1481+ if ws == nil {
1482+ return fmt.Errorf("workspace is required")
1483+ }
1484+ if targetNode == nil {
1485+ return fmt.Errorf("target node is required")
1486+ }
1487+ if newSibling == nil {
1488+ return fmt.Errorf("new sibling node is required")
1489+ }
1490+ if idGen == nil {
1491+ return fmt.Errorf("id generator is required")
1492+ }
1493+
1494+ var splitDir entity.SplitDirection
1495+ switch direction {
1496+ case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1497+ splitDir = entity.SplitHorizontal
1498+ case ConsumeOrExpelUp, ConsumeOrExpelDown:
1499+ splitDir = entity.SplitVertical
1500+ default:
1501+ return fmt.Errorf("invalid direction: %s", direction)
1502+ }
1503+
1504+ parentNode := &entity.PaneNode{
1505+ ID: idGen(),
1506+ SplitDir: splitDir,
1507+ SplitRatio: 0.5,
1508+ Children: make([]*entity.PaneNode, 2),
1509+ }
1510+
1511+ switch direction {
1512+ case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1513+ parentNode.Children[0] = newSibling
1514+ parentNode.Children[1] = targetNode
1515+ case ConsumeOrExpelRight, ConsumeOrExpelDown:
1516+ parentNode.Children[0] = targetNode
1517+ parentNode.Children[1] = newSibling
1518+ }
1519+
1520+ newSibling.Parent = parentNode
1521+ oldParent := targetNode.Parent
1522+ targetNode.Parent = parentNode
1523+
1524+ if oldParent == nil {
1525+ ws.Root = parentNode
1526+ } else {
1527+ for i, child := range oldParent.Children {
1528+ if child == targetNode {
1529+ oldParent.Children[i] = parentNode
1530+ break
1531+ }
1532+ }
1533+ parentNode.Parent = oldParent
1534+ }
1535+
1536+ return nil
1537+}
1538+
1077 // GetAllPanes returns all leaf panes in a workspace.1539 // GetAllPanes returns all leaf panes in a workspace.
1078 //1540 //
1079 //nolint:revive // receiver required for interface consistency1541 //nolint:revive // receiver required for interface consistency
A internal/application/usecase/manage_panes_consume_expel_test.go +321 -0
@@ -0,0 +1,321 @@
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("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_Expel_DissolvesTwoPaneStack(t *testing.T) {
144+ uc := NewManagePanesUseCase(func() string { return "id" })
145+ ctx := context.Background()
146+
147+ stackNode := stack("stack", leaf("a"), leaf("b"))
148+ b := stackNode.Children[1]
149+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
150+
151+ res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
152+ if err != nil {
153+ t.Fatalf("unexpected error: %v", err)
154+ }
155+ if res == nil || res.Action != "expelled" {
156+ t.Fatalf("result action=%v, want expelled", res)
157+ }
158+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitHorizontal {
159+ t.Fatalf("root should be a horizontal split")
160+ }
161+
162+ left := ws.Root.Left()
163+ right := ws.Root.Right()
164+ if left == nil || right == nil {
165+ t.Fatalf("split children should exist")
166+ }
167+ if !left.IsLeaf() || left.Pane == nil || left.Pane.ID != "a" {
168+ t.Fatalf("left child should be leaf a")
169+ }
170+ if !right.IsLeaf() || right.Pane == nil || right.Pane.ID != "b" {
171+ t.Fatalf("right child should be leaf b")
172+ }
173+ if ws.ActivePaneID != "b" {
174+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
175+ }
176+}
177+
178+func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {
179+ uc := NewManagePanesUseCase(func() string { return "id" })
180+ ctx := context.Background()
181+
182+ stackNode := stack("stack", leaf("a"), leaf("b"), leaf("c"))
183+ b := stackNode.Children[1]
184+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
185+
186+ res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
187+ if err != nil {
188+ t.Fatalf("unexpected error: %v", err)
189+ }
190+ if res == nil || res.Action != "expelled" {
191+ t.Fatalf("result action=%v, want expelled", res)
192+ }
193+ if ws.Root == nil || !ws.Root.IsSplit() {
194+ t.Fatalf("root should be a split")
195+ }
196+ left := ws.Root.Left()
197+ right := ws.Root.Right()
198+ if left == nil || !left.IsStacked {
199+ t.Fatalf("left child should be stack")
200+ }
201+ if panesInOrder(left) != "a,c" {
202+ t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(left))
203+ }
204+ if right == nil || !right.IsLeaf() || right.Pane.ID != "b" {
205+ t.Fatalf("right child should be expelled leaf b")
206+ }
207+ if ws.ActivePaneID != "b" {
208+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
209+ }
210+}
211+
212+func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) {
213+ uc := NewManagePanesUseCase(func() string { return "id" })
214+ ctx := context.Background()
215+
216+ left := leaf("left")
217+ right := leaf("right")
218+ root := split(entity.SplitHorizontal, left, right)
219+ ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID}
220+
221+ res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelLeft)
222+ if err != nil {
223+ t.Fatalf("unexpected error: %v", err)
224+ }
225+ if res == nil || res.Action != "none" {
226+ t.Fatalf("result action=%v, want none", res)
227+ }
228+ if res.ErrorMessage == "" {
229+ t.Fatalf("expected warning message")
230+ }
231+ if ws.Root != root {
232+ t.Fatalf("tree should be unchanged")
233+ }
234+}
235+
236+func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) {
237+ uc := NewManagePanesUseCase(func() string { return "id" })
238+ ctx := context.Background()
239+
240+ a := leaf("a")
241+ b := leaf("b")
242+ top := split(entity.SplitHorizontal, a, b)
243+ c := leaf("c")
244+ root := split(entity.SplitVertical, top, c)
245+ ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID}
246+
247+ res, err := uc.ConsumeOrExpel(ctx, ws, a, ConsumeOrExpelDown)
248+ if err != nil {
249+ t.Fatalf("unexpected error: %v", err)
250+ }
251+ if res == nil || res.Action != "consumed" {
252+ t.Fatalf("result action=%v, want consumed", res)
253+ }
254+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
255+ t.Fatalf("root should remain a vertical split")
256+ }
257+ if ws.Root.Right() == nil || !ws.Root.Right().IsStacked {
258+ t.Fatalf("expected bottom pane to become a stack")
259+ }
260+ if panesInOrder(ws.Root.Right()) != "c,a" {
261+ t.Fatalf("bottom stack panes=%s, want c,a", panesInOrder(ws.Root.Right()))
262+ }
263+}
264+
265+func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {
266+ uc := NewManagePanesUseCase(func() string { return "id" })
267+ ctx := context.Background()
268+
269+ only := leaf("only")
270+ ws := &entity.Workspace{Root: only, ActivePaneID: only.Pane.ID}
271+
272+ res, err := uc.ConsumeOrExpel(ctx, ws, only, ConsumeOrExpelLeft)
273+ if err != nil {
274+ t.Fatalf("unexpected error: %v", err)
275+ }
276+ if res == nil || res.Action != "none" {
277+ t.Fatalf("result action=%v, want none", res)
278+ }
279+ if res.ErrorMessage == "" {
280+ t.Fatalf("expected warning message")
281+ }
282+}
283+
284+func leaf(id string) *entity.PaneNode {
285+ return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}}
286+}
287+
288+func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.PaneNode {
289+ root := &entity.PaneNode{ID: "split", SplitDir: dir, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}}
290+ left.Parent = root
291+ right.Parent = root
292+ return root
293+}
294+
295+func stack(id string, panes ...*entity.PaneNode) *entity.PaneNode {
296+ n := &entity.PaneNode{ID: id, IsStacked: true, ActiveStackIndex: 0}
297+ children := make([]*entity.PaneNode, 0, len(panes))
298+ for _, p := range panes {
299+ p.Parent = n
300+ children = append(children, p)
301+ }
302+ n.Children = children
303+ return n
304+}
305+
306+func panesInOrder(stackNode *entity.PaneNode) string {
307+ if stackNode == nil || len(stackNode.Children) == 0 {
308+ return ""
309+ }
310+ out := ""
311+ for i, child := range stackNode.Children {
312+ if child == nil || child.Pane == nil {
313+ continue
314+ }
315+ if i > 0 {
316+ out += ","
317+ }
318+ out += string(child.Pane.ID)
319+ }
320+ return out
321+}
@@ -0,0 +1,321 @@
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("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_Expel_DissolvesTwoPaneStack(t *testing.T) {
144+ uc := NewManagePanesUseCase(func() string { return "id" })
145+ ctx := context.Background()
146+
147+ stackNode := stack("stack", leaf("a"), leaf("b"))
148+ b := stackNode.Children[1]
149+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
150+
151+ res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
152+ if err != nil {
153+ t.Fatalf("unexpected error: %v", err)
154+ }
155+ if res == nil || res.Action != "expelled" {
156+ t.Fatalf("result action=%v, want expelled", res)
157+ }
158+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitHorizontal {
159+ t.Fatalf("root should be a horizontal split")
160+ }
161+
162+ left := ws.Root.Left()
163+ right := ws.Root.Right()
164+ if left == nil || right == nil {
165+ t.Fatalf("split children should exist")
166+ }
167+ if !left.IsLeaf() || left.Pane == nil || left.Pane.ID != "a" {
168+ t.Fatalf("left child should be leaf a")
169+ }
170+ if !right.IsLeaf() || right.Pane == nil || right.Pane.ID != "b" {
171+ t.Fatalf("right child should be leaf b")
172+ }
173+ if ws.ActivePaneID != "b" {
174+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
175+ }
176+}
177+
178+func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {
179+ uc := NewManagePanesUseCase(func() string { return "id" })
180+ ctx := context.Background()
181+
182+ stackNode := stack("stack", leaf("a"), leaf("b"), leaf("c"))
183+ b := stackNode.Children[1]
184+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
185+
186+ res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
187+ if err != nil {
188+ t.Fatalf("unexpected error: %v", err)
189+ }
190+ if res == nil || res.Action != "expelled" {
191+ t.Fatalf("result action=%v, want expelled", res)
192+ }
193+ if ws.Root == nil || !ws.Root.IsSplit() {
194+ t.Fatalf("root should be a split")
195+ }
196+ left := ws.Root.Left()
197+ right := ws.Root.Right()
198+ if left == nil || !left.IsStacked {
199+ t.Fatalf("left child should be stack")
200+ }
201+ if panesInOrder(left) != "a,c" {
202+ t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(left))
203+ }
204+ if right == nil || !right.IsLeaf() || right.Pane.ID != "b" {
205+ t.Fatalf("right child should be expelled leaf b")
206+ }
207+ if ws.ActivePaneID != "b" {
208+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
209+ }
210+}
211+
212+func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) {
213+ uc := NewManagePanesUseCase(func() string { return "id" })
214+ ctx := context.Background()
215+
216+ left := leaf("left")
217+ right := leaf("right")
218+ root := split(entity.SplitHorizontal, left, right)
219+ ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID}
220+
221+ res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelLeft)
222+ if err != nil {
223+ t.Fatalf("unexpected error: %v", err)
224+ }
225+ if res == nil || res.Action != "none" {
226+ t.Fatalf("result action=%v, want none", res)
227+ }
228+ if res.ErrorMessage == "" {
229+ t.Fatalf("expected warning message")
230+ }
231+ if ws.Root != root {
232+ t.Fatalf("tree should be unchanged")
233+ }
234+}
235+
236+func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) {
237+ uc := NewManagePanesUseCase(func() string { return "id" })
238+ ctx := context.Background()
239+
240+ a := leaf("a")
241+ b := leaf("b")
242+ top := split(entity.SplitHorizontal, a, b)
243+ c := leaf("c")
244+ root := split(entity.SplitVertical, top, c)
245+ ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID}
246+
247+ res, err := uc.ConsumeOrExpel(ctx, ws, a, ConsumeOrExpelDown)
248+ if err != nil {
249+ t.Fatalf("unexpected error: %v", err)
250+ }
251+ if res == nil || res.Action != "consumed" {
252+ t.Fatalf("result action=%v, want consumed", res)
253+ }
254+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
255+ t.Fatalf("root should remain a vertical split")
256+ }
257+ if ws.Root.Right() == nil || !ws.Root.Right().IsStacked {
258+ t.Fatalf("expected bottom pane to become a stack")
259+ }
260+ if panesInOrder(ws.Root.Right()) != "c,a" {
261+ t.Fatalf("bottom stack panes=%s, want c,a", panesInOrder(ws.Root.Right()))
262+ }
263+}
264+
265+func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {
266+ uc := NewManagePanesUseCase(func() string { return "id" })
267+ ctx := context.Background()
268+
269+ only := leaf("only")
270+ ws := &entity.Workspace{Root: only, ActivePaneID: only.Pane.ID}
271+
272+ res, err := uc.ConsumeOrExpel(ctx, ws, only, ConsumeOrExpelLeft)
273+ if err != nil {
274+ t.Fatalf("unexpected error: %v", err)
275+ }
276+ if res == nil || res.Action != "none" {
277+ t.Fatalf("result action=%v, want none", res)
278+ }
279+ if res.ErrorMessage == "" {
280+ t.Fatalf("expected warning message")
281+ }
282+}
283+
284+func leaf(id string) *entity.PaneNode {
285+ return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}}
286+}
287+
288+func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.PaneNode {
289+ root := &entity.PaneNode{ID: "split", SplitDir: dir, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}}
290+ left.Parent = root
291+ right.Parent = root
292+ return root
293+}
294+
295+func stack(id string, panes ...*entity.PaneNode) *entity.PaneNode {
296+ n := &entity.PaneNode{ID: id, IsStacked: true, ActiveStackIndex: 0}
297+ children := make([]*entity.PaneNode, 0, len(panes))
298+ for _, p := range panes {
299+ p.Parent = n
300+ children = append(children, p)
301+ }
302+ n.Children = children
303+ return n
304+}
305+
306+func panesInOrder(stackNode *entity.PaneNode) string {
307+ if stackNode == nil || len(stackNode.Children) == 0 {
308+ return ""
309+ }
310+ out := ""
311+ for i, child := range stackNode.Children {
312+ if child == nil || child.Pane == nil {
313+ continue
314+ }
315+ if i > 0 {
316+ out += ","
317+ }
318+ out += string(child.Pane.ID)
319+ }
320+ return out
321+}
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: "ctrl+bracketleft",
222+ ConsumeOrExpelRight: "ctrl+bracketright",
223+ ConsumeOrExpelUp: "ctrl+shift+bracketleft",
224+ ConsumeOrExpelDown: "ctrl+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: "ctrl+bracketleft",
222+ ConsumeOrExpelRight: "ctrl+bracketright",
223+ ConsumeOrExpelUp: "ctrl+shift+bracketleft",
224+ ConsumeOrExpelDown: "ctrl+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 == ModCtrl || modifiers == (ModCtrl|ModShift)) {
216+ if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
217+ if modifiers == (ModCtrl | 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 == ModCtrl || modifiers == (ModCtrl|ModShift)) {
216+ if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
217+ if modifiers == (ModCtrl | 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