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