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}