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