v0.27.1 c78ce83

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

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