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