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