1package usecase
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "math"
8 "sort"
9 "strings"
10
11 "github.com/bnema/dumber/internal/domain/entity"
12 domainurl "github.com/bnema/dumber/internal/domain/url"
13 "github.com/bnema/dumber/internal/logging"
14)
15
16// SplitDirection indicates the direction of a pane split.
17type SplitDirection string
18
19const (
20 SplitLeft SplitDirection = "left"
21 SplitRight SplitDirection = "right"
22 SplitUp SplitDirection = "up"
23 SplitDown SplitDirection = "down"
24)
25
26// NavigateDirection indicates the direction for focus navigation.
27type NavigateDirection string
28
29const (
30 NavLeft NavigateDirection = "left"
31 NavRight NavigateDirection = "right"
32 NavUp NavigateDirection = "up"
33 NavDown NavigateDirection = "down"
34)
35
36// ResizeDirection indicates the direction for pane resizing.
37type ResizeDirection string
38
39const (
40 ResizeIncreaseLeft ResizeDirection = "increase_left"
41 ResizeIncreaseRight ResizeDirection = "increase_right"
42 ResizeIncreaseUp ResizeDirection = "increase_up"
43 ResizeIncreaseDown ResizeDirection = "increase_down"
44
45 ResizeDecreaseLeft ResizeDirection = "decrease_left"
46 ResizeDecreaseRight ResizeDirection = "decrease_right"
47 ResizeDecreaseUp ResizeDirection = "decrease_up"
48 ResizeDecreaseDown ResizeDirection = "decrease_down"
49
50 ResizeIncrease ResizeDirection = "increase"
51 ResizeDecrease ResizeDirection = "decrease"
52)
53
54var ErrNothingToResize = errors.New("nothing to resize")
55
56type ConsumeOrExpelDirection string
57
58const (
59 ConsumeOrExpelLeft ConsumeOrExpelDirection = "left"
60 ConsumeOrExpelRight ConsumeOrExpelDirection = "right"
61 ConsumeOrExpelUp ConsumeOrExpelDirection = "up"
62 ConsumeOrExpelDown ConsumeOrExpelDirection = "down"
63)
64
65type ConsumeOrExpelResult struct {
66 Action string // "consumed", "expelled", or "none"
67 ErrorMessage string
68}
69
70const consumeOrExpelExpelCycleMarker = "_expel_cycle"
71
72// ManagePanesUseCase handles pane tree operations.
73type ManagePanesUseCase struct {
74 idGenerator IDGenerator
75}
76
77// NewManagePanesUseCase creates a new pane management use case.
78func NewManagePanesUseCase(idGenerator IDGenerator) *ManagePanesUseCase {
79 return &ManagePanesUseCase{
80 idGenerator: idGenerator,
81 }
82}
83
84// SplitPaneInput contains parameters for splitting a pane.
85type SplitPaneInput struct {
86 Workspace *entity.Workspace
87 TargetPane *entity.PaneNode
88 Direction SplitDirection
89 NewPane *entity.Pane // Optional: existing pane to insert (for popups)
90 InitialURL string // URL for new pane (default: about:blank)
91}
92
93// SplitPaneOutput contains the result of a split operation.
94type SplitPaneOutput struct {
95 NewPaneNode *entity.PaneNode
96 ParentNode *entity.PaneNode // New parent container
97 SplitRatio float64 // Always 0.5 for new splits
98}
99
100// Split creates a new pane adjacent to the target pane.
101func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (*SplitPaneOutput, error) {
102 log := logging.FromContext(ctx)
103 log.Debug().
104 Str("direction", string(input.Direction)).
105 Str("target_id", input.TargetPane.ID).
106 Msg("splitting pane")
107
108 if input.Workspace == nil {
109 return nil, fmt.Errorf("workspace is required")
110 }
111 if input.TargetPane == nil {
112 return nil, fmt.Errorf("target pane is required")
113 }
114
115 // If target is inside a stack and splitting left/right, split around the entire stack
116 // (i.e., create the new pane as a sibling to the stack, not inside it)
117 targetNode := input.TargetPane
118 if input.Direction == SplitLeft || input.Direction == SplitRight {
119 if targetNode.Parent != nil && targetNode.Parent.IsStacked {
120 // Promote to the stack container so split happens outside the stack
121 targetNode = targetNode.Parent
122 log.Debug().Msg("target is inside a stack, splitting around stack container")
123 }
124 }
125
126 // Create new pane
127 var newPane *entity.Pane
128 if input.NewPane != nil {
129 newPane = input.NewPane
130 } else {
131 paneID := entity.PaneID(uc.idGenerator())
132 newPane = entity.NewPane(paneID)
133 if input.InitialURL != "" {
134 newPane.URI = domainurl.Normalize(input.InitialURL)
135 } else {
136 newPane.URI = "about:blank"
137 }
138 }
139
140 // Create new pane node
141 newPaneNode := &entity.PaneNode{
142 ID: string(newPane.ID),
143 Pane: newPane,
144 }
145
146 // Determine split orientation
147 var splitDir entity.SplitDirection
148 switch input.Direction {
149 case SplitLeft, SplitRight:
150 splitDir = entity.SplitHorizontal
151 case SplitUp, SplitDown:
152 splitDir = entity.SplitVertical
153 }
154
155 // Create parent container node
156 parentID := uc.idGenerator()
157 parentNode := &entity.PaneNode{
158 ID: parentID,
159 SplitDir: splitDir,
160 SplitRatio: 0.5,
161 Children: make([]*entity.PaneNode, 2),
162 }
163
164 // Arrange children based on direction
165 switch input.Direction {
166 case SplitLeft, SplitUp:
167 // New pane goes first (left/top)
168 parentNode.Children[0] = newPaneNode
169 parentNode.Children[1] = targetNode
170 case SplitRight, SplitDown:
171 // Existing pane stays first, new pane goes second (right/bottom)
172 parentNode.Children[0] = targetNode
173 parentNode.Children[1] = newPaneNode
174 }
175
176 // Update parent references
177 newPaneNode.Parent = parentNode
178 oldParent := targetNode.Parent
179 targetNode.Parent = parentNode
180
181 // Insert parent into tree
182 if oldParent == nil {
183 // Target was root
184 input.Workspace.Root = parentNode
185 } else {
186 // Replace target with parent in old parent's children
187 for i, child := range oldParent.Children {
188 if child == targetNode {
189 oldParent.Children[i] = parentNode
190 break
191 }
192 }
193 parentNode.Parent = oldParent
194 }
195
196 log.Info().
197 Str("new_pane_id", string(newPane.ID)).
198 Str("parent_id", parentID).
199 Str("direction", string(input.Direction)).
200 Msg("pane split completed")
201
202 return &SplitPaneOutput{
203 NewPaneNode: newPaneNode,
204 ParentNode: parentNode,
205 SplitRatio: 0.5,
206 }, nil
207}
208
209// Resize adjusts the nearest applicable split ratio for the given direction.
210// stepPercent is applied per keystroke (e.g. 5.0 means 5%).
211// minPanePercent enforces a minimum size for each side of a split.
212func (uc *ManagePanesUseCase) Resize(
213 ctx context.Context,
214 ws *entity.Workspace,
215 paneNode *entity.PaneNode,
216 dir ResizeDirection,
217 stepPercent float64,
218 minPanePercent float64,
219) error {
220 log := logging.FromContext(ctx)
221 if uc == nil {
222 return fmt.Errorf("manage panes use case is nil")
223 }
224
225 if ws == nil {
226 return fmt.Errorf("workspace is required")
227 }
228 if ws.Root == nil {
229 return ErrNothingToResize
230 }
231 if paneNode == nil {
232 return fmt.Errorf("pane node is required")
233 }
234
235 target := paneNode
236 if target.Parent != nil && target.Parent.IsStacked {
237 target = target.Parent
238 }
239
240 actualDir := dir
241 switch dir {
242 case ResizeIncrease:
243 actualDir = findSmartResizeDirection(target, true)
244 case ResizeDecrease:
245 actualDir = findSmartResizeDirection(target, false)
246 }
247 if actualDir == "" {
248 return ErrNothingToResize
249 }
250
251 axis, ok := axisForResizeDirection(actualDir)
252 if !ok {
253 return ErrNothingToResize
254 }
255
256 splitNode := findNearestSplitForAxis(target, axis)
257 if splitNode == nil {
258 return ErrNothingToResize
259 }
260
261 // We treat resize directions as moving the split divider.
262 // SplitRatio is the proportion allocated to the first child (left/top).
263 // - Moving the divider right/down increases SplitRatio.
264 // - Moving the divider left/up decreases SplitRatio.
265 delta := deltaForDividerMove(actualDir, stepPercent)
266
267 minRatio := minPanePercent / 100.0
268 maxRatio := 1.0 - minRatio
269 oldRatio := splitNode.SplitRatio
270 splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio)
271
272 log.Debug().
273 Str("direction", string(dir)).
274 Str("actual_direction", string(actualDir)).
275 Float64("old_ratio", oldRatio).
276 Float64("new_ratio", splitNode.SplitRatio).
277 Msg("pane resized")
278
279 return nil
280}
281
282type SetSplitRatioInput struct {
283 Workspace *entity.Workspace
284 SplitNodeID string
285 Ratio float64
286 MinPanePercent float64
287}
288
289func (uc *ManagePanesUseCase) SetSplitRatio(ctx context.Context, input SetSplitRatioInput) error {
290 log := logging.FromContext(ctx)
291 if uc == nil {
292 return fmt.Errorf("manage panes use case is nil")
293 }
294 if input.Workspace == nil {
295 return fmt.Errorf("workspace is required")
296 }
297 if input.Workspace.Root == nil {
298 return ErrNothingToResize
299 }
300 if input.SplitNodeID == "" {
301 return fmt.Errorf("split node id is required")
302 }
303
304 var splitNode *entity.PaneNode
305 input.Workspace.Root.Walk(func(node *entity.PaneNode) bool {
306 if node.ID == input.SplitNodeID {
307 splitNode = node
308 return false
309 }
310 return true
311 })
312
313 if splitNode == nil || !splitNode.IsSplit() {
314 return fmt.Errorf("split node not found: %s", input.SplitNodeID)
315 }
316
317 minRatio := input.MinPanePercent / 100.0
318 maxRatio := 1.0 - minRatio
319 oldRatio := splitNode.SplitRatio
320 clamped := clampFloat64(input.Ratio, minRatio, maxRatio)
321 splitNode.SplitRatio = roundSplitRatio(clamped)
322
323 log.Debug().
324 Str("split_node_id", input.SplitNodeID).
325 Float64("old_ratio", oldRatio).
326 Float64("new_ratio", splitNode.SplitRatio).
327 Msg("split ratio set")
328
329 return nil
330}
331
332func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection {
333 splitNode, axis, isStartChild := findNearestSplitForResize(target)
334 if splitNode == nil {
335 return ""
336 }
337
338 // Smart resize means "grow/shrink the active pane".
339 // SplitRatio is the proportion allocated to the first child (left/top).
340 // - If active is first child: grow by increasing ratio, shrink by decreasing.
341 // - If active is second child: grow by decreasing ratio, shrink by increasing.
342 growMeansIncreaseRatio := isStartChild
343 if !growActive {
344 growMeansIncreaseRatio = !growMeansIncreaseRatio
345 }
346
347 switch axis {
348 case resizeAxisHorizontal:
349 if growMeansIncreaseRatio {
350 return ResizeIncreaseRight
351 }
352 return ResizeIncreaseLeft
353 case resizeAxisVertical:
354 if growMeansIncreaseRatio {
355 return ResizeIncreaseDown
356 }
357 return ResizeIncreaseUp
358 default:
359 return ""
360 }
361}
362
363// findNearestSplitForResize returns the nearest split ancestor for the active pane.
364// It prefers horizontal splits over vertical when both are available at the same depth.
365func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) {
366 current := node
367 for current != nil && current.Parent != nil {
368 parent := current.Parent
369 if parent.IsSplit() {
370 isStartChild = parent.Left() == current
371 switch parent.SplitDir {
372 case entity.SplitHorizontal:
373 return parent, resizeAxisHorizontal, isStartChild
374 case entity.SplitVertical:
375 return parent, resizeAxisVertical, isStartChild
376 }
377 }
378 current = parent
379 }
380 return nil, resizeAxisNone, false
381}
382
383type resizeAxis int
384
385const (
386 resizeAxisNone resizeAxis = iota
387 resizeAxisHorizontal
388 resizeAxisVertical
389)
390
391func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) {
392 switch dir {
393 case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight:
394 return resizeAxisHorizontal, true
395 case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown:
396 return resizeAxisVertical, true
397 default:
398 return resizeAxisNone, false
399 }
400}
401
402func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 {
403 if stepPercent < 0 {
404 stepPercent = -stepPercent
405 }
406 delta := stepPercent / 100.0
407
408 switch dir {
409 case ResizeIncreaseRight, ResizeIncreaseDown:
410 return delta
411 case ResizeIncreaseLeft, ResizeIncreaseUp:
412 return -delta
413 case ResizeDecreaseRight, ResizeDecreaseDown:
414 return -delta
415 case ResizeDecreaseLeft, ResizeDecreaseUp:
416 return delta
417 default:
418 return 0
419 }
420}
421
422// findNearestSplitForAxis walks up the tree to find the nearest split matching the axis.
423func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode {
424 current := node
425 for current != nil && current.Parent != nil {
426 parent := current.Parent
427 if parent.IsSplit() {
428 if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal {
429 return parent
430 }
431 if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical {
432 return parent
433 }
434 }
435 current = parent
436 }
437 return nil
438}
439
440const splitRatioRoundFactor = 100.0
441
442func roundSplitRatio(ratio float64) float64 {
443 // Keep snapshots stable and readable; avoids persisting noisy float values.
444 return math.Round(ratio*splitRatioRoundFactor) / splitRatioRoundFactor
445}
446
447func clampFloat64(v, minVal, maxVal float64) float64 {
448 if v < minVal {
449 return minVal
450 }
451 if v > maxVal {
452 return maxVal
453 }
454 return v
455}
456
457// Close removes a pane and promotes its sibling.
458// Returns the sibling node that was promoted, or nil if this was the last pane.
459func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
460 log := logging.FromContext(ctx)
461 if uc == nil {
462 return nil, fmt.Errorf("manage panes use case is nil")
463 }
464 log.Debug().Str("pane_id", paneNode.ID).Msg("closing pane")
465
466 if ws == nil {
467 return nil, fmt.Errorf("workspace is required")
468 }
469 if paneNode == nil {
470 return nil, fmt.Errorf("pane node is required")
471 }
472 if !paneNode.IsLeaf() {
473 return nil, fmt.Errorf("cannot close non-leaf node")
474 }
475
476 parent := paneNode.Parent
477
478 // If no parent, this is the root (last pane)
479 if parent == nil {
480 log.Info().Msg("closing last pane in workspace")
481 ws.Root = nil
482 ws.ActivePaneID = ""
483 return nil, nil
484 }
485
486 // Find sibling
487 var sibling *entity.PaneNode
488 for _, child := range parent.Children {
489 if child != paneNode {
490 sibling = child
491 break
492 }
493 }
494
495 if sibling == nil {
496 return nil, fmt.Errorf("no sibling found for pane")
497 }
498
499 grandparent := parent.Parent
500
501 // Promote sibling to parent's position
502 if grandparent == nil {
503 // Parent was root, sibling becomes new root
504 ws.Root = sibling
505 sibling.Parent = nil
506 } else {
507 // Replace parent with sibling in grandparent
508 for i, child := range grandparent.Children {
509 if child == parent {
510 grandparent.Children[i] = sibling
511 break
512 }
513 }
514 sibling.Parent = grandparent
515 }
516
517 // Update active pane if needed
518 if ws.ActivePaneID == paneNode.Pane.ID {
519 // Find a new active pane from the sibling subtree
520 var newActive *entity.Pane
521 sibling.Walk(func(node *entity.PaneNode) bool {
522 if node.IsLeaf() && node.Pane != nil {
523 newActive = node.Pane
524 return false // Stop walking
525 }
526 return true
527 })
528 if newActive != nil {
529 ws.ActivePaneID = newActive.ID
530 } else {
531 ws.ActivePaneID = ""
532 }
533 }
534
535 log.Info().
536 Str("closed_pane_id", paneNode.ID).
537 Str("promoted_sibling_id", sibling.ID).
538 Msg("pane closed, sibling promoted")
539
540 return sibling, nil
541}
542
543// Focus sets the active pane in the workspace.
544func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
545 log := logging.FromContext(ctx)
546 if uc == nil {
547 return fmt.Errorf("manage panes use case is nil")
548 }
549 log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
550
551 if ws == nil {
552 return fmt.Errorf("workspace is required")
553 }
554
555 node := ws.FindPane(paneID)
556 if node == nil {
557 return fmt.Errorf("pane not found: %s", paneID)
558 }
559
560 oldActive := ws.ActivePaneID
561 ws.ActivePaneID = paneID
562
563 log.Info().
564 Str("from", string(oldActive)).
565 Str("to", string(paneID)).
566 Msg("focus changed")
567
568 return nil
569}
570
571// NavigateFocus moves focus to an adjacent pane.
572// Returns the newly focused pane, or nil if navigation not possible.
573func (uc *ManagePanesUseCase) NavigateFocus(
574 ctx context.Context,
575 ws *entity.Workspace,
576 direction NavigateDirection,
577) (*entity.PaneNode, error) {
578 log := logging.FromContext(ctx)
579 log.Debug().Str("direction", string(direction)).Msg("navigating focus")
580
581 if ws == nil {
582 return nil, fmt.Errorf("workspace is required")
583 }
584
585 activeNode := ws.ActivePane()
586 if activeNode == nil {
587 return nil, fmt.Errorf("no active pane")
588 }
589
590 // Try structural navigation first
591 targetNode := uc.findAdjacentPane(activeNode, direction)
592
593 if targetNode == nil {
594 log.Debug().Msg("no adjacent pane found")
595 return nil, nil
596 }
597
598 ws.ActivePaneID = targetNode.Pane.ID
599
600 log.Info().
601 Str("from", activeNode.ID).
602 Str("to", targetNode.ID).
603 Str("direction", string(direction)).
604 Msg("focus navigated")
605
606 return targetNode, nil
607}
608
609// GeometricNavigationInput contains data for geometric focus navigation.
610type GeometricNavigationInput struct {
611 ActivePaneID entity.PaneID
612 PaneRects []entity.PaneRect // All visible panes with their positions
613 Direction NavigateDirection
614}
615
616// GeometricNavigationOutput contains the result.
617type GeometricNavigationOutput struct {
618 TargetPaneID entity.PaneID
619 Found bool
620}
621
622// NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
623// Algorithm:
624// 1. Get center of active pane
625// 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
626// 3. Score by: primary_distance * 1000 + perpendicular_distance
627// 4. Return lowest scoring candidate
628func (uc *ManagePanesUseCase) NavigateFocusGeometric(
629 ctx context.Context,
630 input GeometricNavigationInput,
631) (*GeometricNavigationOutput, error) {
632 log := logging.FromContext(ctx)
633 if uc == nil {
634 return nil, fmt.Errorf("manage panes use case is nil")
635 }
636 log.Debug().
637 Str("direction", string(input.Direction)).
638 Str("active", string(input.ActivePaneID)).
639 Int("candidates", len(input.PaneRects)).
640 Msg("geometric navigation")
641
642 // Find active pane rect
643 var activeRect *entity.PaneRect
644 for i := range input.PaneRects {
645 if input.PaneRects[i].PaneID == input.ActivePaneID {
646 activeRect = &input.PaneRects[i]
647 break
648 }
649 }
650 if activeRect == nil {
651 log.Debug().Msg("active pane rect not found")
652 return &GeometricNavigationOutput{Found: false}, nil
653 }
654
655 acx, acy := activeRect.Center()
656
657 type candidate struct {
658 paneID entity.PaneID
659 score int
660 }
661 var candidates []candidate
662
663 for _, rect := range input.PaneRects {
664 if rect.PaneID == input.ActivePaneID {
665 continue
666 }
667
668 cx, cy := rect.Center()
669 dx := cx - acx
670 dy := cy - acy
671
672 var inDirection bool
673 var primaryDist, perpDist int
674
675 switch input.Direction {
676 case NavLeft:
677 inDirection = dx < 0
678 primaryDist = abs(dx)
679 perpDist = abs(dy)
680 case NavRight:
681 inDirection = dx > 0
682 primaryDist = abs(dx)
683 perpDist = abs(dy)
684 case NavUp:
685 inDirection = dy < 0
686 primaryDist = abs(dy)
687 perpDist = abs(dx)
688 case NavDown:
689 inDirection = dy > 0
690 primaryDist = abs(dy)
691 perpDist = abs(dx)
692 }
693
694 if inDirection {
695 score := primaryDist*1000 + perpDist
696 candidates = append(candidates, candidate{rect.PaneID, score})
697 }
698 }
699
700 if len(candidates) == 0 {
701 log.Debug().Msg("no candidates in direction")
702 return &GeometricNavigationOutput{Found: false}, nil
703 }
704
705 // Sort by score (lowest first)
706 sort.Slice(candidates, func(i, j int) bool {
707 return candidates[i].score < candidates[j].score
708 })
709
710 log.Debug().
711 Str("target", string(candidates[0].paneID)).
712 Int("score", candidates[0].score).
713 Msg("geometric navigation found target")
714
715 return &GeometricNavigationOutput{
716 TargetPaneID: candidates[0].paneID,
717 Found: true,
718 }, nil
719}
720
721// abs returns the absolute value of an integer.
722func abs(x int) int {
723 if x < 0 {
724 return -x
725 }
726 return x
727}
728
729// findAdjacentPane finds the adjacent pane in the given direction using tree structure.
730func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {
731 if node == nil {
732 return nil
733 }
734
735 // Determine if we need to go horizontal or vertical
736 isHorizontal := direction == NavLeft || direction == NavRight
737 isForward := direction == NavRight || direction == NavDown
738
739 // Walk up the tree to find an ancestor with the right split type
740 current := node
741 for current.Parent != nil {
742 parent := current.Parent
743
744 // Check if this parent's split orientation matches our direction
745 parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
746
747 if parentIsHorizontal == isHorizontal {
748 // Find which child we came from
749 childIndex := -1
750 for i, child := range parent.Children {
751 if child == current {
752 childIndex = i
753 break
754 }
755 }
756
757 // Check if we can move in the desired direction
758 var targetIndex int
759 if isForward {
760 targetIndex = childIndex + 1
761 } else {
762 targetIndex = childIndex - 1
763 }
764
765 if targetIndex >= 0 && targetIndex < len(parent.Children) {
766 // Found a valid sibling, descend into it to find a leaf
767 return uc.findLeafInDirection(parent.Children[targetIndex], !isForward)
768 }
769 }
770
771 current = parent
772 }
773
774 return nil
775}
776
777// findLeafInDirection descends into a node to find a leaf pane.
778// If fromEnd is true, prefers the last child; otherwise prefers the first.
779func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd bool) *entity.PaneNode {
780 if node == nil {
781 return nil
782 }
783
784 // If this is a leaf, return it
785 if node.IsLeaf() {
786 return node
787 }
788
789 // If stacked, return active pane
790 if node.IsStacked && len(node.Children) > 0 {
791 activeIdx := node.ActiveStackIndex
792 if activeIdx >= 0 && activeIdx < len(node.Children) {
793 return uc.findLeafInDirection(node.Children[activeIdx], fromEnd)
794 }
795 return uc.findLeafInDirection(node.Children[0], fromEnd)
796 }
797
798 // Descend into children
799 if len(node.Children) > 0 {
800 if fromEnd {
801 return uc.findLeafInDirection(node.Children[len(node.Children)-1], fromEnd)
802 }
803 return uc.findLeafInDirection(node.Children[0], fromEnd)
804 }
805
806 return nil
807}
808
809// CreateStackOutput contains the result of creating a stack from a pane.
810type CreateStackOutput struct {
811 StackNode *entity.PaneNode // The stack container node
812 OriginalNode *entity.PaneNode // The original pane, now a child of the stack
813 NewPaneNode *entity.PaneNode // The new pane added to the stack
814 NewPane *entity.Pane // The new pane entity
815}
816
817// CreateStack converts a pane into a stacked container and adds a new pane.
818// The original pane becomes the first child, a new pane becomes the second child.
819// Returns the stack node and the new pane node for UI operations.
820//
821// Domain tree transformation:
822//
823// Before: parent -> targetNode (leaf with pane)
824// After: parent -> targetNode (stack) -> [originalChild (leaf), newChild (leaf)]
825//
826// The targetNode keeps its ID but becomes a stack container.
827// This allows the UI layer to update widget mappings incrementally.
828func (uc *ManagePanesUseCase) CreateStack(
829 ctx context.Context,
830 ws *entity.Workspace,
831 paneNode *entity.PaneNode,
832) (*CreateStackOutput, error) {
833 log := logging.FromContext(ctx)
834 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
835
836 if ws == nil {
837 return nil, fmt.Errorf("workspace is required")
838 }
839 if paneNode == nil {
840 return nil, fmt.Errorf("pane node is required")
841 }
842 if !paneNode.IsLeaf() {
843 return nil, fmt.Errorf("can only create stack from leaf pane")
844 }
845 if paneNode.IsStacked {
846 return nil, fmt.Errorf("pane is already stacked")
847 }
848
849 // Save original pane reference
850 originalPane := paneNode.Pane
851 originalPaneID := paneNode.ID
852
853 // Create child node for the original pane
854 originalChildNode := &entity.PaneNode{
855 ID: originalPaneID + "_0",
856 Pane: originalPane,
857 Parent: paneNode,
858 }
859
860 // Create new pane
861 newPaneID := entity.PaneID(uc.idGenerator())
862 newPane := entity.NewPane(newPaneID)
863
864 // Create child node for the new pane
865 newChildNode := &entity.PaneNode{
866 ID: string(newPaneID),
867 Pane: newPane,
868 Parent: paneNode,
869 }
870
871 // Convert targetNode to stack container
872 paneNode.Pane = nil
873 paneNode.IsStacked = true
874 paneNode.ActiveStackIndex = 1 // New pane is active
875 paneNode.Children = []*entity.PaneNode{originalChildNode, newChildNode}
876
877 // Update workspace active pane
878 ws.ActivePaneID = newPaneID
879
880 log.Info().
881 Str("stack_id", paneNode.ID).
882 Str("original_pane", string(originalPane.ID)).
883 Str("new_pane", string(newPaneID)).
884 Msg("stack created")
885
886 return &CreateStackOutput{
887 StackNode: paneNode,
888 OriginalNode: originalChildNode,
889 NewPaneNode: newChildNode,
890 NewPane: newPane,
891 }, nil
892}
893
894// AddToStackOutput contains the result of adding a pane to a stack.
895type AddToStackOutput struct {
896 NewPaneNode *entity.PaneNode // The new pane node added to the stack
897 StackIndex int // Index of the new pane in the stack
898}
899
900// AddToStack adds a new pane to an existing stack.
901// Optionally pass a pre-created pane, or nil to create a new one.
902func (uc *ManagePanesUseCase) AddToStack(
903 ctx context.Context,
904 ws *entity.Workspace,
905 stackNode *entity.PaneNode,
906 pane *entity.Pane,
907) (*AddToStackOutput, error) {
908 log := logging.FromContext(ctx)
909
910 if ws == nil {
911 return nil, fmt.Errorf("workspace is required")
912 }
913 if stackNode == nil {
914 return nil, fmt.Errorf("stack node is required")
915 }
916 if !stackNode.IsStacked {
917 return nil, fmt.Errorf("node is not a stack")
918 }
919
920 // Create pane if not provided
921 if pane == nil {
922 paneID := entity.PaneID(uc.idGenerator())
923 pane = entity.NewPane(paneID)
924 }
925
926 log.Debug().
927 Str("stack_id", stackNode.ID).
928 Str("pane_id", string(pane.ID)).
929 Msg("adding pane to stack")
930
931 // Create node for the new pane
932 newNode := &entity.PaneNode{
933 ID: string(pane.ID),
934 Pane: pane,
935 Parent: stackNode,
936 }
937
938 // Insert right after current active pane (not at end)
939 currentActiveIndex := stackNode.ActiveStackIndex
940 if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
941 // Clamp to valid range - default to end-1 if invalid
942 currentActiveIndex = len(stackNode.Children) - 1
943 if currentActiveIndex < 0 {
944 currentActiveIndex = -1 // Will result in insertIndex = 0
945 }
946 }
947 insertIndex := currentActiveIndex + 1
948
949 // Slice insertion at correct position
950 stackNode.Children = append(stackNode.Children, nil)
951 copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
952 stackNode.Children[insertIndex] = newNode
953 newIndex := insertIndex
954 stackNode.ActiveStackIndex = newIndex
955
956 // Update workspace active pane
957 ws.ActivePaneID = pane.ID
958
959 log.Info().
960 Str("stack_id", stackNode.ID).
961 Str("pane_id", string(pane.ID)).
962 Int("stack_size", len(stackNode.Children)).
963 Msg("pane added to stack")
964
965 return &AddToStackOutput{
966 NewPaneNode: newNode,
967 StackIndex: newIndex,
968 }, nil
969}
970
971// NavigateStack cycles through stacked panes.
972// direction: NavUp for previous, NavDown for next.
973//
974//nolint:revive // receiver required for interface consistency
975func (uc *ManagePanesUseCase) NavigateStack(
976 ctx context.Context,
977 stackNode *entity.PaneNode,
978 direction NavigateDirection,
979) (*entity.Pane, error) {
980 log := logging.FromContext(ctx)
981
982 if stackNode == nil {
983 return nil, fmt.Errorf("stack node is required")
984 }
985
986 log.Debug().
987 Str("stack_id", stackNode.ID).
988 Str("direction", string(direction)).
989 Msg("navigating stack")
990 if !stackNode.IsStacked {
991 return nil, fmt.Errorf("node is not a stack")
992 }
993 if len(stackNode.Children) == 0 {
994 return nil, fmt.Errorf("stack is empty")
995 }
996
997 count := len(stackNode.Children)
998 current := stackNode.ActiveStackIndex
999
1000 var newIndex int
1001 switch direction {
1002 case NavUp:
1003 newIndex = (current - 1 + count) % count
1004 case NavDown:
1005 newIndex = (current + 1) % count
1006 default:
1007 return nil, fmt.Errorf("invalid stack navigation direction: %s (use up/down)", direction)
1008 }
1009
1010 stackNode.ActiveStackIndex = newIndex
1011
1012 activeChild := stackNode.Children[newIndex]
1013 var activePaneNode *entity.PaneNode
1014 if activeChild.IsLeaf() {
1015 activePaneNode = activeChild
1016 } else {
1017 // Find first leaf in the child
1018 activeChild.Walk(func(n *entity.PaneNode) bool {
1019 if n.IsLeaf() {
1020 activePaneNode = n
1021 return false
1022 }
1023 return true
1024 })
1025 }
1026
1027 if activePaneNode == nil || activePaneNode.Pane == nil {
1028 return nil, fmt.Errorf("no pane found in stack at index %d", newIndex)
1029 }
1030
1031 log.Info().
1032 Str("stack_id", stackNode.ID).
1033 Int("from_index", current).
1034 Int("to_index", newIndex).
1035 Str("pane_id", string(activePaneNode.Pane.ID)).
1036 Msg("stack navigated")
1037
1038 return activePaneNode.Pane, nil
1039}
1040
1041// RemoveFromStack removes a pane from a stack.
1042// If only one pane remains, the stack is dissolved.
1043//
1044//nolint:revive // receiver required for interface consistency
1045func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error {
1046 log := logging.FromContext(ctx)
1047
1048 if stackNode == nil {
1049 return fmt.Errorf("stack node is required")
1050 }
1051
1052 log.Debug().
1053 Str("stack_id", stackNode.ID).
1054 Str("pane_id", string(paneID)).
1055 Msg("removing pane from stack")
1056 if !stackNode.IsStacked {
1057 return fmt.Errorf("node is not a stack")
1058 }
1059
1060 // Find and remove the pane
1061 var removed bool
1062 newChildren := make([]*entity.PaneNode, 0, len(stackNode.Children))
1063 for _, child := range stackNode.Children {
1064 if child.Pane != nil && child.Pane.ID == paneID {
1065 removed = true
1066 continue
1067 }
1068 newChildren = append(newChildren, child)
1069 }
1070
1071 if !removed {
1072 return fmt.Errorf("pane not found in stack: %s", paneID)
1073 }
1074
1075 stackNode.Children = newChildren
1076
1077 // Adjust active index
1078 if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1079 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1080 }
1081 if stackNode.ActiveStackIndex < 0 {
1082 stackNode.ActiveStackIndex = 0
1083 }
1084
1085 log.Info().
1086 Str("stack_id", stackNode.ID).
1087 Str("pane_id", string(paneID)).
1088 Int("remaining", len(stackNode.Children)).
1089 Msg("pane removed from stack")
1090
1091 return nil
1092}
1093
1094func (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
1129func (*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
1199func (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
1262func 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
1277func 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
1310func 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
1349func 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
1411func 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
1459func 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
1479func 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
1513func 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
1525func 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
1590func 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
1656// GetAllPanes returns all leaf panes in a workspace.
1657//
1658//nolint:revive // receiver required for interface consistency
1659func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane {
1660 if ws == nil || ws.Root == nil {
1661 return nil
1662 }
1663 return ws.AllPanes()
1664}
1665
1666// CountPanes returns the number of panes in a workspace.
1667//
1668//nolint:revive // receiver required for interface consistency
1669func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int {
1670 if ws == nil {
1671 return 0
1672 }
1673 return ws.PaneCount()
1674}