v0.29.0 21b3150

workspace.go RawHistoryFolder

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

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