v0.20.1 d9a391f

manage_panes.go RawHistoryFolder

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

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