v0.33.0 62a09f5

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

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