v0.29.0 21b3150

manage_panes.go RawHistoryFolder

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, split around the entire stack
116	// (i.e., create the new pane as a sibling to the stack, not inside it)
117	// This applies to all directions - we don't want to nest split containers inside stacks.
118	targetNode := input.TargetPane
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	// Create new pane
126	var newPane *entity.Pane
127	if input.NewPane != nil {
128		newPane = input.NewPane
129	} else {
130		paneID := entity.PaneID(uc.idGenerator())
131		newPane = entity.NewPane(paneID)
132		if input.InitialURL != "" {
133			newPane.URI = domainurl.Normalize(input.InitialURL)
134		} else {
135			newPane.URI = "about:blank"
136		}
137	}
138
139	// Create new pane node
140	newPaneNode := &entity.PaneNode{
141		ID:   string(newPane.ID),
142		Pane: newPane,
143	}
144
145	// Determine split orientation
146	var splitDir entity.SplitDirection
147	switch input.Direction {
148	case SplitLeft, SplitRight:
149		splitDir = entity.SplitHorizontal
150	case SplitUp, SplitDown:
151		splitDir = entity.SplitVertical
152	}
153
154	// Create parent container node
155	parentID := uc.idGenerator()
156	parentNode := &entity.PaneNode{
157		ID:         parentID,
158		SplitDir:   splitDir,
159		SplitRatio: 0.5,
160		Children:   make([]*entity.PaneNode, 2),
161	}
162
163	// Arrange children based on direction
164	switch input.Direction {
165	case SplitLeft, SplitUp:
166		// New pane goes first (left/top)
167		parentNode.Children[0] = newPaneNode
168		parentNode.Children[1] = targetNode
169	case SplitRight, SplitDown:
170		// Existing pane stays first, new pane goes second (right/bottom)
171		parentNode.Children[0] = targetNode
172		parentNode.Children[1] = newPaneNode
173	}
174
175	// Update parent references
176	newPaneNode.Parent = parentNode
177	oldParent := targetNode.Parent
178	targetNode.Parent = parentNode
179
180	// Insert parent into tree
181	if oldParent == nil {
182		// Target was root
183		input.Workspace.Root = parentNode
184	} else {
185		// Replace target with parent in old parent's children
186		for i, child := range oldParent.Children {
187			if child == targetNode {
188				oldParent.Children[i] = parentNode
189				break
190			}
191		}
192		parentNode.Parent = oldParent
193	}
194
195	log.Info().
196		Str("new_pane_id", string(newPane.ID)).
197		Str("parent_id", parentID).
198		Str("direction", string(input.Direction)).
199		Msg("pane split completed")
200
201	return &SplitPaneOutput{
202		NewPaneNode: newPaneNode,
203		ParentNode:  parentNode,
204		SplitRatio:  0.5,
205	}, nil
206}
207
208// Resize adjusts the nearest applicable split ratio for the given direction.
209// stepPercent is applied per keystroke (e.g. 5.0 means 5%).
210// minPanePercent enforces a minimum size for each side of a split.
211func (uc *ManagePanesUseCase) Resize(
212	ctx context.Context,
213	ws *entity.Workspace,
214	paneNode *entity.PaneNode,
215	dir ResizeDirection,
216	stepPercent float64,
217	minPanePercent float64,
218) error {
219	log := logging.FromContext(ctx)
220	if uc == nil {
221		return fmt.Errorf("manage panes use case is nil")
222	}
223
224	if ws == nil {
225		return fmt.Errorf("workspace is required")
226	}
227	if ws.Root == nil {
228		return ErrNothingToResize
229	}
230	if paneNode == nil {
231		return fmt.Errorf("pane node is required")
232	}
233
234	target := paneNode
235	if target.Parent != nil && target.Parent.IsStacked {
236		target = target.Parent
237	}
238
239	actualDir := dir
240	switch dir {
241	case ResizeIncrease:
242		actualDir = findSmartResizeDirection(target, true)
243	case ResizeDecrease:
244		actualDir = findSmartResizeDirection(target, false)
245	}
246	if actualDir == "" {
247		return ErrNothingToResize
248	}
249
250	axis, ok := axisForResizeDirection(actualDir)
251	if !ok {
252		return ErrNothingToResize
253	}
254
255	splitNode := findNearestSplitForAxis(target, axis)
256	if splitNode == nil {
257		return ErrNothingToResize
258	}
259
260	// We treat resize directions as moving the split divider.
261	// SplitRatio is the proportion allocated to the first child (left/top).
262	// - Moving the divider right/down increases SplitRatio.
263	// - Moving the divider left/up decreases SplitRatio.
264	delta := deltaForDividerMove(actualDir, stepPercent)
265
266	minRatio := minPanePercent / 100.0
267	maxRatio := 1.0 - minRatio
268	oldRatio := splitNode.SplitRatio
269	splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio)
270
271	log.Debug().
272		Str("direction", string(dir)).
273		Str("actual_direction", string(actualDir)).
274		Float64("old_ratio", oldRatio).
275		Float64("new_ratio", splitNode.SplitRatio).
276		Msg("pane resized")
277
278	return nil
279}
280
281type SetSplitRatioInput struct {
282	Workspace      *entity.Workspace
283	SplitNodeID    string
284	Ratio          float64
285	MinPanePercent float64
286}
287
288func (uc *ManagePanesUseCase) SetSplitRatio(ctx context.Context, input SetSplitRatioInput) error {
289	log := logging.FromContext(ctx)
290	if uc == nil {
291		return fmt.Errorf("manage panes use case is nil")
292	}
293	if input.Workspace == nil {
294		return fmt.Errorf("workspace is required")
295	}
296	if input.Workspace.Root == nil {
297		return ErrNothingToResize
298	}
299	if input.SplitNodeID == "" {
300		return fmt.Errorf("split node id is required")
301	}
302
303	var splitNode *entity.PaneNode
304	input.Workspace.Root.Walk(func(node *entity.PaneNode) bool {
305		if node.ID == input.SplitNodeID {
306			splitNode = node
307			return false
308		}
309		return true
310	})
311
312	if splitNode == nil || !splitNode.IsSplit() {
313		return fmt.Errorf("split node not found: %s", input.SplitNodeID)
314	}
315
316	minRatio := input.MinPanePercent / 100.0
317	maxRatio := 1.0 - minRatio
318	oldRatio := splitNode.SplitRatio
319	clamped := clampFloat64(input.Ratio, minRatio, maxRatio)
320	splitNode.SplitRatio = roundSplitRatio(clamped)
321
322	log.Debug().
323		Str("split_node_id", input.SplitNodeID).
324		Float64("old_ratio", oldRatio).
325		Float64("new_ratio", splitNode.SplitRatio).
326		Msg("split ratio set")
327
328	return nil
329}
330
331func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection {
332	splitNode, axis, isStartChild := findNearestSplitForResize(target)
333	if splitNode == nil {
334		return ""
335	}
336
337	// Smart resize means "grow/shrink the active pane".
338	// SplitRatio is the proportion allocated to the first child (left/top).
339	// - If active is first child: grow by increasing ratio, shrink by decreasing.
340	// - If active is second child: grow by decreasing ratio, shrink by increasing.
341	growMeansIncreaseRatio := isStartChild
342	if !growActive {
343		growMeansIncreaseRatio = !growMeansIncreaseRatio
344	}
345
346	switch axis {
347	case resizeAxisHorizontal:
348		if growMeansIncreaseRatio {
349			return ResizeIncreaseRight
350		}
351		return ResizeIncreaseLeft
352	case resizeAxisVertical:
353		if growMeansIncreaseRatio {
354			return ResizeIncreaseDown
355		}
356		return ResizeIncreaseUp
357	default:
358		return ""
359	}
360}
361
362// findNearestSplitForResize returns the nearest split ancestor for the active pane.
363// It prefers horizontal splits over vertical when both are available at the same depth.
364func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) {
365	current := node
366	for current != nil && current.Parent != nil {
367		parent := current.Parent
368		if parent.IsSplit() {
369			isStartChild = parent.Left() == current
370			switch parent.SplitDir {
371			case entity.SplitHorizontal:
372				return parent, resizeAxisHorizontal, isStartChild
373			case entity.SplitVertical:
374				return parent, resizeAxisVertical, isStartChild
375			}
376		}
377		current = parent
378	}
379	return nil, resizeAxisNone, false
380}
381
382type resizeAxis int
383
384const (
385	resizeAxisNone resizeAxis = iota
386	resizeAxisHorizontal
387	resizeAxisVertical
388)
389
390func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) {
391	switch dir {
392	case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight:
393		return resizeAxisHorizontal, true
394	case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown:
395		return resizeAxisVertical, true
396	default:
397		return resizeAxisNone, false
398	}
399}
400
401func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 {
402	if stepPercent < 0 {
403		stepPercent = -stepPercent
404	}
405	delta := stepPercent / 100.0
406
407	switch dir {
408	case ResizeIncreaseRight, ResizeIncreaseDown:
409		return delta
410	case ResizeIncreaseLeft, ResizeIncreaseUp:
411		return -delta
412	case ResizeDecreaseRight, ResizeDecreaseDown:
413		return -delta
414	case ResizeDecreaseLeft, ResizeDecreaseUp:
415		return delta
416	default:
417		return 0
418	}
419}
420
421// findNearestSplitForAxis walks up the tree to find the nearest split matching the axis.
422func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode {
423	current := node
424	for current != nil && current.Parent != nil {
425		parent := current.Parent
426		if parent.IsSplit() {
427			if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal {
428				return parent
429			}
430			if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical {
431				return parent
432			}
433		}
434		current = parent
435	}
436	return nil
437}
438
439const splitRatioRoundFactor = 100.0
440
441func roundSplitRatio(ratio float64) float64 {
442	// Keep snapshots stable and readable; avoids persisting noisy float values.
443	return math.Round(ratio*splitRatioRoundFactor) / splitRatioRoundFactor
444}
445
446func clampFloat64(v, minVal, maxVal float64) float64 {
447	if v < minVal {
448		return minVal
449	}
450	if v > maxVal {
451		return maxVal
452	}
453	return v
454}
455
456// Close removes a pane and promotes its sibling.
457// Returns the sibling node that was promoted, or nil if this was the last pane.
458// For stacked panes, removes the pane from the stack via RemoveFromStack.
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	// Handle stacked panes differently - they can have more than 2 children
487	if parent.IsStacked {
488		return uc.closeStackedPane(ctx, ws, paneNode, parent)
489	}
490
491	// Binary split: find sibling (the other child of parent)
492	var sibling *entity.PaneNode
493	for _, child := range parent.Children {
494		if child != paneNode {
495			sibling = child
496			break
497		}
498	}
499
500	if sibling == nil {
501		return nil, fmt.Errorf("no sibling found for pane")
502	}
503
504	grandparent := parent.Parent
505
506	// Promote sibling to parent's position
507	if grandparent == nil {
508		// Parent was root, sibling becomes new root
509		ws.Root = sibling
510		sibling.Parent = nil
511	} else {
512		// Replace parent with sibling in grandparent
513		for i, child := range grandparent.Children {
514			if child == parent {
515				grandparent.Children[i] = sibling
516				break
517			}
518		}
519		sibling.Parent = grandparent
520	}
521
522	// Update active pane if needed
523	if ws.ActivePaneID == paneNode.Pane.ID {
524		// Find a new active pane from the sibling subtree
525		var newActive *entity.Pane
526		sibling.Walk(func(node *entity.PaneNode) bool {
527			if node.IsLeaf() && node.Pane != nil {
528				newActive = node.Pane
529				return false // Stop walking
530			}
531			return true
532		})
533		if newActive != nil {
534			ws.ActivePaneID = newActive.ID
535		} else {
536			ws.ActivePaneID = ""
537		}
538	}
539
540	log.Info().
541		Str("closed_pane_id", paneNode.ID).
542		Str("promoted_sibling_id", sibling.ID).
543		Msg("pane closed, sibling promoted")
544
545	return sibling, nil
546}
547
548// closeStackedPane handles closing a pane within a stack.
549// If multiple panes remain, removes the pane from the stack.
550// If only one pane would remain, dissolves the stack.
551func (uc *ManagePanesUseCase) closeStackedPane(
552	ctx context.Context,
553	ws *entity.Workspace,
554	paneNode *entity.PaneNode,
555	stackNode *entity.PaneNode,
556) (*entity.PaneNode, error) {
557	log := logging.FromContext(ctx)
558
559	paneID := paneNode.Pane.ID
560
561	// If more than 2 panes in stack, just remove this one
562	if len(stackNode.Children) > 2 {
563		if err := uc.RemoveFromStack(ctx, stackNode, paneID); err != nil {
564			return nil, fmt.Errorf("failed to remove pane from stack: %w", err)
565		}
566
567		// Update active pane if we closed the active one
568		if ws.ActivePaneID == paneID {
569			// Use the pane at the current active index
570			if stackNode.ActiveStackIndex >= 0 && stackNode.ActiveStackIndex < len(stackNode.Children) {
571				activeChild := stackNode.Children[stackNode.ActiveStackIndex]
572				if activeChild.Pane != nil {
573					ws.ActivePaneID = activeChild.Pane.ID
574				}
575			}
576		}
577
578		log.Info().
579			Str("pane_id", string(paneID)).
580			Int("remaining", len(stackNode.Children)).
581			Msg("pane removed from stack")
582
583		return stackNode, nil
584	}
585
586	// If exactly 2 panes, remove this one and dissolve the stack
587	if len(stackNode.Children) == 2 {
588		// Find the remaining pane
589		var remaining *entity.PaneNode
590		for _, child := range stackNode.Children {
591			if child.Pane != nil && child.Pane.ID != paneID {
592				remaining = child
593				break
594			}
595		}
596
597		if remaining == nil {
598			return nil, fmt.Errorf("no remaining pane found in stack")
599		}
600
601		// Dissolve the stack: promote remaining pane to stack's position
602		grandparent := stackNode.Parent
603
604		if grandparent == nil {
605			// Stack was root, remaining becomes new root
606			ws.Root = remaining
607			remaining.Parent = nil
608		} else {
609			// Replace stack with remaining in grandparent
610			for i, child := range grandparent.Children {
611				if child == stackNode {
612					grandparent.Children[i] = remaining
613					break
614				}
615			}
616			remaining.Parent = grandparent
617		}
618
619		// Update active pane if we closed the active one
620		if ws.ActivePaneID == paneID && remaining.Pane != nil {
621			ws.ActivePaneID = remaining.Pane.ID
622		}
623
624		log.Info().
625			Str("closed_pane_id", string(paneID)).
626			Str("remaining_pane_id", remaining.ID).
627			Msg("stack dissolved, remaining pane promoted")
628
629		return remaining, nil
630	}
631
632	// Should not happen: stack with less than 2 children
633	return nil, fmt.Errorf("invalid stack: has %d children", len(stackNode.Children))
634}
635
636// Focus sets the active pane in the workspace.
637// Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.
638func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
639	log := logging.FromContext(ctx)
640	log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
641
642	_, err := uc.ApplyFocusChange(ctx, ws, paneID)
643	return err
644}
645
646// NavigateFocus moves focus to an adjacent pane.
647// Returns the newly focused pane, or nil if navigation not possible.
648func (uc *ManagePanesUseCase) NavigateFocus(
649	ctx context.Context,
650	ws *entity.Workspace,
651	direction NavigateDirection,
652) (*entity.PaneNode, error) {
653	log := logging.FromContext(ctx)
654	log.Debug().Str("direction", string(direction)).Msg("navigating focus")
655
656	if ws == nil {
657		return nil, fmt.Errorf("workspace is required")
658	}
659
660	activeNode := ws.ActivePane()
661	if activeNode == nil {
662		return nil, fmt.Errorf("no active pane")
663	}
664
665	// Try structural navigation first
666	targetNode := uc.findAdjacentPane(activeNode, direction)
667
668	if targetNode == nil {
669		log.Debug().Msg("no adjacent pane found")
670		return nil, nil
671	}
672
673	ws.ActivePaneID = targetNode.Pane.ID
674
675	log.Info().
676		Str("from", activeNode.ID).
677		Str("to", targetNode.ID).
678		Str("direction", string(direction)).
679		Msg("focus navigated")
680
681	return targetNode, nil
682}
683
684// GeometricNavigationInput contains data for geometric focus navigation.
685type GeometricNavigationInput struct {
686	ActivePaneID entity.PaneID
687	PaneRects    []entity.PaneRect // All visible panes with their positions
688	Direction    NavigateDirection
689}
690
691// GeometricNavigationOutput contains the result.
692type GeometricNavigationOutput struct {
693	TargetPaneID entity.PaneID
694	Found        bool
695}
696
697// ApplyFocusChange sets the active pane and updates the stack index if the target pane is in a stack.
698// This is the canonical way to change focus to a pane, ensuring stack state is kept in sync.
699func (uc *ManagePanesUseCase) ApplyFocusChange(
700	ctx context.Context,
701	ws *entity.Workspace,
702	targetPaneID entity.PaneID,
703) (*entity.PaneNode, error) {
704	log := logging.FromContext(ctx)
705	if uc == nil {
706		return nil, fmt.Errorf("manage panes use case is nil")
707	}
708	if ws == nil {
709		return nil, fmt.Errorf("workspace is required")
710	}
711
712	targetNode := ws.FindPane(targetPaneID)
713	if targetNode == nil {
714		return nil, fmt.Errorf("pane not found: %s", targetPaneID)
715	}
716
717	oldActive := ws.ActivePaneID
718	ws.ActivePaneID = targetPaneID
719
720	// Update stack index if target is in a stack
721	if targetNode.Parent != nil && targetNode.Parent.IsStacked {
722		oldIndex := targetNode.Parent.ActiveStackIndex
723		for i, child := range targetNode.Parent.Children {
724			if child == targetNode {
725				targetNode.Parent.ActiveStackIndex = i
726				log.Debug().
727					Str("target_pane_id", string(targetPaneID)).
728					Int("old_stack_index", oldIndex).
729					Int("new_stack_index", i).
730					Int("num_children", len(targetNode.Parent.Children)).
731					Msg("updated stack index for focus change")
732				break
733			}
734		}
735	}
736
737	log.Info().
738		Str("from", string(oldActive)).
739		Str("to", string(targetPaneID)).
740		Msg("focus changed")
741
742	return targetNode, nil
743}
744
745// NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
746// Algorithm:
747//  1. Get active pane rectangle
748//  2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
749//  3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down)
750//  4. Score by: overlap_penalty + primary_distance * 1000 + perpendicular_distance
751//  5. Return lowest scoring candidate
752func (uc *ManagePanesUseCase) NavigateFocusGeometric(
753	ctx context.Context,
754	input GeometricNavigationInput,
755) (*GeometricNavigationOutput, error) {
756	log := logging.FromContext(ctx)
757	if uc == nil {
758		return nil, fmt.Errorf("manage panes use case is nil")
759	}
760	log.Debug().
761		Str("direction", string(input.Direction)).
762		Str("active", string(input.ActivePaneID)).
763		Int("candidates", len(input.PaneRects)).
764		Msg("geometric navigation")
765
766	// Find active pane rect
767	var activeRect *entity.PaneRect
768	for i := range input.PaneRects {
769		if input.PaneRects[i].PaneID == input.ActivePaneID {
770			activeRect = &input.PaneRects[i]
771			break
772		}
773	}
774	if activeRect == nil {
775		log.Debug().Msg("active pane rect not found")
776		return &GeometricNavigationOutput{Found: false}, nil
777	}
778
779	candidates := scoreNavigationCandidates(*activeRect, input.PaneRects, input.Direction)
780
781	if len(candidates) == 0 {
782		log.Debug().Msg("no candidates in direction")
783		return &GeometricNavigationOutput{Found: false}, nil
784	}
785
786	// Sort by score (lowest first)
787	sort.Slice(candidates, func(i, j int) bool {
788		return candidates[i].score < candidates[j].score
789	})
790
791	log.Debug().
792		Str("target", string(candidates[0].paneID)).
793		Int("score", candidates[0].score).
794		Msg("geometric navigation found target")
795
796	return &GeometricNavigationOutput{
797		TargetPaneID: candidates[0].paneID,
798		Found:        true,
799	}, nil
800}
801
802// navCandidate represents a pane candidate for navigation with its score.
803type navCandidate struct {
804	paneID entity.PaneID
805	score  int
806}
807
808// scoreNavigationCandidates scores all panes in the given direction from activeRect.
809// Panes with perpendicular overlap are heavily preferred (same row for left/right, same column for up/down).
810func scoreNavigationCandidates(
811	activeRect entity.PaneRect,
812	paneRects []entity.PaneRect,
813	direction NavigateDirection,
814) []navCandidate {
815	// Large penalty for panes without perpendicular overlap.
816	// This ensures panes at the same level are always preferred.
817	const noOverlapPenalty = 10_000_000
818
819	acx, acy := activeRect.Center()
820	var candidates []navCandidate
821
822	for _, rect := range paneRects {
823		if rect.PaneID == activeRect.PaneID {
824			continue
825		}
826
827		cx, cy := rect.Center()
828		dx := cx - acx
829		dy := cy - acy
830
831		inDirection, primaryDist, perpDist, hasOverlap := evalDirection(activeRect, rect, dx, dy, direction)
832
833		if inDirection {
834			score := primaryDist*1000 + perpDist
835			if !hasOverlap {
836				score += noOverlapPenalty
837			}
838			candidates = append(candidates, navCandidate{rect.PaneID, score})
839		}
840	}
841
842	return candidates
843}
844
845// evalDirection determines if a candidate rect is in the given direction from activeRect.
846// Returns: inDirection, primaryDist, perpDist, hasOverlap
847func evalDirection(
848	activeRect, rect entity.PaneRect,
849	dx, dy int,
850	direction NavigateDirection,
851) (inDirection bool, primaryDist, perpDist int, hasOverlap bool) {
852	switch direction {
853	case NavLeft:
854		return dx < 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
855	case NavRight:
856		return dx > 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
857	case NavUp:
858		return dy < 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
859	case NavDown:
860		return dy > 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
861	default:
862		return false, 0, 0, false
863	}
864}
865
866// abs returns the absolute value of an integer.
867func abs(x int) int {
868	if x < 0 {
869		return -x
870	}
871	return x
872}
873
874// findAdjacentPane finds the adjacent pane in the given direction using tree structure.
875func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {
876	if node == nil {
877		return nil
878	}
879
880	// Determine if we need to go horizontal or vertical
881	isHorizontal := direction == NavLeft || direction == NavRight
882	isForward := direction == NavRight || direction == NavDown
883
884	// Walk up the tree to find an ancestor with the right split type
885	current := node
886	for current.Parent != nil {
887		parent := current.Parent
888
889		// Check if this parent's split orientation matches our direction
890		parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
891
892		if parentIsHorizontal == isHorizontal {
893			// Find which child we came from
894			childIndex := -1
895			for i, child := range parent.Children {
896				if child == current {
897					childIndex = i
898					break
899				}
900			}
901
902			// Check if we can move in the desired direction
903			var targetIndex int
904			if isForward {
905				targetIndex = childIndex + 1
906			} else {
907				targetIndex = childIndex - 1
908			}
909
910			if targetIndex >= 0 && targetIndex < len(parent.Children) {
911				// Found a valid sibling, descend into it to find a leaf
912				return uc.findLeafInDirection(parent.Children[targetIndex], !isForward)
913			}
914		}
915
916		current = parent
917	}
918
919	return nil
920}
921
922// findLeafInDirection descends into a node to find a leaf pane.
923// If fromEnd is true, prefers the last child; otherwise prefers the first.
924func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd bool) *entity.PaneNode {
925	if node == nil {
926		return nil
927	}
928
929	// If this is a leaf, return it
930	if node.IsLeaf() {
931		return node
932	}
933
934	// If stacked, return active pane
935	if node.IsStacked && len(node.Children) > 0 {
936		activeIdx := node.ActiveStackIndex
937		if activeIdx >= 0 && activeIdx < len(node.Children) {
938			return uc.findLeafInDirection(node.Children[activeIdx], fromEnd)
939		}
940		return uc.findLeafInDirection(node.Children[0], fromEnd)
941	}
942
943	// Descend into children
944	if len(node.Children) > 0 {
945		if fromEnd {
946			return uc.findLeafInDirection(node.Children[len(node.Children)-1], fromEnd)
947		}
948		return uc.findLeafInDirection(node.Children[0], fromEnd)
949	}
950
951	return nil
952}
953
954// CreateStackOutput contains the result of creating a stack from a pane.
955type CreateStackOutput struct {
956	StackNode    *entity.PaneNode // The stack container node
957	OriginalNode *entity.PaneNode // The original pane, now a child of the stack
958	NewPaneNode  *entity.PaneNode // The new pane added to the stack
959	NewPane      *entity.Pane     // The new pane entity
960}
961
962// CreateStack converts a pane into a stacked container and adds a new pane.
963// The original pane becomes the first child, a new pane becomes the second child.
964// Returns the stack node and the new pane node for UI operations.
965//
966// Domain tree transformation:
967//
968//	Before: parent -> targetNode (leaf with pane)
969//	After:  parent -> targetNode (stack) -> [originalChild (leaf), newChild (leaf)]
970//
971// The targetNode keeps its ID but becomes a stack container.
972// This allows the UI layer to update widget mappings incrementally.
973func (uc *ManagePanesUseCase) CreateStack(
974	ctx context.Context,
975	ws *entity.Workspace,
976	paneNode *entity.PaneNode,
977) (*CreateStackOutput, error) {
978	log := logging.FromContext(ctx)
979	log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
980
981	if ws == nil {
982		return nil, fmt.Errorf("workspace is required")
983	}
984	if paneNode == nil {
985		return nil, fmt.Errorf("pane node is required")
986	}
987	if !paneNode.IsLeaf() {
988		return nil, fmt.Errorf("can only create stack from leaf pane")
989	}
990	if paneNode.IsStacked {
991		return nil, fmt.Errorf("pane is already stacked")
992	}
993
994	// Save original pane reference
995	originalPane := paneNode.Pane
996	originalPaneID := paneNode.ID
997
998	// Create child node for the original pane
999	originalChildNode := &entity.PaneNode{
1000		ID:     originalPaneID + "_0",
1001		Pane:   originalPane,
1002		Parent: paneNode,
1003	}
1004
1005	// Create new pane
1006	newPaneID := entity.PaneID(uc.idGenerator())
1007	newPane := entity.NewPane(newPaneID)
1008
1009	// Create child node for the new pane
1010	newChildNode := &entity.PaneNode{
1011		ID:     string(newPaneID),
1012		Pane:   newPane,
1013		Parent: paneNode,
1014	}
1015
1016	// Convert targetNode to stack container
1017	paneNode.Pane = nil
1018	paneNode.IsStacked = true
1019	paneNode.ActiveStackIndex = 1 // New pane is active
1020	paneNode.Children = []*entity.PaneNode{originalChildNode, newChildNode}
1021
1022	// Update workspace active pane
1023	ws.ActivePaneID = newPaneID
1024
1025	log.Info().
1026		Str("stack_id", paneNode.ID).
1027		Str("original_pane", string(originalPane.ID)).
1028		Str("new_pane", string(newPaneID)).
1029		Msg("stack created")
1030
1031	return &CreateStackOutput{
1032		StackNode:    paneNode,
1033		OriginalNode: originalChildNode,
1034		NewPaneNode:  newChildNode,
1035		NewPane:      newPane,
1036	}, nil
1037}
1038
1039// AddToStackOutput contains the result of adding a pane to a stack.
1040type AddToStackOutput struct {
1041	NewPaneNode *entity.PaneNode // The new pane node added to the stack
1042	StackIndex  int              // Index of the new pane in the stack
1043}
1044
1045// AddToStack adds a new pane to an existing stack.
1046// Optionally pass a pre-created pane, or nil to create a new one.
1047func (uc *ManagePanesUseCase) AddToStack(
1048	ctx context.Context,
1049	ws *entity.Workspace,
1050	stackNode *entity.PaneNode,
1051	pane *entity.Pane,
1052) (*AddToStackOutput, error) {
1053	log := logging.FromContext(ctx)
1054
1055	if ws == nil {
1056		return nil, fmt.Errorf("workspace is required")
1057	}
1058	if stackNode == nil {
1059		return nil, fmt.Errorf("stack node is required")
1060	}
1061	if !stackNode.IsStacked {
1062		return nil, fmt.Errorf("node is not a stack")
1063	}
1064
1065	// Create pane if not provided
1066	if pane == nil {
1067		paneID := entity.PaneID(uc.idGenerator())
1068		pane = entity.NewPane(paneID)
1069	}
1070
1071	log.Debug().
1072		Str("stack_id", stackNode.ID).
1073		Str("pane_id", string(pane.ID)).
1074		Msg("adding pane to stack")
1075
1076	// Create node for the new pane
1077	newNode := &entity.PaneNode{
1078		ID:     string(pane.ID),
1079		Pane:   pane,
1080		Parent: stackNode,
1081	}
1082
1083	// Insert right after current active pane (not at end)
1084	currentActiveIndex := stackNode.ActiveStackIndex
1085	if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1086		// Clamp to valid range - default to end-1 if invalid
1087		currentActiveIndex = len(stackNode.Children) - 1
1088		if currentActiveIndex < 0 {
1089			currentActiveIndex = -1 // Will result in insertIndex = 0
1090		}
1091	}
1092	insertIndex := currentActiveIndex + 1
1093
1094	// Slice insertion at correct position
1095	stackNode.Children = append(stackNode.Children, nil)
1096	copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1097	stackNode.Children[insertIndex] = newNode
1098	newIndex := insertIndex
1099	stackNode.ActiveStackIndex = newIndex
1100
1101	// Update workspace active pane
1102	ws.ActivePaneID = pane.ID
1103
1104	log.Info().
1105		Str("stack_id", stackNode.ID).
1106		Str("pane_id", string(pane.ID)).
1107		Int("stack_size", len(stackNode.Children)).
1108		Msg("pane added to stack")
1109
1110	return &AddToStackOutput{
1111		NewPaneNode: newNode,
1112		StackIndex:  newIndex,
1113	}, nil
1114}
1115
1116// NavigateStack cycles through stacked panes.
1117// direction: NavUp for previous, NavDown for next.
1118//
1119//nolint:revive // receiver required for interface consistency
1120func (uc *ManagePanesUseCase) NavigateStack(
1121	ctx context.Context,
1122	stackNode *entity.PaneNode,
1123	direction NavigateDirection,
1124) (*entity.Pane, error) {
1125	log := logging.FromContext(ctx)
1126
1127	if stackNode == nil {
1128		return nil, fmt.Errorf("stack node is required")
1129	}
1130
1131	log.Debug().
1132		Str("stack_id", stackNode.ID).
1133		Str("direction", string(direction)).
1134		Msg("navigating stack")
1135	if !stackNode.IsStacked {
1136		return nil, fmt.Errorf("node is not a stack")
1137	}
1138	if len(stackNode.Children) == 0 {
1139		return nil, fmt.Errorf("stack is empty")
1140	}
1141
1142	count := len(stackNode.Children)
1143	current := stackNode.ActiveStackIndex
1144
1145	var newIndex int
1146	switch direction {
1147	case NavUp:
1148		newIndex = (current - 1 + count) % count
1149	case NavDown:
1150		newIndex = (current + 1) % count
1151	default:
1152		return nil, fmt.Errorf("invalid stack navigation direction: %s (use up/down)", direction)
1153	}
1154
1155	stackNode.ActiveStackIndex = newIndex
1156
1157	activeChild := stackNode.Children[newIndex]
1158	var activePaneNode *entity.PaneNode
1159	if activeChild.IsLeaf() {
1160		activePaneNode = activeChild
1161	} else {
1162		// Find first leaf in the child
1163		activeChild.Walk(func(n *entity.PaneNode) bool {
1164			if n.IsLeaf() {
1165				activePaneNode = n
1166				return false
1167			}
1168			return true
1169		})
1170	}
1171
1172	if activePaneNode == nil || activePaneNode.Pane == nil {
1173		return nil, fmt.Errorf("no pane found in stack at index %d", newIndex)
1174	}
1175
1176	log.Info().
1177		Str("stack_id", stackNode.ID).
1178		Int("from_index", current).
1179		Int("to_index", newIndex).
1180		Str("pane_id", string(activePaneNode.Pane.ID)).
1181		Msg("stack navigated")
1182
1183	return activePaneNode.Pane, nil
1184}
1185
1186// RemoveFromStack removes a pane from a stack.
1187// If only one pane remains, the stack is dissolved.
1188//
1189//nolint:revive // receiver required for interface consistency
1190func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error {
1191	log := logging.FromContext(ctx)
1192
1193	if stackNode == nil {
1194		return fmt.Errorf("stack node is required")
1195	}
1196
1197	log.Debug().
1198		Str("stack_id", stackNode.ID).
1199		Str("pane_id", string(paneID)).
1200		Msg("removing pane from stack")
1201	if !stackNode.IsStacked {
1202		return fmt.Errorf("node is not a stack")
1203	}
1204
1205	// Find and remove the pane
1206	var removed bool
1207	newChildren := make([]*entity.PaneNode, 0, len(stackNode.Children))
1208	for _, child := range stackNode.Children {
1209		if child.Pane != nil && child.Pane.ID == paneID {
1210			removed = true
1211			continue
1212		}
1213		newChildren = append(newChildren, child)
1214	}
1215
1216	if !removed {
1217		return fmt.Errorf("pane not found in stack: %s", paneID)
1218	}
1219
1220	stackNode.Children = newChildren
1221
1222	// Adjust active index
1223	if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1224		stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1225	}
1226	if stackNode.ActiveStackIndex < 0 {
1227		stackNode.ActiveStackIndex = 0
1228	}
1229
1230	log.Info().
1231		Str("stack_id", stackNode.ID).
1232		Str("pane_id", string(paneID)).
1233		Int("remaining", len(stackNode.Children)).
1234		Msg("pane removed from stack")
1235
1236	return nil
1237}
1238
1239func (uc *ManagePanesUseCase) ConsumeOrExpel(
1240	ctx context.Context,
1241	ws *entity.Workspace,
1242	activeNode *entity.PaneNode,
1243	direction ConsumeOrExpelDirection,
1244) (*ConsumeOrExpelResult, error) {
1245	if uc == nil {
1246		return nil, fmt.Errorf("manage panes use case is nil")
1247	}
1248	if ws == nil {
1249		return nil, fmt.Errorf("workspace is required")
1250	}
1251	if ws.Root == nil {
1252		return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1253	}
1254	if activeNode == nil {
1255		return nil, fmt.Errorf("active pane node is required")
1256	}
1257	if activeNode.Pane == nil {
1258		return nil, fmt.Errorf("active pane node has no pane")
1259	}
1260
1261	if ws.PaneCount() <= 1 {
1262		return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1263	}
1264
1265	// Expel if active pane is inside a stack.
1266	if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1267		return uc.expelFromStack(ctx, ws, activeNode, direction)
1268	}
1269
1270	// Consume if active pane is not inside a stack.
1271	return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction)
1272}
1273
1274func (*ManagePanesUseCase) consumeIntoSiblingStack(
1275	ctx context.Context,
1276	ws *entity.Workspace,
1277	activeNode *entity.PaneNode,
1278	direction ConsumeOrExpelDirection,
1279) (*ConsumeOrExpelResult, error) {
1280	log := logging.FromContext(ctx)
1281
1282	sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction)
1283	if sibling == nil {
1284		sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction)
1285	}
1286	if sibling == nil {
1287		return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil
1288	}
1289
1290	// Left/right consume cycles layout for immediate split siblings:
1291	//  1) horizontal split -> vertical split (target on top)
1292	//  2) vertical split -> stack (top then bottom)
1293	if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1294		cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction)
1295		if err != nil {
1296			return nil, err
1297		}
1298		if cycled {
1299			switch cycleAction {
1300			case "vertical_split":
1301				log.Info().
1302					Str("pane_id", activeNode.ID).
1303					Str("sibling_id", sibling.ID).
1304					Msg("pane moved below sibling")
1305			case "stack":
1306				log.Info().
1307					Str("pane_id", activeNode.ID).
1308					Str("sibling_id", sibling.ID).
1309					Msg("split converted to stack")
1310			}
1311			return &ConsumeOrExpelResult{Action: "consumed"}, nil
1312		}
1313	}
1314
1315	moved, err := detachLeafByPromotingSibling(ws, activeNode)
1316	if err != nil {
1317		return nil, err
1318	}
1319	moved.Parent = nil
1320
1321	stackNode := sibling
1322	if stackNode.IsLeaf() {
1323		stackNode = convertLeafToStackContainer(stackNode)
1324	}
1325	if !stackNode.IsStacked {
1326		return nil, fmt.Errorf("target sibling is not stackable")
1327	}
1328
1329	// Insert consumed pane at the bottom/end.
1330	moved.Parent = stackNode
1331	stackNode.Children = append(stackNode.Children, moved)
1332	stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1333	ws.ActivePaneID = moved.Pane.ID
1334
1335	log.Info().
1336		Str("pane_id", moved.ID).
1337		Str("stack_id", stackNode.ID).
1338		Int("stack_size", len(stackNode.Children)).
1339		Msg("pane consumed into sibling stack")
1340
1341	return &ConsumeOrExpelResult{Action: "consumed"}, nil
1342}
1343
1344func (uc *ManagePanesUseCase) expelFromStack(
1345	ctx context.Context,
1346	ws *entity.Workspace,
1347	activeNode *entity.PaneNode,
1348	direction ConsumeOrExpelDirection,
1349) (*ConsumeOrExpelResult, error) {
1350	log := logging.FromContext(ctx)
1351
1352	stackNode := activeNode.Parent
1353	if stackNode == nil || !stackNode.IsStacked {
1354		return &ConsumeOrExpelResult{Action: "none"}, nil
1355	}
1356
1357	expelled, err := removeLeafFromStack(stackNode, activeNode)
1358	if err != nil {
1359		return nil, err
1360	}
1361	expelled.Parent = nil
1362
1363	// If only one pane remains, dissolve stack into a leaf node.
1364	if len(stackNode.Children) == 1 {
1365		dissolveStackIntoLeaf(stackNode)
1366	}
1367
1368	// Split around the remaining container (stackNode) with expelled pane.
1369	// For left/right, expel cycles stack -> vertical split first:
1370	// - left: expelled pane above
1371	// - right: expelled pane below
1372	splitDirection := direction
1373	switch direction {
1374	case ConsumeOrExpelLeft:
1375		splitDirection = ConsumeOrExpelUp
1376	case ConsumeOrExpelRight:
1377		splitDirection = ConsumeOrExpelDown
1378	}
1379
1380	if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1381		err := splitExistingNodeWithMarker(
1382			ws,
1383			stackNode,
1384			expelled,
1385			splitDirection,
1386			uc.idGenerator,
1387			consumeOrExpelExpelCycleMarker,
1388		)
1389		if err != nil {
1390			return nil, err
1391		}
1392	} else {
1393		if err := splitExistingNode(ws, stackNode, expelled, splitDirection, uc.idGenerator); err != nil {
1394			return nil, err
1395		}
1396	}
1397	ws.ActivePaneID = expelled.Pane.ID
1398
1399	log.Info().
1400		Str("pane_id", expelled.ID).
1401		Str("direction", string(direction)).
1402		Msg("pane expelled from stack")
1403
1404	return &ConsumeOrExpelResult{Action: "expelled"}, nil
1405}
1406
1407func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string {
1408	switch direction {
1409	case ConsumeOrExpelLeft:
1410		return "No pane to the left"
1411	case ConsumeOrExpelRight:
1412		return "No pane to the right"
1413	case ConsumeOrExpelUp:
1414		return "No pane above"
1415	case ConsumeOrExpelDown:
1416		return "No pane below"
1417	default:
1418		return "No pane in that direction"
1419	}
1420}
1421
1422func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) {
1423	if node == nil {
1424		return nil, ""
1425	}
1426
1427	// Determine if we need to go horizontal or vertical.
1428	isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1429	isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown
1430
1431	// Walk up the tree to find an ancestor split matching our direction axis.
1432	current := node
1433	for current.Parent != nil {
1434		parent := current.Parent
1435		if parent.IsSplit() {
1436			parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
1437			if parentIsHorizontal == isHorizontal {
1438				if isForward {
1439					if parent.Right() != nil && parent.Left() == current {
1440						return parent.Right(), ""
1441					}
1442				} else {
1443					if parent.Left() != nil && parent.Right() == current {
1444						return parent.Left(), ""
1445					}
1446				}
1447			}
1448		}
1449		current = parent
1450	}
1451
1452	return nil, directionNoSiblingMessage(direction)
1453}
1454
1455func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode {
1456	if node == nil || node.Parent == nil {
1457		return nil
1458	}
1459
1460	parent := node.Parent
1461	if !parent.IsSplit() {
1462		return nil
1463	}
1464
1465	axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1466	if axisHorizontal {
1467		if parent.SplitDir == entity.SplitHorizontal {
1468			return nil
1469		}
1470	} else {
1471		if parent.SplitDir == entity.SplitVertical {
1472			return nil
1473		}
1474	}
1475
1476	// Cross-axis fallback is only used to enable the leaf cycling behavior
1477	// (H -> V -> stack, and after expel: V -> H). It should never cause a leaf to
1478	// consume into a stacked/split sibling when pressing an unrelated axis key.
1479	var sibling *entity.PaneNode
1480	if parent.Left() == node {
1481		sibling = parent.Right()
1482	} else if parent.Right() == node {
1483		sibling = parent.Left()
1484	}
1485	if sibling == nil {
1486		return nil
1487	}
1488	if !node.IsLeaf() || !sibling.IsLeaf() {
1489		return nil
1490	}
1491	return sibling
1492}
1493
1494func cycleHorizontalToVerticalThenStack(
1495	ws *entity.Workspace,
1496	activeNode *entity.PaneNode,
1497	sibling *entity.PaneNode,
1498	direction ConsumeOrExpelDirection,
1499) (cycled bool, cycleAction string, err error) {
1500	if ws == nil {
1501		return false, "", fmt.Errorf("workspace is required")
1502	}
1503	if activeNode == nil || sibling == nil {
1504		return false, "", nil
1505	}
1506	if !activeNode.IsLeaf() || !sibling.IsLeaf() {
1507		return false, "", nil
1508	}
1509
1510	parent := activeNode.Parent
1511	if parent == nil || sibling.Parent != parent || !parent.IsSplit() {
1512		return false, "", nil
1513	}
1514
1515	switch parent.SplitDir {
1516	case entity.SplitHorizontal:
1517		// Horizontal -> Vertical: target sibling should be on top.
1518		parent.SplitDir = entity.SplitVertical
1519		if parent.Children[0] != sibling {
1520			parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1521		}
1522		ws.ActivePaneID = activeNode.Pane.ID
1523		return true, "vertical_split", nil
1524	case entity.SplitVertical:
1525		// Vertical -> Horizontal when pressing left/right (reverse of first step).
1526		// This is used after expel: stack -> vertical; then Ctrl+[ / Ctrl+] should turn it into left/right.
1527		if direction == ConsumeOrExpelRight && strings.Contains(parent.ID, consumeOrExpelExpelCycleMarker) {
1528			parent.SplitDir = entity.SplitHorizontal
1529			// Active should become the right child.
1530			if parent.Children[1] != activeNode {
1531				parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1532			}
1533			ws.ActivePaneID = activeNode.Pane.ID
1534			return true, "horizontal_split", nil
1535		}
1536
1537		// Otherwise, vertical -> stack.
1538		parent.SplitDir = entity.SplitNone
1539		parent.SplitRatio = 0
1540		parent.IsStacked = true
1541		parent.Pane = nil
1542		if parent.Children[0] == activeNode {
1543			parent.ActiveStackIndex = 0
1544		} else if parent.Children[1] == activeNode {
1545			parent.ActiveStackIndex = 1
1546		} else {
1547			parent.ActiveStackIndex = 0
1548		}
1549		ws.ActivePaneID = activeNode.Pane.ID
1550		return true, "stack", nil
1551	default:
1552		return false, "", nil
1553	}
1554}
1555
1556func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1557	if ws == nil {
1558		return nil, fmt.Errorf("workspace is required")
1559	}
1560	if leaf == nil {
1561		return nil, fmt.Errorf("leaf is required")
1562	}
1563	if !leaf.IsLeaf() {
1564		return nil, fmt.Errorf("can only detach leaf node")
1565	}
1566
1567	parent := leaf.Parent
1568	if parent == nil {
1569		return nil, fmt.Errorf("cannot detach root leaf")
1570	}
1571	if !parent.IsSplit() {
1572		return nil, fmt.Errorf("leaf parent is not a split")
1573	}
1574
1575	var sibling *entity.PaneNode
1576	for _, child := range parent.Children {
1577		if child != leaf {
1578			sibling = child
1579			break
1580		}
1581	}
1582	if sibling == nil {
1583		return nil, fmt.Errorf("no sibling found for leaf")
1584	}
1585
1586	grandparent := parent.Parent
1587	if grandparent == nil {
1588		ws.Root = sibling
1589		sibling.Parent = nil
1590	} else {
1591		for i, child := range grandparent.Children {
1592			if child == parent {
1593				grandparent.Children[i] = sibling
1594				break
1595			}
1596		}
1597		sibling.Parent = grandparent
1598	}
1599
1600	leaf.Parent = nil
1601	return leaf, nil
1602}
1603
1604func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode {
1605	if leaf == nil || !leaf.IsLeaf() {
1606		return leaf
1607	}
1608
1609	originalPane := leaf.Pane
1610	originalChildNode := &entity.PaneNode{
1611		ID:     leaf.ID + "_0",
1612		Pane:   originalPane,
1613		Parent: leaf,
1614	}
1615
1616	leaf.Pane = nil
1617	leaf.IsStacked = true
1618	leaf.ActiveStackIndex = 0
1619	leaf.Children = []*entity.PaneNode{originalChildNode}
1620
1621	return leaf
1622}
1623
1624func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1625	if stackNode == nil {
1626		return nil, fmt.Errorf("stack node is required")
1627	}
1628	if !stackNode.IsStacked {
1629		return nil, fmt.Errorf("node is not a stack")
1630	}
1631	if leaf == nil || leaf.Pane == nil {
1632		return nil, fmt.Errorf("leaf is required")
1633	}
1634
1635	idx := -1
1636	for i, child := range stackNode.Children {
1637		if child == leaf {
1638			idx = i
1639			break
1640		}
1641	}
1642	if idx < 0 {
1643		return nil, fmt.Errorf("pane not found in stack")
1644	}
1645
1646	stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
1647	if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1648		stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1649	}
1650	if stackNode.ActiveStackIndex < 0 {
1651		stackNode.ActiveStackIndex = 0
1652	}
1653
1654	leaf.Parent = nil
1655	return leaf, nil
1656}
1657
1658func dissolveStackIntoLeaf(stackNode *entity.PaneNode) {
1659	if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 {
1660		return
1661	}
1662
1663	remaining := stackNode.Children[0]
1664	stackNode.Pane = remaining.Pane
1665	stackNode.Children = nil
1666	stackNode.IsStacked = false
1667	stackNode.ActiveStackIndex = 0
1668}
1669
1670func splitExistingNode(
1671	ws *entity.Workspace,
1672	targetNode *entity.PaneNode,
1673	newSibling *entity.PaneNode,
1674	direction ConsumeOrExpelDirection,
1675	idGen IDGenerator,
1676) error {
1677	if ws == nil {
1678		return fmt.Errorf("workspace is required")
1679	}
1680	if targetNode == nil {
1681		return fmt.Errorf("target node is required")
1682	}
1683	if newSibling == nil {
1684		return fmt.Errorf("new sibling node is required")
1685	}
1686	if idGen == nil {
1687		return fmt.Errorf("id generator is required")
1688	}
1689
1690	var splitDir entity.SplitDirection
1691	switch direction {
1692	case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1693		splitDir = entity.SplitHorizontal
1694	case ConsumeOrExpelUp, ConsumeOrExpelDown:
1695		splitDir = entity.SplitVertical
1696	default:
1697		return fmt.Errorf("invalid direction: %s", direction)
1698	}
1699
1700	parentNode := &entity.PaneNode{
1701		ID:         idGen(),
1702		SplitDir:   splitDir,
1703		SplitRatio: 0.5,
1704		Children:   make([]*entity.PaneNode, 2),
1705	}
1706
1707	switch direction {
1708	case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1709		parentNode.Children[0] = newSibling
1710		parentNode.Children[1] = targetNode
1711	case ConsumeOrExpelRight, ConsumeOrExpelDown:
1712		parentNode.Children[0] = targetNode
1713		parentNode.Children[1] = newSibling
1714	}
1715
1716	newSibling.Parent = parentNode
1717	oldParent := targetNode.Parent
1718	targetNode.Parent = parentNode
1719
1720	if oldParent == nil {
1721		ws.Root = parentNode
1722	} else {
1723		for i, child := range oldParent.Children {
1724			if child == targetNode {
1725				oldParent.Children[i] = parentNode
1726				break
1727			}
1728		}
1729		parentNode.Parent = oldParent
1730	}
1731
1732	return nil
1733}
1734
1735func splitExistingNodeWithMarker(
1736	ws *entity.Workspace,
1737	targetNode *entity.PaneNode,
1738	newSibling *entity.PaneNode,
1739	direction ConsumeOrExpelDirection,
1740	idGen IDGenerator,
1741	marker string,
1742) error {
1743	if ws == nil {
1744		return fmt.Errorf("workspace is required")
1745	}
1746	if targetNode == nil {
1747		return fmt.Errorf("target node is required")
1748	}
1749	if newSibling == nil {
1750		return fmt.Errorf("new sibling node is required")
1751	}
1752	if idGen == nil {
1753		return fmt.Errorf("id generator is required")
1754	}
1755
1756	var splitDir entity.SplitDirection
1757	switch direction {
1758	case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1759		splitDir = entity.SplitHorizontal
1760	case ConsumeOrExpelUp, ConsumeOrExpelDown:
1761		splitDir = entity.SplitVertical
1762	default:
1763		return fmt.Errorf("invalid direction: %s", direction)
1764	}
1765
1766	parentNode := &entity.PaneNode{
1767		ID:         idGen() + marker,
1768		SplitDir:   splitDir,
1769		SplitRatio: 0.5,
1770		Children:   make([]*entity.PaneNode, 2),
1771	}
1772
1773	switch direction {
1774	case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1775		parentNode.Children[0] = newSibling
1776		parentNode.Children[1] = targetNode
1777	case ConsumeOrExpelRight, ConsumeOrExpelDown:
1778		parentNode.Children[0] = targetNode
1779		parentNode.Children[1] = newSibling
1780	}
1781
1782	newSibling.Parent = parentNode
1783	oldParent := targetNode.Parent
1784	targetNode.Parent = parentNode
1785
1786	if oldParent == nil {
1787		ws.Root = parentNode
1788	} else {
1789		for i, child := range oldParent.Children {
1790			if child == targetNode {
1791				oldParent.Children[i] = parentNode
1792				break
1793			}
1794		}
1795		parentNode.Parent = oldParent
1796	}
1797
1798	return nil
1799}
1800
1801// GetAllPanes returns all leaf panes in a workspace.
1802//
1803//nolint:revive // receiver required for interface consistency
1804func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane {
1805	if ws == nil || ws.Root == nil {
1806		return nil
1807	}
1808	return ws.AllPanes()
1809}
1810
1811// CountPanes returns the number of panes in a workspace.
1812//
1813//nolint:revive // receiver required for interface consistency
1814func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int {
1815	if ws == nil {
1816		return 0
1817	}
1818	return ws.PaneCount()
1819}

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