v0.23.2 bfd5eb4

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

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