v0.24.0 a261e52

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.
458func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
459	log := logging.FromContext(ctx)
460	if uc == nil {
461		return nil, fmt.Errorf("manage panes use case is nil")
462	}
463	log.Debug().Str("pane_id", paneNode.ID).Msg("closing pane")
464
465	if ws == nil {
466		return nil, fmt.Errorf("workspace is required")
467	}
468	if paneNode == nil {
469		return nil, fmt.Errorf("pane node is required")
470	}
471	if !paneNode.IsLeaf() {
472		return nil, fmt.Errorf("cannot close non-leaf node")
473	}
474
475	parent := paneNode.Parent
476
477	// If no parent, this is the root (last pane)
478	if parent == nil {
479		log.Info().Msg("closing last pane in workspace")
480		ws.Root = nil
481		ws.ActivePaneID = ""
482		return nil, nil
483	}
484
485	// Find sibling
486	var sibling *entity.PaneNode
487	for _, child := range parent.Children {
488		if child != paneNode {
489			sibling = child
490			break
491		}
492	}
493
494	if sibling == nil {
495		return nil, fmt.Errorf("no sibling found for pane")
496	}
497
498	grandparent := parent.Parent
499
500	// Promote sibling to parent's position
501	if grandparent == nil {
502		// Parent was root, sibling becomes new root
503		ws.Root = sibling
504		sibling.Parent = nil
505	} else {
506		// Replace parent with sibling in grandparent
507		for i, child := range grandparent.Children {
508			if child == parent {
509				grandparent.Children[i] = sibling
510				break
511			}
512		}
513		sibling.Parent = grandparent
514	}
515
516	// Update active pane if needed
517	if ws.ActivePaneID == paneNode.Pane.ID {
518		// Find a new active pane from the sibling subtree
519		var newActive *entity.Pane
520		sibling.Walk(func(node *entity.PaneNode) bool {
521			if node.IsLeaf() && node.Pane != nil {
522				newActive = node.Pane
523				return false // Stop walking
524			}
525			return true
526		})
527		if newActive != nil {
528			ws.ActivePaneID = newActive.ID
529		} else {
530			ws.ActivePaneID = ""
531		}
532	}
533
534	log.Info().
535		Str("closed_pane_id", paneNode.ID).
536		Str("promoted_sibling_id", sibling.ID).
537		Msg("pane closed, sibling promoted")
538
539	return sibling, nil
540}
541
542// Focus sets the active pane in the workspace.
543// Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.
544func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
545	log := logging.FromContext(ctx)
546	log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
547
548	_, err := uc.ApplyFocusChange(ctx, ws, paneID)
549	return err
550}
551
552// NavigateFocus moves focus to an adjacent pane.
553// Returns the newly focused pane, or nil if navigation not possible.
554func (uc *ManagePanesUseCase) NavigateFocus(
555	ctx context.Context,
556	ws *entity.Workspace,
557	direction NavigateDirection,
558) (*entity.PaneNode, error) {
559	log := logging.FromContext(ctx)
560	log.Debug().Str("direction", string(direction)).Msg("navigating focus")
561
562	if ws == nil {
563		return nil, fmt.Errorf("workspace is required")
564	}
565
566	activeNode := ws.ActivePane()
567	if activeNode == nil {
568		return nil, fmt.Errorf("no active pane")
569	}
570
571	// Try structural navigation first
572	targetNode := uc.findAdjacentPane(activeNode, direction)
573
574	if targetNode == nil {
575		log.Debug().Msg("no adjacent pane found")
576		return nil, nil
577	}
578
579	ws.ActivePaneID = targetNode.Pane.ID
580
581	log.Info().
582		Str("from", activeNode.ID).
583		Str("to", targetNode.ID).
584		Str("direction", string(direction)).
585		Msg("focus navigated")
586
587	return targetNode, nil
588}
589
590// GeometricNavigationInput contains data for geometric focus navigation.
591type GeometricNavigationInput struct {
592	ActivePaneID entity.PaneID
593	PaneRects    []entity.PaneRect // All visible panes with their positions
594	Direction    NavigateDirection
595}
596
597// GeometricNavigationOutput contains the result.
598type GeometricNavigationOutput struct {
599	TargetPaneID entity.PaneID
600	Found        bool
601}
602
603// ApplyFocusChange sets the active pane and updates the stack index if the target pane is in a stack.
604// This is the canonical way to change focus to a pane, ensuring stack state is kept in sync.
605func (uc *ManagePanesUseCase) ApplyFocusChange(
606	ctx context.Context,
607	ws *entity.Workspace,
608	targetPaneID entity.PaneID,
609) (*entity.PaneNode, error) {
610	log := logging.FromContext(ctx)
611	if uc == nil {
612		return nil, fmt.Errorf("manage panes use case is nil")
613	}
614	if ws == nil {
615		return nil, fmt.Errorf("workspace is required")
616	}
617
618	targetNode := ws.FindPane(targetPaneID)
619	if targetNode == nil {
620		return nil, fmt.Errorf("pane not found: %s", targetPaneID)
621	}
622
623	oldActive := ws.ActivePaneID
624	ws.ActivePaneID = targetPaneID
625
626	// Update stack index if target is in a stack
627	if targetNode.Parent != nil && targetNode.Parent.IsStacked {
628		oldIndex := targetNode.Parent.ActiveStackIndex
629		for i, child := range targetNode.Parent.Children {
630			if child == targetNode {
631				targetNode.Parent.ActiveStackIndex = i
632				log.Debug().
633					Str("target_pane_id", string(targetPaneID)).
634					Int("old_stack_index", oldIndex).
635					Int("new_stack_index", i).
636					Int("num_children", len(targetNode.Parent.Children)).
637					Msg("updated stack index for focus change")
638				break
639			}
640		}
641	}
642
643	log.Info().
644		Str("from", string(oldActive)).
645		Str("to", string(targetPaneID)).
646		Msg("focus changed")
647
648	return targetNode, nil
649}
650
651// NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
652// Algorithm:
653//  1. Get active pane rectangle
654//  2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
655//  3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down)
656//  4. Score by: overlap_penalty + primary_distance * 1000 + perpendicular_distance
657//  5. Return lowest scoring candidate
658func (uc *ManagePanesUseCase) NavigateFocusGeometric(
659	ctx context.Context,
660	input GeometricNavigationInput,
661) (*GeometricNavigationOutput, error) {
662	log := logging.FromContext(ctx)
663	if uc == nil {
664		return nil, fmt.Errorf("manage panes use case is nil")
665	}
666	log.Debug().
667		Str("direction", string(input.Direction)).
668		Str("active", string(input.ActivePaneID)).
669		Int("candidates", len(input.PaneRects)).
670		Msg("geometric navigation")
671
672	// Find active pane rect
673	var activeRect *entity.PaneRect
674	for i := range input.PaneRects {
675		if input.PaneRects[i].PaneID == input.ActivePaneID {
676			activeRect = &input.PaneRects[i]
677			break
678		}
679	}
680	if activeRect == nil {
681		log.Debug().Msg("active pane rect not found")
682		return &GeometricNavigationOutput{Found: false}, nil
683	}
684
685	candidates := scoreNavigationCandidates(*activeRect, input.PaneRects, input.Direction)
686
687	if len(candidates) == 0 {
688		log.Debug().Msg("no candidates in direction")
689		return &GeometricNavigationOutput{Found: false}, nil
690	}
691
692	// Sort by score (lowest first)
693	sort.Slice(candidates, func(i, j int) bool {
694		return candidates[i].score < candidates[j].score
695	})
696
697	log.Debug().
698		Str("target", string(candidates[0].paneID)).
699		Int("score", candidates[0].score).
700		Msg("geometric navigation found target")
701
702	return &GeometricNavigationOutput{
703		TargetPaneID: candidates[0].paneID,
704		Found:        true,
705	}, nil
706}
707
708// navCandidate represents a pane candidate for navigation with its score.
709type navCandidate struct {
710	paneID entity.PaneID
711	score  int
712}
713
714// scoreNavigationCandidates scores all panes in the given direction from activeRect.
715// Panes with perpendicular overlap are heavily preferred (same row for left/right, same column for up/down).
716func scoreNavigationCandidates(
717	activeRect entity.PaneRect,
718	paneRects []entity.PaneRect,
719	direction NavigateDirection,
720) []navCandidate {
721	// Large penalty for panes without perpendicular overlap.
722	// This ensures panes at the same level are always preferred.
723	const noOverlapPenalty = 10_000_000
724
725	acx, acy := activeRect.Center()
726	var candidates []navCandidate
727
728	for _, rect := range paneRects {
729		if rect.PaneID == activeRect.PaneID {
730			continue
731		}
732
733		cx, cy := rect.Center()
734		dx := cx - acx
735		dy := cy - acy
736
737		inDirection, primaryDist, perpDist, hasOverlap := evalDirection(activeRect, rect, dx, dy, direction)
738
739		if inDirection {
740			score := primaryDist*1000 + perpDist
741			if !hasOverlap {
742				score += noOverlapPenalty
743			}
744			candidates = append(candidates, navCandidate{rect.PaneID, score})
745		}
746	}
747
748	return candidates
749}
750
751// evalDirection determines if a candidate rect is in the given direction from activeRect.
752// Returns: inDirection, primaryDist, perpDist, hasOverlap
753func evalDirection(
754	activeRect, rect entity.PaneRect,
755	dx, dy int,
756	direction NavigateDirection,
757) (inDirection bool, primaryDist, perpDist int, hasOverlap bool) {
758	switch direction {
759	case NavLeft:
760		return dx < 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
761	case NavRight:
762		return dx > 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
763	case NavUp:
764		return dy < 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
765	case NavDown:
766		return dy > 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
767	default:
768		return false, 0, 0, false
769	}
770}
771
772// abs returns the absolute value of an integer.
773func abs(x int) int {
774	if x < 0 {
775		return -x
776	}
777	return x
778}
779
780// findAdjacentPane finds the adjacent pane in the given direction using tree structure.
781func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {
782	if node == nil {
783		return nil
784	}
785
786	// Determine if we need to go horizontal or vertical
787	isHorizontal := direction == NavLeft || direction == NavRight
788	isForward := direction == NavRight || direction == NavDown
789
790	// Walk up the tree to find an ancestor with the right split type
791	current := node
792	for current.Parent != nil {
793		parent := current.Parent
794
795		// Check if this parent's split orientation matches our direction
796		parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
797
798		if parentIsHorizontal == isHorizontal {
799			// Find which child we came from
800			childIndex := -1
801			for i, child := range parent.Children {
802				if child == current {
803					childIndex = i
804					break
805				}
806			}
807
808			// Check if we can move in the desired direction
809			var targetIndex int
810			if isForward {
811				targetIndex = childIndex + 1
812			} else {
813				targetIndex = childIndex - 1
814			}
815
816			if targetIndex >= 0 && targetIndex < len(parent.Children) {
817				// Found a valid sibling, descend into it to find a leaf
818				return uc.findLeafInDirection(parent.Children[targetIndex], !isForward)
819			}
820		}
821
822		current = parent
823	}
824
825	return nil
826}
827
828// findLeafInDirection descends into a node to find a leaf pane.
829// If fromEnd is true, prefers the last child; otherwise prefers the first.
830func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd bool) *entity.PaneNode {
831	if node == nil {
832		return nil
833	}
834
835	// If this is a leaf, return it
836	if node.IsLeaf() {
837		return node
838	}
839
840	// If stacked, return active pane
841	if node.IsStacked && len(node.Children) > 0 {
842		activeIdx := node.ActiveStackIndex
843		if activeIdx >= 0 && activeIdx < len(node.Children) {
844			return uc.findLeafInDirection(node.Children[activeIdx], fromEnd)
845		}
846		return uc.findLeafInDirection(node.Children[0], fromEnd)
847	}
848
849	// Descend into children
850	if len(node.Children) > 0 {
851		if fromEnd {
852			return uc.findLeafInDirection(node.Children[len(node.Children)-1], fromEnd)
853		}
854		return uc.findLeafInDirection(node.Children[0], fromEnd)
855	}
856
857	return nil
858}
859
860// CreateStackOutput contains the result of creating a stack from a pane.
861type CreateStackOutput struct {
862	StackNode    *entity.PaneNode // The stack container node
863	OriginalNode *entity.PaneNode // The original pane, now a child of the stack
864	NewPaneNode  *entity.PaneNode // The new pane added to the stack
865	NewPane      *entity.Pane     // The new pane entity
866}
867
868// CreateStack converts a pane into a stacked container and adds a new pane.
869// The original pane becomes the first child, a new pane becomes the second child.
870// Returns the stack node and the new pane node for UI operations.
871//
872// Domain tree transformation:
873//
874//	Before: parent -> targetNode (leaf with pane)
875//	After:  parent -> targetNode (stack) -> [originalChild (leaf), newChild (leaf)]
876//
877// The targetNode keeps its ID but becomes a stack container.
878// This allows the UI layer to update widget mappings incrementally.
879func (uc *ManagePanesUseCase) CreateStack(
880	ctx context.Context,
881	ws *entity.Workspace,
882	paneNode *entity.PaneNode,
883) (*CreateStackOutput, error) {
884	log := logging.FromContext(ctx)
885	log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
886
887	if ws == nil {
888		return nil, fmt.Errorf("workspace is required")
889	}
890	if paneNode == nil {
891		return nil, fmt.Errorf("pane node is required")
892	}
893	if !paneNode.IsLeaf() {
894		return nil, fmt.Errorf("can only create stack from leaf pane")
895	}
896	if paneNode.IsStacked {
897		return nil, fmt.Errorf("pane is already stacked")
898	}
899
900	// Save original pane reference
901	originalPane := paneNode.Pane
902	originalPaneID := paneNode.ID
903
904	// Create child node for the original pane
905	originalChildNode := &entity.PaneNode{
906		ID:     originalPaneID + "_0",
907		Pane:   originalPane,
908		Parent: paneNode,
909	}
910
911	// Create new pane
912	newPaneID := entity.PaneID(uc.idGenerator())
913	newPane := entity.NewPane(newPaneID)
914
915	// Create child node for the new pane
916	newChildNode := &entity.PaneNode{
917		ID:     string(newPaneID),
918		Pane:   newPane,
919		Parent: paneNode,
920	}
921
922	// Convert targetNode to stack container
923	paneNode.Pane = nil
924	paneNode.IsStacked = true
925	paneNode.ActiveStackIndex = 1 // New pane is active
926	paneNode.Children = []*entity.PaneNode{originalChildNode, newChildNode}
927
928	// Update workspace active pane
929	ws.ActivePaneID = newPaneID
930
931	log.Info().
932		Str("stack_id", paneNode.ID).
933		Str("original_pane", string(originalPane.ID)).
934		Str("new_pane", string(newPaneID)).
935		Msg("stack created")
936
937	return &CreateStackOutput{
938		StackNode:    paneNode,
939		OriginalNode: originalChildNode,
940		NewPaneNode:  newChildNode,
941		NewPane:      newPane,
942	}, nil
943}
944
945// AddToStackOutput contains the result of adding a pane to a stack.
946type AddToStackOutput struct {
947	NewPaneNode *entity.PaneNode // The new pane node added to the stack
948	StackIndex  int              // Index of the new pane in the stack
949}
950
951// AddToStack adds a new pane to an existing stack.
952// Optionally pass a pre-created pane, or nil to create a new one.
953func (uc *ManagePanesUseCase) AddToStack(
954	ctx context.Context,
955	ws *entity.Workspace,
956	stackNode *entity.PaneNode,
957	pane *entity.Pane,
958) (*AddToStackOutput, error) {
959	log := logging.FromContext(ctx)
960
961	if ws == nil {
962		return nil, fmt.Errorf("workspace is required")
963	}
964	if stackNode == nil {
965		return nil, fmt.Errorf("stack node is required")
966	}
967	if !stackNode.IsStacked {
968		return nil, fmt.Errorf("node is not a stack")
969	}
970
971	// Create pane if not provided
972	if pane == nil {
973		paneID := entity.PaneID(uc.idGenerator())
974		pane = entity.NewPane(paneID)
975	}
976
977	log.Debug().
978		Str("stack_id", stackNode.ID).
979		Str("pane_id", string(pane.ID)).
980		Msg("adding pane to stack")
981
982	// Create node for the new pane
983	newNode := &entity.PaneNode{
984		ID:     string(pane.ID),
985		Pane:   pane,
986		Parent: stackNode,
987	}
988
989	// Insert right after current active pane (not at end)
990	currentActiveIndex := stackNode.ActiveStackIndex
991	if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
992		// Clamp to valid range - default to end-1 if invalid
993		currentActiveIndex = len(stackNode.Children) - 1
994		if currentActiveIndex < 0 {
995			currentActiveIndex = -1 // Will result in insertIndex = 0
996		}
997	}
998	insertIndex := currentActiveIndex + 1
999
1000	// Slice insertion at correct position
1001	stackNode.Children = append(stackNode.Children, nil)
1002	copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1003	stackNode.Children[insertIndex] = newNode
1004	newIndex := insertIndex
1005	stackNode.ActiveStackIndex = newIndex
1006
1007	// Update workspace active pane
1008	ws.ActivePaneID = pane.ID
1009
1010	log.Info().
1011		Str("stack_id", stackNode.ID).
1012		Str("pane_id", string(pane.ID)).
1013		Int("stack_size", len(stackNode.Children)).
1014		Msg("pane added to stack")
1015
1016	return &AddToStackOutput{
1017		NewPaneNode: newNode,
1018		StackIndex:  newIndex,
1019	}, nil
1020}
1021
1022// NavigateStack cycles through stacked panes.
1023// direction: NavUp for previous, NavDown for next.
1024//
1025//nolint:revive // receiver required for interface consistency
1026func (uc *ManagePanesUseCase) NavigateStack(
1027	ctx context.Context,
1028	stackNode *entity.PaneNode,
1029	direction NavigateDirection,
1030) (*entity.Pane, error) {
1031	log := logging.FromContext(ctx)
1032
1033	if stackNode == nil {
1034		return nil, fmt.Errorf("stack node is required")
1035	}
1036
1037	log.Debug().
1038		Str("stack_id", stackNode.ID).
1039		Str("direction", string(direction)).
1040		Msg("navigating stack")
1041	if !stackNode.IsStacked {
1042		return nil, fmt.Errorf("node is not a stack")
1043	}
1044	if len(stackNode.Children) == 0 {
1045		return nil, fmt.Errorf("stack is empty")
1046	}
1047
1048	count := len(stackNode.Children)
1049	current := stackNode.ActiveStackIndex
1050
1051	var newIndex int
1052	switch direction {
1053	case NavUp:
1054		newIndex = (current - 1 + count) % count
1055	case NavDown:
1056		newIndex = (current + 1) % count
1057	default:
1058		return nil, fmt.Errorf("invalid stack navigation direction: %s (use up/down)", direction)
1059	}
1060
1061	stackNode.ActiveStackIndex = newIndex
1062
1063	activeChild := stackNode.Children[newIndex]
1064	var activePaneNode *entity.PaneNode
1065	if activeChild.IsLeaf() {
1066		activePaneNode = activeChild
1067	} else {
1068		// Find first leaf in the child
1069		activeChild.Walk(func(n *entity.PaneNode) bool {
1070			if n.IsLeaf() {
1071				activePaneNode = n
1072				return false
1073			}
1074			return true
1075		})
1076	}
1077
1078	if activePaneNode == nil || activePaneNode.Pane == nil {
1079		return nil, fmt.Errorf("no pane found in stack at index %d", newIndex)
1080	}
1081
1082	log.Info().
1083		Str("stack_id", stackNode.ID).
1084		Int("from_index", current).
1085		Int("to_index", newIndex).
1086		Str("pane_id", string(activePaneNode.Pane.ID)).
1087		Msg("stack navigated")
1088
1089	return activePaneNode.Pane, nil
1090}
1091
1092// RemoveFromStack removes a pane from a stack.
1093// If only one pane remains, the stack is dissolved.
1094//
1095//nolint:revive // receiver required for interface consistency
1096func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error {
1097	log := logging.FromContext(ctx)
1098
1099	if stackNode == nil {
1100		return fmt.Errorf("stack node is required")
1101	}
1102
1103	log.Debug().
1104		Str("stack_id", stackNode.ID).
1105		Str("pane_id", string(paneID)).
1106		Msg("removing pane from stack")
1107	if !stackNode.IsStacked {
1108		return fmt.Errorf("node is not a stack")
1109	}
1110
1111	// Find and remove the pane
1112	var removed bool
1113	newChildren := make([]*entity.PaneNode, 0, len(stackNode.Children))
1114	for _, child := range stackNode.Children {
1115		if child.Pane != nil && child.Pane.ID == paneID {
1116			removed = true
1117			continue
1118		}
1119		newChildren = append(newChildren, child)
1120	}
1121
1122	if !removed {
1123		return fmt.Errorf("pane not found in stack: %s", paneID)
1124	}
1125
1126	stackNode.Children = newChildren
1127
1128	// Adjust active index
1129	if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1130		stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1131	}
1132	if stackNode.ActiveStackIndex < 0 {
1133		stackNode.ActiveStackIndex = 0
1134	}
1135
1136	log.Info().
1137		Str("stack_id", stackNode.ID).
1138		Str("pane_id", string(paneID)).
1139		Int("remaining", len(stackNode.Children)).
1140		Msg("pane removed from stack")
1141
1142	return nil
1143}
1144
1145func (uc *ManagePanesUseCase) ConsumeOrExpel(
1146	ctx context.Context,
1147	ws *entity.Workspace,
1148	activeNode *entity.PaneNode,
1149	direction ConsumeOrExpelDirection,
1150) (*ConsumeOrExpelResult, error) {
1151	if uc == nil {
1152		return nil, fmt.Errorf("manage panes use case is nil")
1153	}
1154	if ws == nil {
1155		return nil, fmt.Errorf("workspace is required")
1156	}
1157	if ws.Root == nil {
1158		return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1159	}
1160	if activeNode == nil {
1161		return nil, fmt.Errorf("active pane node is required")
1162	}
1163	if activeNode.Pane == nil {
1164		return nil, fmt.Errorf("active pane node has no pane")
1165	}
1166
1167	if ws.PaneCount() <= 1 {
1168		return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1169	}
1170
1171	// Expel if active pane is inside a stack.
1172	if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1173		return uc.expelFromStack(ctx, ws, activeNode, direction)
1174	}
1175
1176	// Consume if active pane is not inside a stack.
1177	return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction)
1178}
1179
1180func (*ManagePanesUseCase) consumeIntoSiblingStack(
1181	ctx context.Context,
1182	ws *entity.Workspace,
1183	activeNode *entity.PaneNode,
1184	direction ConsumeOrExpelDirection,
1185) (*ConsumeOrExpelResult, error) {
1186	log := logging.FromContext(ctx)
1187
1188	sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction)
1189	if sibling == nil {
1190		sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction)
1191	}
1192	if sibling == nil {
1193		return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil
1194	}
1195
1196	// Left/right consume cycles layout for immediate split siblings:
1197	//  1) horizontal split -> vertical split (target on top)
1198	//  2) vertical split -> stack (top then bottom)
1199	if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1200		cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction)
1201		if err != nil {
1202			return nil, err
1203		}
1204		if cycled {
1205			switch cycleAction {
1206			case "vertical_split":
1207				log.Info().
1208					Str("pane_id", activeNode.ID).
1209					Str("sibling_id", sibling.ID).
1210					Msg("pane moved below sibling")
1211			case "stack":
1212				log.Info().
1213					Str("pane_id", activeNode.ID).
1214					Str("sibling_id", sibling.ID).
1215					Msg("split converted to stack")
1216			}
1217			return &ConsumeOrExpelResult{Action: "consumed"}, nil
1218		}
1219	}
1220
1221	moved, err := detachLeafByPromotingSibling(ws, activeNode)
1222	if err != nil {
1223		return nil, err
1224	}
1225	moved.Parent = nil
1226
1227	stackNode := sibling
1228	if stackNode.IsLeaf() {
1229		stackNode = convertLeafToStackContainer(stackNode)
1230	}
1231	if !stackNode.IsStacked {
1232		return nil, fmt.Errorf("target sibling is not stackable")
1233	}
1234
1235	// Insert consumed pane at the bottom/end.
1236	moved.Parent = stackNode
1237	stackNode.Children = append(stackNode.Children, moved)
1238	stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1239	ws.ActivePaneID = moved.Pane.ID
1240
1241	log.Info().
1242		Str("pane_id", moved.ID).
1243		Str("stack_id", stackNode.ID).
1244		Int("stack_size", len(stackNode.Children)).
1245		Msg("pane consumed into sibling stack")
1246
1247	return &ConsumeOrExpelResult{Action: "consumed"}, nil
1248}
1249
1250func (uc *ManagePanesUseCase) expelFromStack(
1251	ctx context.Context,
1252	ws *entity.Workspace,
1253	activeNode *entity.PaneNode,
1254	direction ConsumeOrExpelDirection,
1255) (*ConsumeOrExpelResult, error) {
1256	log := logging.FromContext(ctx)
1257
1258	stackNode := activeNode.Parent
1259	if stackNode == nil || !stackNode.IsStacked {
1260		return &ConsumeOrExpelResult{Action: "none"}, nil
1261	}
1262
1263	expelled, err := removeLeafFromStack(stackNode, activeNode)
1264	if err != nil {
1265		return nil, err
1266	}
1267	expelled.Parent = nil
1268
1269	// If only one pane remains, dissolve stack into a leaf node.
1270	if len(stackNode.Children) == 1 {
1271		dissolveStackIntoLeaf(stackNode)
1272	}
1273
1274	// Split around the remaining container (stackNode) with expelled pane.
1275	// For left/right, expel cycles stack -> vertical split first:
1276	// - left: expelled pane above
1277	// - right: expelled pane below
1278	splitDirection := direction
1279	switch direction {
1280	case ConsumeOrExpelLeft:
1281		splitDirection = ConsumeOrExpelUp
1282	case ConsumeOrExpelRight:
1283		splitDirection = ConsumeOrExpelDown
1284	}
1285
1286	if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1287		err := splitExistingNodeWithMarker(
1288			ws,
1289			stackNode,
1290			expelled,
1291			splitDirection,
1292			uc.idGenerator,
1293			consumeOrExpelExpelCycleMarker,
1294		)
1295		if err != nil {
1296			return nil, err
1297		}
1298	} else {
1299		if err := splitExistingNode(ws, stackNode, expelled, splitDirection, uc.idGenerator); err != nil {
1300			return nil, err
1301		}
1302	}
1303	ws.ActivePaneID = expelled.Pane.ID
1304
1305	log.Info().
1306		Str("pane_id", expelled.ID).
1307		Str("direction", string(direction)).
1308		Msg("pane expelled from stack")
1309
1310	return &ConsumeOrExpelResult{Action: "expelled"}, nil
1311}
1312
1313func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string {
1314	switch direction {
1315	case ConsumeOrExpelLeft:
1316		return "No pane to the left"
1317	case ConsumeOrExpelRight:
1318		return "No pane to the right"
1319	case ConsumeOrExpelUp:
1320		return "No pane above"
1321	case ConsumeOrExpelDown:
1322		return "No pane below"
1323	default:
1324		return "No pane in that direction"
1325	}
1326}
1327
1328func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) {
1329	if node == nil {
1330		return nil, ""
1331	}
1332
1333	// Determine if we need to go horizontal or vertical.
1334	isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1335	isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown
1336
1337	// Walk up the tree to find an ancestor split matching our direction axis.
1338	current := node
1339	for current.Parent != nil {
1340		parent := current.Parent
1341		if parent.IsSplit() {
1342			parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
1343			if parentIsHorizontal == isHorizontal {
1344				if isForward {
1345					if parent.Right() != nil && parent.Left() == current {
1346						return parent.Right(), ""
1347					}
1348				} else {
1349					if parent.Left() != nil && parent.Right() == current {
1350						return parent.Left(), ""
1351					}
1352				}
1353			}
1354		}
1355		current = parent
1356	}
1357
1358	return nil, directionNoSiblingMessage(direction)
1359}
1360
1361func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode {
1362	if node == nil || node.Parent == nil {
1363		return nil
1364	}
1365
1366	parent := node.Parent
1367	if !parent.IsSplit() {
1368		return nil
1369	}
1370
1371	axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1372	if axisHorizontal {
1373		if parent.SplitDir == entity.SplitHorizontal {
1374			return nil
1375		}
1376	} else {
1377		if parent.SplitDir == entity.SplitVertical {
1378			return nil
1379		}
1380	}
1381
1382	// Cross-axis fallback is only used to enable the leaf cycling behavior
1383	// (H -> V -> stack, and after expel: V -> H). It should never cause a leaf to
1384	// consume into a stacked/split sibling when pressing an unrelated axis key.
1385	var sibling *entity.PaneNode
1386	if parent.Left() == node {
1387		sibling = parent.Right()
1388	} else if parent.Right() == node {
1389		sibling = parent.Left()
1390	}
1391	if sibling == nil {
1392		return nil
1393	}
1394	if !node.IsLeaf() || !sibling.IsLeaf() {
1395		return nil
1396	}
1397	return sibling
1398}
1399
1400func cycleHorizontalToVerticalThenStack(
1401	ws *entity.Workspace,
1402	activeNode *entity.PaneNode,
1403	sibling *entity.PaneNode,
1404	direction ConsumeOrExpelDirection,
1405) (cycled bool, cycleAction string, err error) {
1406	if ws == nil {
1407		return false, "", fmt.Errorf("workspace is required")
1408	}
1409	if activeNode == nil || sibling == nil {
1410		return false, "", nil
1411	}
1412	if !activeNode.IsLeaf() || !sibling.IsLeaf() {
1413		return false, "", nil
1414	}
1415
1416	parent := activeNode.Parent
1417	if parent == nil || sibling.Parent != parent || !parent.IsSplit() {
1418		return false, "", nil
1419	}
1420
1421	switch parent.SplitDir {
1422	case entity.SplitHorizontal:
1423		// Horizontal -> Vertical: target sibling should be on top.
1424		parent.SplitDir = entity.SplitVertical
1425		if parent.Children[0] != sibling {
1426			parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1427		}
1428		ws.ActivePaneID = activeNode.Pane.ID
1429		return true, "vertical_split", nil
1430	case entity.SplitVertical:
1431		// Vertical -> Horizontal when pressing left/right (reverse of first step).
1432		// This is used after expel: stack -> vertical; then Ctrl+[ / Ctrl+] should turn it into left/right.
1433		if direction == ConsumeOrExpelRight && strings.Contains(parent.ID, consumeOrExpelExpelCycleMarker) {
1434			parent.SplitDir = entity.SplitHorizontal
1435			// Active should become the right child.
1436			if parent.Children[1] != activeNode {
1437				parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1438			}
1439			ws.ActivePaneID = activeNode.Pane.ID
1440			return true, "horizontal_split", nil
1441		}
1442
1443		// Otherwise, vertical -> stack.
1444		parent.SplitDir = entity.SplitNone
1445		parent.SplitRatio = 0
1446		parent.IsStacked = true
1447		parent.Pane = nil
1448		if parent.Children[0] == activeNode {
1449			parent.ActiveStackIndex = 0
1450		} else if parent.Children[1] == activeNode {
1451			parent.ActiveStackIndex = 1
1452		} else {
1453			parent.ActiveStackIndex = 0
1454		}
1455		ws.ActivePaneID = activeNode.Pane.ID
1456		return true, "stack", nil
1457	default:
1458		return false, "", nil
1459	}
1460}
1461
1462func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1463	if ws == nil {
1464		return nil, fmt.Errorf("workspace is required")
1465	}
1466	if leaf == nil {
1467		return nil, fmt.Errorf("leaf is required")
1468	}
1469	if !leaf.IsLeaf() {
1470		return nil, fmt.Errorf("can only detach leaf node")
1471	}
1472
1473	parent := leaf.Parent
1474	if parent == nil {
1475		return nil, fmt.Errorf("cannot detach root leaf")
1476	}
1477	if !parent.IsSplit() {
1478		return nil, fmt.Errorf("leaf parent is not a split")
1479	}
1480
1481	var sibling *entity.PaneNode
1482	for _, child := range parent.Children {
1483		if child != leaf {
1484			sibling = child
1485			break
1486		}
1487	}
1488	if sibling == nil {
1489		return nil, fmt.Errorf("no sibling found for leaf")
1490	}
1491
1492	grandparent := parent.Parent
1493	if grandparent == nil {
1494		ws.Root = sibling
1495		sibling.Parent = nil
1496	} else {
1497		for i, child := range grandparent.Children {
1498			if child == parent {
1499				grandparent.Children[i] = sibling
1500				break
1501			}
1502		}
1503		sibling.Parent = grandparent
1504	}
1505
1506	leaf.Parent = nil
1507	return leaf, nil
1508}
1509
1510func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode {
1511	if leaf == nil || !leaf.IsLeaf() {
1512		return leaf
1513	}
1514
1515	originalPane := leaf.Pane
1516	originalChildNode := &entity.PaneNode{
1517		ID:     leaf.ID + "_0",
1518		Pane:   originalPane,
1519		Parent: leaf,
1520	}
1521
1522	leaf.Pane = nil
1523	leaf.IsStacked = true
1524	leaf.ActiveStackIndex = 0
1525	leaf.Children = []*entity.PaneNode{originalChildNode}
1526
1527	return leaf
1528}
1529
1530func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1531	if stackNode == nil {
1532		return nil, fmt.Errorf("stack node is required")
1533	}
1534	if !stackNode.IsStacked {
1535		return nil, fmt.Errorf("node is not a stack")
1536	}
1537	if leaf == nil || leaf.Pane == nil {
1538		return nil, fmt.Errorf("leaf is required")
1539	}
1540
1541	idx := -1
1542	for i, child := range stackNode.Children {
1543		if child == leaf {
1544			idx = i
1545			break
1546		}
1547	}
1548	if idx < 0 {
1549		return nil, fmt.Errorf("pane not found in stack")
1550	}
1551
1552	stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
1553	if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1554		stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1555	}
1556	if stackNode.ActiveStackIndex < 0 {
1557		stackNode.ActiveStackIndex = 0
1558	}
1559
1560	leaf.Parent = nil
1561	return leaf, nil
1562}
1563
1564func dissolveStackIntoLeaf(stackNode *entity.PaneNode) {
1565	if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 {
1566		return
1567	}
1568
1569	remaining := stackNode.Children[0]
1570	stackNode.Pane = remaining.Pane
1571	stackNode.Children = nil
1572	stackNode.IsStacked = false
1573	stackNode.ActiveStackIndex = 0
1574}
1575
1576func splitExistingNode(
1577	ws *entity.Workspace,
1578	targetNode *entity.PaneNode,
1579	newSibling *entity.PaneNode,
1580	direction ConsumeOrExpelDirection,
1581	idGen IDGenerator,
1582) error {
1583	if ws == nil {
1584		return fmt.Errorf("workspace is required")
1585	}
1586	if targetNode == nil {
1587		return fmt.Errorf("target node is required")
1588	}
1589	if newSibling == nil {
1590		return fmt.Errorf("new sibling node is required")
1591	}
1592	if idGen == nil {
1593		return fmt.Errorf("id generator is required")
1594	}
1595
1596	var splitDir entity.SplitDirection
1597	switch direction {
1598	case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1599		splitDir = entity.SplitHorizontal
1600	case ConsumeOrExpelUp, ConsumeOrExpelDown:
1601		splitDir = entity.SplitVertical
1602	default:
1603		return fmt.Errorf("invalid direction: %s", direction)
1604	}
1605
1606	parentNode := &entity.PaneNode{
1607		ID:         idGen(),
1608		SplitDir:   splitDir,
1609		SplitRatio: 0.5,
1610		Children:   make([]*entity.PaneNode, 2),
1611	}
1612
1613	switch direction {
1614	case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1615		parentNode.Children[0] = newSibling
1616		parentNode.Children[1] = targetNode
1617	case ConsumeOrExpelRight, ConsumeOrExpelDown:
1618		parentNode.Children[0] = targetNode
1619		parentNode.Children[1] = newSibling
1620	}
1621
1622	newSibling.Parent = parentNode
1623	oldParent := targetNode.Parent
1624	targetNode.Parent = parentNode
1625
1626	if oldParent == nil {
1627		ws.Root = parentNode
1628	} else {
1629		for i, child := range oldParent.Children {
1630			if child == targetNode {
1631				oldParent.Children[i] = parentNode
1632				break
1633			}
1634		}
1635		parentNode.Parent = oldParent
1636	}
1637
1638	return nil
1639}
1640
1641func splitExistingNodeWithMarker(
1642	ws *entity.Workspace,
1643	targetNode *entity.PaneNode,
1644	newSibling *entity.PaneNode,
1645	direction ConsumeOrExpelDirection,
1646	idGen IDGenerator,
1647	marker string,
1648) error {
1649	if ws == nil {
1650		return fmt.Errorf("workspace is required")
1651	}
1652	if targetNode == nil {
1653		return fmt.Errorf("target node is required")
1654	}
1655	if newSibling == nil {
1656		return fmt.Errorf("new sibling node is required")
1657	}
1658	if idGen == nil {
1659		return fmt.Errorf("id generator is required")
1660	}
1661
1662	var splitDir entity.SplitDirection
1663	switch direction {
1664	case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1665		splitDir = entity.SplitHorizontal
1666	case ConsumeOrExpelUp, ConsumeOrExpelDown:
1667		splitDir = entity.SplitVertical
1668	default:
1669		return fmt.Errorf("invalid direction: %s", direction)
1670	}
1671
1672	parentNode := &entity.PaneNode{
1673		ID:         idGen() + marker,
1674		SplitDir:   splitDir,
1675		SplitRatio: 0.5,
1676		Children:   make([]*entity.PaneNode, 2),
1677	}
1678
1679	switch direction {
1680	case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1681		parentNode.Children[0] = newSibling
1682		parentNode.Children[1] = targetNode
1683	case ConsumeOrExpelRight, ConsumeOrExpelDown:
1684		parentNode.Children[0] = targetNode
1685		parentNode.Children[1] = newSibling
1686	}
1687
1688	newSibling.Parent = parentNode
1689	oldParent := targetNode.Parent
1690	targetNode.Parent = parentNode
1691
1692	if oldParent == nil {
1693		ws.Root = parentNode
1694	} else {
1695		for i, child := range oldParent.Children {
1696			if child == targetNode {
1697				oldParent.Children[i] = parentNode
1698				break
1699			}
1700		}
1701		parentNode.Parent = oldParent
1702	}
1703
1704	return nil
1705}
1706
1707// GetAllPanes returns all leaf panes in a workspace.
1708//
1709//nolint:revive // receiver required for interface consistency
1710func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane {
1711	if ws == nil || ws.Root == nil {
1712		return nil
1713	}
1714	return ws.AllPanes()
1715}
1716
1717// CountPanes returns the number of panes in a workspace.
1718//
1719//nolint:revive // receiver required for interface consistency
1720func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int {
1721	if ws == nil {
1722		return 0
1723	}
1724	return ws.PaneCount()
1725}

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