v0.28.0 3cb3770

workspace.go RawHistoryFolder

1package coordinator
2
3import (
4	"context"
5	"errors"
6	"fmt"
7
8	"github.com/bnema/dumber/internal/application/usecase"
9	"github.com/bnema/dumber/internal/domain/entity"
10	domainurl "github.com/bnema/dumber/internal/domain/url"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/dumber/internal/ui/component"
13	"github.com/bnema/dumber/internal/ui/coordinator/content"
14	"github.com/bnema/dumber/internal/ui/focus"
15	"github.com/bnema/dumber/internal/ui/layout"
16	"github.com/rs/zerolog"
17)
18
19const (
20	defaultPaneTitle = "Untitled"
21	nilString        = "nil"
22)
23
24// WorkspaceCoordinator manages pane operations within a workspace.
25type WorkspaceCoordinator struct {
26	panesUC        *usecase.ManagePanesUseCase
27	focusMgr       *focus.Manager
28	stackedPaneMgr *component.StackedPaneManager
29	widgetFactory  layout.WidgetFactory
30	contentCoord   *content.Coordinator
31
32	// Config-derived values (injected to avoid direct config dependency)
33	newPaneURL           string
34	resizeStepPercent    float64
35	resizeMinPanePercent float64
36
37	// Callbacks to avoid circular dependencies
38	getActiveWS      func() (*entity.Workspace, *component.WorkspaceView)
39	generateID       func() string
40	onCloseLastPane  func(ctx context.Context) error
41	onCreatePopupTab func(ctx context.Context, input content.InsertPopupInput) error // For tabbed popup behavior
42	onStateChanged   func()                                                          // For session snapshots
43	onPaneClosed     func(paneID entity.PaneID)                                      // For pane-specific cleanup hooks
44}
45
46// WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
47type WorkspaceCoordinatorConfig struct {
48	PanesUC              *usecase.ManagePanesUseCase
49	FocusMgr             *focus.Manager
50	StackedPaneMgr       *component.StackedPaneManager
51	WidgetFactory        layout.WidgetFactory
52	ContentCoord         *content.Coordinator
53	GetActiveWS          func() (*entity.Workspace, *component.WorkspaceView)
54	GenerateID           func() string
55	NewPaneURL           string
56	ResizeStepPercent    float64
57	ResizeMinPanePercent float64
58}
59
60type splitContext struct {
61	ws             *entity.Workspace
62	wsView         *component.WorkspaceView
63	activePane     *entity.PaneNode
64	existingWidget layout.Widget
65	isStackSplit   bool
66}
67
68type stackPaneContext struct {
69	ws            *entity.Workspace
70	wsView        *component.WorkspaceView
71	activeNode    *entity.PaneNode
72	activePaneID  entity.PaneID
73	originalTitle string
74}
75
76type incrementalCloseContext struct {
77	parentNode           *entity.PaneNode
78	siblingNode          *entity.PaneNode
79	grandparentNode      *entity.PaneNode
80	parentWidget         layout.Widget
81	siblingIsStartChild  bool
82	parentIsStartInGrand bool
83	precheckReason       string
84}
85
86const (
87	defaultResizeStepPercent    = 0.05
88	defaultResizeMinPanePercent = 0.1
89)
90
91// clampResizeStep clamps resizeStepPercent to (0, 0.5], returning the default (0.05) if out of range.
92func clampResizeStep(v float64) float64 {
93	if v <= 0 || v > 0.5 {
94		return defaultResizeStepPercent
95	}
96	return v
97}
98
99// clampResizeMin clamps resizeMinPanePercent to (0, 0.5), returning the default (0.1) if out of range.
100func clampResizeMin(v float64) float64 {
101	if v <= 0 || v >= 0.5 {
102		return defaultResizeMinPanePercent
103	}
104	return v
105}
106
107// NewWorkspaceCoordinator creates a new WorkspaceCoordinator.
108func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator {
109	log := logging.FromContext(ctx)
110	log.Debug().Msg("creating workspace coordinator")
111
112	return &WorkspaceCoordinator{
113		panesUC:              cfg.PanesUC,
114		focusMgr:             cfg.FocusMgr,
115		stackedPaneMgr:       cfg.StackedPaneMgr,
116		widgetFactory:        cfg.WidgetFactory,
117		contentCoord:         cfg.ContentCoord,
118		getActiveWS:          cfg.GetActiveWS,
119		generateID:           cfg.GenerateID,
120		newPaneURL:           cfg.NewPaneURL,
121		resizeStepPercent:    clampResizeStep(cfg.ResizeStepPercent),
122		resizeMinPanePercent: clampResizeMin(cfg.ResizeMinPanePercent),
123	}
124}
125
126// SetOnCloseLastPane sets the callback for when the last pane is closed.
127func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) error) {
128	c.onCloseLastPane = fn
129}
130
131// SetOnStateChanged sets the callback for when workspace state changes (for session snapshots).
132func (c *WorkspaceCoordinator) SetOnStateChanged(fn func()) {
133	c.onStateChanged = fn
134}
135
136// SetOnPaneClosed sets a callback invoked after a pane is successfully closed.
137func (c *WorkspaceCoordinator) SetOnPaneClosed(fn func(paneID entity.PaneID)) {
138	c.onPaneClosed = fn
139}
140
141// notifyStateChanged triggers the state changed callback if set.
142func (c *WorkspaceCoordinator) notifyStateChanged() {
143	if c.onStateChanged != nil {
144		c.onStateChanged()
145	}
146}
147
148// setupPaneViewHover configures hover-to-focus behavior on a PaneView.
149func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {
150	pv.SetOnHover(func(paneID entity.PaneID) {
151		// Skip if this pane is already active
152		if wsView.GetActivePaneID() == paneID {
153			return
154		}
155
156		// Activate the hovered pane and grab focus
157		if err := wsView.SetActivePaneID(paneID); err == nil {
158			wsView.FocusPane(paneID)
159		}
160	})
161
162	pv.AttachHoverHandler(ctx)
163}
164
165// Split splits the active pane in the given direction.
166func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
167	log := logging.FromContext(ctx)
168
169	splitCtx, ok := c.prepareSplit(ctx, direction)
170	if !ok {
171		return nil
172	}
173
174	output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
175		Workspace:  splitCtx.ws,
176		TargetPane: splitCtx.activePane,
177		Direction:  direction,
178		InitialURL: domainurl.Normalize(c.newPaneURL),
179	})
180	if err != nil {
181		log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
182		return err
183	}
184
185	// Remember old active pane before changing
186	oldActivePaneID := splitCtx.activePane.Pane.ID
187
188	// Set the new pane as active
189	splitCtx.ws.ActivePaneID = output.NewPaneNode.Pane.ID
190
191	// Update the workspace view
192	if splitCtx.wsView != nil {
193		c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
194	}
195
196	if splitCtx.wsView != nil {
197		splitCtx.wsView.NotifyNewPaneCreated(ctx)
198	}
199
200	// Notify state change for session snapshots
201	c.notifyStateChanged()
202
203	log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
204
205	return nil
206}
207
208func (c *WorkspaceCoordinator) prepareSplit(
209	ctx context.Context,
210	direction usecase.SplitDirection,
211) (*splitContext, bool) {
212	log := logging.FromContext(ctx)
213	if c.panesUC == nil {
214		log.Warn().Msg("panes use case not available")
215		return nil, false
216	}
217
218	ws, wsView := c.getActiveWS()
219	if ws == nil {
220		log.Warn().Msg("no active workspace")
221		return nil, false
222	}
223
224	activePane := ws.ActivePane()
225	if activePane == nil {
226		log.Warn().Msg("no active pane to split")
227		return nil, false
228	}
229
230	// Check if active pane is inside a stack
231	isInStack := activePane.Parent != nil && activePane.Parent.IsStacked
232	// isStackSplit is only true for horizontal splits - these use a special code path
233	isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && isInStack
234
235	var existingWidget layout.Widget
236	if wsView != nil {
237		existingWidget = c.resolveSplitWidget(wsView, activePane, isInStack)
238	}
239
240	return &splitContext{
241		ws:             ws,
242		wsView:         wsView,
243		activePane:     activePane,
244		existingWidget: existingWidget,
245		isStackSplit:   isStackSplit,
246	}, true
247}
248
249func (c *WorkspaceCoordinator) resolveSplitWidget(
250	wsView *component.WorkspaceView,
251	activePane *entity.PaneNode,
252	isInStack bool,
253) layout.Widget {
254	if wsView == nil {
255		return nil
256	}
257
258	// When splitting from inside a stack (any direction), we need the stack widget
259	// because the domain model promotes the split to happen around the stack container
260	if isInStack {
261		tr := wsView.TreeRenderer()
262		if tr == nil {
263			return nil
264		}
265
266		stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
267		if stackedView == nil {
268			return nil
269		}
270
271		return stackedView.Widget()
272	}
273
274	return wsView.GetRootWidget()
275}
276
277func (c *WorkspaceCoordinator) applySplitToView(
278	ctx context.Context,
279	wsView *component.WorkspaceView,
280	ws *entity.Workspace,
281	output *usecase.SplitPaneOutput,
282	direction usecase.SplitDirection,
283	existingWidget layout.Widget,
284	isStackSplit bool,
285	oldActivePaneID entity.PaneID,
286) {
287	log := logging.FromContext(ctx)
288	needsAttach := false
289
290	if existingWidget != nil {
291		var splitErr error
292		if isStackSplit {
293			splitErr = c.doIncrementalStackSplit(ctx, wsView, output, direction, existingWidget, oldActivePaneID)
294		} else {
295			splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID)
296		}
297
298		if splitErr != nil {
299			log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")
300			if err := wsView.Rebuild(ctx); err != nil {
301				log.Error().Err(err).Msg("failed to rebuild workspace view")
302			}
303			needsAttach = true
304		}
305	} else {
306		if err := wsView.Rebuild(ctx); err != nil {
307			log.Error().Err(err).Msg("failed to rebuild workspace view")
308		}
309		needsAttach = true
310	}
311
312	if needsAttach {
313		c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
314		c.SetupStackedPaneCallbacks(ctx, ws, wsView)
315	}
316	if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
317		log.Warn().Err(err).Msg("failed to set active pane in workspace view")
318	}
319	wsView.FocusPane(ws.ActivePaneID)
320}
321
322// doIncrementalStackSplit performs an incremental split around an existing stacked pane.
323func (c *WorkspaceCoordinator) doIncrementalStackSplit(
324	ctx context.Context,
325	wsView *component.WorkspaceView,
326	output *usecase.SplitPaneOutput,
327	direction usecase.SplitDirection,
328	existingStackWidget layout.Widget,
329	oldActivePaneID entity.PaneID,
330) error {
331	log := logging.FromContext(ctx)
332	factory := wsView.Factory()
333
334	log.Debug().
335		Str("direction", string(direction)).
336		Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
337		Str("old_active_pane_id", string(oldActivePaneID)).
338		Msg("performing incremental stack split")
339
340	// 1. Deactivate the old active pane (in the stack)
341	if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
342		oldPaneView.SetActive(false)
343	}
344
345	// 2. Determine if this is a root split or non-root split
346	// The grandparent of the new split node tells us where the stack currently sits
347	grandparentNode := output.ParentNode.Parent
348	isRootSplit := grandparentNode == nil
349
350	log.Debug().
351		Bool("is_root_split", isRootSplit).
352		Str("grandparent_id", func() string {
353			if grandparentNode != nil {
354				return grandparentNode.ID
355			}
356			return nilString
357		}()).
358		Msg("stack split: determined split type")
359
360	// 3. Create new PaneView for the new pane (without WebView - will attach later)
361	newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
362	setupPaneViewHover(ctx, newPaneView, wsView)
363
364	// 4. Wrap the new PaneView in a StackedView
365	newStackedView := layout.NewStackedView(factory)
366	newStackedView.AddPane(ctx, string(output.NewPaneNode.Pane.ID), output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
367
368	tr := wsView.TreeRenderer()
369
370	// 5. Handle root vs non-root split differently
371	if isRootSplit {
372		// Root split: stack is direct child of container
373		wsView.ClearRootWidgetRef()
374		wsView.Container().Remove(existingStackWidget)
375
376		// Create a SplitView with the appropriate orientation and child placement
377		var splitView *layout.SplitView
378		if direction == usecase.SplitLeft {
379			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
380				newStackedView.Widget(), existingStackWidget, output.SplitRatio)
381		} else {
382			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
383				existingStackWidget, newStackedView.Widget(), output.SplitRatio)
384		}
385		c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
386
387		wsView.SetRootWidgetDirect(splitView.Widget())
388
389		if tr != nil {
390			tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
391		}
392	} else {
393		// Non-root split: stack is inside another split, need to replace in grandparent
394		if tr == nil {
395			return fmt.Errorf("tree renderer not available for non-root stack split")
396		}
397
398		grandparentWidget := tr.Lookup(grandparentNode.ID)
399		if grandparentWidget == nil {
400			log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found")
401			return fmt.Errorf("grandparent widget not found")
402		}
403
404		panedWidget, ok := grandparentWidget.(layout.PanedWidget)
405		if !ok {
406			log.Warn().Msg("grandparent widget is not a PanedWidget")
407			return fmt.Errorf("grandparent is not a PanedWidget")
408		}
409
410		// Determine if the stack's parent (the new split node) is start or end child
411		isStartChild := grandparentNode.Left() == output.ParentNode
412
413		log.Debug().
414			Bool("is_start_child", isStartChild).
415			Msg("stack split: determined position in grandparent")
416
417		// Clear the position first to unparent the stack widget
418		if isStartChild {
419			panedWidget.SetStartChild(nil)
420		} else {
421			panedWidget.SetEndChild(nil)
422		}
423
424		// Create the new split view
425		var splitView *layout.SplitView
426		if direction == usecase.SplitLeft {
427			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
428				newStackedView.Widget(), existingStackWidget, output.SplitRatio)
429		} else {
430			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
431				existingStackWidget, newStackedView.Widget(), output.SplitRatio)
432		}
433		c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
434
435		// Put the new split view in the grandparent's slot
436		if isStartChild {
437			panedWidget.SetStartChild(splitView.Widget())
438		} else {
439			panedWidget.SetEndChild(splitView.Widget())
440		}
441
442		tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
443
444		log.Debug().
445			Bool("was_start_child", isStartChild).
446			Msg("stack split: replaced stack in grandparent with new split view")
447	}
448
449	// 6. Register the new pane in tracking maps and activate it
450	wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
451	newPaneView.SetActive(true)
452
453	if tr != nil {
454		tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
455	}
456
457	// 7. Attach WebView only for the new pane
458	wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
459	if err != nil {
460		log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
461		return err
462	}
463
464	// Load the default URL for new pane
465	newPaneEntity := output.NewPaneNode.Pane
466	if newPaneEntity.URI != "" {
467		if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
468			log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
469		}
470	}
471
472	widget := c.contentCoord.WrapWidget(ctx, wv)
473	if widget != nil {
474		newPaneView.SetWebViewWidget(widget)
475	}
476
477	log.Debug().Msg("incremental stack split completed successfully")
478	return nil
479}
480
481// doIncrementalSplit performs an incremental split by reusing the existing widget tree.
482// This avoids rebuilding the entire tree which would destroy existing WebView widgets.
483func (c *WorkspaceCoordinator) doIncrementalSplit(
484	ctx context.Context,
485	wsView *component.WorkspaceView,
486	ws *entity.Workspace,
487	output *usecase.SplitPaneOutput,
488	direction usecase.SplitDirection,
489	existingRootWidget layout.Widget,
490	oldActivePaneID entity.PaneID,
491) error {
492	log := logging.FromContext(ctx)
493	factory := wsView.Factory()
494
495	log.Debug().
496		Str("direction", string(direction)).
497		Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
498		Str("old_active_pane_id", string(oldActivePaneID)).
499		Int("pane_count", ws.PaneCount()).
500		Msg("performing incremental split")
501
502	// 1. Deactivate the old active pane
503	if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
504		oldPaneView.SetActive(false)
505	}
506
507	// 2. Get the active pane's StackedView widget (what we're actually splitting)
508	tr := wsView.TreeRenderer()
509	if tr == nil {
510		return fmt.Errorf("tree renderer not available")
511	}
512
513	activeStackedView := tr.GetStackedViewForPane(string(oldActivePaneID))
514	if activeStackedView == nil {
515		log.Warn().Msg("no stacked view found for active pane")
516		return fmt.Errorf("no stacked view found for active pane")
517	}
518	activePaneWidget := activeStackedView.Widget()
519
520	// 3. Determine if this is a root split by checking domain tree
521	// output.ParentNode is the NEW split node (h0)
522	// output.ParentNode's parent in domain tree is the ORIGINAL parent (f0) or nil if root
523	grandparentNode := output.ParentNode.Parent
524	isRootSplit := grandparentNode == nil
525
526	log.Debug().
527		Bool("is_root_split", isRootSplit).
528		Str("grandparent_id", func() string {
529			if grandparentNode != nil {
530				return grandparentNode.ID
531			}
532			return nilString
533		}()).
534		Msg("determined split type")
535
536	// 4. Create new PaneView for the new pane
537	newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
538	setupPaneViewHover(ctx, newPaneView, wsView)
539
540	// 5. Wrap the new PaneView in a StackedView
541	newStackedView := layout.NewStackedView(factory)
542	newStackedView.AddPane(ctx, string(output.NewPaneNode.Pane.ID), output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
543
544	orientation, existingFirst, ok := splitOrientation(direction)
545	if !ok {
546		return fmt.Errorf("unsupported split direction: %s", direction)
547	}
548
549	// 7. Handle root vs non-root split differently
550	if isRootSplit {
551		if err := c.replaceRootSplit(
552			ctx,
553			wsView,
554			tr,
555			factory,
556			existingRootWidget,
557			newStackedView,
558			output,
559			orientation,
560			existingFirst,
561		); err != nil {
562			return err
563		}
564	} else {
565		if err := c.replaceNonRootSplit(
566			ctx,
567			tr,
568			factory,
569			grandparentNode,
570			output,
571			activePaneWidget,
572			newStackedView,
573			orientation,
574			existingFirst,
575		); err != nil {
576			return err
577		}
578	}
579
580	// 8. Register the new pane in tracking maps and activate it
581	wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
582	newPaneView.SetActive(true)
583
584	// Register the new pane's StackedView mapping
585	tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
586
587	// 9. Attach WebView only for the new pane
588	wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
589	if err != nil {
590		log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
591		return err
592	}
593
594	// Load the default URL for new pane
595	newPaneEntity := output.NewPaneNode.Pane
596	if newPaneEntity.URI != "" {
597		if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
598			log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
599		}
600	}
601
602	widget := c.contentCoord.WrapWidget(ctx, wv)
603	if widget != nil {
604		newPaneView.SetWebViewWidget(widget)
605	}
606
607	log.Debug().Msg("incremental split completed successfully")
608	return nil
609}
610
611func splitOrientation(direction usecase.SplitDirection) (layout.Orientation, bool, bool) {
612	switch direction {
613	case usecase.SplitRight:
614		return layout.OrientationHorizontal, true, true
615	case usecase.SplitLeft:
616		return layout.OrientationHorizontal, false, true
617	case usecase.SplitDown:
618		return layout.OrientationVertical, true, true
619	case usecase.SplitUp:
620		return layout.OrientationVertical, false, true
621	default:
622		return layout.OrientationHorizontal, false, false
623	}
624}
625
626func (c *WorkspaceCoordinator) wireSplitRatioPersistence(ctx context.Context, splitView *layout.SplitView, splitNodeID string) {
627	if c == nil || splitView == nil || splitNodeID == "" {
628		return
629	}
630
631	splitView.SetOnRatioChanged(func(ratio float64) {
632		if err := c.SetSplitRatio(ctx, splitNodeID, ratio); err != nil {
633			logging.FromContext(ctx).Warn().Err(err).Str("split_node_id", splitNodeID).Msg("failed to persist split ratio")
634		}
635	})
636}
637
638func (c *WorkspaceCoordinator) replaceRootSplit(
639	ctx context.Context,
640	wsView *component.WorkspaceView,
641	tr *layout.TreeRenderer,
642	factory layout.WidgetFactory,
643	existingRootWidget layout.Widget,
644	newStackedView *layout.StackedView,
645	output *usecase.SplitPaneOutput,
646	orientation layout.Orientation,
647	existingFirst bool,
648) error {
649	wsView.ClearRootWidgetRef()
650	wsView.Container().Remove(existingRootWidget)
651
652	var splitView *layout.SplitView
653	if existingFirst {
654		splitView = layout.NewSplitView(ctx, factory, orientation,
655			existingRootWidget, newStackedView.Widget(), output.SplitRatio)
656	} else {
657		splitView = layout.NewSplitView(ctx, factory, orientation,
658			newStackedView.Widget(), existingRootWidget, output.SplitRatio)
659	}
660	c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
661
662	wsView.SetRootWidgetDirect(splitView.Widget())
663	tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
664
665	logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view")
666	return nil
667}
668
669func (c *WorkspaceCoordinator) replaceNonRootSplit(
670	ctx context.Context,
671	tr *layout.TreeRenderer,
672	factory layout.WidgetFactory,
673	grandparentNode *entity.PaneNode,
674	output *usecase.SplitPaneOutput,
675	activePaneWidget layout.Widget,
676	newStackedView *layout.StackedView,
677	orientation layout.Orientation,
678	existingFirst bool,
679) error {
680	log := logging.FromContext(ctx)
681	grandparentWidget := tr.Lookup(grandparentNode.ID)
682	if grandparentWidget == nil {
683		log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found in TreeRenderer")
684		return fmt.Errorf("grandparent widget not found")
685	}
686
687	panedWidget, ok := grandparentWidget.(layout.PanedWidget)
688	if !ok {
689		log.Warn().Msg("grandparent widget is not a PanedWidget, falling back")
690		return fmt.Errorf("grandparent is not a PanedWidget")
691	}
692
693	isStartChild := grandparentNode.Left() == output.ParentNode
694
695	log.Debug().
696		Bool("is_start_child", isStartChild).
697		Str("grandparent_left_id", func() string {
698			if grandparentNode.Left() != nil {
699				return grandparentNode.Left().ID
700			}
701			return nilString
702		}()).
703		Str("new_parent_id", output.ParentNode.ID).
704		Msg("determined position in grandparent")
705
706	if isStartChild {
707		panedWidget.SetStartChild(nil)
708	} else {
709		panedWidget.SetEndChild(nil)
710	}
711
712	var splitView *layout.SplitView
713	if existingFirst {
714		splitView = layout.NewSplitView(ctx, factory, orientation,
715			activePaneWidget, newStackedView.Widget(), output.SplitRatio)
716	} else {
717		splitView = layout.NewSplitView(ctx, factory, orientation,
718			newStackedView.Widget(), activePaneWidget, output.SplitRatio)
719	}
720	c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
721
722	if isStartChild {
723		panedWidget.SetStartChild(splitView.Widget())
724	} else {
725		panedWidget.SetEndChild(splitView.Widget())
726	}
727
728	tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
729
730	log.Debug().
731		Bool("was_start_child", isStartChild).
732		Msg("non-root split: replaced pane in grandparent with new split view")
733	return nil
734}
735
736// ClosePane closes the active pane.
737func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
738	log := logging.FromContext(ctx)
739
740	if c.panesUC == nil {
741		log.Warn().Msg("panes use case not available")
742		return nil
743	}
744
745	ws, wsView := c.getActiveWS()
746	if ws == nil {
747		log.Warn().Msg("no active workspace")
748		return nil
749	}
750
751	activePane := ws.ActivePane()
752	if activePane == nil {
753		log.Warn().Msg("no active pane to close")
754		return nil
755	}
756	closingPaneID := activePane.Pane.ID
757
758	log.Debug().Str("pane_id", string(closingPaneID)).Msg("closing pane")
759
760	// Don't close the last pane - close the tab instead
761	if ws.PaneCount() <= 1 {
762		if c.onCloseLastPane != nil {
763			return c.onCloseLastPane(ctx)
764		}
765		return nil
766	}
767
768	// BEFORE domain changes: capture incremental close context.
769	closeCtx := c.captureIncrementalCloseContext(wsView, activePane)
770
771	// Now do domain changes
772	_, err := c.panesUC.Close(ctx, ws, activePane)
773	if err != nil {
774		log.Error().Err(err).Msg("failed to close pane")
775		return err
776	}
777
778	c.finalizePaneClose(
779		ctx,
780		wsView,
781		ws,
782		closingPaneID,
783		closeCtx,
784	)
785	if c.onPaneClosed != nil {
786		c.onPaneClosed(closingPaneID)
787	}
788
789	// Notify state change for session snapshots
790	c.notifyStateChanged()
791
792	log.Info().Msg("pane closed")
793	return nil
794}
795
796// ClosePaneByID closes a specific pane by ID.
797// This is used for closing popup panes when window.close() is called.
798func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.PaneID) error {
799	log := logging.FromContext(ctx)
800
801	if c.panesUC == nil {
802		log.Warn().Msg("panes use case not available")
803		return nil
804	}
805
806	ws, wsView := c.getActiveWS()
807	if ws == nil {
808		log.Warn().Msg("no active workspace")
809		return nil
810	}
811
812	// Find the pane node
813	paneNode := ws.FindPane(paneID)
814	if paneNode == nil {
815		log.Warn().Str("pane_id", string(paneID)).Msg("pane not found")
816		return nil
817	}
818
819	log.Debug().Str("pane_id", string(paneID)).Msg("closing pane by ID")
820
821	// Don't close the last pane - close the tab instead
822	if ws.PaneCount() <= 1 {
823		if c.onCloseLastPane != nil {
824			return c.onCloseLastPane(ctx)
825		}
826		return nil
827	}
828
829	// BEFORE domain changes: capture incremental close context.
830	closeCtx := c.captureIncrementalCloseContext(wsView, paneNode)
831
832	// Now do domain changes
833	_, err := c.panesUC.Close(ctx, ws, paneNode)
834	if err != nil {
835		log.Error().Err(err).Msg("failed to close pane")
836		return err
837	}
838
839	c.finalizePaneClose(
840		ctx,
841		wsView,
842		ws,
843		paneID,
844		closeCtx,
845	)
846	if c.onPaneClosed != nil {
847		c.onPaneClosed(paneID)
848	}
849
850	// Notify state change for session snapshots
851	c.notifyStateChanged()
852
853	log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID")
854	return nil
855}
856
857// doIncrementalClose performs incremental close by promoting sibling without rebuild.
858func (c *WorkspaceCoordinator) doIncrementalClose(
859	ctx context.Context,
860	wsView *component.WorkspaceView,
861	closingPaneID entity.PaneID,
862	siblingNode *entity.PaneNode,
863	parentNode *entity.PaneNode,
864	grandparentNode *entity.PaneNode,
865	parentWidget layout.Widget,
866	siblingIsStartChild bool, // true if sibling is start/left child in parent
867	parentIsStartInGrand bool, // true if parent is start/left child in grandparent
868) error {
869	log := logging.FromContext(ctx)
870	if parentNode == nil {
871		return fmt.Errorf("parent node missing")
872	}
873	if siblingNode == nil {
874		return fmt.Errorf("sibling node missing")
875	}
876	if parentWidget == nil {
877		return fmt.Errorf("parent widget missing")
878	}
879
880	tr := wsView.TreeRenderer()
881	if tr == nil {
882		return fmt.Errorf("tree renderer not available")
883	}
884
885	log.Debug().
886		Str("closing_pane", string(closingPaneID)).
887		Str("sibling_id", siblingNode.ID).
888		Bool("sibling_is_leaf", siblingNode.IsLeaf()).
889		Bool("sibling_is_start", siblingIsStartChild).
890		Bool("parent_is_start_in_grand", parentIsStartInGrand).
891		Msg("performing incremental close")
892
893	// Get sibling's widget
894	var siblingWidget layout.Widget
895	if siblingNode.IsLeaf() && siblingNode.Pane != nil {
896		stackedView := tr.GetStackedViewForPane(string(siblingNode.Pane.ID))
897		if stackedView != nil {
898			siblingWidget = stackedView.Widget()
899		}
900	} else {
901		// Sibling is a split node
902		siblingWidget = tr.Lookup(siblingNode.ID)
903	}
904
905	if siblingWidget == nil {
906		return fmt.Errorf("sibling widget not found")
907	}
908
909	// Cast parent widget to PanedWidget
910	panedWidget, ok := parentWidget.(layout.PanedWidget)
911	if !ok {
912		return fmt.Errorf("parent widget is not a PanedWidget")
913	}
914
915	// Remove BOTH children from parent paned before any reparenting
916	// This is critical - GTK requires widgets to be unparented before reparenting
917	// Order: unparent closing pane first, then sibling
918	if siblingIsStartChild {
919		// Sibling is start, closing pane is end
920		panedWidget.SetEndChild(nil)   // Unparent closing pane
921		panedWidget.SetStartChild(nil) // Unparent sibling
922	} else {
923		// Sibling is end, closing pane is start
924		panedWidget.SetStartChild(nil) // Unparent closing pane
925		panedWidget.SetEndChild(nil)   // Unparent sibling
926	}
927
928	if grandparentNode == nil {
929		// Sibling becomes new root
930		// Remove the old root (parent paned) from container manually since
931		// ClearRootWidgetRef only clears the reference, not the actual widget
932		wsView.Container().Remove(parentWidget)
933		wsView.ClearRootWidgetRef()
934
935		// Grab focus on sibling before reparenting to avoid GTK focus warnings
936		siblingWidget.GrabFocus()
937
938		// Ensure sibling expands to fill container (was constrained by paned before)
939		siblingWidget.SetHexpand(true)
940		siblingWidget.SetVexpand(true)
941
942		wsView.SetRootWidgetDirect(siblingWidget)
943
944		log.Debug().Msg("sibling promoted to root")
945	} else {
946		// Replace parent with sibling in grandparent
947		grandparentWidget := tr.Lookup(grandparentNode.ID)
948		if grandparentWidget == nil {
949			return fmt.Errorf("grandparent widget not found")
950		}
951
952		grandPaned, ok := grandparentWidget.(layout.PanedWidget)
953		if !ok {
954			return fmt.Errorf("grandparent widget is not a PanedWidget")
955		}
956
957		// Grab focus on sibling before reparenting to avoid GTK focus warnings
958		siblingWidget.GrabFocus()
959
960		// Remove parent from grandparent using known position from domain tree
961		if parentIsStartInGrand {
962			grandPaned.SetStartChild(nil)
963		} else {
964			grandPaned.SetEndChild(nil)
965		}
966
967		// Insert sibling at same position
968		if parentIsStartInGrand {
969			grandPaned.SetStartChild(siblingWidget)
970		} else {
971			grandPaned.SetEndChild(siblingWidget)
972		}
973
974		log.Debug().Bool("was_start_child", parentIsStartInGrand).Msg("sibling promoted in grandparent")
975	}
976
977	// Clean up tracking
978	tr.UnregisterWidget(parentNode.ID)
979	wsView.UnregisterPaneView(closingPaneID)
980	tr.UnregisterPane(string(closingPaneID))
981
982	// Release the closing pane's webview
983	c.contentCoord.ReleaseWebView(ctx, closingPaneID)
984
985	// Activate sibling
986	if siblingNode.IsLeaf() && siblingNode.Pane != nil {
987		if siblingPaneView := wsView.GetPaneView(siblingNode.Pane.ID); siblingPaneView != nil {
988			siblingPaneView.SetActive(true)
989		}
990	}
991
992	log.Debug().Msg("incremental close completed successfully")
993	return nil
994}
995
996func (c *WorkspaceCoordinator) finalizePaneClose(
997	ctx context.Context,
998	wsView *component.WorkspaceView,
999	ws *entity.Workspace,
1000	paneID entity.PaneID,
1001	closeCtx incrementalCloseContext,
1002) {
1003	if wsView == nil {
1004		return
1005	}
1006
1007	log := logging.FromContext(ctx)
1008
1009	if closeCtx.parentNode != nil {
1010		var closeErr error
1011
1012		if closeCtx.parentNode.IsStacked {
1013			closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, closeCtx.parentNode)
1014		} else if closeCtx.precheckReason != "" {
1015			closeErr = fmt.Errorf("incremental close precheck failed: %s", closeCtx.precheckReason)
1016		} else if closeCtx.siblingNode != nil && closeCtx.parentWidget != nil {
1017			closeErr = c.doIncrementalClose(
1018				ctx,
1019				wsView,
1020				paneID,
1021				closeCtx.siblingNode,
1022				closeCtx.parentNode,
1023				closeCtx.grandparentNode,
1024				closeCtx.parentWidget,
1025				closeCtx.siblingIsStartChild,
1026				closeCtx.parentIsStartInGrand,
1027			)
1028		} else {
1029			closeErr = fmt.Errorf("missing context for incremental close")
1030		}
1031
1032		if closeErr != nil {
1033			fallbackLog := log.Warn().Err(closeErr).
1034				Str("pane_id", string(paneID)).
1035				Str("parent_id", paneNodeID(closeCtx.parentNode)).
1036				Str("sibling_id", paneNodeID(closeCtx.siblingNode)).
1037				Str("grandparent_id", paneNodeID(closeCtx.grandparentNode)).
1038				Bool("has_parent_widget", closeCtx.parentWidget != nil)
1039			if closeCtx.precheckReason != "" {
1040				fallbackLog = fallbackLog.Str("precheck_reason", closeCtx.precheckReason)
1041			}
1042			fallbackLog.Msg("incremental close failed, falling back to rebuild")
1043			if err := wsView.Rebuild(ctx); err != nil {
1044				log.Error().Err(err).Msg("failed to rebuild workspace view")
1045			}
1046			c.contentCoord.ReleaseWebView(ctx, paneID)
1047			c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1048			c.SetupStackedPaneCallbacks(ctx, ws, wsView)
1049		}
1050	} else {
1051		if err := wsView.Rebuild(ctx); err != nil {
1052			log.Error().Err(err).Msg("failed to rebuild workspace view")
1053		}
1054		c.contentCoord.ReleaseWebView(ctx, paneID)
1055		c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1056		c.SetupStackedPaneCallbacks(ctx, ws, wsView)
1057	}
1058
1059	if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
1060		log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1061	}
1062	wsView.FocusPane(ws.ActivePaneID)
1063}
1064
1065func (c *WorkspaceCoordinator) captureIncrementalCloseContext(
1066	wsView *component.WorkspaceView,
1067	closingPane *entity.PaneNode,
1068) incrementalCloseContext {
1069	closeCtx, err := deriveIncrementalCloseTreeContext(closingPane)
1070	if err != nil {
1071		closeCtx.precheckReason = err.Error()
1072		return closeCtx
1073	}
1074
1075	if closeCtx.parentNode == nil || closeCtx.parentNode.IsStacked {
1076		return closeCtx
1077	}
1078
1079	if wsView == nil {
1080		closeCtx.precheckReason = "workspace view unavailable"
1081		return closeCtx
1082	}
1083
1084	tr := wsView.TreeRenderer()
1085	if tr == nil {
1086		closeCtx.precheckReason = "tree renderer unavailable"
1087		return closeCtx
1088	}
1089
1090	closeCtx.parentWidget = tr.Lookup(closeCtx.parentNode.ID)
1091	if closeCtx.parentWidget == nil {
1092		closeCtx.precheckReason = "parent widget missing"
1093	}
1094
1095	return closeCtx
1096}
1097
1098func deriveIncrementalCloseTreeContext(closingPane *entity.PaneNode) (incrementalCloseContext, error) {
1099	var closeCtx incrementalCloseContext
1100	if closingPane == nil {
1101		return closeCtx, errors.New("closing pane missing")
1102	}
1103
1104	parentNode := closingPane.Parent
1105	if parentNode == nil {
1106		return closeCtx, errors.New("closing pane has no parent")
1107	}
1108
1109	closeCtx.parentNode = parentNode
1110	closeCtx.grandparentNode = parentNode.Parent
1111
1112	if parentNode.IsStacked {
1113		return closeCtx, nil
1114	}
1115
1116	if !parentNode.IsSplit() {
1117		return closeCtx, fmt.Errorf("parent node is not split: %s", parentNode.ID)
1118	}
1119
1120	if len(parentNode.Children) != 2 {
1121		return closeCtx, fmt.Errorf("split parent has invalid child count: %d", len(parentNode.Children))
1122	}
1123
1124	leftChild := parentNode.Left()
1125	rightChild := parentNode.Right()
1126	if leftChild == closingPane && rightChild == nil {
1127		return closeCtx, errors.New("sibling missing")
1128	}
1129	if rightChild == closingPane && leftChild == nil {
1130		return closeCtx, errors.New("sibling missing")
1131	}
1132	if leftChild == nil || rightChild == nil {
1133		return closeCtx, errors.New("split parent has nil child")
1134	}
1135
1136	switch {
1137	case leftChild == closingPane:
1138		closeCtx.siblingNode = rightChild
1139		closeCtx.siblingIsStartChild = false
1140	case rightChild == closingPane:
1141		closeCtx.siblingNode = leftChild
1142		closeCtx.siblingIsStartChild = true
1143	default:
1144		return closeCtx, fmt.Errorf("closing pane not found under parent: %s", parentNode.ID)
1145	}
1146
1147	if closeCtx.siblingNode == nil {
1148		return closeCtx, errors.New("sibling missing")
1149	}
1150
1151	if closeCtx.grandparentNode != nil {
1152		switch {
1153		case closeCtx.grandparentNode.Left() == parentNode:
1154			closeCtx.parentIsStartInGrand = true
1155		case closeCtx.grandparentNode.Right() == parentNode:
1156			closeCtx.parentIsStartInGrand = false
1157		default:
1158			return closeCtx, fmt.Errorf("parent not found under grandparent: %s", closeCtx.grandparentNode.ID)
1159		}
1160	}
1161
1162	return closeCtx, nil
1163}
1164
1165func paneNodeID(node *entity.PaneNode) string {
1166	if node == nil {
1167		return nilString
1168	}
1169	return node.ID
1170}
1171
1172// doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
1173func (c *WorkspaceCoordinator) doIncrementalStackClose(
1174	ctx context.Context,
1175	wsView *component.WorkspaceView,
1176	closingPaneID entity.PaneID,
1177	stackNode *entity.PaneNode,
1178) error {
1179	log := logging.FromContext(ctx)
1180	tr := wsView.TreeRenderer()
1181	if tr == nil {
1182		return fmt.Errorf("tree renderer not available")
1183	}
1184
1185	log.Debug().
1186		Str("closing_pane", string(closingPaneID)).
1187		Str("stack_node", stackNode.ID).
1188		Int("stack_children", len(stackNode.Children)).
1189		Msg("performing incremental stack close")
1190
1191	// Get the StackedView for this pane
1192	stackedView := tr.GetStackedViewForPane(string(closingPaneID))
1193	if stackedView == nil {
1194		return fmt.Errorf("stacked view not found for pane %s", closingPaneID)
1195	}
1196
1197	// Find the index of the closing pane in the StackedView UI
1198	closingIndex := stackedView.FindPaneIndex(string(closingPaneID))
1199	if closingIndex < 0 {
1200		return fmt.Errorf("pane %s not found in stacked view", closingPaneID)
1201	}
1202
1203	// Remove the pane from the StackedView widget
1204	if err := stackedView.RemovePane(ctx, closingIndex); err != nil {
1205		return fmt.Errorf("failed to remove pane from stacked view: %w", err)
1206	}
1207
1208	// Clean up tracking
1209	wsView.UnregisterPaneView(closingPaneID)
1210	tr.UnregisterPane(string(closingPaneID))
1211
1212	// Release the closing pane's webview
1213	c.contentCoord.ReleaseWebView(ctx, closingPaneID)
1214
1215	// Sync remaining title bars with current titles from content.Coordinator.
1216	// The domain model (stackNode.Children) has already been updated by the use case,
1217	// so we iterate the remaining children and update their titles in the UI.
1218	c.syncStackedPaneTitles(ctx, stackedView, stackNode)
1219
1220	log.Debug().
1221		Int("removed_index", closingIndex).
1222		Msg("incremental stack close completed successfully")
1223	return nil
1224}
1225
1226// syncStackedPaneTitles updates all title bars in a StackedView with current titles.
1227func (c *WorkspaceCoordinator) syncStackedPaneTitles(ctx context.Context, stackedView *layout.StackedView, stackNode *entity.PaneNode) {
1228	log := logging.FromContext(ctx)
1229
1230	if stackedView == nil || stackNode == nil {
1231		return
1232	}
1233
1234	// Get UI pane count to avoid index out of bounds when domain and UI are out of sync
1235	uiPaneCount := stackedView.Count()
1236
1237	for i, child := range stackNode.Children {
1238		if child.Pane == nil {
1239			continue
1240		}
1241
1242		// Skip if index exceeds UI pane count (domain/UI desync)
1243		if i >= uiPaneCount {
1244			log.Debug().
1245				Int("index", i).
1246				Int("ui_count", uiPaneCount).
1247				Str("pane_id", string(child.Pane.ID)).
1248				Msg("skipping title sync: index exceeds UI pane count")
1249			continue
1250		}
1251
1252		title := c.contentCoord.GetTitle(child.Pane.ID)
1253		if title == "" {
1254			title = child.Pane.Title
1255		}
1256		if title == "" {
1257			title = defaultPaneTitle
1258		}
1259
1260		if err := stackedView.UpdateTitle(i, title); err != nil {
1261			log.Warn().
1262				Err(err).
1263				Int("index", i).
1264				Str("pane_id", string(child.Pane.ID)).
1265				Msg("failed to sync title bar")
1266		}
1267	}
1268}
1269
1270// FocusPane navigates focus to an adjacent pane.
1271func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {
1272	log := logging.FromContext(ctx)
1273
1274	ws, wsView := c.getActiveWS()
1275	if ws == nil {
1276		log.Warn().Msg("no active workspace")
1277		return nil
1278	}
1279
1280	if wsView == nil {
1281		log.Warn().Msg("no active workspace view")
1282		return nil
1283	}
1284
1285	// Save old active pane ID before navigation updates the domain model
1286	oldActivePaneID := ws.ActivePaneID
1287
1288	// Use geometric navigation if focus manager is available
1289	var newPane *entity.PaneNode
1290	var err error
1291
1292	if c.focusMgr != nil {
1293		newPane, err = c.focusMgr.NavigateGeometric(ctx, ws, wsView, direction)
1294	} else if c.panesUC != nil {
1295		// Fallback to structural navigation
1296		newPane, err = c.panesUC.NavigateFocus(ctx, ws, direction)
1297	} else {
1298		log.Warn().Msg("no navigation manager available")
1299		return nil
1300	}
1301
1302	if err != nil {
1303		log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
1304		return err
1305	}
1306
1307	if newPane == nil {
1308		log.Debug().Str("direction", string(direction)).Msg("no pane in that direction")
1309		return nil
1310	}
1311
1312	// Cancel pending hover timers and suppress future hover focus (Issue #89)
1313	wsView.CancelAllPendingHovers()
1314	wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1315
1316	// Deactivate old pane explicitly (domain model already updated by NavigateGeometric)
1317	if oldActivePaneID != "" && oldActivePaneID != newPane.Pane.ID {
1318		wsView.DeactivatePane(oldActivePaneID)
1319	}
1320
1321	// Update the workspace view's active pane
1322	if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
1323		log.Warn().Err(err).Msg("failed to update active pane in view")
1324	} else {
1325		wsView.FocusPane(newPane.Pane.ID)
1326	}
1327
1328	// Sync StackedView visibility if new pane is in a stack
1329	if newPane.Parent != nil && newPane.Parent.IsStacked {
1330		c.syncStackedViewActive(ctx, wsView, newPane)
1331	}
1332
1333	log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")
1334
1335	return nil
1336}
1337
1338// syncStackedViewActive updates the StackedView's visibility to match the domain model.
1339func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView *component.WorkspaceView, paneNode *entity.PaneNode) {
1340	log := logging.FromContext(ctx)
1341
1342	if paneNode == nil || paneNode.Parent == nil || !paneNode.Parent.IsStacked {
1343		return
1344	}
1345
1346	tr := wsView.TreeRenderer()
1347	if tr == nil {
1348		return
1349	}
1350
1351	// Get the StackedView for this pane
1352	stackedView := tr.GetStackedViewForPane(string(paneNode.Pane.ID))
1353	if stackedView == nil {
1354		log.Warn().Str("pane_id", string(paneNode.Pane.ID)).Msg("stacked view not found for pane")
1355		return
1356	}
1357
1358	// Use the parent's ActiveStackIndex which was set by the focus manager
1359	stackIndex := paneNode.Parent.ActiveStackIndex
1360
1361	log.Debug().
1362		Str("pane_id", string(paneNode.Pane.ID)).
1363		Int("stack_index", stackIndex).
1364		Msg("syncing stacked view visibility")
1365
1366	if err := stackedView.SetActive(ctx, stackIndex); err != nil {
1367		log.Warn().Err(err).Msg("failed to set stacked view active index")
1368	}
1369}
1370
1371// StackPane adds a new pane stacked on top of the active pane.
1372// Uses CreateStack use case for new stacks, AddToStack for existing stacks.
1373func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1374	log := logging.FromContext(ctx)
1375
1376	stackCtx, ok := c.prepareStackPane(ctx)
1377	if !ok {
1378		return nil
1379	}
1380
1381	// Create new pane entity
1382	newPaneID := entity.PaneID(c.generateID())
1383	newPane := entity.NewPane(newPaneID)
1384	newPane.URI = domainurl.Normalize(c.newPaneURL)
1385	newPane.Title = defaultPaneTitle
1386
1387	// Determine if we need to create a new stack or add to existing
1388	var stackNode *entity.PaneNode
1389	var needsFirstPaneTitleUpdate bool
1390
1391	if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
1392		// Already in a stack - use AddToStack use case
1393		stackNode = stackCtx.activeNode.Parent
1394		output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1395		if err != nil {
1396			log.Error().Err(err).Msg("failed to add pane to stack via use case")
1397			return err
1398		}
1399		log.Debug().
1400			Int("stack_size", len(stackNode.Children)).
1401			Int("insert_index", output.StackIndex).
1402			Msg("added to existing stack via use case")
1403	} else if stackCtx.activeNode.IsStacked {
1404		// Active node is already a stack container - add to it
1405		stackNode = stackCtx.activeNode
1406		output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1407		if err != nil {
1408			log.Error().Err(err).Msg("failed to add pane to stack via use case")
1409			return err
1410		}
1411		log.Debug().
1412			Int("stack_size", len(stackNode.Children)).
1413			Int("insert_index", output.StackIndex).
1414			Msg("added to stack container via use case")
1415	} else {
1416		// Need to create a new stack - use CreateStack use case
1417		// But CreateStack creates its own pane, so we need a different approach
1418		// Convert to stack first, then the new pane is already created
1419		output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode)
1420		if err != nil {
1421			log.Error().Err(err).Msg("failed to create stack via use case")
1422			return err
1423		}
1424		stackNode = output.StackNode
1425		// CreateStack creates its own new pane, use that instead
1426		newPane = output.NewPane
1427		newPaneID = newPane.ID
1428		needsFirstPaneTitleUpdate = true
1429
1430		// Update the original pane's title in the domain
1431		if output.OriginalNode != nil && output.OriginalNode.Pane != nil {
1432			output.OriginalNode.Pane.Title = stackCtx.originalTitle
1433		}
1434
1435		log.Debug().
1436			Int("stack_size", len(stackNode.Children)).
1437			Msg("created new stack via use case")
1438	}
1439
1440	// Create PaneView for the new pane
1441	newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
1442	setupPaneViewHover(ctx, newPaneView, stackCtx.wsView)
1443	stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)
1444
1445	// Add to the UI StackedView
1446	if err := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle); err != nil {
1447		log.Error().Err(err).Msg("failed to add pane to stack")
1448		return err
1449	}
1450
1451	// Update the first pane's title if we just converted from leaf to stacked
1452	if needsFirstPaneTitleUpdate {
1453		c.updateFirstStackTitle(ctx, stackCtx.wsView, stackCtx.activePaneID, stackCtx.originalTitle)
1454	}
1455
1456	// Get WebView and attach
1457	wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)
1458	if err != nil {
1459		log.Warn().Err(err).Msg("failed to get webview for new pane")
1460	} else {
1461		widget := c.contentCoord.WrapWidget(ctx, wv)
1462		if widget != nil {
1463			newPaneView.SetWebViewWidget(widget)
1464		}
1465		// Load initial page for the new pane
1466		if err := wv.LoadURI(ctx, newPane.URI); err != nil {
1467			log.Warn().Err(err).Str("uri", newPane.URI).Msg("failed to load initial page")
1468		}
1469	}
1470
1471	// Update workspace view
1472	if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
1473		log.Warn().Err(err).Msg("failed to set active pane")
1474	}
1475
1476	stackCtx.wsView.NotifyNewPaneCreated(ctx)
1477
1478	// Set up title bar click and close callbacks
1479	tr := stackCtx.wsView.TreeRenderer()
1480	if tr != nil {
1481		stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))
1482		if stackedView != nil {
1483			capturedStackNode := stackNode
1484			stackedView.SetOnActivate(func(index int) {
1485				c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1486			})
1487			stackedView.SetOnClosePane(func(paneID string) {
1488				c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1489			})
1490		}
1491	}
1492
1493	// Notify state change for session snapshots
1494	c.notifyStateChanged()
1495
1496	log.Info().
1497		Str("original_pane", string(stackCtx.activePaneID)).
1498		Str("new_pane", string(newPaneID)).
1499		Int("stack_size", len(stackNode.Children)).
1500		Msg("stacked new pane")
1501
1502	return nil
1503}
1504
1505func (c *WorkspaceCoordinator) prepareStackPane(
1506	ctx context.Context,
1507) (*stackPaneContext, bool) {
1508	log := logging.FromContext(ctx)
1509	if c.stackedPaneMgr == nil {
1510		log.Warn().Msg("stacked pane manager not available")
1511		return nil, false
1512	}
1513
1514	ws, wsView := c.getActiveWS()
1515	if ws == nil {
1516		log.Warn().Msg("no active workspace")
1517		return nil, false
1518	}
1519
1520	activeNode := ws.ActivePane()
1521	if activeNode == nil || activeNode.Pane == nil {
1522		log.Warn().Msg("no active pane")
1523		return nil, false
1524	}
1525
1526	if wsView == nil {
1527		log.Warn().Msg("no workspace view")
1528		return nil, false
1529	}
1530
1531	activePaneID := activeNode.Pane.ID
1532	originalTitle := c.contentCoord.GetTitle(activePaneID)
1533	if originalTitle == "" {
1534		originalTitle = activeNode.Pane.Title
1535	}
1536	if originalTitle == "" {
1537		originalTitle = defaultPaneTitle
1538	}
1539
1540	return &stackPaneContext{
1541		ws:            ws,
1542		wsView:        wsView,
1543		activeNode:    activeNode,
1544		activePaneID:  activePaneID,
1545		originalTitle: originalTitle,
1546	}, true
1547}
1548
1549func (c *WorkspaceCoordinator) updateFirstStackTitle(
1550	ctx context.Context,
1551	wsView *component.WorkspaceView,
1552	activePaneID entity.PaneID,
1553	originalTitle string,
1554) {
1555	log := logging.FromContext(ctx)
1556	tr := wsView.TreeRenderer()
1557	if tr == nil {
1558		return
1559	}
1560	stackedView := tr.GetStackedViewForPane(string(activePaneID))
1561	if stackedView == nil {
1562		return
1563	}
1564	if err := stackedView.UpdateTitle(0, originalTitle); err != nil {
1565		log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title")
1566		return
1567	}
1568	log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView")
1569}
1570
1571// NavigateStack navigates up or down within a stacked pane container.
1572func (c *WorkspaceCoordinator) NavigateStack(ctx context.Context, direction string) error {
1573	log := logging.FromContext(ctx)
1574
1575	if c.stackedPaneMgr == nil {
1576		return nil
1577	}
1578
1579	ws, wsView := c.getActiveWS()
1580	if ws == nil || wsView == nil {
1581		return nil
1582	}
1583
1584	activePane := ws.ActivePane()
1585	if activePane == nil || activePane.Pane == nil {
1586		return nil
1587	}
1588
1589	currentPaneID := activePane.Pane.ID
1590
1591	// Check if we're actually in a multi-pane stack
1592	if !c.stackedPaneMgr.IsStacked(wsView, currentPaneID) {
1593		log.Debug().Msg("current pane is not in a multi-pane stack")
1594		return nil
1595	}
1596
1597	// Navigate within the stack
1598	_, err := c.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction)
1599	if err != nil {
1600		log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack")
1601		return err
1602	}
1603
1604	log.Debug().
1605		Str("direction", direction).
1606		Str("current_pane", string(currentPaneID)).
1607		Msg("navigated stack")
1608
1609	return nil
1610}
1611
1612// onTitleBarClick handles clicks on title bars to switch the active pane in a stack.
1613func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) {
1614	log := logging.FromContext(ctx)
1615
1616	if stackNode == nil || sv == nil {
1617		return
1618	}
1619
1620	// Validate index
1621	if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) {
1622		log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked")
1623		return
1624	}
1625
1626	// Get the current active index
1627	currentIndex := sv.ActiveIndex()
1628	if clickedIndex == currentIndex {
1629		log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active")
1630		return
1631	}
1632
1633	// Update the outgoing pane's title bar with its current webpage title
1634	if currentIndex >= 0 && currentIndex < len(stackNode.Children) {
1635		outgoingChild := stackNode.Children[currentIndex]
1636		if outgoingChild.Pane != nil {
1637			outgoingPaneID := outgoingChild.Pane.ID
1638			outgoingTitle := c.contentCoord.GetTitle(outgoingPaneID)
1639			if outgoingTitle == "" {
1640				outgoingTitle = outgoingChild.Pane.Title
1641			}
1642			if outgoingTitle != "" {
1643				if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil {
1644					log.Warn().Err(err).Msg("failed to update outgoing pane title")
1645				}
1646			}
1647		}
1648	}
1649
1650	// Get the pane ID at the clicked index
1651	clickedChild := stackNode.Children[clickedIndex]
1652	if clickedChild.Pane == nil {
1653		log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane")
1654		return
1655	}
1656	clickedPaneID := clickedChild.Pane.ID
1657
1658	// Sync the incoming pane's title bar with its current WebView title
1659	// to avoid showing a stale title after navigation occurred while hidden.
1660	incomingTitle := c.contentCoord.GetTitle(clickedPaneID)
1661	if incomingTitle == "" {
1662		incomingTitle = clickedChild.Pane.Title
1663	}
1664	if incomingTitle != "" {
1665		if err := sv.UpdateTitle(clickedIndex, incomingTitle); err != nil {
1666			log.Warn().Err(err).Msg("failed to update incoming pane title")
1667		}
1668	}
1669
1670	// Update StackedView active index
1671	if err := sv.SetActive(ctx, clickedIndex); err != nil {
1672		log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
1673		return
1674	}
1675
1676	// Update domain model
1677	stackNode.ActiveStackIndex = clickedIndex
1678
1679	// Update workspace view (also updates domain model)
1680	_, wsView := c.getActiveWS()
1681	if wsView != nil {
1682		// Cancel pending hover timers and suppress future hover focus (Issue #89)
1683		wsView.CancelAllPendingHovers()
1684		wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1685
1686		if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
1687			log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1688		}
1689	}
1690
1691	log.Info().
1692		Int("from_index", currentIndex).
1693		Int("to_index", clickedIndex).
1694		Str("pane_id", string(clickedPaneID)).
1695		Msg("switched active pane via title bar click")
1696}
1697
1698// onStackedPaneClose handles close button clicks on stacked pane title bars.
1699func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID entity.PaneID) {
1700	log := logging.FromContext(ctx)
1701
1702	log.Debug().Str("pane_id", string(paneID)).Msg("closing stacked pane via close button")
1703
1704	if err := c.ClosePaneByID(ctx, paneID); err != nil {
1705		log.Error().Err(err).Str("pane_id", string(paneID)).Msg("failed to close stacked pane")
1706	}
1707}
1708
1709// SetupStackedPaneCallbacks sets up title bar click and close callbacks for all stacked panes.
1710// This must be called after Rebuild() to restore callbacks on newly created StackedView instances.
1711func (c *WorkspaceCoordinator) SetupStackedPaneCallbacks(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
1712	log := logging.FromContext(ctx)
1713
1714	if ws == nil || ws.Root == nil || wsView == nil {
1715		return
1716	}
1717
1718	tr := wsView.TreeRenderer()
1719	if tr == nil {
1720		return
1721	}
1722
1723	// Walk the tree and set up callbacks for all stacked nodes
1724	ws.Root.Walk(func(node *entity.PaneNode) bool {
1725		if !node.IsStacked || len(node.Children) == 0 {
1726			return true
1727		}
1728
1729		// Get the StackedView for any pane in this stack
1730		firstChild := node.Children[0]
1731		if firstChild.Pane == nil {
1732			return true
1733		}
1734
1735		stackedView := tr.GetStackedViewForPane(string(firstChild.Pane.ID))
1736		if stackedView == nil {
1737			return true
1738		}
1739
1740		capturedStackNode := node
1741		stackedView.SetOnActivate(func(index int) {
1742			c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1743		})
1744		stackedView.SetOnClosePane(func(paneID string) {
1745			c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1746		})
1747
1748		// Populate stacked title bar favicons from cache.
1749		// When panes are stacked (split → stack conversion) or restored from session,
1750		// the title bars are created with default icons. We need to apply any cached
1751		// favicon textures so they display correctly without waiting for a new navigation.
1752		if fa := c.contentCoord.FaviconAdapter(); fa != nil {
1753			for i, child := range node.Children {
1754				if child.Pane == nil {
1755					continue
1756				}
1757				// Try to get the pane's current URI from its WebView
1758				uri := child.Pane.URI
1759				if wv := c.contentCoord.GetWebView(child.Pane.ID); wv != nil {
1760					if currentURI := wv.URI(); currentURI != "" {
1761						uri = currentURI
1762					}
1763				}
1764				if uri == "" {
1765					continue
1766				}
1767				if texture := fa.GetTextureByURL(uri); texture != nil {
1768					if err := stackedView.UpdateFaviconTexture(i, texture); err == nil {
1769						log.Debug().
1770							Str("pane_id", string(child.Pane.ID)).
1771							Str("domain", domainurl.ExtractDomain(uri)).
1772							Msg("populated stacked title bar favicon from cache")
1773					}
1774				}
1775			}
1776		}
1777
1778		log.Debug().
1779			Str("stack_id", node.ID).
1780			Int("stack_size", len(node.Children)).
1781			Msg("set up stacked pane callbacks")
1782
1783		return true
1784	})
1785}
1786
1787// --- Popup Insertion ---
1788
1789// SetOnCreatePopupTab sets the callback for creating popup tabs.
1790// This is used when popup behavior is "tabbed".
1791func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input content.InsertPopupInput) error) {
1792	c.onCreatePopupTab = fn
1793}
1794
1795// InsertPopup inserts a popup pane into the workspace based on the specified behavior.
1796// Supports split, stacked, and tabbed behaviors.
1797func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input content.InsertPopupInput) error {
1798	log := logging.FromContext(ctx)
1799
1800	log.Debug().
1801		Str("parent_pane", string(input.ParentPaneID)).
1802		Str("popup_pane", string(input.PopupPane.ID)).
1803		Str("behavior", string(input.Behavior)).
1804		Str("placement", input.Placement).
1805		Str("popup_type", input.PopupType.String()).
1806		Msg("inserting popup into workspace")
1807
1808	switch input.Behavior {
1809	case entity.PopupBehaviorSplit:
1810		return c.insertPopupSplit(ctx, input)
1811	case entity.PopupBehaviorStacked:
1812		return c.insertPopupStacked(ctx, input)
1813	case entity.PopupBehaviorTabbed:
1814		return c.insertPopupTabbed(ctx, input)
1815	default:
1816		// Default to split right
1817		return c.insertPopupSplit(ctx, input)
1818	}
1819}
1820
1821// insertPopupSplit inserts a popup as a split pane adjacent to the parent.
1822func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input content.InsertPopupInput) error {
1823	log := logging.FromContext(ctx)
1824
1825	ws, wsView := c.getActiveWS()
1826	if ws == nil {
1827		return fmt.Errorf("no active workspace")
1828	}
1829
1830	// Find parent pane node
1831	parentNode := ws.FindPane(input.ParentPaneID)
1832	if parentNode == nil {
1833		return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1834	}
1835
1836	direction := resolvePopupSplitDirection(input.Placement)
1837
1838	// Check if parent is in a stack - if so, split around the stack
1839	isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
1840		parentNode.Parent != nil && parentNode.Parent.IsStacked
1841
1842	// Get existing widget before domain changes
1843	existingWidget := c.resolvePopupSplitWidget(wsView, parentNode, isStackSplit)
1844
1845	// Perform the split with pre-created pane
1846	output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
1847		Workspace:  ws,
1848		TargetPane: parentNode,
1849		Direction:  direction,
1850		NewPane:    input.PopupPane,
1851	})
1852	if err != nil {
1853		return fmt.Errorf("split for popup: %w", err)
1854	}
1855
1856	// Set popup as active
1857	ws.ActivePaneID = input.PopupPane.ID
1858
1859	// Update UI
1860	if wsView != nil {
1861		c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID)
1862		c.attachPopupWebView(ctx, wsView, input)
1863	}
1864
1865	c.notifyStateChanged()
1866
1867	log.Info().
1868		Str("popup_pane", string(input.PopupPane.ID)).
1869		Str("direction", string(direction)).
1870		Msg("popup inserted as split pane")
1871
1872	return nil
1873}
1874
1875func resolvePopupSplitDirection(placement string) usecase.SplitDirection {
1876	switch placement {
1877	case string(usecase.SplitLeft):
1878		return usecase.SplitLeft
1879	case string(usecase.SplitRight):
1880		return usecase.SplitRight
1881	case "top", string(usecase.SplitUp):
1882		return usecase.SplitUp
1883	case "bottom", string(usecase.SplitDown):
1884		return usecase.SplitDown
1885	default:
1886		return usecase.SplitRight
1887	}
1888}
1889
1890func (c *WorkspaceCoordinator) resolvePopupSplitWidget(
1891	wsView *component.WorkspaceView,
1892	parentNode *entity.PaneNode,
1893	isStackSplit bool,
1894) layout.Widget {
1895	if wsView == nil {
1896		return nil
1897	}
1898
1899	if !isStackSplit {
1900		return wsView.GetRootWidget()
1901	}
1902
1903	tr := wsView.TreeRenderer()
1904	if tr == nil {
1905		return nil
1906	}
1907
1908	stackedView := tr.GetStackedViewForPane(string(parentNode.Pane.ID))
1909	if stackedView == nil {
1910		return nil
1911	}
1912
1913	return stackedView.Widget()
1914}
1915
1916func (c *WorkspaceCoordinator) attachPopupWebView(
1917	ctx context.Context,
1918	wsView *component.WorkspaceView,
1919	input content.InsertPopupInput,
1920) {
1921	if wsView == nil || input.WebView == nil {
1922		return
1923	}
1924
1925	widget := c.contentCoord.WrapWidget(ctx, input.WebView)
1926	if widget == nil {
1927		return
1928	}
1929
1930	paneView := wsView.GetPaneView(input.PopupPane.ID)
1931	if paneView == nil {
1932		return
1933	}
1934	paneView.SetWebViewWidget(widget)
1935}
1936
1937// insertPopupStacked inserts a popup as a stacked pane on top of the parent.
1938// Uses CreateStack and AddToStack use cases for proper domain model management.
1939func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input content.InsertPopupInput) error {
1940	log := logging.FromContext(ctx)
1941
1942	ws, wsView := c.getActiveWS()
1943	if ws == nil {
1944		return fmt.Errorf("no active workspace")
1945	}
1946
1947	parentNode := ws.FindPane(input.ParentPaneID)
1948	if parentNode == nil {
1949		return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1950	}
1951
1952	// Resolve or create stack node (track conversion for potential rollback)
1953	stackNode, conversionInfo := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
1954
1955	// Add popup pane to stack using use case
1956	if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane); err != nil {
1957		// Rollback stack conversion if we created a new stack
1958		conversionInfo.revert()
1959		return fmt.Errorf("failed to add popup to stack: %w", err)
1960	}
1961
1962	if wsView == nil {
1963		// No workspace view to update; domain model already updated by use case.
1964		return nil
1965	}
1966
1967	if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
1968		// UI update failed - rollback domain changes to maintain consistency
1969		log.Warn().Err(err).Msg("rolling back domain changes due to UI failure")
1970
1971		// Remove popup pane from stack
1972		if removeErr := c.panesUC.RemoveFromStack(ctx, stackNode, input.PopupPane.ID); removeErr != nil {
1973			log.Error().Err(removeErr).Msg("failed to rollback popup pane addition")
1974		}
1975
1976		// Revert stack conversion if we created a new stack
1977		conversionInfo.revert()
1978
1979		return err
1980	}
1981
1982	c.notifyStateChanged()
1983
1984	log.Info().
1985		Str("popup_pane", string(input.PopupPane.ID)).
1986		Int("stack_size", len(stackNode.Children)).
1987		Msg("popup inserted as stacked pane")
1988
1989	return nil
1990}
1991
1992// stackConversionInfo holds state needed to revert a leaf-to-stack conversion.
1993type stackConversionInfo struct {
1994	wasConverted bool             // true if resolveOrCreateStackNode converted a leaf to a stack
1995	originalPane *entity.Pane     // the pane that was in the node before conversion
1996	node         *entity.PaneNode // the node that was converted
1997}
1998
1999// revert undoes a leaf-to-stack conversion.
2000func (info *stackConversionInfo) revert() {
2001	if !info.wasConverted || info.node == nil {
2002		return
2003	}
2004
2005	// Restore node to leaf state
2006	info.node.Pane = info.originalPane
2007	info.node.IsStacked = false
2008	info.node.ActiveStackIndex = 0
2009	info.node.Children = nil
2010}
2011
2012// resolveOrCreateStackNode finds an existing stack or creates one for popup insertion.
2013// Note: For popups, we can't use CreateStack use case since it creates a new pane,
2014// but we already have the popup pane. This conversion is a special case.
2015// Returns the stack node and conversion info for potential rollback.
2016func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
2017	ctx context.Context,
2018	parentNode *entity.PaneNode,
2019	parentPaneID entity.PaneID,
2020) (*entity.PaneNode, stackConversionInfo) {
2021	log := logging.FromContext(ctx)
2022
2023	// Check if parent is already in a stack
2024	if parentNode.Parent != nil && parentNode.Parent.IsStacked {
2025		log.Debug().
2026			Str("stack_id", parentNode.Parent.ID).
2027			Msg("using existing parent stack for popup")
2028		return parentNode.Parent, stackConversionInfo{}
2029	}
2030
2031	// Check if parent is already a stack container
2032	if parentNode.IsStacked {
2033		log.Debug().
2034			Str("stack_id", parentNode.ID).
2035			Msg("parent is already a stack container")
2036		return parentNode, stackConversionInfo{}
2037	}
2038
2039	// Need to convert leaf to stack for popup insertion.
2040	// We can't use CreateStack use case since it creates a new pane,
2041	// but we already have the popup pane to insert.
2042	originalPane := parentNode.Pane
2043	originalTitle := c.contentCoord.GetTitle(parentPaneID)
2044	if originalTitle == "" {
2045		originalTitle = originalPane.Title
2046	}
2047	if originalTitle == "" {
2048		originalTitle = defaultPaneTitle
2049	}
2050	originalPane.Title = originalTitle
2051
2052	// Create child node for the original pane
2053	originalPaneChild := &entity.PaneNode{
2054		ID:     parentNode.ID + "_0",
2055		Pane:   originalPane,
2056		Parent: parentNode,
2057	}
2058
2059	// Convert parentNode to stack container
2060	parentNode.Pane = nil
2061	parentNode.IsStacked = true
2062	parentNode.ActiveStackIndex = 0
2063	parentNode.Children = []*entity.PaneNode{originalPaneChild}
2064
2065	log.Debug().
2066		Str("node_id", parentNode.ID).
2067		Str("original_pane", string(originalPane.ID)).
2068		Msg("converted leaf to stacked node for popup")
2069
2070	return parentNode, stackConversionInfo{
2071		wasConverted: true,
2072		originalPane: originalPane,
2073		node:         parentNode,
2074	}
2075}
2076
2077func (c *WorkspaceCoordinator) attachPopupPaneView(
2078	ctx context.Context,
2079	input content.InsertPopupInput,
2080	wsView *component.WorkspaceView,
2081	stackNode *entity.PaneNode,
2082	log *zerolog.Logger,
2083) error {
2084	if wsView == nil {
2085		return nil
2086	}
2087	newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil)
2088	setupPaneViewHover(ctx, newPaneView, wsView)
2089	wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)
2090
2091	if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil {
2092		wsView.UnregisterPaneView(input.PopupPane.ID)
2093		log.Error().Err(err).Msg("failed to add popup pane to stack")
2094		return err
2095	}
2096
2097	if input.WebView != nil {
2098		widget := c.contentCoord.WrapWidget(ctx, input.WebView)
2099		if widget != nil {
2100			newPaneView.SetWebViewWidget(widget)
2101		}
2102	}
2103
2104	if err := wsView.SetActivePaneID(input.PopupPane.ID); err != nil {
2105		log.Warn().Err(err).Msg("failed to set active pane in workspace view")
2106	}
2107
2108	tr := wsView.TreeRenderer()
2109	if tr != nil {
2110		stackedView := tr.GetStackedViewForPane(string(input.ParentPaneID))
2111		if stackedView != nil {
2112			capturedStackNode := stackNode
2113			stackedView.SetOnActivate(func(index int) {
2114				c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
2115			})
2116			stackedView.SetOnClosePane(func(paneID string) {
2117				c.onStackedPaneClose(ctx, entity.PaneID(paneID))
2118			})
2119		}
2120	}
2121	return nil
2122}
2123
2124// insertPopupTabbed creates a new tab for the popup.
2125func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input content.InsertPopupInput) error {
2126	log := logging.FromContext(ctx)
2127
2128	if c.onCreatePopupTab == nil {
2129		log.Warn().Msg("tabbed popup behavior requested but no tab handler configured, falling back to split")
2130		return c.insertPopupSplit(ctx, input)
2131	}
2132
2133	// Delegate to tab coordinator via callback
2134	if err := c.onCreatePopupTab(ctx, input); err != nil {
2135		return fmt.Errorf("create popup tab: %w", err)
2136	}
2137
2138	log.Info().
2139		Str("popup_pane", string(input.PopupPane.ID)).
2140		Msg("popup inserted as new tab")
2141
2142	return nil
2143}
2144
2145func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction usecase.ConsumeOrExpelDirection) error {
2146	log := logging.FromContext(ctx)
2147
2148	if c.panesUC == nil {
2149		log.Warn().Msg("panes use case not available")
2150		return nil
2151	}
2152
2153	ws, wsView := c.getActiveWS()
2154	if ws == nil {
2155		log.Warn().Msg("no active workspace")
2156		return nil
2157	}
2158
2159	activeNode := ws.ActivePane()
2160	if activeNode == nil {
2161		log.Warn().Msg("no active pane")
2162		return nil
2163	}
2164
2165	result, err := c.panesUC.ConsumeOrExpel(ctx, ws, activeNode, direction)
2166	if err != nil {
2167		return err
2168	}
2169	if result != nil && result.ErrorMessage != "" {
2170		c.ShowToastOnActivePane(ctx, result.ErrorMessage, component.ToastWarning)
2171		return nil
2172	}
2173
2174	if wsView != nil {
2175		if err := wsView.Rebuild(ctx); err != nil {
2176			log.Warn().Err(err).Msg("failed to rebuild workspace view")
2177		}
2178		c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
2179		c.SetupStackedPaneCallbacks(ctx, ws, wsView)
2180		if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
2181			log.Warn().Err(err).Msg("failed to set active pane in workspace view")
2182		}
2183		wsView.FocusPane(ws.ActivePaneID)
2184	}
2185
2186	c.notifyStateChanged()
2187	return nil
2188}
2189
2190// ShowZoomToast displays a zoom level toast on the active pane.
2191func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) {
2192	_, wsView := c.getActiveWS()
2193	if wsView == nil {
2194		return
2195	}
2196
2197	paneView := wsView.GetActivePaneView()
2198	if paneView != nil {
2199		paneView.ShowZoomToast(ctx, zoomPercent)
2200	}
2201}
2202
2203// ShowToastOnActivePane displays a toast notification on the active pane.
2204func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, message string, level component.ToastLevel) {
2205	_, wsView := c.getActiveWS()
2206	if wsView == nil {
2207		return
2208	}
2209
2210	paneView := wsView.GetActivePaneView()
2211	if paneView != nil {
2212		paneView.ShowToast(ctx, message, level)
2213	}
2214}
2215
2216// Resize updates the active split ratio and applies it to GTK widgets.
2217func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDirection) error {
2218	log := logging.FromContext(ctx)
2219
2220	if c.panesUC == nil {
2221		log.Warn().Msg("panes use case not available")
2222		return nil
2223	}
2224
2225	ws, wsView := c.getActiveWS()
2226	if ws == nil {
2227		log.Warn().Msg("no active workspace")
2228		return nil
2229	}
2230
2231	target := ws.ActivePane()
2232	if target == nil {
2233		return nil
2234	}
2235
2236	err := c.panesUC.Resize(ctx, ws, target, dir, c.resizeStepPercent, c.resizeMinPanePercent)
2237	if errors.Is(err, usecase.ErrNothingToResize) {
2238		c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)
2239		return nil
2240	}
2241	if err != nil {
2242		return err
2243	}
2244
2245	if wsView != nil {
2246		c.updateSplitPositions(wsView, ws)
2247	}
2248
2249	c.notifyStateChanged()
2250	return nil
2251}
2252
2253func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID string, ratio float64) error {
2254	log := logging.FromContext(ctx)
2255
2256	if c.panesUC == nil {
2257		log.Warn().Msg("panes use case not available")
2258		return nil
2259	}
2260
2261	ws, _ := c.getActiveWS()
2262	if ws == nil {
2263		log.Warn().Msg("no active workspace")
2264		return nil
2265	}
2266
2267	err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{
2268		Workspace:      ws,
2269		SplitNodeID:    splitNodeID,
2270		Ratio:          ratio,
2271		MinPanePercent: c.resizeMinPanePercent,
2272	})
2273	if err != nil {
2274		return err
2275	}
2276
2277	c.notifyStateChanged()
2278	return nil
2279}
2280
2281func (c *WorkspaceCoordinator) updateSplitPositions(wsView *component.WorkspaceView, ws *entity.Workspace) {
2282	if wsView == nil || ws == nil || ws.Root == nil {
2283		return
2284	}
2285
2286	tr := wsView.TreeRenderer()
2287	if tr == nil {
2288		return
2289	}
2290
2291	ws.Root.Walk(func(node *entity.PaneNode) bool {
2292		if node.IsSplit() {
2293			_ = tr.UpdateSplitRatio(node.ID, node.SplitRatio)
2294		}
2295		return true
2296	})
2297}

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