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