1package usecase
2
3import (
4 "context"
5 "fmt"
6 "sort"
7
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"
10)
11
12// SplitDirection indicates the direction of a pane split.
13type SplitDirection string
14
15const (
16 SplitLeft SplitDirection = "left"
17 SplitRight SplitDirection = "right"
18 SplitUp SplitDirection = "up"
19 SplitDown SplitDirection = "down"
20)
21
22// NavigateDirection indicates the direction for focus navigation.
23type NavigateDirection string
24
25const (
26 NavLeft NavigateDirection = "left"
27 NavRight NavigateDirection = "right"
28 NavUp NavigateDirection = "up"
29 NavDown NavigateDirection = "down"
30)
31
32// ManagePanesUseCase handles pane tree operations.
33type ManagePanesUseCase struct {
34 idGenerator IDGenerator
35}
36
37// NewManagePanesUseCase creates a new pane management use case.
38func NewManagePanesUseCase(idGenerator IDGenerator) *ManagePanesUseCase {
39 return &ManagePanesUseCase{
40 idGenerator: idGenerator,
41 }
42}
43
44// SplitPaneInput contains parameters for splitting a pane.
45type SplitPaneInput struct {
46 Workspace *entity.Workspace
47 TargetPane *entity.PaneNode
48 Direction SplitDirection
49 NewPane *entity.Pane // Optional: existing pane to insert (for popups)
50 InitialURL string // URL for new pane (default: about:blank)
51}
52
53// SplitPaneOutput contains the result of a split operation.
54type SplitPaneOutput struct {
55 NewPaneNode *entity.PaneNode
56 ParentNode *entity.PaneNode // New parent container
57 SplitRatio float64 // Always 0.5 for new splits
58}
59
60// Split creates a new pane adjacent to the target pane.
61func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (*SplitPaneOutput, error) {
62 log := logging.FromContext(ctx)
63 log.Debug().
64 Str("direction", string(input.Direction)).
65 Str("target_id", input.TargetPane.ID).
66 Msg("splitting pane")
67
68 if input.Workspace == nil {
69 return nil, fmt.Errorf("workspace is required")
70 }
71 if input.TargetPane == nil {
72 return nil, fmt.Errorf("target pane is required")
73 }
74
75 // If target is inside a stack and splitting left/right, split around the entire stack
76 // (i.e., create the new pane as a sibling to the stack, not inside it)
77 targetNode := input.TargetPane
78 if input.Direction == SplitLeft || input.Direction == SplitRight {
79 if targetNode.Parent != nil && targetNode.Parent.IsStacked {
80 // Promote to the stack container so split happens outside the stack
81 targetNode = targetNode.Parent
82 log.Debug().Msg("target is inside a stack, splitting around stack container")
83 }
84 }
85
86 // Create new pane
87 var newPane *entity.Pane
88 if input.NewPane != nil {
89 newPane = input.NewPane
90 } else {
91 paneID := entity.PaneID(uc.idGenerator())
92 newPane = entity.NewPane(paneID)
93 if input.InitialURL != "" {
94 newPane.URI = input.InitialURL
95 } else {
96 newPane.URI = "about:blank"
97 }
98 }
99
100 // Create new pane node
101 newPaneNode := &entity.PaneNode{
102 ID: string(newPane.ID),
103 Pane: newPane,
104 }
105
106 // Determine split orientation
107 var splitDir entity.SplitDirection
108 switch input.Direction {
109 case SplitLeft, SplitRight:
110 splitDir = entity.SplitHorizontal
111 case SplitUp, SplitDown:
112 splitDir = entity.SplitVertical
113 }
114
115 // Create parent container node
116 parentID := uc.idGenerator()
117 parentNode := &entity.PaneNode{
118 ID: parentID,
119 SplitDir: splitDir,
120 SplitRatio: 0.5,
121 Children: make([]*entity.PaneNode, 2),
122 }
123
124 // Arrange children based on direction
125 switch input.Direction {
126 case SplitLeft, SplitUp:
127 // New pane goes first (left/top)
128 parentNode.Children[0] = newPaneNode
129 parentNode.Children[1] = targetNode
130 case SplitRight, SplitDown:
131 // Existing pane stays first, new pane goes second (right/bottom)
132 parentNode.Children[0] = targetNode
133 parentNode.Children[1] = newPaneNode
134 }
135
136 // Update parent references
137 newPaneNode.Parent = parentNode
138 oldParent := targetNode.Parent
139 targetNode.Parent = parentNode
140
141 // Insert parent into tree
142 if oldParent == nil {
143 // Target was root
144 input.Workspace.Root = parentNode
145 } else {
146 // Replace target with parent in old parent's children
147 for i, child := range oldParent.Children {
148 if child == targetNode {
149 oldParent.Children[i] = parentNode
150 break
151 }
152 }
153 parentNode.Parent = oldParent
154 }
155
156 log.Info().
157 Str("new_pane_id", string(newPane.ID)).
158 Str("parent_id", parentID).
159 Str("direction", string(input.Direction)).
160 Msg("pane split completed")
161
162 return &SplitPaneOutput{
163 NewPaneNode: newPaneNode,
164 ParentNode: parentNode,
165 SplitRatio: 0.5,
166 }, nil
167}
168
169// Close removes a pane and promotes its sibling.
170// Returns the sibling node that was promoted, or nil if this was the last pane.
171func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
172 log := logging.FromContext(ctx)
173 if uc == nil {
174 return nil, fmt.Errorf("manage panes use case is nil")
175 }
176 log.Debug().Str("pane_id", paneNode.ID).Msg("closing pane")
177
178 if ws == nil {
179 return nil, fmt.Errorf("workspace is required")
180 }
181 if paneNode == nil {
182 return nil, fmt.Errorf("pane node is required")
183 }
184 if !paneNode.IsLeaf() {
185 return nil, fmt.Errorf("cannot close non-leaf node")
186 }
187
188 parent := paneNode.Parent
189
190 // If no parent, this is the root (last pane)
191 if parent == nil {
192 log.Info().Msg("closing last pane in workspace")
193 ws.Root = nil
194 ws.ActivePaneID = ""
195 return nil, nil
196 }
197
198 // Find sibling
199 var sibling *entity.PaneNode
200 for _, child := range parent.Children {
201 if child != paneNode {
202 sibling = child
203 break
204 }
205 }
206
207 if sibling == nil {
208 return nil, fmt.Errorf("no sibling found for pane")
209 }
210
211 grandparent := parent.Parent
212
213 // Promote sibling to parent's position
214 if grandparent == nil {
215 // Parent was root, sibling becomes new root
216 ws.Root = sibling
217 sibling.Parent = nil
218 } else {
219 // Replace parent with sibling in grandparent
220 for i, child := range grandparent.Children {
221 if child == parent {
222 grandparent.Children[i] = sibling
223 break
224 }
225 }
226 sibling.Parent = grandparent
227 }
228
229 // Update active pane if needed
230 if ws.ActivePaneID == paneNode.Pane.ID {
231 // Find a new active pane from the sibling subtree
232 var newActive *entity.Pane
233 sibling.Walk(func(node *entity.PaneNode) bool {
234 if node.IsLeaf() && node.Pane != nil {
235 newActive = node.Pane
236 return false // Stop walking
237 }
238 return true
239 })
240 if newActive != nil {
241 ws.ActivePaneID = newActive.ID
242 } else {
243 ws.ActivePaneID = ""
244 }
245 }
246
247 log.Info().
248 Str("closed_pane_id", paneNode.ID).
249 Str("promoted_sibling_id", sibling.ID).
250 Msg("pane closed, sibling promoted")
251
252 return sibling, nil
253}
254
255// Focus sets the active pane in the workspace.
256func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
257 log := logging.FromContext(ctx)
258 if uc == nil {
259 return fmt.Errorf("manage panes use case is nil")
260 }
261 log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
262
263 if ws == nil {
264 return fmt.Errorf("workspace is required")
265 }
266
267 node := ws.FindPane(paneID)
268 if node == nil {
269 return fmt.Errorf("pane not found: %s", paneID)
270 }
271
272 oldActive := ws.ActivePaneID
273 ws.ActivePaneID = paneID
274
275 log.Info().
276 Str("from", string(oldActive)).
277 Str("to", string(paneID)).
278 Msg("focus changed")
279
280 return nil
281}
282
283// NavigateFocus moves focus to an adjacent pane.
284// Returns the newly focused pane, or nil if navigation not possible.
285func (uc *ManagePanesUseCase) NavigateFocus(
286 ctx context.Context,
287 ws *entity.Workspace,
288 direction NavigateDirection,
289) (*entity.PaneNode, error) {
290 log := logging.FromContext(ctx)
291 log.Debug().Str("direction", string(direction)).Msg("navigating focus")
292
293 if ws == nil {
294 return nil, fmt.Errorf("workspace is required")
295 }
296
297 activeNode := ws.ActivePane()
298 if activeNode == nil {
299 return nil, fmt.Errorf("no active pane")
300 }
301
302 // Try structural navigation first
303 targetNode := uc.findAdjacentPane(activeNode, direction)
304
305 if targetNode == nil {
306 log.Debug().Msg("no adjacent pane found")
307 return nil, nil
308 }
309
310 ws.ActivePaneID = targetNode.Pane.ID
311
312 log.Info().
313 Str("from", activeNode.ID).
314 Str("to", targetNode.ID).
315 Str("direction", string(direction)).
316 Msg("focus navigated")
317
318 return targetNode, nil
319}
320
321// GeometricNavigationInput contains data for geometric focus navigation.
322type GeometricNavigationInput struct {
323 ActivePaneID entity.PaneID
324 PaneRects []entity.PaneRect // All visible panes with their positions
325 Direction NavigateDirection
326}
327
328// GeometricNavigationOutput contains the result.
329type GeometricNavigationOutput struct {
330 TargetPaneID entity.PaneID
331 Found bool
332}
333
334// NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
335// Algorithm:
336// 1. Get center of active pane
337// 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
338// 3. Score by: primary_distance * 1000 + perpendicular_distance
339// 4. Return lowest scoring candidate
340func (uc *ManagePanesUseCase) NavigateFocusGeometric(
341 ctx context.Context,
342 input GeometricNavigationInput,
343) (*GeometricNavigationOutput, error) {
344 log := logging.FromContext(ctx)
345 if uc == nil {
346 return nil, fmt.Errorf("manage panes use case is nil")
347 }
348 log.Debug().
349 Str("direction", string(input.Direction)).
350 Str("active", string(input.ActivePaneID)).
351 Int("candidates", len(input.PaneRects)).
352 Msg("geometric navigation")
353
354 // Find active pane rect
355 var activeRect *entity.PaneRect
356 for i := range input.PaneRects {
357 if input.PaneRects[i].PaneID == input.ActivePaneID {
358 activeRect = &input.PaneRects[i]
359 break
360 }
361 }
362 if activeRect == nil {
363 log.Debug().Msg("active pane rect not found")
364 return &GeometricNavigationOutput{Found: false}, nil
365 }
366
367 acx, acy := activeRect.Center()
368
369 type candidate struct {
370 paneID entity.PaneID
371 score int
372 }
373 var candidates []candidate
374
375 for _, rect := range input.PaneRects {
376 if rect.PaneID == input.ActivePaneID {
377 continue
378 }
379
380 cx, cy := rect.Center()
381 dx := cx - acx
382 dy := cy - acy
383
384 var inDirection bool
385 var primaryDist, perpDist int
386
387 switch input.Direction {
388 case NavLeft:
389 inDirection = dx < 0
390 primaryDist = abs(dx)
391 perpDist = abs(dy)
392 case NavRight:
393 inDirection = dx > 0
394 primaryDist = abs(dx)
395 perpDist = abs(dy)
396 case NavUp:
397 inDirection = dy < 0
398 primaryDist = abs(dy)
399 perpDist = abs(dx)
400 case NavDown:
401 inDirection = dy > 0
402 primaryDist = abs(dy)
403 perpDist = abs(dx)
404 }
405
406 if inDirection {
407 score := primaryDist*1000 + perpDist
408 candidates = append(candidates, candidate{rect.PaneID, score})
409 }
410 }
411
412 if len(candidates) == 0 {
413 log.Debug().Msg("no candidates in direction")
414 return &GeometricNavigationOutput{Found: false}, nil
415 }
416
417 // Sort by score (lowest first)
418 sort.Slice(candidates, func(i, j int) bool {
419 return candidates[i].score < candidates[j].score
420 })
421
422 log.Debug().
423 Str("target", string(candidates[0].paneID)).
424 Int("score", candidates[0].score).
425 Msg("geometric navigation found target")
426
427 return &GeometricNavigationOutput{
428 TargetPaneID: candidates[0].paneID,
429 Found: true,
430 }, nil
431}
432
433// abs returns the absolute value of an integer.
434func abs(x int) int {
435 if x < 0 {
436 return -x
437 }
438 return x
439}
440
441// findAdjacentPane finds the adjacent pane in the given direction using tree structure.
442func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {
443 if node == nil {
444 return nil
445 }
446
447 // Determine if we need to go horizontal or vertical
448 isHorizontal := direction == NavLeft || direction == NavRight
449 isForward := direction == NavRight || direction == NavDown
450
451 // Walk up the tree to find an ancestor with the right split type
452 current := node
453 for current.Parent != nil {
454 parent := current.Parent
455
456 // Check if this parent's split orientation matches our direction
457 parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
458
459 if parentIsHorizontal == isHorizontal {
460 // Find which child we came from
461 childIndex := -1
462 for i, child := range parent.Children {
463 if child == current {
464 childIndex = i
465 break
466 }
467 }
468
469 // Check if we can move in the desired direction
470 var targetIndex int
471 if isForward {
472 targetIndex = childIndex + 1
473 } else {
474 targetIndex = childIndex - 1
475 }
476
477 if targetIndex >= 0 && targetIndex < len(parent.Children) {
478 // Found a valid sibling, descend into it to find a leaf
479 return uc.findLeafInDirection(parent.Children[targetIndex], !isForward)
480 }
481 }
482
483 current = parent
484 }
485
486 return nil
487}
488
489// findLeafInDirection descends into a node to find a leaf pane.
490// If fromEnd is true, prefers the last child; otherwise prefers the first.
491func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd bool) *entity.PaneNode {
492 if node == nil {
493 return nil
494 }
495
496 // If this is a leaf, return it
497 if node.IsLeaf() {
498 return node
499 }
500
501 // If stacked, return active pane
502 if node.IsStacked && len(node.Children) > 0 {
503 activeIdx := node.ActiveStackIndex
504 if activeIdx >= 0 && activeIdx < len(node.Children) {
505 return uc.findLeafInDirection(node.Children[activeIdx], fromEnd)
506 }
507 return uc.findLeafInDirection(node.Children[0], fromEnd)
508 }
509
510 // Descend into children
511 if len(node.Children) > 0 {
512 if fromEnd {
513 return uc.findLeafInDirection(node.Children[len(node.Children)-1], fromEnd)
514 }
515 return uc.findLeafInDirection(node.Children[0], fromEnd)
516 }
517
518 return nil
519}
520
521// CreateStackOutput contains the result of creating a stack from a pane.
522type CreateStackOutput struct {
523 StackNode *entity.PaneNode // The stack container node
524 OriginalNode *entity.PaneNode // The original pane, now a child of the stack
525 NewPaneNode *entity.PaneNode // The new pane added to the stack
526 NewPane *entity.Pane // The new pane entity
527}
528
529// CreateStack converts a pane into a stacked container and adds a new pane.
530// The original pane becomes the first child, a new pane becomes the second child.
531// Returns the stack node and the new pane node for UI operations.
532//
533// Domain tree transformation:
534//
535// Before: parent -> targetNode (leaf with pane)
536// After: parent -> targetNode (stack) -> [originalChild (leaf), newChild (leaf)]
537//
538// The targetNode keeps its ID but becomes a stack container.
539// This allows the UI layer to update widget mappings incrementally.
540func (uc *ManagePanesUseCase) CreateStack(
541 ctx context.Context,
542 ws *entity.Workspace,
543 paneNode *entity.PaneNode,
544) (*CreateStackOutput, error) {
545 log := logging.FromContext(ctx)
546 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
547
548 if ws == nil {
549 return nil, fmt.Errorf("workspace is required")
550 }
551 if paneNode == nil {
552 return nil, fmt.Errorf("pane node is required")
553 }
554 if !paneNode.IsLeaf() {
555 return nil, fmt.Errorf("can only create stack from leaf pane")
556 }
557 if paneNode.IsStacked {
558 return nil, fmt.Errorf("pane is already stacked")
559 }
560
561 // Save original pane reference
562 originalPane := paneNode.Pane
563 originalPaneID := paneNode.ID
564
565 // Create child node for the original pane
566 originalChildNode := &entity.PaneNode{
567 ID: originalPaneID + "_0",
568 Pane: originalPane,
569 Parent: paneNode,
570 }
571
572 // Create new pane
573 newPaneID := entity.PaneID(uc.idGenerator())
574 newPane := entity.NewPane(newPaneID)
575
576 // Create child node for the new pane
577 newChildNode := &entity.PaneNode{
578 ID: string(newPaneID),
579 Pane: newPane,
580 Parent: paneNode,
581 }
582
583 // Convert targetNode to stack container
584 paneNode.Pane = nil
585 paneNode.IsStacked = true
586 paneNode.ActiveStackIndex = 1 // New pane is active
587 paneNode.Children = []*entity.PaneNode{originalChildNode, newChildNode}
588
589 // Update workspace active pane
590 ws.ActivePaneID = newPaneID
591
592 log.Info().
593 Str("stack_id", paneNode.ID).
594 Str("original_pane", string(originalPane.ID)).
595 Str("new_pane", string(newPaneID)).
596 Msg("stack created")
597
598 return &CreateStackOutput{
599 StackNode: paneNode,
600 OriginalNode: originalChildNode,
601 NewPaneNode: newChildNode,
602 NewPane: newPane,
603 }, nil
604}
605
606// AddToStackOutput contains the result of adding a pane to a stack.
607type AddToStackOutput struct {
608 NewPaneNode *entity.PaneNode // The new pane node added to the stack
609 StackIndex int // Index of the new pane in the stack
610}
611
612// AddToStack adds a new pane to an existing stack.
613// Optionally pass a pre-created pane, or nil to create a new one.
614func (uc *ManagePanesUseCase) AddToStack(
615 ctx context.Context,
616 ws *entity.Workspace,
617 stackNode *entity.PaneNode,
618 pane *entity.Pane,
619) (*AddToStackOutput, error) {
620 log := logging.FromContext(ctx)
621
622 if ws == nil {
623 return nil, fmt.Errorf("workspace is required")
624 }
625 if stackNode == nil {
626 return nil, fmt.Errorf("stack node is required")
627 }
628 if !stackNode.IsStacked {
629 return nil, fmt.Errorf("node is not a stack")
630 }
631
632 // Create pane if not provided
633 if pane == nil {
634 paneID := entity.PaneID(uc.idGenerator())
635 pane = entity.NewPane(paneID)
636 }
637
638 log.Debug().
639 Str("stack_id", stackNode.ID).
640 Str("pane_id", string(pane.ID)).
641 Msg("adding pane to stack")
642
643 // Create node for the new pane
644 newNode := &entity.PaneNode{
645 ID: string(pane.ID),
646 Pane: pane,
647 Parent: stackNode,
648 }
649
650 // Insert right after current active pane (not at end)
651 currentActiveIndex := stackNode.ActiveStackIndex
652 if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
653 // Clamp to valid range - default to end-1 if invalid
654 currentActiveIndex = len(stackNode.Children) - 1
655 if currentActiveIndex < 0 {
656 currentActiveIndex = -1 // Will result in insertIndex = 0
657 }
658 }
659 insertIndex := currentActiveIndex + 1
660
661 // Slice insertion at correct position
662 stackNode.Children = append(stackNode.Children, nil)
663 copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
664 stackNode.Children[insertIndex] = newNode
665 newIndex := insertIndex
666 stackNode.ActiveStackIndex = newIndex
667
668 // Update workspace active pane
669 ws.ActivePaneID = pane.ID
670
671 log.Info().
672 Str("stack_id", stackNode.ID).
673 Str("pane_id", string(pane.ID)).
674 Int("stack_size", len(stackNode.Children)).
675 Msg("pane added to stack")
676
677 return &AddToStackOutput{
678 NewPaneNode: newNode,
679 StackIndex: newIndex,
680 }, nil
681}
682
683// NavigateStack cycles through stacked panes.
684// direction: NavUp for previous, NavDown for next.
685//
686//nolint:revive // receiver required for interface consistency
687func (uc *ManagePanesUseCase) NavigateStack(
688 ctx context.Context,
689 stackNode *entity.PaneNode,
690 direction NavigateDirection,
691) (*entity.Pane, error) {
692 log := logging.FromContext(ctx)
693
694 if stackNode == nil {
695 return nil, fmt.Errorf("stack node is required")
696 }
697
698 log.Debug().
699 Str("stack_id", stackNode.ID).
700 Str("direction", string(direction)).
701 Msg("navigating stack")
702 if !stackNode.IsStacked {
703 return nil, fmt.Errorf("node is not a stack")
704 }
705 if len(stackNode.Children) == 0 {
706 return nil, fmt.Errorf("stack is empty")
707 }
708
709 count := len(stackNode.Children)
710 current := stackNode.ActiveStackIndex
711
712 var newIndex int
713 switch direction {
714 case NavUp:
715 newIndex = (current - 1 + count) % count
716 case NavDown:
717 newIndex = (current + 1) % count
718 default:
719 return nil, fmt.Errorf("invalid stack navigation direction: %s (use up/down)", direction)
720 }
721
722 stackNode.ActiveStackIndex = newIndex
723
724 activeChild := stackNode.Children[newIndex]
725 var activePaneNode *entity.PaneNode
726 if activeChild.IsLeaf() {
727 activePaneNode = activeChild
728 } else {
729 // Find first leaf in the child
730 activeChild.Walk(func(n *entity.PaneNode) bool {
731 if n.IsLeaf() {
732 activePaneNode = n
733 return false
734 }
735 return true
736 })
737 }
738
739 if activePaneNode == nil || activePaneNode.Pane == nil {
740 return nil, fmt.Errorf("no pane found in stack at index %d", newIndex)
741 }
742
743 log.Info().
744 Str("stack_id", stackNode.ID).
745 Int("from_index", current).
746 Int("to_index", newIndex).
747 Str("pane_id", string(activePaneNode.Pane.ID)).
748 Msg("stack navigated")
749
750 return activePaneNode.Pane, nil
751}
752
753// RemoveFromStack removes a pane from a stack.
754// If only one pane remains, the stack is dissolved.
755//
756//nolint:revive // receiver required for interface consistency
757func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error {
758 log := logging.FromContext(ctx)
759
760 if stackNode == nil {
761 return fmt.Errorf("stack node is required")
762 }
763
764 log.Debug().
765 Str("stack_id", stackNode.ID).
766 Str("pane_id", string(paneID)).
767 Msg("removing pane from stack")
768 if !stackNode.IsStacked {
769 return fmt.Errorf("node is not a stack")
770 }
771
772 // Find and remove the pane
773 var removed bool
774 newChildren := make([]*entity.PaneNode, 0, len(stackNode.Children))
775 for _, child := range stackNode.Children {
776 if child.Pane != nil && child.Pane.ID == paneID {
777 removed = true
778 continue
779 }
780 newChildren = append(newChildren, child)
781 }
782
783 if !removed {
784 return fmt.Errorf("pane not found in stack: %s", paneID)
785 }
786
787 stackNode.Children = newChildren
788
789 // Adjust active index
790 if stackNode.ActiveStackIndex >= len(stackNode.Children) {
791 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
792 }
793 if stackNode.ActiveStackIndex < 0 {
794 stackNode.ActiveStackIndex = 0
795 }
796
797 log.Info().
798 Str("stack_id", stackNode.ID).
799 Str("pane_id", string(paneID)).
800 Int("remaining", len(stackNode.Children)).
801 Msg("pane removed from stack")
802
803 return nil
804}
805
806// GetAllPanes returns all leaf panes in a workspace.
807//
808//nolint:revive // receiver required for interface consistency
809func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane {
810 if ws == nil || ws.Root == nil {
811 return nil
812 }
813 return ws.AllPanes()
814}
815
816// CountPanes returns the number of panes in a workspace.
817//
818//nolint:revive // receiver required for interface consistency
819func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int {
820 if ws == nil {
821 return 0
822 }
823 return ws.PaneCount()
824}