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