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