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