v0.30.0 eb3068f

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

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