v0.24.0 a261e52

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

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