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

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