v0.20.1 d9a391f

workspace.go RawHistoryFolder

1package coordinator
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/application/usecase"
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/infrastructure/config"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/dumber/internal/ui/component"
12	"github.com/bnema/dumber/internal/ui/focus"
13	"github.com/bnema/dumber/internal/ui/layout"
14	"github.com/rs/zerolog"
15)
16
17const (
18	defaultPaneTitle = "Untitled"
19	nilString        = "nil"
20)
21
22// WorkspaceCoordinator manages pane operations within a workspace.
23type WorkspaceCoordinator struct {
24	panesUC        *usecase.ManagePanesUseCase
25	focusMgr       *focus.Manager
26	stackedPaneMgr *component.StackedPaneManager
27	widgetFactory  layout.WidgetFactory
28	contentCoord   *ContentCoordinator
29
30	// Callbacks to avoid circular dependencies
31	getActiveWS      func() (*entity.Workspace, *component.WorkspaceView)
32	generateID       func() string
33	onCloseLastPane  func(ctx context.Context) error
34	onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior
35}
36
37// WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
38type WorkspaceCoordinatorConfig struct {
39	PanesUC        *usecase.ManagePanesUseCase
40	FocusMgr       *focus.Manager
41	StackedPaneMgr *component.StackedPaneManager
42	WidgetFactory  layout.WidgetFactory
43	ContentCoord   *ContentCoordinator
44	GetActiveWS    func() (*entity.Workspace, *component.WorkspaceView)
45	GenerateID     func() string
46}
47
48type splitContext struct {
49	ws             *entity.Workspace
50	wsView         *component.WorkspaceView
51	activePane     *entity.PaneNode
52	existingWidget layout.Widget
53	isStackSplit   bool
54}
55
56type stackPaneContext struct {
57	ws            *entity.Workspace
58	wsView        *component.WorkspaceView
59	activeNode    *entity.PaneNode
60	activePaneID  entity.PaneID
61	originalTitle string
62}
63
64// NewWorkspaceCoordinator creates a new WorkspaceCoordinator.
65func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator {
66	log := logging.FromContext(ctx)
67	log.Debug().Msg("creating workspace coordinator")
68
69	return &WorkspaceCoordinator{
70		panesUC:        cfg.PanesUC,
71		focusMgr:       cfg.FocusMgr,
72		stackedPaneMgr: cfg.StackedPaneMgr,
73		widgetFactory:  cfg.WidgetFactory,
74		contentCoord:   cfg.ContentCoord,
75		getActiveWS:    cfg.GetActiveWS,
76		generateID:     cfg.GenerateID,
77	}
78}
79
80// SetOnCloseLastPane sets the callback for when the last pane is closed.
81func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) error) {
82	c.onCloseLastPane = fn
83}
84
85// setupPaneViewHover configures hover-to-focus behavior on a PaneView.
86func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {
87	pv.SetOnHover(func(paneID entity.PaneID) {
88		// Skip if this pane is already active
89		if wsView.GetActivePaneID() == paneID {
90			return
91		}
92
93		// Activate the hovered pane and grab focus
94		if err := wsView.SetActivePaneID(paneID); err == nil {
95			wsView.FocusPane(paneID)
96		}
97	})
98
99	pv.AttachHoverHandler(ctx)
100}
101
102// Split splits the active pane in the given direction.
103func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
104	log := logging.FromContext(ctx)
105
106	splitCtx, ok := c.prepareSplit(ctx, direction)
107	if !ok {
108		return nil
109	}
110
111	output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
112		Workspace:  splitCtx.ws,
113		TargetPane: splitCtx.activePane,
114		Direction:  direction,
115	})
116	if err != nil {
117		log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
118		return err
119	}
120
121	// Remember old active pane before changing
122	oldActivePaneID := splitCtx.activePane.Pane.ID
123
124	// Set the new pane as active
125	splitCtx.ws.ActivePaneID = output.NewPaneNode.Pane.ID
126
127	// Update the workspace view
128	if splitCtx.wsView != nil {
129		c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
130	}
131
132	log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
133
134	return nil
135}
136
137func (c *WorkspaceCoordinator) prepareSplit(
138	ctx context.Context,
139	direction usecase.SplitDirection,
140) (*splitContext, bool) {
141	log := logging.FromContext(ctx)
142	if c.panesUC == nil {
143		log.Warn().Msg("panes use case not available")
144		return nil, false
145	}
146
147	ws, wsView := c.getActiveWS()
148	if ws == nil {
149		log.Warn().Msg("no active workspace")
150		return nil, false
151	}
152
153	activePane := ws.ActivePane()
154	if activePane == nil {
155		log.Warn().Msg("no active pane to split")
156		return nil, false
157	}
158
159	isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
160		activePane.Parent != nil && activePane.Parent.IsStacked
161
162	var existingWidget layout.Widget
163	if wsView != nil {
164		existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit)
165	}
166
167	return &splitContext{
168		ws:             ws,
169		wsView:         wsView,
170		activePane:     activePane,
171		existingWidget: existingWidget,
172		isStackSplit:   isStackSplit,
173	}, true
174}
175
176func (c *WorkspaceCoordinator) resolveSplitWidget(
177	wsView *component.WorkspaceView,
178	activePane *entity.PaneNode,
179	isStackSplit bool,
180) layout.Widget {
181	if wsView == nil {
182		return nil
183	}
184
185	if !isStackSplit {
186		return wsView.GetRootWidget()
187	}
188
189	tr := wsView.TreeRenderer()
190	if tr == nil {
191		return nil
192	}
193
194	stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
195	if stackedView == nil {
196		return nil
197	}
198
199	return stackedView.Widget()
200}
201
202func (c *WorkspaceCoordinator) applySplitToView(
203	ctx context.Context,
204	wsView *component.WorkspaceView,
205	ws *entity.Workspace,
206	output *usecase.SplitPaneOutput,
207	direction usecase.SplitDirection,
208	existingWidget layout.Widget,
209	isStackSplit bool,
210	oldActivePaneID entity.PaneID,
211) {
212	log := logging.FromContext(ctx)
213	needsAttach := false
214
215	if existingWidget != nil {
216		var splitErr error
217		if isStackSplit {
218			splitErr = c.doIncrementalStackSplit(ctx, wsView, output, direction, existingWidget, oldActivePaneID)
219		} else {
220			splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID)
221		}
222
223		if splitErr != nil {
224			log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")
225			if err := wsView.Rebuild(ctx); err != nil {
226				log.Error().Err(err).Msg("failed to rebuild workspace view")
227			}
228			needsAttach = true
229		}
230	} else {
231		if err := wsView.Rebuild(ctx); err != nil {
232			log.Error().Err(err).Msg("failed to rebuild workspace view")
233		}
234		needsAttach = true
235	}
236
237	if needsAttach {
238		c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
239	}
240	if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
241		log.Warn().Err(err).Msg("failed to set active pane in workspace view")
242	}
243	wsView.FocusPane(ws.ActivePaneID)
244}
245
246// doIncrementalStackSplit performs an incremental split around an existing stacked pane.
247func (c *WorkspaceCoordinator) doIncrementalStackSplit(
248	ctx context.Context,
249	wsView *component.WorkspaceView,
250	output *usecase.SplitPaneOutput,
251	direction usecase.SplitDirection,
252	existingStackWidget layout.Widget,
253	oldActivePaneID entity.PaneID,
254) error {
255	log := logging.FromContext(ctx)
256	factory := wsView.Factory()
257
258	log.Debug().
259		Str("direction", string(direction)).
260		Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
261		Str("old_active_pane_id", string(oldActivePaneID)).
262		Msg("performing incremental stack split")
263
264	// 1. Deactivate the old active pane (in the stack)
265	if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
266		oldPaneView.SetActive(false)
267	}
268
269	// 2. Determine if this is a root split or non-root split
270	// The grandparent of the new split node tells us where the stack currently sits
271	grandparentNode := output.ParentNode.Parent
272	isRootSplit := grandparentNode == nil
273
274	log.Debug().
275		Bool("is_root_split", isRootSplit).
276		Str("grandparent_id", func() string {
277			if grandparentNode != nil {
278				return grandparentNode.ID
279			}
280			return nilString
281		}()).
282		Msg("stack split: determined split type")
283
284	// 3. Create new PaneView for the new pane (without WebView - will attach later)
285	newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
286	setupPaneViewHover(ctx, newPaneView, wsView)
287
288	// 4. Wrap the new PaneView in a StackedView
289	newStackedView := layout.NewStackedView(factory)
290	newStackedView.AddPane(ctx, string(output.NewPaneNode.Pane.ID), output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
291
292	tr := wsView.TreeRenderer()
293
294	// 5. Handle root vs non-root split differently
295	if isRootSplit {
296		// Root split: stack is direct child of container
297		wsView.ClearRootWidgetRef()
298		wsView.Container().Remove(existingStackWidget)
299
300		// Create a SplitView with the appropriate orientation and child placement
301		var splitView *layout.SplitView
302		if direction == usecase.SplitLeft {
303			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
304				newStackedView.Widget(), existingStackWidget, output.SplitRatio)
305		} else {
306			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
307				existingStackWidget, newStackedView.Widget(), output.SplitRatio)
308		}
309
310		wsView.SetRootWidgetDirect(splitView.Widget())
311
312		if tr != nil {
313			tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
314		}
315	} else {
316		// Non-root split: stack is inside another split, need to replace in grandparent
317		if tr == nil {
318			return fmt.Errorf("tree renderer not available for non-root stack split")
319		}
320
321		grandparentWidget := tr.Lookup(grandparentNode.ID)
322		if grandparentWidget == nil {
323			log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found")
324			return fmt.Errorf("grandparent widget not found")
325		}
326
327		panedWidget, ok := grandparentWidget.(layout.PanedWidget)
328		if !ok {
329			log.Warn().Msg("grandparent widget is not a PanedWidget")
330			return fmt.Errorf("grandparent is not a PanedWidget")
331		}
332
333		// Determine if the stack's parent (the new split node) is start or end child
334		isStartChild := grandparentNode.Left() == output.ParentNode
335
336		log.Debug().
337			Bool("is_start_child", isStartChild).
338			Msg("stack split: determined position in grandparent")
339
340		// Clear the position first to unparent the stack widget
341		if isStartChild {
342			panedWidget.SetStartChild(nil)
343		} else {
344			panedWidget.SetEndChild(nil)
345		}
346
347		// Create the new split view
348		var splitView *layout.SplitView
349		if direction == usecase.SplitLeft {
350			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
351				newStackedView.Widget(), existingStackWidget, output.SplitRatio)
352		} else {
353			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
354				existingStackWidget, newStackedView.Widget(), output.SplitRatio)
355		}
356
357		// Put the new split view in the grandparent's slot
358		if isStartChild {
359			panedWidget.SetStartChild(splitView.Widget())
360		} else {
361			panedWidget.SetEndChild(splitView.Widget())
362		}
363
364		tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
365
366		log.Debug().
367			Bool("was_start_child", isStartChild).
368			Msg("stack split: replaced stack in grandparent with new split view")
369	}
370
371	// 6. Register the new pane in tracking maps and activate it
372	wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
373	newPaneView.SetActive(true)
374
375	if tr != nil {
376		tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
377	}
378
379	// 7. Attach WebView only for the new pane
380	wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
381	if err != nil {
382		log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
383		return err
384	}
385
386	// Load the default URL for new pane
387	newPaneEntity := output.NewPaneNode.Pane
388	if newPaneEntity.URI != "" {
389		if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
390			log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
391		}
392	}
393
394	widget := c.contentCoord.WrapWidget(ctx, wv)
395	if widget != nil {
396		newPaneView.SetWebViewWidget(widget)
397	}
398
399	log.Debug().Msg("incremental stack split completed successfully")
400	return nil
401}
402
403// doIncrementalSplit performs an incremental split by reusing the existing widget tree.
404// This avoids rebuilding the entire tree which would destroy existing WebView widgets.
405func (c *WorkspaceCoordinator) doIncrementalSplit(
406	ctx context.Context,
407	wsView *component.WorkspaceView,
408	ws *entity.Workspace,
409	output *usecase.SplitPaneOutput,
410	direction usecase.SplitDirection,
411	existingRootWidget layout.Widget,
412	oldActivePaneID entity.PaneID,
413) error {
414	log := logging.FromContext(ctx)
415	factory := wsView.Factory()
416
417	log.Debug().
418		Str("direction", string(direction)).
419		Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
420		Str("old_active_pane_id", string(oldActivePaneID)).
421		Int("pane_count", ws.PaneCount()).
422		Msg("performing incremental split")
423
424	// 1. Deactivate the old active pane
425	if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
426		oldPaneView.SetActive(false)
427	}
428
429	// 2. Get the active pane's StackedView widget (what we're actually splitting)
430	tr := wsView.TreeRenderer()
431	if tr == nil {
432		return nil
433	}
434
435	activeStackedView := tr.GetStackedViewForPane(string(oldActivePaneID))
436	if activeStackedView == nil {
437		log.Warn().Msg("no stacked view found for active pane")
438		return nil
439	}
440	activePaneWidget := activeStackedView.Widget()
441
442	// 3. Determine if this is a root split by checking domain tree
443	// output.ParentNode is the NEW split node (h0)
444	// output.ParentNode's parent in domain tree is the ORIGINAL parent (f0) or nil if root
445	grandparentNode := output.ParentNode.Parent
446	isRootSplit := grandparentNode == nil
447
448	log.Debug().
449		Bool("is_root_split", isRootSplit).
450		Str("grandparent_id", func() string {
451			if grandparentNode != nil {
452				return grandparentNode.ID
453			}
454			return nilString
455		}()).
456		Msg("determined split type")
457
458	// 4. Create new PaneView for the new pane
459	newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
460	setupPaneViewHover(ctx, newPaneView, wsView)
461
462	// 5. Wrap the new PaneView in a StackedView
463	newStackedView := layout.NewStackedView(factory)
464	newStackedView.AddPane(ctx, string(output.NewPaneNode.Pane.ID), output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
465
466	orientation, existingFirst, ok := splitOrientation(direction)
467	if !ok {
468		return nil
469	}
470
471	// 7. Handle root vs non-root split differently
472	if isRootSplit {
473		if err := c.replaceRootSplit(
474			ctx,
475			wsView,
476			tr,
477			factory,
478			existingRootWidget,
479			newStackedView,
480			output,
481			orientation,
482			existingFirst,
483		); err != nil {
484			return err
485		}
486	} else {
487		if err := c.replaceNonRootSplit(
488			ctx,
489			tr,
490			factory,
491			grandparentNode,
492			output,
493			activePaneWidget,
494			newStackedView,
495			orientation,
496			existingFirst,
497		); err != nil {
498			return err
499		}
500	}
501
502	// 8. Register the new pane in tracking maps and activate it
503	wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
504	newPaneView.SetActive(true)
505
506	// Register the new pane's StackedView mapping
507	tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
508
509	// 9. Attach WebView only for the new pane
510	wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
511	if err != nil {
512		log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
513		return err
514	}
515
516	// Load the default URL for new pane
517	newPaneEntity := output.NewPaneNode.Pane
518	if newPaneEntity.URI != "" {
519		if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
520			log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
521		}
522	}
523
524	widget := c.contentCoord.WrapWidget(ctx, wv)
525	if widget != nil {
526		newPaneView.SetWebViewWidget(widget)
527	}
528
529	log.Debug().Msg("incremental split completed successfully")
530	return nil
531}
532
533func splitOrientation(direction usecase.SplitDirection) (layout.Orientation, bool, bool) {
534	switch direction {
535	case usecase.SplitRight:
536		return layout.OrientationHorizontal, true, true
537	case usecase.SplitLeft:
538		return layout.OrientationHorizontal, false, true
539	case usecase.SplitDown:
540		return layout.OrientationVertical, true, true
541	case usecase.SplitUp:
542		return layout.OrientationVertical, false, true
543	default:
544		return layout.OrientationHorizontal, false, false
545	}
546}
547
548func (c *WorkspaceCoordinator) replaceRootSplit(
549	ctx context.Context,
550	wsView *component.WorkspaceView,
551	tr *layout.TreeRenderer,
552	factory layout.WidgetFactory,
553	existingRootWidget layout.Widget,
554	newStackedView *layout.StackedView,
555	output *usecase.SplitPaneOutput,
556	orientation layout.Orientation,
557	existingFirst bool,
558) error {
559	wsView.ClearRootWidgetRef()
560	wsView.Container().Remove(existingRootWidget)
561
562	var splitView *layout.SplitView
563	if existingFirst {
564		splitView = layout.NewSplitView(ctx, factory, orientation,
565			existingRootWidget, newStackedView.Widget(), output.SplitRatio)
566	} else {
567		splitView = layout.NewSplitView(ctx, factory, orientation,
568			newStackedView.Widget(), existingRootWidget, output.SplitRatio)
569	}
570
571	wsView.SetRootWidgetDirect(splitView.Widget())
572	tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
573
574	logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view")
575	return nil
576}
577
578func (c *WorkspaceCoordinator) replaceNonRootSplit(
579	ctx context.Context,
580	tr *layout.TreeRenderer,
581	factory layout.WidgetFactory,
582	grandparentNode *entity.PaneNode,
583	output *usecase.SplitPaneOutput,
584	activePaneWidget layout.Widget,
585	newStackedView *layout.StackedView,
586	orientation layout.Orientation,
587	existingFirst bool,
588) error {
589	log := logging.FromContext(ctx)
590	grandparentWidget := tr.Lookup(grandparentNode.ID)
591	if grandparentWidget == nil {
592		log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found in TreeRenderer")
593		return fmt.Errorf("grandparent widget not found")
594	}
595
596	panedWidget, ok := grandparentWidget.(layout.PanedWidget)
597	if !ok {
598		log.Warn().Msg("grandparent widget is not a PanedWidget, falling back")
599		return fmt.Errorf("grandparent is not a PanedWidget")
600	}
601
602	isStartChild := grandparentNode.Left() == output.ParentNode
603
604	log.Debug().
605		Bool("is_start_child", isStartChild).
606		Str("grandparent_left_id", func() string {
607			if grandparentNode.Left() != nil {
608				return grandparentNode.Left().ID
609			}
610			return nilString
611		}()).
612		Str("new_parent_id", output.ParentNode.ID).
613		Msg("determined position in grandparent")
614
615	if isStartChild {
616		panedWidget.SetStartChild(nil)
617	} else {
618		panedWidget.SetEndChild(nil)
619	}
620
621	var splitView *layout.SplitView
622	if existingFirst {
623		splitView = layout.NewSplitView(ctx, factory, orientation,
624			activePaneWidget, newStackedView.Widget(), output.SplitRatio)
625	} else {
626		splitView = layout.NewSplitView(ctx, factory, orientation,
627			newStackedView.Widget(), activePaneWidget, output.SplitRatio)
628	}
629
630	if isStartChild {
631		panedWidget.SetStartChild(splitView.Widget())
632	} else {
633		panedWidget.SetEndChild(splitView.Widget())
634	}
635
636	tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
637
638	log.Debug().
639		Bool("was_start_child", isStartChild).
640		Msg("non-root split: replaced pane in grandparent with new split view")
641	return nil
642}
643
644// ClosePane closes the active pane.
645func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
646	log := logging.FromContext(ctx)
647
648	if c.panesUC == nil {
649		log.Warn().Msg("panes use case not available")
650		return nil
651	}
652
653	ws, wsView := c.getActiveWS()
654	if ws == nil {
655		log.Warn().Msg("no active workspace")
656		return nil
657	}
658
659	activePane := ws.ActivePane()
660	if activePane == nil {
661		log.Warn().Msg("no active pane to close")
662		return nil
663	}
664	closingPaneID := activePane.Pane.ID
665
666	log.Debug().Str("pane_id", string(closingPaneID)).Msg("closing pane")
667
668	// Don't close the last pane - close the tab instead
669	if ws.PaneCount() <= 1 {
670		if c.onCloseLastPane != nil {
671			return c.onCloseLastPane(ctx)
672		}
673		return nil
674	}
675
676	// BEFORE domain changes: capture widget info for incremental close
677	var parentNode *entity.PaneNode
678	var siblingNode *entity.PaneNode
679	var grandparentNode *entity.PaneNode
680	var parentWidget layout.Widget
681	var siblingIsStartChild bool  // true if sibling is the start (left/top) child in parent
682	var parentIsStartInGrand bool // true if parent is the start (left/top) child in grandparent
683
684	if wsView != nil {
685		tr := wsView.TreeRenderer()
686		if tr != nil && activePane.Parent != nil {
687			parentNode = activePane.Parent
688
689			// Find sibling (the other child of parent)
690			// If closing pane is start child, sibling is end child (and vice versa)
691			if parentNode.Left() == activePane {
692				siblingNode = parentNode.Right()
693				siblingIsStartChild = false // sibling is end/right child
694			} else {
695				siblingNode = parentNode.Left()
696				siblingIsStartChild = true // sibling is start/left child
697			}
698
699			grandparentNode = parentNode.Parent
700			parentWidget = tr.Lookup(parentNode.ID)
701
702			// Determine parent's position in grandparent (if grandparent exists)
703			if grandparentNode != nil {
704				parentIsStartInGrand = grandparentNode.Left() == parentNode
705			}
706
707			log.Debug().
708				Str("parent_id", parentNode.ID).
709				Str("sibling_id", func() string {
710					if siblingNode != nil {
711						return siblingNode.ID
712					}
713					return nilString
714				}()).
715				Bool("sibling_is_start", siblingIsStartChild).
716				Bool("parent_is_start_in_grand", parentIsStartInGrand).
717				Bool("has_grandparent", grandparentNode != nil).
718				Bool("has_parent_widget", parentWidget != nil).
719				Msg("captured close context")
720		}
721	}
722
723	// Now do domain changes
724	_, err := c.panesUC.Close(ctx, ws, activePane)
725	if err != nil {
726		log.Error().Err(err).Msg("failed to close pane")
727		return err
728	}
729
730	c.finalizePaneClose(
731		ctx,
732		wsView,
733		ws,
734		closingPaneID,
735		parentNode,
736		siblingNode,
737		grandparentNode,
738		parentWidget,
739		siblingIsStartChild,
740		parentIsStartInGrand,
741	)
742
743	log.Info().Msg("pane closed")
744	return nil
745}
746
747// ClosePaneByID closes a specific pane by ID.
748// This is used for closing popup panes when window.close() is called.
749func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.PaneID) error {
750	log := logging.FromContext(ctx)
751
752	if c.panesUC == nil {
753		log.Warn().Msg("panes use case not available")
754		return nil
755	}
756
757	ws, wsView := c.getActiveWS()
758	if ws == nil {
759		log.Warn().Msg("no active workspace")
760		return nil
761	}
762
763	// Find the pane node
764	paneNode := ws.FindPane(paneID)
765	if paneNode == nil {
766		log.Warn().Str("pane_id", string(paneID)).Msg("pane not found")
767		return nil
768	}
769
770	log.Debug().Str("pane_id", string(paneID)).Msg("closing pane by ID")
771
772	// Don't close the last pane - close the tab instead
773	if ws.PaneCount() <= 1 {
774		if c.onCloseLastPane != nil {
775			return c.onCloseLastPane(ctx)
776		}
777		return nil
778	}
779
780	// BEFORE domain changes: capture widget info for incremental close
781	var parentNode *entity.PaneNode
782	var siblingNode *entity.PaneNode
783	var grandparentNode *entity.PaneNode
784	var parentWidget layout.Widget
785	var siblingIsStartChild bool
786	var parentIsStartInGrand bool
787
788	if wsView != nil {
789		tr := wsView.TreeRenderer()
790		if tr != nil && paneNode.Parent != nil {
791			parentNode = paneNode.Parent
792
793			if parentNode.Left() == paneNode {
794				siblingNode = parentNode.Right()
795				siblingIsStartChild = false
796			} else {
797				siblingNode = parentNode.Left()
798				siblingIsStartChild = true
799			}
800
801			grandparentNode = parentNode.Parent
802			parentWidget = tr.Lookup(parentNode.ID)
803
804			if grandparentNode != nil {
805				parentIsStartInGrand = grandparentNode.Left() == parentNode
806			}
807		}
808	}
809
810	// Now do domain changes
811	_, err := c.panesUC.Close(ctx, ws, paneNode)
812	if err != nil {
813		log.Error().Err(err).Msg("failed to close pane")
814		return err
815	}
816
817	c.finalizePaneClose(
818		ctx,
819		wsView,
820		ws,
821		paneID,
822		parentNode,
823		siblingNode,
824		grandparentNode,
825		parentWidget,
826		siblingIsStartChild,
827		parentIsStartInGrand,
828	)
829
830	log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID")
831	return nil
832}
833
834// doIncrementalClose performs incremental close by promoting sibling without rebuild.
835func (c *WorkspaceCoordinator) doIncrementalClose(
836	ctx context.Context,
837	wsView *component.WorkspaceView,
838	closingPaneID entity.PaneID,
839	siblingNode *entity.PaneNode,
840	parentNode *entity.PaneNode,
841	grandparentNode *entity.PaneNode,
842	parentWidget layout.Widget,
843	siblingIsStartChild bool, // true if sibling is start/left child in parent
844	parentIsStartInGrand bool, // true if parent is start/left child in grandparent
845) error {
846	log := logging.FromContext(ctx)
847	tr := wsView.TreeRenderer()
848	if tr == nil {
849		return fmt.Errorf("tree renderer not available")
850	}
851
852	log.Debug().
853		Str("closing_pane", string(closingPaneID)).
854		Str("sibling_id", siblingNode.ID).
855		Bool("sibling_is_leaf", siblingNode.IsLeaf()).
856		Bool("sibling_is_start", siblingIsStartChild).
857		Bool("parent_is_start_in_grand", parentIsStartInGrand).
858		Msg("performing incremental close")
859
860	// Get sibling's widget
861	var siblingWidget layout.Widget
862	if siblingNode.IsLeaf() && siblingNode.Pane != nil {
863		stackedView := tr.GetStackedViewForPane(string(siblingNode.Pane.ID))
864		if stackedView != nil {
865			siblingWidget = stackedView.Widget()
866		}
867	} else {
868		// Sibling is a split node
869		siblingWidget = tr.Lookup(siblingNode.ID)
870	}
871
872	if siblingWidget == nil {
873		return fmt.Errorf("sibling widget not found")
874	}
875
876	// Cast parent widget to PanedWidget
877	panedWidget, ok := parentWidget.(layout.PanedWidget)
878	if !ok {
879		return fmt.Errorf("parent widget is not a PanedWidget")
880	}
881
882	// Remove BOTH children from parent paned before any reparenting
883	// This is critical - GTK requires widgets to be unparented before reparenting
884	// Order: unparent closing pane first, then sibling
885	if siblingIsStartChild {
886		// Sibling is start, closing pane is end
887		panedWidget.SetEndChild(nil)   // Unparent closing pane
888		panedWidget.SetStartChild(nil) // Unparent sibling
889	} else {
890		// Sibling is end, closing pane is start
891		panedWidget.SetStartChild(nil) // Unparent closing pane
892		panedWidget.SetEndChild(nil)   // Unparent sibling
893	}
894
895	if grandparentNode == nil {
896		// Sibling becomes new root
897		// Remove the old root (parent paned) from container manually since
898		// ClearRootWidgetRef only clears the reference, not the actual widget
899		wsView.Container().Remove(parentWidget)
900		wsView.ClearRootWidgetRef()
901
902		// Grab focus on sibling before reparenting to avoid GTK focus warnings
903		siblingWidget.GrabFocus()
904
905		// Ensure sibling expands to fill container (was constrained by paned before)
906		siblingWidget.SetHexpand(true)
907		siblingWidget.SetVexpand(true)
908
909		wsView.SetRootWidgetDirect(siblingWidget)
910
911		log.Debug().Msg("sibling promoted to root")
912	} else {
913		// Replace parent with sibling in grandparent
914		grandparentWidget := tr.Lookup(grandparentNode.ID)
915		if grandparentWidget == nil {
916			return fmt.Errorf("grandparent widget not found")
917		}
918
919		grandPaned, ok := grandparentWidget.(layout.PanedWidget)
920		if !ok {
921			return fmt.Errorf("grandparent widget is not a PanedWidget")
922		}
923
924		// Grab focus on sibling before reparenting to avoid GTK focus warnings
925		siblingWidget.GrabFocus()
926
927		// Remove parent from grandparent using known position from domain tree
928		if parentIsStartInGrand {
929			grandPaned.SetStartChild(nil)
930		} else {
931			grandPaned.SetEndChild(nil)
932		}
933
934		// Insert sibling at same position
935		if parentIsStartInGrand {
936			grandPaned.SetStartChild(siblingWidget)
937		} else {
938			grandPaned.SetEndChild(siblingWidget)
939		}
940
941		log.Debug().Bool("was_start_child", parentIsStartInGrand).Msg("sibling promoted in grandparent")
942	}
943
944	// Clean up tracking
945	tr.UnregisterWidget(parentNode.ID)
946	wsView.UnregisterPaneView(closingPaneID)
947	tr.UnregisterPane(string(closingPaneID))
948
949	// Release the closing pane's webview
950	c.contentCoord.ReleaseWebView(ctx, closingPaneID)
951
952	// Activate sibling
953	if siblingNode.IsLeaf() && siblingNode.Pane != nil {
954		if siblingPaneView := wsView.GetPaneView(siblingNode.Pane.ID); siblingPaneView != nil {
955			siblingPaneView.SetActive(true)
956		}
957	}
958
959	log.Debug().Msg("incremental close completed successfully")
960	return nil
961}
962
963func (c *WorkspaceCoordinator) finalizePaneClose(
964	ctx context.Context,
965	wsView *component.WorkspaceView,
966	ws *entity.Workspace,
967	paneID entity.PaneID,
968	parentNode *entity.PaneNode,
969	siblingNode *entity.PaneNode,
970	grandparentNode *entity.PaneNode,
971	parentWidget layout.Widget,
972	siblingIsStartChild bool,
973	parentIsStartInGrand bool,
974) {
975	if wsView == nil {
976		return
977	}
978
979	log := logging.FromContext(ctx)
980
981	if parentNode != nil {
982		var closeErr error
983
984		if parentNode.IsStacked {
985			closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, parentNode)
986		} else if siblingNode != nil && parentWidget != nil {
987			closeErr = c.doIncrementalClose(
988				ctx,
989				wsView,
990				paneID,
991				siblingNode,
992				parentNode,
993				grandparentNode,
994				parentWidget,
995				siblingIsStartChild,
996				parentIsStartInGrand,
997			)
998		} else {
999			closeErr = fmt.Errorf("missing context for incremental close")
1000		}
1001
1002		if closeErr != nil {
1003			log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild")
1004			if err := wsView.Rebuild(ctx); err != nil {
1005				log.Error().Err(err).Msg("failed to rebuild workspace view")
1006			}
1007			c.contentCoord.ReleaseWebView(ctx, paneID)
1008			c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1009		}
1010	} else {
1011		if err := wsView.Rebuild(ctx); err != nil {
1012			log.Error().Err(err).Msg("failed to rebuild workspace view")
1013		}
1014		c.contentCoord.ReleaseWebView(ctx, paneID)
1015		c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1016	}
1017
1018	if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
1019		log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1020	}
1021	wsView.FocusPane(ws.ActivePaneID)
1022}
1023
1024// doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
1025func (c *WorkspaceCoordinator) doIncrementalStackClose(
1026	ctx context.Context,
1027	wsView *component.WorkspaceView,
1028	closingPaneID entity.PaneID,
1029	stackNode *entity.PaneNode,
1030) error {
1031	log := logging.FromContext(ctx)
1032	tr := wsView.TreeRenderer()
1033	if tr == nil {
1034		return fmt.Errorf("tree renderer not available")
1035	}
1036
1037	log.Debug().
1038		Str("closing_pane", string(closingPaneID)).
1039		Str("stack_node", stackNode.ID).
1040		Int("stack_children", len(stackNode.Children)).
1041		Msg("performing incremental stack close")
1042
1043	// Get the StackedView for this pane
1044	stackedView := tr.GetStackedViewForPane(string(closingPaneID))
1045	if stackedView == nil {
1046		return fmt.Errorf("stacked view not found for pane %s", closingPaneID)
1047	}
1048
1049	// Find the index of the closing pane in the stack
1050	closingIndex := -1
1051	for i, child := range stackNode.Children {
1052		if child.Pane != nil && child.Pane.ID == closingPaneID {
1053			closingIndex = i
1054			break
1055		}
1056	}
1057
1058	if closingIndex < 0 {
1059		return fmt.Errorf("pane %s not found in stack", closingPaneID)
1060	}
1061
1062	// Remove the pane from the StackedView widget
1063	if err := stackedView.RemovePane(ctx, closingIndex); err != nil {
1064		return fmt.Errorf("failed to remove pane from stacked view: %w", err)
1065	}
1066
1067	// Clean up tracking
1068	wsView.UnregisterPaneView(closingPaneID)
1069	tr.UnregisterPane(string(closingPaneID))
1070
1071	// Release the closing pane's webview
1072	c.contentCoord.ReleaseWebView(ctx, closingPaneID)
1073
1074	log.Debug().
1075		Int("removed_index", closingIndex).
1076		Msg("incremental stack close completed successfully")
1077	return nil
1078}
1079
1080// FocusPane navigates focus to an adjacent pane.
1081func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {
1082	log := logging.FromContext(ctx)
1083
1084	ws, wsView := c.getActiveWS()
1085	if ws == nil {
1086		log.Warn().Msg("no active workspace")
1087		return nil
1088	}
1089
1090	if wsView == nil {
1091		log.Warn().Msg("no active workspace view")
1092		return nil
1093	}
1094
1095	// Use geometric navigation if focus manager is available
1096	var newPane *entity.PaneNode
1097	var err error
1098
1099	if c.focusMgr != nil {
1100		newPane, err = c.focusMgr.NavigateGeometric(ctx, ws, wsView, direction)
1101	} else if c.panesUC != nil {
1102		// Fallback to structural navigation
1103		newPane, err = c.panesUC.NavigateFocus(ctx, ws, direction)
1104	} else {
1105		log.Warn().Msg("no navigation manager available")
1106		return nil
1107	}
1108
1109	if err != nil {
1110		log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
1111		return err
1112	}
1113
1114	if newPane == nil {
1115		log.Debug().Str("direction", string(direction)).Msg("no pane in that direction")
1116		return nil
1117	}
1118
1119	// Update the workspace view's active pane
1120	if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
1121		log.Warn().Err(err).Msg("failed to update active pane in view")
1122	} else {
1123		wsView.FocusPane(newPane.Pane.ID)
1124	}
1125
1126	// Sync StackedView visibility if new pane is in a stack
1127	if newPane.Parent != nil && newPane.Parent.IsStacked {
1128		c.syncStackedViewActive(ctx, wsView, newPane)
1129	}
1130
1131	log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")
1132
1133	return nil
1134}
1135
1136// syncStackedViewActive updates the StackedView's visibility to match the domain model.
1137func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView *component.WorkspaceView, paneNode *entity.PaneNode) {
1138	log := logging.FromContext(ctx)
1139
1140	if paneNode == nil || paneNode.Parent == nil || !paneNode.Parent.IsStacked {
1141		return
1142	}
1143
1144	tr := wsView.TreeRenderer()
1145	if tr == nil {
1146		return
1147	}
1148
1149	// Get the StackedView for this pane
1150	stackedView := tr.GetStackedViewForPane(string(paneNode.Pane.ID))
1151	if stackedView == nil {
1152		log.Warn().Str("pane_id", string(paneNode.Pane.ID)).Msg("stacked view not found for pane")
1153		return
1154	}
1155
1156	// Use the parent's ActiveStackIndex which was set by the focus manager
1157	stackIndex := paneNode.Parent.ActiveStackIndex
1158
1159	log.Debug().
1160		Str("pane_id", string(paneNode.Pane.ID)).
1161		Int("stack_index", stackIndex).
1162		Msg("syncing stacked view visibility")
1163
1164	if err := stackedView.SetActive(ctx, stackIndex); err != nil {
1165		log.Warn().Err(err).Msg("failed to set stacked view active index")
1166	}
1167}
1168
1169// StackPane adds a new pane stacked on top of the active pane.
1170func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1171	log := logging.FromContext(ctx)
1172
1173	stackCtx, ok := c.prepareStackPane(ctx)
1174	if !ok {
1175		return nil
1176	}
1177
1178	newPaneID := entity.PaneID(c.generateID())
1179	newPane := entity.NewPane(newPaneID)
1180	newPane.URI = "about:blank"
1181	newPane.Title = defaultPaneTitle
1182
1183	stackNode, needsFirstPaneTitleUpdate := c.ensureStackNode(ctx, stackCtx.activeNode, stackCtx.activePaneID, stackCtx.originalTitle)
1184	insertIndex := insertStackChild(stackNode, newPaneID, newPane)
1185
1186	log.Debug().
1187		Int("stack_size", len(stackNode.Children)).
1188		Int("insert_index", insertIndex).
1189		Int("active_index", stackNode.ActiveStackIndex).
1190		Msg("domain tree updated with position-aware insertion")
1191
1192	// Create PaneView for the new pane
1193	newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
1194	setupPaneViewHover(ctx, newPaneView, stackCtx.wsView)
1195	stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)
1196
1197	// Add to the UI StackedView
1198	if err := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle); err != nil {
1199		log.Error().Err(err).Msg("failed to add pane to stack")
1200		return err
1201	}
1202
1203	// Update the first pane's title if we just converted from leaf to stacked
1204	if needsFirstPaneTitleUpdate {
1205		c.updateFirstStackTitle(ctx, stackCtx.wsView, stackCtx.activePaneID, stackCtx.originalTitle)
1206	}
1207
1208	// Get WebView and attach
1209	wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)
1210	if err != nil {
1211		log.Warn().Err(err).Msg("failed to get webview for new pane")
1212	} else {
1213		widget := c.contentCoord.WrapWidget(ctx, wv)
1214		if widget != nil {
1215			newPaneView.SetWebViewWidget(widget)
1216		}
1217		// Load blank page
1218		if err := wv.LoadURI(ctx, "about:blank"); err != nil {
1219			log.Warn().Err(err).Msg("failed to load blank page")
1220		}
1221	}
1222
1223	// Update workspace active pane ID
1224	stackCtx.ws.ActivePaneID = newPaneID
1225
1226	// Update workspace view
1227	if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
1228		log.Warn().Err(err).Msg("failed to set active pane")
1229	}
1230
1231	// Set up title bar click callback
1232	tr := stackCtx.wsView.TreeRenderer()
1233	if tr != nil {
1234		stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))
1235		if stackedView != nil {
1236			capturedStackNode := stackNode
1237			stackedView.SetOnActivate(func(index int) {
1238				c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1239			})
1240		}
1241	}
1242
1243	log.Info().
1244		Str("original_pane", string(stackCtx.activePaneID)).
1245		Str("new_pane", string(newPaneID)).
1246		Int("stack_size", len(stackNode.Children)).
1247		Msg("stacked new pane")
1248
1249	return nil
1250}
1251
1252func (c *WorkspaceCoordinator) prepareStackPane(
1253	ctx context.Context,
1254) (*stackPaneContext, bool) {
1255	log := logging.FromContext(ctx)
1256	if c.stackedPaneMgr == nil {
1257		log.Warn().Msg("stacked pane manager not available")
1258		return nil, false
1259	}
1260
1261	ws, wsView := c.getActiveWS()
1262	if ws == nil {
1263		log.Warn().Msg("no active workspace")
1264		return nil, false
1265	}
1266
1267	activeNode := ws.ActivePane()
1268	if activeNode == nil || activeNode.Pane == nil {
1269		log.Warn().Msg("no active pane")
1270		return nil, false
1271	}
1272
1273	if wsView == nil {
1274		log.Warn().Msg("no workspace view")
1275		return nil, false
1276	}
1277
1278	activePaneID := activeNode.Pane.ID
1279	originalTitle := c.contentCoord.GetTitle(activePaneID)
1280	if originalTitle == "" {
1281		originalTitle = activeNode.Pane.Title
1282	}
1283	if originalTitle == "" {
1284		originalTitle = defaultPaneTitle
1285	}
1286
1287	return &stackPaneContext{
1288		ws:            ws,
1289		wsView:        wsView,
1290		activeNode:    activeNode,
1291		activePaneID:  activePaneID,
1292		originalTitle: originalTitle,
1293	}, true
1294}
1295
1296func (c *WorkspaceCoordinator) ensureStackNode(
1297	ctx context.Context,
1298	activeNode *entity.PaneNode,
1299	activePaneID entity.PaneID,
1300	originalTitle string,
1301) (*entity.PaneNode, bool) {
1302	log := logging.FromContext(ctx)
1303	if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1304		stackNode := activeNode.Parent
1305		log.Debug().
1306			Str("stack_id", stackNode.ID).
1307			Str("active_pane", string(activePaneID)).
1308			Int("stack_size", len(stackNode.Children)).
1309			Msg("adding to existing stack via parent")
1310		return stackNode, false
1311	}
1312
1313	if activeNode.IsStacked {
1314		return activeNode, false
1315	}
1316
1317	originalPane := activeNode.Pane
1318	originalPane.Title = originalTitle
1319	originalPaneChild := &entity.PaneNode{
1320		ID:     activeNode.ID + "_0",
1321		Pane:   originalPane,
1322		Parent: activeNode,
1323	}
1324
1325	activeNode.Pane = nil
1326	activeNode.IsStacked = true
1327	activeNode.Children = []*entity.PaneNode{originalPaneChild}
1328
1329	log.Debug().
1330		Str("node_id", activeNode.ID).
1331		Str("original_pane", string(originalPane.ID)).
1332		Str("original_title", originalTitle).
1333		Msg("converted leaf to stacked node")
1334
1335	return activeNode, true
1336}
1337
1338func insertStackChild(stackNode *entity.PaneNode, newPaneID entity.PaneID, newPane *entity.Pane) int {
1339	newChildNode := &entity.PaneNode{
1340		ID:     stackNode.ID + "_" + string(newPaneID),
1341		Pane:   newPane,
1342		Parent: stackNode,
1343	}
1344
1345	currentActiveIndex := stackNode.ActiveStackIndex
1346	if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1347		currentActiveIndex = len(stackNode.Children) - 1
1348		if currentActiveIndex < 0 {
1349			currentActiveIndex = -1
1350		}
1351	}
1352	insertIndex := currentActiveIndex + 1
1353
1354	stackNode.Children = append(stackNode.Children, nil)
1355	copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1356	stackNode.Children[insertIndex] = newChildNode
1357	stackNode.ActiveStackIndex = insertIndex
1358	return insertIndex
1359}
1360
1361func (c *WorkspaceCoordinator) updateFirstStackTitle(
1362	ctx context.Context,
1363	wsView *component.WorkspaceView,
1364	activePaneID entity.PaneID,
1365	originalTitle string,
1366) {
1367	log := logging.FromContext(ctx)
1368	tr := wsView.TreeRenderer()
1369	if tr == nil {
1370		return
1371	}
1372	stackedView := tr.GetStackedViewForPane(string(activePaneID))
1373	if stackedView == nil {
1374		return
1375	}
1376	if err := stackedView.UpdateTitle(0, originalTitle); err != nil {
1377		log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title")
1378		return
1379	}
1380	log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView")
1381}
1382
1383// NavigateStack navigates up or down within a stacked pane container.
1384func (c *WorkspaceCoordinator) NavigateStack(ctx context.Context, direction string) error {
1385	log := logging.FromContext(ctx)
1386
1387	if c.stackedPaneMgr == nil {
1388		return nil
1389	}
1390
1391	ws, wsView := c.getActiveWS()
1392	if ws == nil || wsView == nil {
1393		return nil
1394	}
1395
1396	activePane := ws.ActivePane()
1397	if activePane == nil || activePane.Pane == nil {
1398		return nil
1399	}
1400
1401	currentPaneID := activePane.Pane.ID
1402
1403	// Check if we're actually in a multi-pane stack
1404	if !c.stackedPaneMgr.IsStacked(wsView, currentPaneID) {
1405		log.Debug().Msg("current pane is not in a multi-pane stack")
1406		return nil
1407	}
1408
1409	// Navigate within the stack
1410	_, err := c.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction)
1411	if err != nil {
1412		log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack")
1413		return err
1414	}
1415
1416	log.Debug().
1417		Str("direction", direction).
1418		Str("current_pane", string(currentPaneID)).
1419		Msg("navigated stack")
1420
1421	return nil
1422}
1423
1424// onTitleBarClick handles clicks on title bars to switch the active pane in a stack.
1425func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) {
1426	log := logging.FromContext(ctx)
1427
1428	if stackNode == nil || sv == nil {
1429		return
1430	}
1431
1432	// Validate index
1433	if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) {
1434		log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked")
1435		return
1436	}
1437
1438	// Get the current active index
1439	currentIndex := sv.ActiveIndex()
1440	if clickedIndex == currentIndex {
1441		log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active")
1442		return
1443	}
1444
1445	// Update the outgoing pane's title bar with its current webpage title
1446	if currentIndex >= 0 && currentIndex < len(stackNode.Children) {
1447		outgoingChild := stackNode.Children[currentIndex]
1448		if outgoingChild.Pane != nil {
1449			outgoingPaneID := outgoingChild.Pane.ID
1450			outgoingTitle := c.contentCoord.GetTitle(outgoingPaneID)
1451			if outgoingTitle == "" {
1452				outgoingTitle = outgoingChild.Pane.Title
1453			}
1454			if outgoingTitle != "" {
1455				if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil {
1456					log.Warn().Err(err).Msg("failed to update outgoing pane title")
1457				}
1458			}
1459		}
1460	}
1461
1462	// Get the pane ID at the clicked index
1463	clickedChild := stackNode.Children[clickedIndex]
1464	if clickedChild.Pane == nil {
1465		log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane")
1466		return
1467	}
1468	clickedPaneID := clickedChild.Pane.ID
1469
1470	// Update StackedView active index
1471	if err := sv.SetActive(ctx, clickedIndex); err != nil {
1472		log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
1473		return
1474	}
1475
1476	// Update domain model
1477	stackNode.ActiveStackIndex = clickedIndex
1478
1479	// Update workspace view (also updates domain model)
1480	_, wsView := c.getActiveWS()
1481	if wsView != nil {
1482		if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
1483			log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1484		}
1485	}
1486
1487	log.Info().
1488		Int("from_index", currentIndex).
1489		Int("to_index", clickedIndex).
1490		Str("pane_id", string(clickedPaneID)).
1491		Msg("switched active pane via title bar click")
1492}
1493
1494// --- Popup Insertion ---
1495
1496// SetOnCreatePopupTab sets the callback for creating popup tabs.
1497// This is used when popup behavior is "tabbed".
1498func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input InsertPopupInput) error) {
1499	c.onCreatePopupTab = fn
1500}
1501
1502// InsertPopup inserts a popup pane into the workspace based on the specified behavior.
1503// Supports split, stacked, and tabbed behaviors.
1504func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopupInput) error {
1505	log := logging.FromContext(ctx)
1506
1507	log.Debug().
1508		Str("parent_pane", string(input.ParentPaneID)).
1509		Str("popup_pane", string(input.PopupPane.ID)).
1510		Str("behavior", string(input.Behavior)).
1511		Str("placement", input.Placement).
1512		Str("popup_type", input.PopupType.String()).
1513		Msg("inserting popup into workspace")
1514
1515	switch input.Behavior {
1516	case config.PopupBehaviorSplit:
1517		return c.insertPopupSplit(ctx, input)
1518	case config.PopupBehaviorStacked:
1519		return c.insertPopupStacked(ctx, input)
1520	case config.PopupBehaviorTabbed:
1521		return c.insertPopupTabbed(ctx, input)
1522	default:
1523		// Default to split right
1524		return c.insertPopupSplit(ctx, input)
1525	}
1526}
1527
1528// insertPopupSplit inserts a popup as a split pane adjacent to the parent.
1529func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input InsertPopupInput) error {
1530	log := logging.FromContext(ctx)
1531
1532	ws, wsView := c.getActiveWS()
1533	if ws == nil {
1534		return fmt.Errorf("no active workspace")
1535	}
1536
1537	// Find parent pane node
1538	parentNode := ws.FindPane(input.ParentPaneID)
1539	if parentNode == nil {
1540		return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1541	}
1542
1543	direction := resolvePopupSplitDirection(input.Placement)
1544
1545	// Check if parent is in a stack - if so, split around the stack
1546	isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
1547		parentNode.Parent != nil && parentNode.Parent.IsStacked
1548
1549	// Get existing widget before domain changes
1550	existingWidget := c.resolvePopupSplitWidget(wsView, parentNode, isStackSplit)
1551
1552	// Perform the split with pre-created pane
1553	output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
1554		Workspace:  ws,
1555		TargetPane: parentNode,
1556		Direction:  direction,
1557		NewPane:    input.PopupPane,
1558	})
1559	if err != nil {
1560		return fmt.Errorf("split for popup: %w", err)
1561	}
1562
1563	// Set popup as active
1564	ws.ActivePaneID = input.PopupPane.ID
1565
1566	// Update UI
1567	if wsView != nil {
1568		c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID)
1569		c.attachPopupWebView(ctx, wsView, input)
1570	}
1571
1572	log.Info().
1573		Str("popup_pane", string(input.PopupPane.ID)).
1574		Str("direction", string(direction)).
1575		Msg("popup inserted as split pane")
1576
1577	return nil
1578}
1579
1580func resolvePopupSplitDirection(placement string) usecase.SplitDirection {
1581	switch placement {
1582	case string(usecase.SplitLeft):
1583		return usecase.SplitLeft
1584	case string(usecase.SplitRight):
1585		return usecase.SplitRight
1586	case "top", string(usecase.SplitUp):
1587		return usecase.SplitUp
1588	case "bottom", string(usecase.SplitDown):
1589		return usecase.SplitDown
1590	default:
1591		return usecase.SplitRight
1592	}
1593}
1594
1595func (c *WorkspaceCoordinator) resolvePopupSplitWidget(
1596	wsView *component.WorkspaceView,
1597	parentNode *entity.PaneNode,
1598	isStackSplit bool,
1599) layout.Widget {
1600	if wsView == nil {
1601		return nil
1602	}
1603
1604	if !isStackSplit {
1605		return wsView.GetRootWidget()
1606	}
1607
1608	tr := wsView.TreeRenderer()
1609	if tr == nil {
1610		return nil
1611	}
1612
1613	stackedView := tr.GetStackedViewForPane(string(parentNode.Pane.ID))
1614	if stackedView == nil {
1615		return nil
1616	}
1617
1618	return stackedView.Widget()
1619}
1620
1621func (c *WorkspaceCoordinator) attachPopupWebView(
1622	ctx context.Context,
1623	wsView *component.WorkspaceView,
1624	input InsertPopupInput,
1625) {
1626	if wsView == nil || input.WebView == nil {
1627		return
1628	}
1629
1630	widget := c.contentCoord.WrapWidget(ctx, input.WebView)
1631	if widget == nil {
1632		return
1633	}
1634
1635	paneView := wsView.GetPaneView(input.PopupPane.ID)
1636	if paneView == nil {
1637		return
1638	}
1639	paneView.SetWebViewWidget(widget)
1640}
1641
1642// insertPopupStacked inserts a popup as a stacked pane on top of the parent.
1643func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
1644	log := logging.FromContext(ctx)
1645
1646	ws, wsView := c.getActiveWS()
1647	if ws == nil {
1648		return fmt.Errorf("no active workspace")
1649	}
1650
1651	parentNode := ws.FindPane(input.ParentPaneID)
1652	if parentNode == nil {
1653		return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1654	}
1655
1656	stackNode := c.resolveStackNode(input, parentNode, log)
1657	if stackNode == nil {
1658		return fmt.Errorf("failed to resolve stack node for popup")
1659	}
1660
1661	c.insertStackChild(stackNode, input.PopupPane)
1662
1663	if wsView == nil {
1664		// No workspace view to update; keep domain model consistent.
1665		ws.ActivePaneID = input.PopupPane.ID
1666	}
1667
1668	if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
1669		return err
1670	}
1671
1672	log.Info().
1673		Str("popup_pane", string(input.PopupPane.ID)).
1674		Int("stack_size", len(stackNode.Children)).
1675		Msg("popup inserted as stacked pane")
1676
1677	return nil
1678}
1679
1680func (c *WorkspaceCoordinator) resolveStackNode(
1681	input InsertPopupInput,
1682	parentNode *entity.PaneNode,
1683	log *zerolog.Logger,
1684) *entity.PaneNode {
1685	if parentNode.Parent != nil && parentNode.Parent.IsStacked {
1686		return parentNode.Parent
1687	}
1688	if parentNode.IsStacked {
1689		return parentNode
1690	}
1691	return c.convertToStackedNode(input, parentNode, log)
1692}
1693
1694func (c *WorkspaceCoordinator) convertToStackedNode(
1695	input InsertPopupInput,
1696	parentNode *entity.PaneNode,
1697	log *zerolog.Logger,
1698) *entity.PaneNode {
1699	originalPane := parentNode.Pane
1700	originalTitle := c.contentCoord.GetTitle(input.ParentPaneID)
1701	if originalTitle == "" {
1702		originalTitle = originalPane.Title
1703	}
1704	if originalTitle == "" {
1705		originalTitle = defaultPaneTitle
1706	}
1707	originalPane.Title = originalTitle
1708
1709	originalPaneChild := &entity.PaneNode{
1710		ID:     parentNode.ID + "_0",
1711		Pane:   originalPane,
1712		Parent: parentNode,
1713	}
1714
1715	parentNode.Pane = nil
1716	parentNode.IsStacked = true
1717	parentNode.Children = []*entity.PaneNode{originalPaneChild}
1718
1719	log.Debug().
1720		Str("node_id", parentNode.ID).
1721		Str("original_pane", string(originalPane.ID)).
1722		Msg("converted leaf to stacked node for popup")
1723
1724	return parentNode
1725}
1726
1727func (c *WorkspaceCoordinator) insertStackChild(stackNode *entity.PaneNode, pane *entity.Pane) {
1728	newChildNode := &entity.PaneNode{
1729		ID:     stackNode.ID + "_popup_" + string(pane.ID),
1730		Pane:   pane,
1731		Parent: stackNode,
1732	}
1733
1734	currentActiveIndex := stackNode.ActiveStackIndex
1735	if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1736		currentActiveIndex = len(stackNode.Children) - 1
1737		if currentActiveIndex < 0 {
1738			currentActiveIndex = -1
1739		}
1740	}
1741	insertIndex := currentActiveIndex + 1
1742
1743	stackNode.Children = append(stackNode.Children, nil)
1744	copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1745	stackNode.Children[insertIndex] = newChildNode
1746	stackNode.ActiveStackIndex = insertIndex
1747}
1748
1749func (c *WorkspaceCoordinator) attachPopupPaneView(
1750	ctx context.Context,
1751	input InsertPopupInput,
1752	wsView *component.WorkspaceView,
1753	stackNode *entity.PaneNode,
1754	log *zerolog.Logger,
1755) error {
1756	if wsView == nil {
1757		return nil
1758	}
1759	newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil)
1760	setupPaneViewHover(ctx, newPaneView, wsView)
1761	wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)
1762
1763	if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil {
1764		log.Error().Err(err).Msg("failed to add popup pane to stack")
1765		return err
1766	}
1767
1768	if input.WebView != nil {
1769		widget := c.contentCoord.WrapWidget(ctx, input.WebView)
1770		if widget != nil {
1771			newPaneView.SetWebViewWidget(widget)
1772		}
1773	}
1774
1775	if err := wsView.SetActivePaneID(input.PopupPane.ID); err != nil {
1776		log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1777	}
1778
1779	tr := wsView.TreeRenderer()
1780	if tr != nil {
1781		stackedView := tr.GetStackedViewForPane(string(input.ParentPaneID))
1782		if stackedView != nil {
1783			capturedStackNode := stackNode
1784			stackedView.SetOnActivate(func(index int) {
1785				c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1786			})
1787		}
1788	}
1789	return nil
1790}
1791
1792// insertPopupTabbed creates a new tab for the popup.
1793func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input InsertPopupInput) error {
1794	log := logging.FromContext(ctx)
1795
1796	if c.onCreatePopupTab == nil {
1797		log.Warn().Msg("tabbed popup behavior requested but no tab handler configured, falling back to split")
1798		return c.insertPopupSplit(ctx, input)
1799	}
1800
1801	// Delegate to tab coordinator via callback
1802	if err := c.onCreatePopupTab(ctx, input); err != nil {
1803		return fmt.Errorf("create popup tab: %w", err)
1804	}
1805
1806	log.Info().
1807		Str("popup_pane", string(input.PopupPane.ID)).
1808		Msg("popup inserted as new tab")
1809
1810	return nil
1811}
1812
1813// ShowZoomToast displays a zoom level toast on the active pane.
1814func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) {
1815	_, wsView := c.getActiveWS()
1816	if wsView == nil {
1817		return
1818	}
1819
1820	paneView := wsView.GetActivePaneView()
1821	if paneView != nil {
1822		paneView.ShowZoomToast(ctx, zoomPercent)
1823	}
1824}
1825
1826// ShowToastOnActivePane displays a toast notification on the active pane.
1827func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, message string, level component.ToastLevel) {
1828	_, wsView := c.getActiveWS()
1829	if wsView == nil {
1830		return
1831	}
1832
1833	paneView := wsView.GetActivePaneView()
1834	if paneView != nil {
1835		paneView.ShowToast(ctx, message, level)
1836	}
1837}

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