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