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