v0.25.0 2e6c485

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	for i, child := range stackNode.Children {
1139		if child.Pane == nil {
1140			continue
1141		}
1142
1143		title := c.contentCoord.GetTitle(child.Pane.ID)
1144		if title == "" {
1145			title = child.Pane.Title
1146		}
1147		if title == "" {
1148			title = defaultPaneTitle
1149		}
1150
1151		if err := stackedView.UpdateTitle(i, title); err != nil {
1152			log.Warn().
1153				Err(err).
1154				Int("index", i).
1155				Str("pane_id", string(child.Pane.ID)).
1156				Msg("failed to sync title bar")
1157		}
1158	}
1159}
1160
1161// FocusPane navigates focus to an adjacent pane.
1162func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {
1163	log := logging.FromContext(ctx)
1164
1165	ws, wsView := c.getActiveWS()
1166	if ws == nil {
1167		log.Warn().Msg("no active workspace")
1168		return nil
1169	}
1170
1171	if wsView == nil {
1172		log.Warn().Msg("no active workspace view")
1173		return nil
1174	}
1175
1176	// Save old active pane ID before navigation updates the domain model
1177	oldActivePaneID := ws.ActivePaneID
1178
1179	// Use geometric navigation if focus manager is available
1180	var newPane *entity.PaneNode
1181	var err error
1182
1183	if c.focusMgr != nil {
1184		newPane, err = c.focusMgr.NavigateGeometric(ctx, ws, wsView, direction)
1185	} else if c.panesUC != nil {
1186		// Fallback to structural navigation
1187		newPane, err = c.panesUC.NavigateFocus(ctx, ws, direction)
1188	} else {
1189		log.Warn().Msg("no navigation manager available")
1190		return nil
1191	}
1192
1193	if err != nil {
1194		log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
1195		return err
1196	}
1197
1198	if newPane == nil {
1199		log.Debug().Str("direction", string(direction)).Msg("no pane in that direction")
1200		return nil
1201	}
1202
1203	// Cancel pending hover timers and suppress future hover focus (Issue #89)
1204	wsView.CancelAllPendingHovers()
1205	wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1206
1207	// Deactivate old pane explicitly (domain model already updated by NavigateGeometric)
1208	if oldActivePaneID != "" && oldActivePaneID != newPane.Pane.ID {
1209		wsView.DeactivatePane(oldActivePaneID)
1210	}
1211
1212	// Update the workspace view's active pane
1213	if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
1214		log.Warn().Err(err).Msg("failed to update active pane in view")
1215	} else {
1216		wsView.FocusPane(newPane.Pane.ID)
1217	}
1218
1219	// Sync StackedView visibility if new pane is in a stack
1220	if newPane.Parent != nil && newPane.Parent.IsStacked {
1221		c.syncStackedViewActive(ctx, wsView, newPane)
1222	}
1223
1224	log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")
1225
1226	return nil
1227}
1228
1229// syncStackedViewActive updates the StackedView's visibility to match the domain model.
1230func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView *component.WorkspaceView, paneNode *entity.PaneNode) {
1231	log := logging.FromContext(ctx)
1232
1233	if paneNode == nil || paneNode.Parent == nil || !paneNode.Parent.IsStacked {
1234		return
1235	}
1236
1237	tr := wsView.TreeRenderer()
1238	if tr == nil {
1239		return
1240	}
1241
1242	// Get the StackedView for this pane
1243	stackedView := tr.GetStackedViewForPane(string(paneNode.Pane.ID))
1244	if stackedView == nil {
1245		log.Warn().Str("pane_id", string(paneNode.Pane.ID)).Msg("stacked view not found for pane")
1246		return
1247	}
1248
1249	// Use the parent's ActiveStackIndex which was set by the focus manager
1250	stackIndex := paneNode.Parent.ActiveStackIndex
1251
1252	log.Debug().
1253		Str("pane_id", string(paneNode.Pane.ID)).
1254		Int("stack_index", stackIndex).
1255		Msg("syncing stacked view visibility")
1256
1257	if err := stackedView.SetActive(ctx, stackIndex); err != nil {
1258		log.Warn().Err(err).Msg("failed to set stacked view active index")
1259	}
1260}
1261
1262// StackPane adds a new pane stacked on top of the active pane.
1263// Uses CreateStack use case for new stacks, AddToStack for existing stacks.
1264func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1265	log := logging.FromContext(ctx)
1266
1267	stackCtx, ok := c.prepareStackPane(ctx)
1268	if !ok {
1269		return nil
1270	}
1271
1272	// Create new pane entity
1273	newPaneID := entity.PaneID(c.generateID())
1274	newPane := entity.NewPane(newPaneID)
1275	newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)
1276	newPane.Title = defaultPaneTitle
1277
1278	// Determine if we need to create a new stack or add to existing
1279	var stackNode *entity.PaneNode
1280	var needsFirstPaneTitleUpdate bool
1281
1282	if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
1283		// Already in a stack - use AddToStack use case
1284		stackNode = stackCtx.activeNode.Parent
1285		output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1286		if err != nil {
1287			log.Error().Err(err).Msg("failed to add pane to stack via use case")
1288			return err
1289		}
1290		log.Debug().
1291			Int("stack_size", len(stackNode.Children)).
1292			Int("insert_index", output.StackIndex).
1293			Msg("added to existing stack via use case")
1294	} else if stackCtx.activeNode.IsStacked {
1295		// Active node is already a stack container - add to it
1296		stackNode = stackCtx.activeNode
1297		output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1298		if err != nil {
1299			log.Error().Err(err).Msg("failed to add pane to stack via use case")
1300			return err
1301		}
1302		log.Debug().
1303			Int("stack_size", len(stackNode.Children)).
1304			Int("insert_index", output.StackIndex).
1305			Msg("added to stack container via use case")
1306	} else {
1307		// Need to create a new stack - use CreateStack use case
1308		// But CreateStack creates its own pane, so we need a different approach
1309		// Convert to stack first, then the new pane is already created
1310		output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode)
1311		if err != nil {
1312			log.Error().Err(err).Msg("failed to create stack via use case")
1313			return err
1314		}
1315		stackNode = output.StackNode
1316		// CreateStack creates its own new pane, use that instead
1317		newPane = output.NewPane
1318		newPaneID = newPane.ID
1319		needsFirstPaneTitleUpdate = true
1320
1321		// Update the original pane's title in the domain
1322		if output.OriginalNode != nil && output.OriginalNode.Pane != nil {
1323			output.OriginalNode.Pane.Title = stackCtx.originalTitle
1324		}
1325
1326		log.Debug().
1327			Int("stack_size", len(stackNode.Children)).
1328			Msg("created new stack via use case")
1329	}
1330
1331	// Create PaneView for the new pane
1332	newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
1333	setupPaneViewHover(ctx, newPaneView, stackCtx.wsView)
1334	stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)
1335
1336	// Add to the UI StackedView
1337	if err := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle); err != nil {
1338		log.Error().Err(err).Msg("failed to add pane to stack")
1339		return err
1340	}
1341
1342	// Update the first pane's title if we just converted from leaf to stacked
1343	if needsFirstPaneTitleUpdate {
1344		c.updateFirstStackTitle(ctx, stackCtx.wsView, stackCtx.activePaneID, stackCtx.originalTitle)
1345	}
1346
1347	// Get WebView and attach
1348	wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)
1349	if err != nil {
1350		log.Warn().Err(err).Msg("failed to get webview for new pane")
1351	} else {
1352		widget := c.contentCoord.WrapWidget(ctx, wv)
1353		if widget != nil {
1354			newPaneView.SetWebViewWidget(widget)
1355		}
1356		// Load initial page for the new pane
1357		if err := wv.LoadURI(ctx, newPane.URI); err != nil {
1358			log.Warn().Err(err).Str("uri", newPane.URI).Msg("failed to load initial page")
1359		}
1360	}
1361
1362	// Update workspace view
1363	if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
1364		log.Warn().Err(err).Msg("failed to set active pane")
1365	}
1366
1367	if config.Get().Omnibox.AutoOpenOnNewPane {
1368		stackCtx.wsView.ShowOmnibox(ctx, "")
1369	}
1370
1371	// Set up title bar click and close callbacks
1372	tr := stackCtx.wsView.TreeRenderer()
1373	if tr != nil {
1374		stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))
1375		if stackedView != nil {
1376			capturedStackNode := stackNode
1377			stackedView.SetOnActivate(func(index int) {
1378				c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1379			})
1380			stackedView.SetOnClosePane(func(paneID string) {
1381				c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1382			})
1383		}
1384	}
1385
1386	// Notify state change for session snapshots
1387	c.notifyStateChanged()
1388
1389	log.Info().
1390		Str("original_pane", string(stackCtx.activePaneID)).
1391		Str("new_pane", string(newPaneID)).
1392		Int("stack_size", len(stackNode.Children)).
1393		Msg("stacked new pane")
1394
1395	return nil
1396}
1397
1398func (c *WorkspaceCoordinator) prepareStackPane(
1399	ctx context.Context,
1400) (*stackPaneContext, bool) {
1401	log := logging.FromContext(ctx)
1402	if c.stackedPaneMgr == nil {
1403		log.Warn().Msg("stacked pane manager not available")
1404		return nil, false
1405	}
1406
1407	ws, wsView := c.getActiveWS()
1408	if ws == nil {
1409		log.Warn().Msg("no active workspace")
1410		return nil, false
1411	}
1412
1413	activeNode := ws.ActivePane()
1414	if activeNode == nil || activeNode.Pane == nil {
1415		log.Warn().Msg("no active pane")
1416		return nil, false
1417	}
1418
1419	if wsView == nil {
1420		log.Warn().Msg("no workspace view")
1421		return nil, false
1422	}
1423
1424	activePaneID := activeNode.Pane.ID
1425	originalTitle := c.contentCoord.GetTitle(activePaneID)
1426	if originalTitle == "" {
1427		originalTitle = activeNode.Pane.Title
1428	}
1429	if originalTitle == "" {
1430		originalTitle = defaultPaneTitle
1431	}
1432
1433	return &stackPaneContext{
1434		ws:            ws,
1435		wsView:        wsView,
1436		activeNode:    activeNode,
1437		activePaneID:  activePaneID,
1438		originalTitle: originalTitle,
1439	}, true
1440}
1441
1442func (c *WorkspaceCoordinator) updateFirstStackTitle(
1443	ctx context.Context,
1444	wsView *component.WorkspaceView,
1445	activePaneID entity.PaneID,
1446	originalTitle string,
1447) {
1448	log := logging.FromContext(ctx)
1449	tr := wsView.TreeRenderer()
1450	if tr == nil {
1451		return
1452	}
1453	stackedView := tr.GetStackedViewForPane(string(activePaneID))
1454	if stackedView == nil {
1455		return
1456	}
1457	if err := stackedView.UpdateTitle(0, originalTitle); err != nil {
1458		log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title")
1459		return
1460	}
1461	log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView")
1462}
1463
1464// NavigateStack navigates up or down within a stacked pane container.
1465func (c *WorkspaceCoordinator) NavigateStack(ctx context.Context, direction string) error {
1466	log := logging.FromContext(ctx)
1467
1468	if c.stackedPaneMgr == nil {
1469		return nil
1470	}
1471
1472	ws, wsView := c.getActiveWS()
1473	if ws == nil || wsView == nil {
1474		return nil
1475	}
1476
1477	activePane := ws.ActivePane()
1478	if activePane == nil || activePane.Pane == nil {
1479		return nil
1480	}
1481
1482	currentPaneID := activePane.Pane.ID
1483
1484	// Check if we're actually in a multi-pane stack
1485	if !c.stackedPaneMgr.IsStacked(wsView, currentPaneID) {
1486		log.Debug().Msg("current pane is not in a multi-pane stack")
1487		return nil
1488	}
1489
1490	// Navigate within the stack
1491	_, err := c.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction)
1492	if err != nil {
1493		log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack")
1494		return err
1495	}
1496
1497	log.Debug().
1498		Str("direction", direction).
1499		Str("current_pane", string(currentPaneID)).
1500		Msg("navigated stack")
1501
1502	return nil
1503}
1504
1505// onTitleBarClick handles clicks on title bars to switch the active pane in a stack.
1506func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) {
1507	log := logging.FromContext(ctx)
1508
1509	if stackNode == nil || sv == nil {
1510		return
1511	}
1512
1513	// Validate index
1514	if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) {
1515		log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked")
1516		return
1517	}
1518
1519	// Get the current active index
1520	currentIndex := sv.ActiveIndex()
1521	if clickedIndex == currentIndex {
1522		log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active")
1523		return
1524	}
1525
1526	// Update the outgoing pane's title bar with its current webpage title
1527	if currentIndex >= 0 && currentIndex < len(stackNode.Children) {
1528		outgoingChild := stackNode.Children[currentIndex]
1529		if outgoingChild.Pane != nil {
1530			outgoingPaneID := outgoingChild.Pane.ID
1531			outgoingTitle := c.contentCoord.GetTitle(outgoingPaneID)
1532			if outgoingTitle == "" {
1533				outgoingTitle = outgoingChild.Pane.Title
1534			}
1535			if outgoingTitle != "" {
1536				if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil {
1537					log.Warn().Err(err).Msg("failed to update outgoing pane title")
1538				}
1539			}
1540		}
1541	}
1542
1543	// Get the pane ID at the clicked index
1544	clickedChild := stackNode.Children[clickedIndex]
1545	if clickedChild.Pane == nil {
1546		log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane")
1547		return
1548	}
1549	clickedPaneID := clickedChild.Pane.ID
1550
1551	// Update StackedView active index
1552	if err := sv.SetActive(ctx, clickedIndex); err != nil {
1553		log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
1554		return
1555	}
1556
1557	// Update domain model
1558	stackNode.ActiveStackIndex = clickedIndex
1559
1560	// Update workspace view (also updates domain model)
1561	_, wsView := c.getActiveWS()
1562	if wsView != nil {
1563		// Cancel pending hover timers and suppress future hover focus (Issue #89)
1564		wsView.CancelAllPendingHovers()
1565		wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1566
1567		if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
1568			log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1569		}
1570	}
1571
1572	log.Info().
1573		Int("from_index", currentIndex).
1574		Int("to_index", clickedIndex).
1575		Str("pane_id", string(clickedPaneID)).
1576		Msg("switched active pane via title bar click")
1577}
1578
1579// onStackedPaneClose handles close button clicks on stacked pane title bars.
1580func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID entity.PaneID) {
1581	log := logging.FromContext(ctx)
1582
1583	log.Debug().Str("pane_id", string(paneID)).Msg("closing stacked pane via close button")
1584
1585	if err := c.ClosePaneByID(ctx, paneID); err != nil {
1586		log.Error().Err(err).Str("pane_id", string(paneID)).Msg("failed to close stacked pane")
1587	}
1588}
1589
1590// SetupStackedPaneCallbacks sets up title bar click and close callbacks for all stacked panes.
1591// This must be called after Rebuild() to restore callbacks on newly created StackedView instances.
1592func (c *WorkspaceCoordinator) SetupStackedPaneCallbacks(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
1593	log := logging.FromContext(ctx)
1594
1595	if ws == nil || ws.Root == nil || wsView == nil {
1596		return
1597	}
1598
1599	tr := wsView.TreeRenderer()
1600	if tr == nil {
1601		return
1602	}
1603
1604	// Walk the tree and set up callbacks for all stacked nodes
1605	ws.Root.Walk(func(node *entity.PaneNode) bool {
1606		if !node.IsStacked || len(node.Children) == 0 {
1607			return true
1608		}
1609
1610		// Get the StackedView for any pane in this stack
1611		firstChild := node.Children[0]
1612		if firstChild.Pane == nil {
1613			return true
1614		}
1615
1616		stackedView := tr.GetStackedViewForPane(string(firstChild.Pane.ID))
1617		if stackedView == nil {
1618			return true
1619		}
1620
1621		capturedStackNode := node
1622		stackedView.SetOnActivate(func(index int) {
1623			c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1624		})
1625		stackedView.SetOnClosePane(func(paneID string) {
1626			c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1627		})
1628
1629		log.Debug().
1630			Str("stack_id", node.ID).
1631			Int("stack_size", len(node.Children)).
1632			Msg("set up stacked pane callbacks")
1633
1634		return true
1635	})
1636}
1637
1638// --- Popup Insertion ---
1639
1640// SetOnCreatePopupTab sets the callback for creating popup tabs.
1641// This is used when popup behavior is "tabbed".
1642func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input InsertPopupInput) error) {
1643	c.onCreatePopupTab = fn
1644}
1645
1646// InsertPopup inserts a popup pane into the workspace based on the specified behavior.
1647// Supports split, stacked, and tabbed behaviors.
1648func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopupInput) error {
1649	log := logging.FromContext(ctx)
1650
1651	log.Debug().
1652		Str("parent_pane", string(input.ParentPaneID)).
1653		Str("popup_pane", string(input.PopupPane.ID)).
1654		Str("behavior", string(input.Behavior)).
1655		Str("placement", input.Placement).
1656		Str("popup_type", input.PopupType.String()).
1657		Msg("inserting popup into workspace")
1658
1659	switch input.Behavior {
1660	case config.PopupBehaviorSplit:
1661		return c.insertPopupSplit(ctx, input)
1662	case config.PopupBehaviorStacked:
1663		return c.insertPopupStacked(ctx, input)
1664	case config.PopupBehaviorTabbed:
1665		return c.insertPopupTabbed(ctx, input)
1666	default:
1667		// Default to split right
1668		return c.insertPopupSplit(ctx, input)
1669	}
1670}
1671
1672// insertPopupSplit inserts a popup as a split pane adjacent to the parent.
1673func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input InsertPopupInput) error {
1674	log := logging.FromContext(ctx)
1675
1676	ws, wsView := c.getActiveWS()
1677	if ws == nil {
1678		return fmt.Errorf("no active workspace")
1679	}
1680
1681	// Find parent pane node
1682	parentNode := ws.FindPane(input.ParentPaneID)
1683	if parentNode == nil {
1684		return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1685	}
1686
1687	direction := resolvePopupSplitDirection(input.Placement)
1688
1689	// Check if parent is in a stack - if so, split around the stack
1690	isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
1691		parentNode.Parent != nil && parentNode.Parent.IsStacked
1692
1693	// Get existing widget before domain changes
1694	existingWidget := c.resolvePopupSplitWidget(wsView, parentNode, isStackSplit)
1695
1696	// Perform the split with pre-created pane
1697	output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
1698		Workspace:  ws,
1699		TargetPane: parentNode,
1700		Direction:  direction,
1701		NewPane:    input.PopupPane,
1702	})
1703	if err != nil {
1704		return fmt.Errorf("split for popup: %w", err)
1705	}
1706
1707	// Set popup as active
1708	ws.ActivePaneID = input.PopupPane.ID
1709
1710	// Update UI
1711	if wsView != nil {
1712		c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID)
1713		c.attachPopupWebView(ctx, wsView, input)
1714	}
1715
1716	log.Info().
1717		Str("popup_pane", string(input.PopupPane.ID)).
1718		Str("direction", string(direction)).
1719		Msg("popup inserted as split pane")
1720
1721	return nil
1722}
1723
1724func resolvePopupSplitDirection(placement string) usecase.SplitDirection {
1725	switch placement {
1726	case string(usecase.SplitLeft):
1727		return usecase.SplitLeft
1728	case string(usecase.SplitRight):
1729		return usecase.SplitRight
1730	case "top", string(usecase.SplitUp):
1731		return usecase.SplitUp
1732	case "bottom", string(usecase.SplitDown):
1733		return usecase.SplitDown
1734	default:
1735		return usecase.SplitRight
1736	}
1737}
1738
1739func (c *WorkspaceCoordinator) resolvePopupSplitWidget(
1740	wsView *component.WorkspaceView,
1741	parentNode *entity.PaneNode,
1742	isStackSplit bool,
1743) layout.Widget {
1744	if wsView == nil {
1745		return nil
1746	}
1747
1748	if !isStackSplit {
1749		return wsView.GetRootWidget()
1750	}
1751
1752	tr := wsView.TreeRenderer()
1753	if tr == nil {
1754		return nil
1755	}
1756
1757	stackedView := tr.GetStackedViewForPane(string(parentNode.Pane.ID))
1758	if stackedView == nil {
1759		return nil
1760	}
1761
1762	return stackedView.Widget()
1763}
1764
1765func (c *WorkspaceCoordinator) attachPopupWebView(
1766	ctx context.Context,
1767	wsView *component.WorkspaceView,
1768	input InsertPopupInput,
1769) {
1770	if wsView == nil || input.WebView == nil {
1771		return
1772	}
1773
1774	widget := c.contentCoord.WrapWidget(ctx, input.WebView)
1775	if widget == nil {
1776		return
1777	}
1778
1779	paneView := wsView.GetPaneView(input.PopupPane.ID)
1780	if paneView == nil {
1781		return
1782	}
1783	paneView.SetWebViewWidget(widget)
1784}
1785
1786// insertPopupStacked inserts a popup as a stacked pane on top of the parent.
1787// Uses CreateStack and AddToStack use cases for proper domain model management.
1788func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
1789	log := logging.FromContext(ctx)
1790
1791	ws, wsView := c.getActiveWS()
1792	if ws == nil {
1793		return fmt.Errorf("no active workspace")
1794	}
1795
1796	parentNode := ws.FindPane(input.ParentPaneID)
1797	if parentNode == nil {
1798		return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1799	}
1800
1801	// Resolve or create stack node
1802	stackNode := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
1803
1804	// Add popup pane to stack using use case
1805	if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane); err != nil {
1806		return fmt.Errorf("failed to add popup to stack: %w", err)
1807	}
1808
1809	if wsView == nil {
1810		// No workspace view to update; domain model already updated by use case.
1811		return nil
1812	}
1813
1814	if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
1815		return err
1816	}
1817
1818	log.Info().
1819		Str("popup_pane", string(input.PopupPane.ID)).
1820		Int("stack_size", len(stackNode.Children)).
1821		Msg("popup inserted as stacked pane")
1822
1823	return nil
1824}
1825
1826// resolveOrCreateStackNode finds an existing stack or creates one for popup insertion.
1827// Note: For popups, we can't use CreateStack use case since it creates a new pane,
1828// but we already have the popup pane. This conversion is a special case.
1829func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
1830	ctx context.Context,
1831	parentNode *entity.PaneNode,
1832	parentPaneID entity.PaneID,
1833) *entity.PaneNode {
1834	log := logging.FromContext(ctx)
1835
1836	// Check if parent is already in a stack
1837	if parentNode.Parent != nil && parentNode.Parent.IsStacked {
1838		log.Debug().
1839			Str("stack_id", parentNode.Parent.ID).
1840			Msg("using existing parent stack for popup")
1841		return parentNode.Parent
1842	}
1843
1844	// Check if parent is already a stack container
1845	if parentNode.IsStacked {
1846		log.Debug().
1847			Str("stack_id", parentNode.ID).
1848			Msg("parent is already a stack container")
1849		return parentNode
1850	}
1851
1852	// Need to convert leaf to stack for popup insertion.
1853	// We can't use CreateStack use case since it creates a new pane,
1854	// but we already have the popup pane to insert.
1855	originalPane := parentNode.Pane
1856	originalTitle := c.contentCoord.GetTitle(parentPaneID)
1857	if originalTitle == "" {
1858		originalTitle = originalPane.Title
1859	}
1860	if originalTitle == "" {
1861		originalTitle = defaultPaneTitle
1862	}
1863	originalPane.Title = originalTitle
1864
1865	// Create child node for the original pane
1866	originalPaneChild := &entity.PaneNode{
1867		ID:     parentNode.ID + "_0",
1868		Pane:   originalPane,
1869		Parent: parentNode,
1870	}
1871
1872	// Convert parentNode to stack container
1873	parentNode.Pane = nil
1874	parentNode.IsStacked = true
1875	parentNode.ActiveStackIndex = 0
1876	parentNode.Children = []*entity.PaneNode{originalPaneChild}
1877
1878	log.Debug().
1879		Str("node_id", parentNode.ID).
1880		Str("original_pane", string(originalPane.ID)).
1881		Msg("converted leaf to stacked node for popup")
1882
1883	return parentNode
1884}
1885
1886func (c *WorkspaceCoordinator) attachPopupPaneView(
1887	ctx context.Context,
1888	input InsertPopupInput,
1889	wsView *component.WorkspaceView,
1890	stackNode *entity.PaneNode,
1891	log *zerolog.Logger,
1892) error {
1893	if wsView == nil {
1894		return nil
1895	}
1896	newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil)
1897	setupPaneViewHover(ctx, newPaneView, wsView)
1898	wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)
1899
1900	if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil {
1901		log.Error().Err(err).Msg("failed to add popup pane to stack")
1902		return err
1903	}
1904
1905	if input.WebView != nil {
1906		widget := c.contentCoord.WrapWidget(ctx, input.WebView)
1907		if widget != nil {
1908			newPaneView.SetWebViewWidget(widget)
1909		}
1910	}
1911
1912	if err := wsView.SetActivePaneID(input.PopupPane.ID); err != nil {
1913		log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1914	}
1915
1916	tr := wsView.TreeRenderer()
1917	if tr != nil {
1918		stackedView := tr.GetStackedViewForPane(string(input.ParentPaneID))
1919		if stackedView != nil {
1920			capturedStackNode := stackNode
1921			stackedView.SetOnActivate(func(index int) {
1922				c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1923			})
1924			stackedView.SetOnClosePane(func(paneID string) {
1925				c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1926			})
1927		}
1928	}
1929	return nil
1930}
1931
1932// insertPopupTabbed creates a new tab for the popup.
1933func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input InsertPopupInput) error {
1934	log := logging.FromContext(ctx)
1935
1936	if c.onCreatePopupTab == nil {
1937		log.Warn().Msg("tabbed popup behavior requested but no tab handler configured, falling back to split")
1938		return c.insertPopupSplit(ctx, input)
1939	}
1940
1941	// Delegate to tab coordinator via callback
1942	if err := c.onCreatePopupTab(ctx, input); err != nil {
1943		return fmt.Errorf("create popup tab: %w", err)
1944	}
1945
1946	log.Info().
1947		Str("popup_pane", string(input.PopupPane.ID)).
1948		Msg("popup inserted as new tab")
1949
1950	return nil
1951}
1952
1953func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction usecase.ConsumeOrExpelDirection) error {
1954	log := logging.FromContext(ctx)
1955
1956	if c.panesUC == nil {
1957		log.Warn().Msg("panes use case not available")
1958		return nil
1959	}
1960
1961	ws, wsView := c.getActiveWS()
1962	if ws == nil {
1963		log.Warn().Msg("no active workspace")
1964		return nil
1965	}
1966
1967	activeNode := ws.ActivePane()
1968	if activeNode == nil {
1969		log.Warn().Msg("no active pane")
1970		return nil
1971	}
1972
1973	result, err := c.panesUC.ConsumeOrExpel(ctx, ws, activeNode, direction)
1974	if err != nil {
1975		return err
1976	}
1977	if result != nil && result.ErrorMessage != "" {
1978		c.ShowToastOnActivePane(ctx, result.ErrorMessage, component.ToastWarning)
1979		return nil
1980	}
1981
1982	if wsView != nil {
1983		if err := wsView.Rebuild(ctx); err != nil {
1984			log.Warn().Err(err).Msg("failed to rebuild workspace view")
1985		}
1986		c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1987		c.SetupStackedPaneCallbacks(ctx, ws, wsView)
1988		if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
1989			log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1990		}
1991		wsView.FocusPane(ws.ActivePaneID)
1992	}
1993
1994	c.notifyStateChanged()
1995	return nil
1996}
1997
1998// ShowZoomToast displays a zoom level toast on the active pane.
1999func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) {
2000	_, wsView := c.getActiveWS()
2001	if wsView == nil {
2002		return
2003	}
2004
2005	paneView := wsView.GetActivePaneView()
2006	if paneView != nil {
2007		paneView.ShowZoomToast(ctx, zoomPercent)
2008	}
2009}
2010
2011// ShowToastOnActivePane displays a toast notification on the active pane.
2012func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, message string, level component.ToastLevel) {
2013	_, wsView := c.getActiveWS()
2014	if wsView == nil {
2015		return
2016	}
2017
2018	paneView := wsView.GetActivePaneView()
2019	if paneView != nil {
2020		paneView.ShowToast(ctx, message, level)
2021	}
2022}
2023
2024// Resize updates the active split ratio and applies it to GTK widgets.
2025func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDirection) error {
2026	log := logging.FromContext(ctx)
2027
2028	if c.panesUC == nil {
2029		log.Warn().Msg("panes use case not available")
2030		return nil
2031	}
2032
2033	ws, wsView := c.getActiveWS()
2034	if ws == nil {
2035		log.Warn().Msg("no active workspace")
2036		return nil
2037	}
2038
2039	target := ws.ActivePane()
2040	if target == nil {
2041		return nil
2042	}
2043
2044	cfg := config.Get()
2045	err := c.panesUC.Resize(ctx, ws, target, dir, cfg.Workspace.ResizeMode.StepPercent, cfg.Workspace.ResizeMode.MinPanePercent)
2046	if errors.Is(err, usecase.ErrNothingToResize) {
2047		c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)
2048		return nil
2049	}
2050	if err != nil {
2051		return err
2052	}
2053
2054	if wsView != nil {
2055		c.updateSplitPositions(wsView, ws)
2056	}
2057
2058	c.notifyStateChanged()
2059	return nil
2060}
2061
2062func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID string, ratio float64) error {
2063	log := logging.FromContext(ctx)
2064
2065	if c.panesUC == nil {
2066		log.Warn().Msg("panes use case not available")
2067		return nil
2068	}
2069
2070	ws, _ := c.getActiveWS()
2071	if ws == nil {
2072		log.Warn().Msg("no active workspace")
2073		return nil
2074	}
2075
2076	cfg := config.Get()
2077	err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{
2078		Workspace:      ws,
2079		SplitNodeID:    splitNodeID,
2080		Ratio:          ratio,
2081		MinPanePercent: cfg.Workspace.ResizeMode.MinPanePercent,
2082	})
2083	if err != nil {
2084		return err
2085	}
2086
2087	c.notifyStateChanged()
2088	return nil
2089}
2090
2091func (c *WorkspaceCoordinator) updateSplitPositions(wsView *component.WorkspaceView, ws *entity.Workspace) {
2092	if wsView == nil || ws == nil || ws.Root == nil {
2093		return
2094	}
2095
2096	tr := wsView.TreeRenderer()
2097	if tr == nil {
2098		return
2099	}
2100
2101	ws.Root.Walk(func(node *entity.PaneNode) bool {
2102		if node.IsSplit() {
2103			_ = tr.UpdateSplitRatio(node.ID, node.SplitRatio)
2104		}
2105		return true
2106	})
2107}

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