v0.33.1 9577fee

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

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