v0.26.1 75cd777

workspace.go RawHistoryFolder

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

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