feat(app): integrate popup and middle-click handling into application coordinators
Wire popup handling throughout the application: - App initializes WebViewFactory and configures ContentCoordinator with popup callbacks - WorkspaceCoordinator adds ClosePaneByID for popup window close handling - TabCoordinator adds CreateWithPane for tabbed popup behavior - ManageTabsUseCase adds CreateWithPane for tab creation with pre-existing panes - Setup callbacks for InsertPopupInput to handle tabbed vs split/stacked popups Enables complete popup lifecycle from creation through insertion into workspace.
- commit
- 5740d1231dea22b765da2f8b763d3adde85841b4
- author
- brice · · 2025-12-17 18:14 UTC
- committer
- brice · · 2025-12-17 17:19 UTC
- parent
- 24f586c
- signature
- unsigned
4 files · +588 -11
M internal/application/usecase/manage_tabs.go +56 -0
| @@ -308,3 +308,59 @@ func (uc *ManageTabsUseCase) Pin(ctx context.Context, tabs *entity.TabList, tabI | |||
| 308 | 308 | ||
| 309 | 309 | return nil | |
| 310 | 310 | } | |
| 311 | + | ||
| 312 | + | // CreateTabWithPaneInput contains parameters for creating a tab with an existing pane. | |
| 313 | + | type CreateTabWithPaneInput struct { | |
| 314 | + | TabList *entity.TabList | |
| 315 | + | Name string // Optional custom name | |
| 316 | + | Pane *entity.Pane // Pre-created pane (for popups) | |
| 317 | + | InitialURL string // URL for the pane | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // CreateWithPane creates a new tab using a pre-existing pane. | |
| 321 | + | // This is used for tabbed popup behavior where the popup pane is already created. | |
| 322 | + | func (uc *ManageTabsUseCase) CreateWithPane(ctx context.Context, input CreateTabWithPaneInput) (*CreateTabOutput, error) { | |
| 323 | + | log := logging.FromContext(ctx) | |
| 324 | + | log.Debug(). | |
| 325 | + | Str("name", input.Name). | |
| 326 | + | Str("pane_id", string(input.Pane.ID)). | |
| 327 | + | Str("initial_url", input.InitialURL). | |
| 328 | + | Msg("creating new tab with existing pane") | |
| 329 | + | ||
| 330 | + | if input.TabList == nil { | |
| 331 | + | return nil, fmt.Errorf("tab list is required") | |
| 332 | + | } | |
| 333 | + | if input.Pane == nil { | |
| 334 | + | return nil, fmt.Errorf("pane is required") | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // Generate IDs for tab and workspace | |
| 338 | + | tabID := entity.TabID(uc.idGenerator()) | |
| 339 | + | workspaceID := entity.WorkspaceID(uc.idGenerator()) | |
| 340 | + | ||
| 341 | + | // Use the provided pane | |
| 342 | + | pane := input.Pane | |
| 343 | + | if input.InitialURL != "" { | |
| 344 | + | pane.URI = url.Normalize(input.InitialURL) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // Create tab with workspace using the pre-created pane | |
| 348 | + | tab := entity.NewTab(tabID, workspaceID, pane) | |
| 349 | + | if input.Name != "" { | |
| 350 | + | tab.Name = input.Name | |
| 351 | + | } else if pane.Title != "" { | |
| 352 | + | tab.Name = pane.Title | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // Add to list | |
| 356 | + | input.TabList.Add(tab) | |
| 357 | + | ||
| 358 | + | log.Info(). | |
| 359 | + | Str("tab_id", string(tabID)). | |
| 360 | + | Str("workspace_id", string(workspaceID)). | |
| 361 | + | Str("pane_id", string(pane.ID)). | |
| 362 | + | Int("position", tab.Position). | |
| 363 | + | Msg("tab created with existing pane") | |
| 364 | + | ||
| 365 | + | return &CreateTabOutput{Tab: tab}, nil | |
| 366 | + | } | |
| @@ -308,3 +308,59 @@ func (uc *ManageTabsUseCase) Pin(ctx context.Context, tabs *entity.TabList, tabI | |||||
| 308 | 308 | ||||
| 309 | return nil | 309 | return nil | ||
| 310 | } | 310 | } | ||
| 311 | + | ||||
| 312 | + | // CreateTabWithPaneInput contains parameters for creating a tab with an existing pane. | |||
| 313 | + | type CreateTabWithPaneInput struct { | |||
| 314 | + | TabList *entity.TabList | |||
| 315 | + | Name string // Optional custom name | |||
| 316 | + | Pane *entity.Pane // Pre-created pane (for popups) | |||
| 317 | + | InitialURL string // URL for the pane | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | // CreateWithPane creates a new tab using a pre-existing pane. | |||
| 321 | + | // This is used for tabbed popup behavior where the popup pane is already created. | |||
| 322 | + | func (uc *ManageTabsUseCase) CreateWithPane(ctx context.Context, input CreateTabWithPaneInput) (*CreateTabOutput, error) { | |||
| 323 | + | log := logging.FromContext(ctx) | |||
| 324 | + | log.Debug(). | |||
| 325 | + | Str("name", input.Name). | |||
| 326 | + | Str("pane_id", string(input.Pane.ID)). | |||
| 327 | + | Str("initial_url", input.InitialURL). | |||
| 328 | + | Msg("creating new tab with existing pane") | |||
| 329 | + | ||||
| 330 | + | if input.TabList == nil { | |||
| 331 | + | return nil, fmt.Errorf("tab list is required") | |||
| 332 | + | } | |||
| 333 | + | if input.Pane == nil { | |||
| 334 | + | return nil, fmt.Errorf("pane is required") | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | // Generate IDs for tab and workspace | |||
| 338 | + | tabID := entity.TabID(uc.idGenerator()) | |||
| 339 | + | workspaceID := entity.WorkspaceID(uc.idGenerator()) | |||
| 340 | + | ||||
| 341 | + | // Use the provided pane | |||
| 342 | + | pane := input.Pane | |||
| 343 | + | if input.InitialURL != "" { | |||
| 344 | + | pane.URI = url.Normalize(input.InitialURL) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // Create tab with workspace using the pre-created pane | |||
| 348 | + | tab := entity.NewTab(tabID, workspaceID, pane) | |||
| 349 | + | if input.Name != "" { | |||
| 350 | + | tab.Name = input.Name | |||
| 351 | + | } else if pane.Title != "" { | |||
| 352 | + | tab.Name = pane.Title | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // Add to list | |||
| 356 | + | input.TabList.Add(tab) | |||
| 357 | + | ||||
| 358 | + | log.Info(). | |||
| 359 | + | Str("tab_id", string(tabID)). | |||
| 360 | + | Str("workspace_id", string(workspaceID)). | |||
| 361 | + | Str("pane_id", string(pane.ID)). | |||
| 362 | + | Int("position", tab.Position). | |||
| 363 | + | Msg("tab created with existing pane") | |||
| 364 | + | ||||
| 365 | + | return &CreateTabOutput{Tab: tab}, nil | |||
| 366 | + | } | |||
M internal/ui/app.go +77 -5
| @@ -63,11 +63,12 @@ type App struct { | |||
| 63 | 63 | omniboxCfg component.OmniboxConfig | |
| 64 | 64 | ||
| 65 | 65 | // Web content (managed by ContentCoordinator) | |
| 66 | - | pool *webkit.WebViewPool | |
| 67 | - | injector *webkit.ContentInjector | |
| 68 | - | router *webkit.MessageRouter | |
| 69 | - | settings *webkit.SettingsManager | |
| 70 | - | faviconCache *cache.FaviconCache | |
| 66 | + | pool *webkit.WebViewPool | |
| 67 | + | webViewFactory *webkit.WebViewFactory | |
| 68 | + | injector *webkit.ContentInjector | |
| 69 | + | router *webkit.MessageRouter | |
| 70 | + | settings *webkit.SettingsManager | |
| 71 | + | faviconCache *cache.FaviconCache | |
| 71 | 72 | ||
| 72 | 73 | // ID generator for tabs/panes | |
| 73 | 74 | idCounter uint64 | |
| @@ -299,6 +300,10 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 299 | 300 | a.switchWorkspaceView(ctx, tab.ID) | |
| 300 | 301 | }) | |
| 301 | 302 | a.tabCoord.SetOnQuit(a.Quit) | |
| 303 | + | // Wire popup tab WebView attachment | |
| 304 | + | a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) { | |
| 305 | + | a.attachPopupToTab(ctx, tabID, pane, wv) | |
| 306 | + | }) | |
| 302 | 307 | ||
| 303 | 308 | // Wire tab bar click handling to coordinator | |
| 304 | 309 | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | |
| @@ -321,6 +326,36 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 321 | 326 | return a.tabCoord.Close(ctx) | |
| 322 | 327 | }) | |
| 323 | 328 | ||
| 329 | + | // Wire popup handling | |
| 330 | + | a.webViewFactory = webkit.NewWebViewFactory( | |
| 331 | + | a.deps.WebContext, | |
| 332 | + | a.settings, | |
| 333 | + | a.pool, | |
| 334 | + | a.injector, | |
| 335 | + | a.router, | |
| 336 | + | ) | |
| 337 | + | a.contentCoord.SetPopupConfig( | |
| 338 | + | a.webViewFactory, | |
| 339 | + | &a.deps.Config.Workspace.Popups, | |
| 340 | + | a.generateID, | |
| 341 | + | ) | |
| 342 | + | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error { | |
| 343 | + | return a.wsCoord.InsertPopup(ctx, input) | |
| 344 | + | }) | |
| 345 | + | a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error { | |
| 346 | + | return a.wsCoord.ClosePaneByID(ctx, paneID) | |
| 347 | + | }) | |
| 348 | + | // Wire tabbed popup behavior to create new tabs | |
| 349 | + | a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error { | |
| 350 | + | // Create a new tab with the popup pane | |
| 351 | + | tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI) | |
| 352 | + | if err != nil { | |
| 353 | + | return err | |
| 354 | + | } | |
| 355 | + | log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup") | |
| 356 | + | return nil | |
| 357 | + | }) | |
| 358 | + | ||
| 324 | 359 | // 4. Navigation Coordinator | |
| 325 | 360 | a.navCoord = coordinator.NewNavigationCoordinator( | |
| 326 | 361 | ctx, | |
| @@ -471,6 +506,43 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView { | |||
| 471 | 506 | return a.workspaceViews[activeTab.ID] | |
| 472 | 507 | } | |
| 473 | 508 | ||
| 509 | + | // attachPopupToTab attaches a popup WebView to a newly created tab. | |
| 510 | + | // This is called when a popup uses tabbed behavior. | |
| 511 | + | func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) { | |
| 512 | + | log := logging.FromContext(ctx) | |
| 513 | + | ||
| 514 | + | wsView := a.workspaceViews[tabID] | |
| 515 | + | if wsView == nil { | |
| 516 | + | log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab") | |
| 517 | + | return | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | // Register WebView with content coordinator | |
| 521 | + | if a.contentCoord != nil { | |
| 522 | + | // Track the WebView | |
| 523 | + | a.contentCoord.RegisterPopupWebView(pane.ID, wv) | |
| 524 | + | ||
| 525 | + | // Wrap and attach widget | |
| 526 | + | widget := a.contentCoord.WrapWidget(ctx, wv) | |
| 527 | + | if widget != nil { | |
| 528 | + | paneView := wsView.GetPaneView(pane.ID) | |
| 529 | + | if paneView != nil { | |
| 530 | + | paneView.SetWebViewWidget(widget) | |
| 531 | + | } else { | |
| 532 | + | log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | |
| 533 | + | } | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | // Setup popup handling for nested popups | |
| 537 | + | a.contentCoord.SetupPopupHandling(ctx, pane.ID, wv) | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | log.Debug(). | |
| 541 | + | Str("tab_id", string(tabID)). | |
| 542 | + | Str("pane_id", string(pane.ID)). | |
| 543 | + | Msg("popup webview attached to tab") | |
| 544 | + | } | |
| 545 | + | ||
| 474 | 546 | // switchWorkspaceView swaps the displayed workspace view for a tab. | |
| 475 | 547 | func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |
| 476 | 548 | log := logging.FromContext(ctx) | |
| @@ -63,11 +63,12 @@ type App struct { | |||||
| 63 | omniboxCfg component.OmniboxConfig | 63 | omniboxCfg component.OmniboxConfig | ||
| 64 | 64 | ||||
| 65 | // Web content (managed by ContentCoordinator) | 65 | // Web content (managed by ContentCoordinator) | ||
| 66 | - | pool *webkit.WebViewPool | 66 | + | pool *webkit.WebViewPool |
| 67 | - | injector *webkit.ContentInjector | 67 | + | webViewFactory *webkit.WebViewFactory |
| 68 | - | router *webkit.MessageRouter | 68 | + | injector *webkit.ContentInjector |
| 69 | - | settings *webkit.SettingsManager | 69 | + | router *webkit.MessageRouter |
| 70 | - | faviconCache *cache.FaviconCache | 70 | + | settings *webkit.SettingsManager |
| 71 | + | faviconCache *cache.FaviconCache | |||
| 71 | 72 | ||||
| 72 | // ID generator for tabs/panes | 73 | // ID generator for tabs/panes | ||
| 73 | idCounter uint64 | 74 | idCounter uint64 | ||
| @@ -299,6 +300,10 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 299 | a.switchWorkspaceView(ctx, tab.ID) | 300 | a.switchWorkspaceView(ctx, tab.ID) | ||
| 300 | }) | 301 | }) | ||
| 301 | a.tabCoord.SetOnQuit(a.Quit) | 302 | a.tabCoord.SetOnQuit(a.Quit) | ||
| 303 | + | // Wire popup tab WebView attachment | |||
| 304 | + | a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) { | |||
| 305 | + | a.attachPopupToTab(ctx, tabID, pane, wv) | |||
| 306 | + | }) | |||
| 302 | 307 | ||||
| 303 | // Wire tab bar click handling to coordinator | 308 | // Wire tab bar click handling to coordinator | ||
| 304 | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | 309 | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | ||
| @@ -321,6 +326,36 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 321 | return a.tabCoord.Close(ctx) | 326 | return a.tabCoord.Close(ctx) | ||
| 322 | }) | 327 | }) | ||
| 323 | 328 | ||||
| 329 | + | // Wire popup handling | |||
| 330 | + | a.webViewFactory = webkit.NewWebViewFactory( | |||
| 331 | + | a.deps.WebContext, | |||
| 332 | + | a.settings, | |||
| 333 | + | a.pool, | |||
| 334 | + | a.injector, | |||
| 335 | + | a.router, | |||
| 336 | + | ) | |||
| 337 | + | a.contentCoord.SetPopupConfig( | |||
| 338 | + | a.webViewFactory, | |||
| 339 | + | &a.deps.Config.Workspace.Popups, | |||
| 340 | + | a.generateID, | |||
| 341 | + | ) | |||
| 342 | + | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error { | |||
| 343 | + | return a.wsCoord.InsertPopup(ctx, input) | |||
| 344 | + | }) | |||
| 345 | + | a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error { | |||
| 346 | + | return a.wsCoord.ClosePaneByID(ctx, paneID) | |||
| 347 | + | }) | |||
| 348 | + | // Wire tabbed popup behavior to create new tabs | |||
| 349 | + | a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error { | |||
| 350 | + | // Create a new tab with the popup pane | |||
| 351 | + | tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI) | |||
| 352 | + | if err != nil { | |||
| 353 | + | return err | |||
| 354 | + | } | |||
| 355 | + | log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup") | |||
| 356 | + | return nil | |||
| 357 | + | }) | |||
| 358 | + | ||||
| 324 | // 4. Navigation Coordinator | 359 | // 4. Navigation Coordinator | ||
| 325 | a.navCoord = coordinator.NewNavigationCoordinator( | 360 | a.navCoord = coordinator.NewNavigationCoordinator( | ||
| 326 | ctx, | 361 | ctx, | ||
| @@ -471,6 +506,43 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView { | |||||
| 471 | return a.workspaceViews[activeTab.ID] | 506 | return a.workspaceViews[activeTab.ID] | ||
| 472 | } | 507 | } | ||
| 473 | 508 | ||||
| 509 | + | // attachPopupToTab attaches a popup WebView to a newly created tab. | |||
| 510 | + | // This is called when a popup uses tabbed behavior. | |||
| 511 | + | func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) { | |||
| 512 | + | log := logging.FromContext(ctx) | |||
| 513 | + | ||||
| 514 | + | wsView := a.workspaceViews[tabID] | |||
| 515 | + | if wsView == nil { | |||
| 516 | + | log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab") | |||
| 517 | + | return | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | // Register WebView with content coordinator | |||
| 521 | + | if a.contentCoord != nil { | |||
| 522 | + | // Track the WebView | |||
| 523 | + | a.contentCoord.RegisterPopupWebView(pane.ID, wv) | |||
| 524 | + | ||||
| 525 | + | // Wrap and attach widget | |||
| 526 | + | widget := a.contentCoord.WrapWidget(ctx, wv) | |||
| 527 | + | if widget != nil { | |||
| 528 | + | paneView := wsView.GetPaneView(pane.ID) | |||
| 529 | + | if paneView != nil { | |||
| 530 | + | paneView.SetWebViewWidget(widget) | |||
| 531 | + | } else { | |||
| 532 | + | log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | |||
| 533 | + | } | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | // Setup popup handling for nested popups | |||
| 537 | + | a.contentCoord.SetupPopupHandling(ctx, pane.ID, wv) | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | log.Debug(). | |||
| 541 | + | Str("tab_id", string(tabID)). | |||
| 542 | + | Str("pane_id", string(pane.ID)). | |||
| 543 | + | Msg("popup webview attached to tab") | |||
| 544 | + | } | |||
| 545 | + | ||||
| 474 | // switchWorkspaceView swaps the displayed workspace view for a tab. | 546 | // switchWorkspaceView swaps the displayed workspace view for a tab. | ||
| 475 | func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | 547 | func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | ||
| 476 | log := logging.FromContext(ctx) | 548 | log := logging.FromContext(ctx) | ||
M internal/ui/coordinator/tab.go +60 -3
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "github.com/bnema/dumber/internal/application/usecase" | |
| 7 | 7 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 11 | "github.com/bnema/dumber/internal/ui/component" | |
| 11 | 12 | "github.com/bnema/dumber/internal/ui/window" | |
| @@ -19,9 +20,10 @@ type TabCoordinator struct { | |||
| 19 | 20 | config *config.Config | |
| 20 | 21 | ||
| 21 | 22 | // Callbacks to avoid circular dependencies | |
| 22 | - | onTabCreated func(ctx context.Context, tab *entity.Tab) | |
| 23 | - | onTabSwitched func(ctx context.Context, tab *entity.Tab) | |
| 24 | - | onQuit func() | |
| 23 | + | onTabCreated func(ctx context.Context, tab *entity.Tab) | |
| 24 | + | onTabSwitched func(ctx context.Context, tab *entity.Tab) | |
| 25 | + | onQuit func() | |
| 26 | + | onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) // For popup tabs | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | // TabCoordinatorConfig holds configuration for TabCoordinator. | |
| @@ -310,3 +312,58 @@ func (c *TabCoordinator) GetTabBar() *component.TabBar { | |||
| 310 | 312 | } | |
| 311 | 313 | return nil | |
| 312 | 314 | } | |
| 315 | + | ||
| 316 | + | // SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs. | |
| 317 | + | func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView)) { | |
| 318 | + | c.onAttachPopupToTab = fn | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // CreateWithPane creates a new tab with a pre-created pane and WebView. | |
| 322 | + | // This is used for tabbed popup behavior where the popup pane already exists. | |
| 323 | + | func (c *TabCoordinator) CreateWithPane(ctx context.Context, pane *entity.Pane, wv *webkit.WebView, initialURL string) (*entity.Tab, error) { | |
| 324 | + | log := logging.FromContext(ctx) | |
| 325 | + | ||
| 326 | + | output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{ | |
| 327 | + | TabList: c.tabs, | |
| 328 | + | Name: pane.Title, | |
| 329 | + | Pane: pane, | |
| 330 | + | InitialURL: initialURL, | |
| 331 | + | }) | |
| 332 | + | if err != nil { | |
| 333 | + | log.Error().Err(err).Msg("failed to create tab with pane") | |
| 334 | + | return nil, err | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // Set new tab as active | |
| 338 | + | c.tabs.SetActive(output.Tab.ID) | |
| 339 | + | ||
| 340 | + | // Update tab bar | |
| 341 | + | if c.mainWindow != nil && c.mainWindow.TabBar() != nil { | |
| 342 | + | c.mainWindow.TabBar().AddTab(output.Tab) | |
| 343 | + | c.mainWindow.TabBar().SetActive(output.Tab.ID) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // Update tab bar visibility | |
| 347 | + | c.UpdateBarVisibility(ctx) | |
| 348 | + | ||
| 349 | + | // Notify app to create workspace view | |
| 350 | + | if c.onTabCreated != nil { | |
| 351 | + | c.onTabCreated(ctx, output.Tab) | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // Attach the popup WebView to the new tab's workspace | |
| 355 | + | if c.onAttachPopupToTab != nil { | |
| 356 | + | c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv) | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // Switch to the new tab's workspace view | |
| 360 | + | if c.onTabSwitched != nil { | |
| 361 | + | c.onTabSwitched(ctx, output.Tab) | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | log.Debug(). | |
| 365 | + | Str("tab_id", string(output.Tab.ID)). | |
| 366 | + | Str("pane_id", string(pane.ID)). | |
| 367 | + | Msg("tab created with popup pane") | |
| 368 | + | return output.Tab, nil | |
| 369 | + | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "github.com/bnema/dumber/internal/application/usecase" | 6 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 7 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 9 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | "github.com/bnema/dumber/internal/ui/component" | 11 | "github.com/bnema/dumber/internal/ui/component" | ||
| 11 | "github.com/bnema/dumber/internal/ui/window" | 12 | "github.com/bnema/dumber/internal/ui/window" | ||
| @@ -19,9 +20,10 @@ type TabCoordinator struct { | |||||
| 19 | config *config.Config | 20 | config *config.Config | ||
| 20 | 21 | ||||
| 21 | // Callbacks to avoid circular dependencies | 22 | // Callbacks to avoid circular dependencies | ||
| 22 | - | onTabCreated func(ctx context.Context, tab *entity.Tab) | 23 | + | onTabCreated func(ctx context.Context, tab *entity.Tab) |
| 23 | - | onTabSwitched func(ctx context.Context, tab *entity.Tab) | 24 | + | onTabSwitched func(ctx context.Context, tab *entity.Tab) |
| 24 | - | onQuit func() | 25 | + | onQuit func() |
| 26 | + | onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) // For popup tabs | |||
| 25 | } | 27 | } | ||
| 26 | 28 | ||||
| 27 | // TabCoordinatorConfig holds configuration for TabCoordinator. | 29 | // TabCoordinatorConfig holds configuration for TabCoordinator. | ||
| @@ -310,3 +312,58 @@ func (c *TabCoordinator) GetTabBar() *component.TabBar { | |||||
| 310 | } | 312 | } | ||
| 311 | return nil | 313 | return nil | ||
| 312 | } | 314 | } | ||
| 315 | + | ||||
| 316 | + | // SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs. | |||
| 317 | + | func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView)) { | |||
| 318 | + | c.onAttachPopupToTab = fn | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // CreateWithPane creates a new tab with a pre-created pane and WebView. | |||
| 322 | + | // This is used for tabbed popup behavior where the popup pane already exists. | |||
| 323 | + | func (c *TabCoordinator) CreateWithPane(ctx context.Context, pane *entity.Pane, wv *webkit.WebView, initialURL string) (*entity.Tab, error) { | |||
| 324 | + | log := logging.FromContext(ctx) | |||
| 325 | + | ||||
| 326 | + | output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{ | |||
| 327 | + | TabList: c.tabs, | |||
| 328 | + | Name: pane.Title, | |||
| 329 | + | Pane: pane, | |||
| 330 | + | InitialURL: initialURL, | |||
| 331 | + | }) | |||
| 332 | + | if err != nil { | |||
| 333 | + | log.Error().Err(err).Msg("failed to create tab with pane") | |||
| 334 | + | return nil, err | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | // Set new tab as active | |||
| 338 | + | c.tabs.SetActive(output.Tab.ID) | |||
| 339 | + | ||||
| 340 | + | // Update tab bar | |||
| 341 | + | if c.mainWindow != nil && c.mainWindow.TabBar() != nil { | |||
| 342 | + | c.mainWindow.TabBar().AddTab(output.Tab) | |||
| 343 | + | c.mainWindow.TabBar().SetActive(output.Tab.ID) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // Update tab bar visibility | |||
| 347 | + | c.UpdateBarVisibility(ctx) | |||
| 348 | + | ||||
| 349 | + | // Notify app to create workspace view | |||
| 350 | + | if c.onTabCreated != nil { | |||
| 351 | + | c.onTabCreated(ctx, output.Tab) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | // Attach the popup WebView to the new tab's workspace | |||
| 355 | + | if c.onAttachPopupToTab != nil { | |||
| 356 | + | c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv) | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | // Switch to the new tab's workspace view | |||
| 360 | + | if c.onTabSwitched != nil { | |||
| 361 | + | c.onTabSwitched(ctx, output.Tab) | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | log.Debug(). | |||
| 365 | + | Str("tab_id", string(output.Tab.ID)). | |||
| 366 | + | Str("pane_id", string(pane.ID)). | |||
| 367 | + | Msg("tab created with popup pane") | |||
| 368 | + | return output.Tab, nil | |||
| 369 | + | } | |||
M internal/ui/coordinator/workspace.go +395 -3
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 11 | "github.com/bnema/dumber/internal/ui/component" | |
| 11 | 12 | "github.com/bnema/dumber/internal/ui/focus" | |
| @@ -21,9 +22,10 @@ type WorkspaceCoordinator struct { | |||
| 21 | 22 | contentCoord *ContentCoordinator | |
| 22 | 23 | ||
| 23 | 24 | // Callbacks to avoid circular dependencies | |
| 24 | - | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) | |
| 25 | - | generateID func() string | |
| 26 | - | onCloseLastPane func(ctx context.Context) error | |
| 25 | + | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) | |
| 26 | + | generateID func() string | |
| 27 | + | onCloseLastPane func(ctx context.Context) error | |
| 28 | + | onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior | |
| 27 | 29 | } | |
| 28 | 30 | ||
| 29 | 31 | // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator. | |
| @@ -592,6 +594,117 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | |||
| 592 | 594 | return nil | |
| 593 | 595 | } | |
| 594 | 596 | ||
| 597 | + | // ClosePaneByID closes a specific pane by ID. | |
| 598 | + | // This is used for closing popup panes when window.close() is called. | |
| 599 | + | func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.PaneID) error { | |
| 600 | + | log := logging.FromContext(ctx) | |
| 601 | + | ||
| 602 | + | if c.panesUC == nil { | |
| 603 | + | log.Warn().Msg("panes use case not available") | |
| 604 | + | return nil | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | ws, wsView := c.getActiveWS() | |
| 608 | + | if ws == nil { | |
| 609 | + | log.Warn().Msg("no active workspace") | |
| 610 | + | return nil | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | // Find the pane node | |
| 614 | + | paneNode := ws.FindPane(paneID) | |
| 615 | + | if paneNode == nil { | |
| 616 | + | log.Warn().Str("pane_id", string(paneID)).Msg("pane not found") | |
| 617 | + | return nil | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | log.Debug().Str("pane_id", string(paneID)).Msg("closing pane by ID") | |
| 621 | + | ||
| 622 | + | // Don't close the last pane - close the tab instead | |
| 623 | + | if ws.PaneCount() <= 1 { | |
| 624 | + | if c.onCloseLastPane != nil { | |
| 625 | + | return c.onCloseLastPane(ctx) | |
| 626 | + | } | |
| 627 | + | return nil | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // BEFORE domain changes: capture widget info for incremental close | |
| 631 | + | var parentNode *entity.PaneNode | |
| 632 | + | var siblingNode *entity.PaneNode | |
| 633 | + | var grandparentNode *entity.PaneNode | |
| 634 | + | var parentWidget layout.Widget | |
| 635 | + | var siblingIsStartChild bool | |
| 636 | + | var parentIsStartInGrand bool | |
| 637 | + | ||
| 638 | + | if wsView != nil { | |
| 639 | + | tr := wsView.TreeRenderer() | |
| 640 | + | if tr != nil && paneNode.Parent != nil { | |
| 641 | + | parentNode = paneNode.Parent | |
| 642 | + | ||
| 643 | + | if parentNode.Left() == paneNode { | |
| 644 | + | siblingNode = parentNode.Right() | |
| 645 | + | siblingIsStartChild = false | |
| 646 | + | } else { | |
| 647 | + | siblingNode = parentNode.Left() | |
| 648 | + | siblingIsStartChild = true | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | grandparentNode = parentNode.Parent | |
| 652 | + | parentWidget = tr.Lookup(parentNode.ID) | |
| 653 | + | ||
| 654 | + | if grandparentNode != nil { | |
| 655 | + | parentIsStartInGrand = grandparentNode.Left() == parentNode | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | // Now do domain changes | |
| 661 | + | _, err := c.panesUC.Close(ctx, ws, paneNode) | |
| 662 | + | if err != nil { | |
| 663 | + | log.Error().Err(err).Msg("failed to close pane") | |
| 664 | + | return err | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | // Try incremental close | |
| 668 | + | if wsView != nil && parentNode != nil { | |
| 669 | + | var closeErr error | |
| 670 | + | ||
| 671 | + | if parentNode.IsStacked { | |
| 672 | + | closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, parentNode) | |
| 673 | + | } else if siblingNode != nil && parentWidget != nil { | |
| 674 | + | closeErr = c.doIncrementalClose(ctx, wsView, ws, paneID, siblingNode, parentNode, grandparentNode, parentWidget, siblingIsStartChild, parentIsStartInGrand) | |
| 675 | + | } else { | |
| 676 | + | closeErr = fmt.Errorf("missing context for incremental close") | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | if closeErr != nil { | |
| 680 | + | log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild") | |
| 681 | + | if err := wsView.Rebuild(ctx); err != nil { | |
| 682 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |
| 683 | + | } | |
| 684 | + | c.contentCoord.ReleaseWebView(ctx, paneID) | |
| 685 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |
| 689 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |
| 690 | + | } | |
| 691 | + | wsView.FocusPane(ws.ActivePaneID) | |
| 692 | + | } else if wsView != nil { | |
| 693 | + | if err := wsView.Rebuild(ctx); err != nil { | |
| 694 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |
| 695 | + | } | |
| 696 | + | c.contentCoord.ReleaseWebView(ctx, paneID) | |
| 697 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |
| 698 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |
| 699 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |
| 700 | + | } | |
| 701 | + | wsView.FocusPane(ws.ActivePaneID) | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID") | |
| 705 | + | return nil | |
| 706 | + | } | |
| 707 | + | ||
| 595 | 708 | // doIncrementalClose performs incremental close by promoting sibling without rebuild. | |
| 596 | 709 | func (c *WorkspaceCoordinator) doIncrementalClose( | |
| 597 | 710 | ctx context.Context, | |
| @@ -1138,3 +1251,282 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||
| 1138 | 1251 | Str("pane_id", string(clickedPaneID)). | |
| 1139 | 1252 | Msg("switched active pane via title bar click") | |
| 1140 | 1253 | } | |
| 1254 | + | ||
| 1255 | + | // --- Popup Insertion --- | |
| 1256 | + | ||
| 1257 | + | // SetOnCreatePopupTab sets the callback for creating popup tabs. | |
| 1258 | + | // This is used when popup behavior is "tabbed". | |
| 1259 | + | func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input InsertPopupInput) error) { | |
| 1260 | + | c.onCreatePopupTab = fn | |
| 1261 | + | } | |
| 1262 | + | ||
| 1263 | + | // InsertPopup inserts a popup pane into the workspace based on the specified behavior. | |
| 1264 | + | // Supports split, stacked, and tabbed behaviors. | |
| 1265 | + | func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopupInput) error { | |
| 1266 | + | log := logging.FromContext(ctx) | |
| 1267 | + | ||
| 1268 | + | log.Debug(). | |
| 1269 | + | Str("parent_pane", string(input.ParentPaneID)). | |
| 1270 | + | Str("popup_pane", string(input.PopupPane.ID)). | |
| 1271 | + | Str("behavior", string(input.Behavior)). | |
| 1272 | + | Str("placement", input.Placement). | |
| 1273 | + | Str("popup_type", input.PopupType.String()). | |
| 1274 | + | Msg("inserting popup into workspace") | |
| 1275 | + | ||
| 1276 | + | switch input.Behavior { | |
| 1277 | + | case config.PopupBehaviorSplit: | |
| 1278 | + | return c.insertPopupSplit(ctx, input) | |
| 1279 | + | case config.PopupBehaviorStacked: | |
| 1280 | + | return c.insertPopupStacked(ctx, input) | |
| 1281 | + | case config.PopupBehaviorTabbed: | |
| 1282 | + | return c.insertPopupTabbed(ctx, input) | |
| 1283 | + | default: | |
| 1284 | + | // Default to split right | |
| 1285 | + | return c.insertPopupSplit(ctx, input) | |
| 1286 | + | } | |
| 1287 | + | } | |
| 1288 | + | ||
| 1289 | + | // insertPopupSplit inserts a popup as a split pane adjacent to the parent. | |
| 1290 | + | func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input InsertPopupInput) error { | |
| 1291 | + | log := logging.FromContext(ctx) | |
| 1292 | + | ||
| 1293 | + | ws, wsView := c.getActiveWS() | |
| 1294 | + | if ws == nil { | |
| 1295 | + | return fmt.Errorf("no active workspace") | |
| 1296 | + | } | |
| 1297 | + | ||
| 1298 | + | // Find parent pane node | |
| 1299 | + | parentNode := ws.FindPane(input.ParentPaneID) | |
| 1300 | + | if parentNode == nil { | |
| 1301 | + | return fmt.Errorf("parent pane not found: %s", input.ParentPaneID) | |
| 1302 | + | } | |
| 1303 | + | ||
| 1304 | + | // Map placement to split direction | |
| 1305 | + | var direction usecase.SplitDirection | |
| 1306 | + | switch input.Placement { | |
| 1307 | + | case "left": | |
| 1308 | + | direction = usecase.SplitLeft | |
| 1309 | + | case "right": | |
| 1310 | + | direction = usecase.SplitRight | |
| 1311 | + | case "top", "up": | |
| 1312 | + | direction = usecase.SplitUp | |
| 1313 | + | case "bottom", "down": | |
| 1314 | + | direction = usecase.SplitDown | |
| 1315 | + | default: | |
| 1316 | + | direction = usecase.SplitRight | |
| 1317 | + | } | |
| 1318 | + | ||
| 1319 | + | // Check if parent is in a stack - if so, split around the stack | |
| 1320 | + | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | |
| 1321 | + | parentNode.Parent != nil && parentNode.Parent.IsStacked | |
| 1322 | + | ||
| 1323 | + | // Get existing widget before domain changes | |
| 1324 | + | var existingWidget layout.Widget | |
| 1325 | + | if wsView != nil { | |
| 1326 | + | if isStackSplit { | |
| 1327 | + | tr := wsView.TreeRenderer() | |
| 1328 | + | if tr != nil { | |
| 1329 | + | stackedView := tr.GetStackedViewForPane(string(parentNode.Pane.ID)) | |
| 1330 | + | if stackedView != nil { | |
| 1331 | + | existingWidget = stackedView.Widget() | |
| 1332 | + | } | |
| 1333 | + | } | |
| 1334 | + | } else { | |
| 1335 | + | existingWidget = wsView.GetRootWidget() | |
| 1336 | + | } | |
| 1337 | + | } | |
| 1338 | + | ||
| 1339 | + | // Perform the split with pre-created pane | |
| 1340 | + | output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{ | |
| 1341 | + | Workspace: ws, | |
| 1342 | + | TargetPane: parentNode, | |
| 1343 | + | Direction: direction, | |
| 1344 | + | NewPane: input.PopupPane, | |
| 1345 | + | }) | |
| 1346 | + | if err != nil { | |
| 1347 | + | return fmt.Errorf("split for popup: %w", err) | |
| 1348 | + | } | |
| 1349 | + | ||
| 1350 | + | // Set popup as active | |
| 1351 | + | ws.ActivePaneID = input.PopupPane.ID | |
| 1352 | + | ||
| 1353 | + | // Update UI | |
| 1354 | + | if wsView != nil && existingWidget != nil { | |
| 1355 | + | var splitErr error | |
| 1356 | + | if isStackSplit { | |
| 1357 | + | splitErr = c.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingWidget, input.ParentPaneID) | |
| 1358 | + | } else { | |
| 1359 | + | splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, input.ParentPaneID) | |
| 1360 | + | } | |
| 1361 | + | ||
| 1362 | + | if splitErr != nil { | |
| 1363 | + | log.Warn().Err(splitErr).Msg("incremental popup split failed, falling back to rebuild") | |
| 1364 | + | if err := wsView.Rebuild(ctx); err != nil { | |
| 1365 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |
| 1366 | + | } | |
| 1367 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |
| 1368 | + | } | |
| 1369 | + | ||
| 1370 | + | // Update active pane tracking | |
| 1371 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |
| 1372 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |
| 1373 | + | } | |
| 1374 | + | wsView.FocusPane(ws.ActivePaneID) | |
| 1375 | + | } | |
| 1376 | + | ||
| 1377 | + | // Attach WebView widget to the new pane | |
| 1378 | + | if wsView != nil && input.WebView != nil { | |
| 1379 | + | widget := c.contentCoord.WrapWidget(ctx, input.WebView) | |
| 1380 | + | if widget != nil { | |
| 1381 | + | paneView := wsView.GetPaneView(input.PopupPane.ID) | |
| 1382 | + | if paneView != nil { | |
| 1383 | + | paneView.SetWebViewWidget(widget) | |
| 1384 | + | } | |
| 1385 | + | } | |
| 1386 | + | } | |
| 1387 | + | ||
| 1388 | + | log.Info(). | |
| 1389 | + | Str("popup_pane", string(input.PopupPane.ID)). | |
| 1390 | + | Str("direction", string(direction)). | |
| 1391 | + | Msg("popup inserted as split pane") | |
| 1392 | + | ||
| 1393 | + | return nil | |
| 1394 | + | } | |
| 1395 | + | ||
| 1396 | + | // insertPopupStacked inserts a popup as a stacked pane on top of the parent. | |
| 1397 | + | func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error { | |
| 1398 | + | log := logging.FromContext(ctx) | |
| 1399 | + | ||
| 1400 | + | ws, wsView := c.getActiveWS() | |
| 1401 | + | if ws == nil { | |
| 1402 | + | return fmt.Errorf("no active workspace") | |
| 1403 | + | } | |
| 1404 | + | ||
| 1405 | + | // Find parent pane node | |
| 1406 | + | parentNode := ws.FindPane(input.ParentPaneID) | |
| 1407 | + | if parentNode == nil { | |
| 1408 | + | return fmt.Errorf("parent pane not found: %s", input.ParentPaneID) | |
| 1409 | + | } | |
| 1410 | + | ||
| 1411 | + | // Determine stack node - either parent is already a stack, or we need to convert it | |
| 1412 | + | var stackNode *entity.PaneNode | |
| 1413 | + | var needsStackConversion bool | |
| 1414 | + | ||
| 1415 | + | if parentNode.Parent != nil && parentNode.Parent.IsStacked { | |
| 1416 | + | // Parent is already in a stack - add to existing stack | |
| 1417 | + | stackNode = parentNode.Parent | |
| 1418 | + | } else if parentNode.IsStacked { | |
| 1419 | + | // Parent itself is a stack - add to it | |
| 1420 | + | stackNode = parentNode | |
| 1421 | + | } else { | |
| 1422 | + | // Need to convert parent leaf to a stack | |
| 1423 | + | needsStackConversion = true | |
| 1424 | + | stackNode = parentNode | |
| 1425 | + | } | |
| 1426 | + | ||
| 1427 | + | if needsStackConversion { | |
| 1428 | + | // Convert leaf node to stacked container | |
| 1429 | + | originalPane := parentNode.Pane | |
| 1430 | + | originalTitle := c.contentCoord.GetTitle(input.ParentPaneID) | |
| 1431 | + | if originalTitle == "" { | |
| 1432 | + | originalTitle = originalPane.Title | |
| 1433 | + | } | |
| 1434 | + | if originalTitle == "" { | |
| 1435 | + | originalTitle = "Untitled" | |
| 1436 | + | } | |
| 1437 | + | originalPane.Title = originalTitle | |
| 1438 | + | ||
| 1439 | + | originalPaneChild := &entity.PaneNode{ | |
| 1440 | + | ID: parentNode.ID + "_0", | |
| 1441 | + | Pane: originalPane, | |
| 1442 | + | Parent: parentNode, | |
| 1443 | + | } | |
| 1444 | + | ||
| 1445 | + | parentNode.Pane = nil | |
| 1446 | + | parentNode.IsStacked = true | |
| 1447 | + | parentNode.Children = []*entity.PaneNode{originalPaneChild} | |
| 1448 | + | ||
| 1449 | + | log.Debug(). | |
| 1450 | + | Str("node_id", parentNode.ID). | |
| 1451 | + | Str("original_pane", string(originalPane.ID)). | |
| 1452 | + | Msg("converted leaf to stacked node for popup") | |
| 1453 | + | } | |
| 1454 | + | ||
| 1455 | + | // Create new child node for the popup pane | |
| 1456 | + | newChildNode := &entity.PaneNode{ | |
| 1457 | + | ID: stackNode.ID + "_popup_" + string(input.PopupPane.ID), | |
| 1458 | + | Pane: input.PopupPane, | |
| 1459 | + | Parent: stackNode, | |
| 1460 | + | } | |
| 1461 | + | stackNode.Children = append(stackNode.Children, newChildNode) | |
| 1462 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |
| 1463 | + | ||
| 1464 | + | // Update workspace active pane | |
| 1465 | + | ws.ActivePaneID = input.PopupPane.ID | |
| 1466 | + | ||
| 1467 | + | // Create PaneView for the popup | |
| 1468 | + | if wsView != nil { | |
| 1469 | + | newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil) | |
| 1470 | + | setupPaneViewHover(ctx, newPaneView, wsView) | |
| 1471 | + | wsView.RegisterPaneView(input.PopupPane.ID, newPaneView) | |
| 1472 | + | ||
| 1473 | + | // Add to StackedView | |
| 1474 | + | if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil { | |
| 1475 | + | log.Error().Err(err).Msg("failed to add popup pane to stack") | |
| 1476 | + | return err | |
| 1477 | + | } | |
| 1478 | + | ||
| 1479 | + | // Attach WebView widget | |
| 1480 | + | if input.WebView != nil { | |
| 1481 | + | widget := c.contentCoord.WrapWidget(ctx, input.WebView) | |
| 1482 | + | if widget != nil { | |
| 1483 | + | newPaneView.SetWebViewWidget(widget) | |
| 1484 | + | } | |
| 1485 | + | } | |
| 1486 | + | ||
| 1487 | + | // Update active pane tracking | |
| 1488 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |
| 1489 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |
| 1490 | + | } | |
| 1491 | + | ||
| 1492 | + | // Setup title bar click callback | |
| 1493 | + | tr := wsView.TreeRenderer() | |
| 1494 | + | if tr != nil { | |
| 1495 | + | stackedView := tr.GetStackedViewForPane(string(input.ParentPaneID)) | |
| 1496 | + | if stackedView != nil { | |
| 1497 | + | capturedStackNode := stackNode | |
| 1498 | + | stackedView.SetOnActivate(func(index int) { | |
| 1499 | + | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | |
| 1500 | + | }) | |
| 1501 | + | } | |
| 1502 | + | } | |
| 1503 | + | } | |
| 1504 | + | ||
| 1505 | + | log.Info(). | |
| 1506 | + | Str("popup_pane", string(input.PopupPane.ID)). | |
| 1507 | + | Int("stack_size", len(stackNode.Children)). | |
| 1508 | + | Msg("popup inserted as stacked pane") | |
| 1509 | + | ||
| 1510 | + | return nil | |
| 1511 | + | } | |
| 1512 | + | ||
| 1513 | + | // insertPopupTabbed creates a new tab for the popup. | |
| 1514 | + | func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input InsertPopupInput) error { | |
| 1515 | + | log := logging.FromContext(ctx) | |
| 1516 | + | ||
| 1517 | + | if c.onCreatePopupTab == nil { | |
| 1518 | + | log.Warn().Msg("tabbed popup behavior requested but no tab handler configured, falling back to split") | |
| 1519 | + | return c.insertPopupSplit(ctx, input) | |
| 1520 | + | } | |
| 1521 | + | ||
| 1522 | + | // Delegate to tab coordinator via callback | |
| 1523 | + | if err := c.onCreatePopupTab(ctx, input); err != nil { | |
| 1524 | + | return fmt.Errorf("create popup tab: %w", err) | |
| 1525 | + | } | |
| 1526 | + | ||
| 1527 | + | log.Info(). | |
| 1528 | + | Str("popup_pane", string(input.PopupPane.ID)). | |
| 1529 | + | Msg("popup inserted as new tab") | |
| 1530 | + | ||
| 1531 | + | return nil | |
| 1532 | + | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/usecase" | 7 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 9 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | "github.com/bnema/dumber/internal/ui/component" | 11 | "github.com/bnema/dumber/internal/ui/component" | ||
| 11 | "github.com/bnema/dumber/internal/ui/focus" | 12 | "github.com/bnema/dumber/internal/ui/focus" | ||
| @@ -21,9 +22,10 @@ type WorkspaceCoordinator struct { | |||||
| 21 | contentCoord *ContentCoordinator | 22 | contentCoord *ContentCoordinator | ||
| 22 | 23 | ||||
| 23 | // Callbacks to avoid circular dependencies | 24 | // Callbacks to avoid circular dependencies | ||
| 24 | - | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) | 25 | + | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) |
| 25 | - | generateID func() string | 26 | + | generateID func() string |
| 26 | - | onCloseLastPane func(ctx context.Context) error | 27 | + | onCloseLastPane func(ctx context.Context) error |
| 28 | + | onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior | |||
| 27 | } | 29 | } | ||
| 28 | 30 | ||||
| 29 | // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator. | 31 | // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator. | ||
| @@ -592,6 +594,117 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | |||||
| 592 | return nil | 594 | return nil | ||
| 593 | } | 595 | } | ||
| 594 | 596 | ||||
| 597 | + | // ClosePaneByID closes a specific pane by ID. | |||
| 598 | + | // This is used for closing popup panes when window.close() is called. | |||
| 599 | + | func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.PaneID) error { | |||
| 600 | + | log := logging.FromContext(ctx) | |||
| 601 | + | ||||
| 602 | + | if c.panesUC == nil { | |||
| 603 | + | log.Warn().Msg("panes use case not available") | |||
| 604 | + | return nil | |||
| 605 | + | } | |||
| 606 | + | ||||
| 607 | + | ws, wsView := c.getActiveWS() | |||
| 608 | + | if ws == nil { | |||
| 609 | + | log.Warn().Msg("no active workspace") | |||
| 610 | + | return nil | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | // Find the pane node | |||
| 614 | + | paneNode := ws.FindPane(paneID) | |||
| 615 | + | if paneNode == nil { | |||
| 616 | + | log.Warn().Str("pane_id", string(paneID)).Msg("pane not found") | |||
| 617 | + | return nil | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | log.Debug().Str("pane_id", string(paneID)).Msg("closing pane by ID") | |||
| 621 | + | ||||
| 622 | + | // Don't close the last pane - close the tab instead | |||
| 623 | + | if ws.PaneCount() <= 1 { | |||
| 624 | + | if c.onCloseLastPane != nil { | |||
| 625 | + | return c.onCloseLastPane(ctx) | |||
| 626 | + | } | |||
| 627 | + | return nil | |||
| 628 | + | } | |||
| 629 | + | ||||
| 630 | + | // BEFORE domain changes: capture widget info for incremental close | |||
| 631 | + | var parentNode *entity.PaneNode | |||
| 632 | + | var siblingNode *entity.PaneNode | |||
| 633 | + | var grandparentNode *entity.PaneNode | |||
| 634 | + | var parentWidget layout.Widget | |||
| 635 | + | var siblingIsStartChild bool | |||
| 636 | + | var parentIsStartInGrand bool | |||
| 637 | + | ||||
| 638 | + | if wsView != nil { | |||
| 639 | + | tr := wsView.TreeRenderer() | |||
| 640 | + | if tr != nil && paneNode.Parent != nil { | |||
| 641 | + | parentNode = paneNode.Parent | |||
| 642 | + | ||||
| 643 | + | if parentNode.Left() == paneNode { | |||
| 644 | + | siblingNode = parentNode.Right() | |||
| 645 | + | siblingIsStartChild = false | |||
| 646 | + | } else { | |||
| 647 | + | siblingNode = parentNode.Left() | |||
| 648 | + | siblingIsStartChild = true | |||
| 649 | + | } | |||
| 650 | + | ||||
| 651 | + | grandparentNode = parentNode.Parent | |||
| 652 | + | parentWidget = tr.Lookup(parentNode.ID) | |||
| 653 | + | ||||
| 654 | + | if grandparentNode != nil { | |||
| 655 | + | parentIsStartInGrand = grandparentNode.Left() == parentNode | |||
| 656 | + | } | |||
| 657 | + | } | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | // Now do domain changes | |||
| 661 | + | _, err := c.panesUC.Close(ctx, ws, paneNode) | |||
| 662 | + | if err != nil { | |||
| 663 | + | log.Error().Err(err).Msg("failed to close pane") | |||
| 664 | + | return err | |||
| 665 | + | } | |||
| 666 | + | ||||
| 667 | + | // Try incremental close | |||
| 668 | + | if wsView != nil && parentNode != nil { | |||
| 669 | + | var closeErr error | |||
| 670 | + | ||||
| 671 | + | if parentNode.IsStacked { | |||
| 672 | + | closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, parentNode) | |||
| 673 | + | } else if siblingNode != nil && parentWidget != nil { | |||
| 674 | + | closeErr = c.doIncrementalClose(ctx, wsView, ws, paneID, siblingNode, parentNode, grandparentNode, parentWidget, siblingIsStartChild, parentIsStartInGrand) | |||
| 675 | + | } else { | |||
| 676 | + | closeErr = fmt.Errorf("missing context for incremental close") | |||
| 677 | + | } | |||
| 678 | + | ||||
| 679 | + | if closeErr != nil { | |||
| 680 | + | log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild") | |||
| 681 | + | if err := wsView.Rebuild(ctx); err != nil { | |||
| 682 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 683 | + | } | |||
| 684 | + | c.contentCoord.ReleaseWebView(ctx, paneID) | |||
| 685 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 686 | + | } | |||
| 687 | + | ||||
| 688 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 689 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 690 | + | } | |||
| 691 | + | wsView.FocusPane(ws.ActivePaneID) | |||
| 692 | + | } else if wsView != nil { | |||
| 693 | + | if err := wsView.Rebuild(ctx); err != nil { | |||
| 694 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 695 | + | } | |||
| 696 | + | c.contentCoord.ReleaseWebView(ctx, paneID) | |||
| 697 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 698 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 699 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 700 | + | } | |||
| 701 | + | wsView.FocusPane(ws.ActivePaneID) | |||
| 702 | + | } | |||
| 703 | + | ||||
| 704 | + | log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID") | |||
| 705 | + | return nil | |||
| 706 | + | } | |||
| 707 | + | ||||
| 595 | // doIncrementalClose performs incremental close by promoting sibling without rebuild. | 708 | // doIncrementalClose performs incremental close by promoting sibling without rebuild. | ||
| 596 | func (c *WorkspaceCoordinator) doIncrementalClose( | 709 | func (c *WorkspaceCoordinator) doIncrementalClose( | ||
| 597 | ctx context.Context, | 710 | ctx context.Context, | ||
| @@ -1138,3 +1251,282 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||||
| 1138 | Str("pane_id", string(clickedPaneID)). | 1251 | Str("pane_id", string(clickedPaneID)). | ||
| 1139 | Msg("switched active pane via title bar click") | 1252 | Msg("switched active pane via title bar click") | ||
| 1140 | } | 1253 | } | ||
| 1254 | + | ||||
| 1255 | + | // --- Popup Insertion --- | |||
| 1256 | + | ||||
| 1257 | + | // SetOnCreatePopupTab sets the callback for creating popup tabs. | |||
| 1258 | + | // This is used when popup behavior is "tabbed". | |||
| 1259 | + | func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input InsertPopupInput) error) { | |||
| 1260 | + | c.onCreatePopupTab = fn | |||
| 1261 | + | } | |||
| 1262 | + | ||||
| 1263 | + | // InsertPopup inserts a popup pane into the workspace based on the specified behavior. | |||
| 1264 | + | // Supports split, stacked, and tabbed behaviors. | |||
| 1265 | + | func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopupInput) error { | |||
| 1266 | + | log := logging.FromContext(ctx) | |||
| 1267 | + | ||||
| 1268 | + | log.Debug(). | |||
| 1269 | + | Str("parent_pane", string(input.ParentPaneID)). | |||
| 1270 | + | Str("popup_pane", string(input.PopupPane.ID)). | |||
| 1271 | + | Str("behavior", string(input.Behavior)). | |||
| 1272 | + | Str("placement", input.Placement). | |||
| 1273 | + | Str("popup_type", input.PopupType.String()). | |||
| 1274 | + | Msg("inserting popup into workspace") | |||
| 1275 | + | ||||
| 1276 | + | switch input.Behavior { | |||
| 1277 | + | case config.PopupBehaviorSplit: | |||
| 1278 | + | return c.insertPopupSplit(ctx, input) | |||
| 1279 | + | case config.PopupBehaviorStacked: | |||
| 1280 | + | return c.insertPopupStacked(ctx, input) | |||
| 1281 | + | case config.PopupBehaviorTabbed: | |||
| 1282 | + | return c.insertPopupTabbed(ctx, input) | |||
| 1283 | + | default: | |||
| 1284 | + | // Default to split right | |||
| 1285 | + | return c.insertPopupSplit(ctx, input) | |||
| 1286 | + | } | |||
| 1287 | + | } | |||
| 1288 | + | ||||
| 1289 | + | // insertPopupSplit inserts a popup as a split pane adjacent to the parent. | |||
| 1290 | + | func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input InsertPopupInput) error { | |||
| 1291 | + | log := logging.FromContext(ctx) | |||
| 1292 | + | ||||
| 1293 | + | ws, wsView := c.getActiveWS() | |||
| 1294 | + | if ws == nil { | |||
| 1295 | + | return fmt.Errorf("no active workspace") | |||
| 1296 | + | } | |||
| 1297 | + | ||||
| 1298 | + | // Find parent pane node | |||
| 1299 | + | parentNode := ws.FindPane(input.ParentPaneID) | |||
| 1300 | + | if parentNode == nil { | |||
| 1301 | + | return fmt.Errorf("parent pane not found: %s", input.ParentPaneID) | |||
| 1302 | + | } | |||
| 1303 | + | ||||
| 1304 | + | // Map placement to split direction | |||
| 1305 | + | var direction usecase.SplitDirection | |||
| 1306 | + | switch input.Placement { | |||
| 1307 | + | case "left": | |||
| 1308 | + | direction = usecase.SplitLeft | |||
| 1309 | + | case "right": | |||
| 1310 | + | direction = usecase.SplitRight | |||
| 1311 | + | case "top", "up": | |||
| 1312 | + | direction = usecase.SplitUp | |||
| 1313 | + | case "bottom", "down": | |||
| 1314 | + | direction = usecase.SplitDown | |||
| 1315 | + | default: | |||
| 1316 | + | direction = usecase.SplitRight | |||
| 1317 | + | } | |||
| 1318 | + | ||||
| 1319 | + | // Check if parent is in a stack - if so, split around the stack | |||
| 1320 | + | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | |||
| 1321 | + | parentNode.Parent != nil && parentNode.Parent.IsStacked | |||
| 1322 | + | ||||
| 1323 | + | // Get existing widget before domain changes | |||
| 1324 | + | var existingWidget layout.Widget | |||
| 1325 | + | if wsView != nil { | |||
| 1326 | + | if isStackSplit { | |||
| 1327 | + | tr := wsView.TreeRenderer() | |||
| 1328 | + | if tr != nil { | |||
| 1329 | + | stackedView := tr.GetStackedViewForPane(string(parentNode.Pane.ID)) | |||
| 1330 | + | if stackedView != nil { | |||
| 1331 | + | existingWidget = stackedView.Widget() | |||
| 1332 | + | } | |||
| 1333 | + | } | |||
| 1334 | + | } else { | |||
| 1335 | + | existingWidget = wsView.GetRootWidget() | |||
| 1336 | + | } | |||
| 1337 | + | } | |||
| 1338 | + | ||||
| 1339 | + | // Perform the split with pre-created pane | |||
| 1340 | + | output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{ | |||
| 1341 | + | Workspace: ws, | |||
| 1342 | + | TargetPane: parentNode, | |||
| 1343 | + | Direction: direction, | |||
| 1344 | + | NewPane: input.PopupPane, | |||
| 1345 | + | }) | |||
| 1346 | + | if err != nil { | |||
| 1347 | + | return fmt.Errorf("split for popup: %w", err) | |||
| 1348 | + | } | |||
| 1349 | + | ||||
| 1350 | + | // Set popup as active | |||
| 1351 | + | ws.ActivePaneID = input.PopupPane.ID | |||
| 1352 | + | ||||
| 1353 | + | // Update UI | |||
| 1354 | + | if wsView != nil && existingWidget != nil { | |||
| 1355 | + | var splitErr error | |||
| 1356 | + | if isStackSplit { | |||
| 1357 | + | splitErr = c.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingWidget, input.ParentPaneID) | |||
| 1358 | + | } else { | |||
| 1359 | + | splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, input.ParentPaneID) | |||
| 1360 | + | } | |||
| 1361 | + | ||||
| 1362 | + | if splitErr != nil { | |||
| 1363 | + | log.Warn().Err(splitErr).Msg("incremental popup split failed, falling back to rebuild") | |||
| 1364 | + | if err := wsView.Rebuild(ctx); err != nil { | |||
| 1365 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 1366 | + | } | |||
| 1367 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 1368 | + | } | |||
| 1369 | + | ||||
| 1370 | + | // Update active pane tracking | |||
| 1371 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 1372 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 1373 | + | } | |||
| 1374 | + | wsView.FocusPane(ws.ActivePaneID) | |||
| 1375 | + | } | |||
| 1376 | + | ||||
| 1377 | + | // Attach WebView widget to the new pane | |||
| 1378 | + | if wsView != nil && input.WebView != nil { | |||
| 1379 | + | widget := c.contentCoord.WrapWidget(ctx, input.WebView) | |||
| 1380 | + | if widget != nil { | |||
| 1381 | + | paneView := wsView.GetPaneView(input.PopupPane.ID) | |||
| 1382 | + | if paneView != nil { | |||
| 1383 | + | paneView.SetWebViewWidget(widget) | |||
| 1384 | + | } | |||
| 1385 | + | } | |||
| 1386 | + | } | |||
| 1387 | + | ||||
| 1388 | + | log.Info(). | |||
| 1389 | + | Str("popup_pane", string(input.PopupPane.ID)). | |||
| 1390 | + | Str("direction", string(direction)). | |||
| 1391 | + | Msg("popup inserted as split pane") | |||
| 1392 | + | ||||
| 1393 | + | return nil | |||
| 1394 | + | } | |||
| 1395 | + | ||||
| 1396 | + | // insertPopupStacked inserts a popup as a stacked pane on top of the parent. | |||
| 1397 | + | func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error { | |||
| 1398 | + | log := logging.FromContext(ctx) | |||
| 1399 | + | ||||
| 1400 | + | ws, wsView := c.getActiveWS() | |||
| 1401 | + | if ws == nil { | |||
| 1402 | + | return fmt.Errorf("no active workspace") | |||
| 1403 | + | } | |||
| 1404 | + | ||||
| 1405 | + | // Find parent pane node | |||
| 1406 | + | parentNode := ws.FindPane(input.ParentPaneID) | |||
| 1407 | + | if parentNode == nil { | |||
| 1408 | + | return fmt.Errorf("parent pane not found: %s", input.ParentPaneID) | |||
| 1409 | + | } | |||
| 1410 | + | ||||
| 1411 | + | // Determine stack node - either parent is already a stack, or we need to convert it | |||
| 1412 | + | var stackNode *entity.PaneNode | |||
| 1413 | + | var needsStackConversion bool | |||
| 1414 | + | ||||
| 1415 | + | if parentNode.Parent != nil && parentNode.Parent.IsStacked { | |||
| 1416 | + | // Parent is already in a stack - add to existing stack | |||
| 1417 | + | stackNode = parentNode.Parent | |||
| 1418 | + | } else if parentNode.IsStacked { | |||
| 1419 | + | // Parent itself is a stack - add to it | |||
| 1420 | + | stackNode = parentNode | |||
| 1421 | + | } else { | |||
| 1422 | + | // Need to convert parent leaf to a stack | |||
| 1423 | + | needsStackConversion = true | |||
| 1424 | + | stackNode = parentNode | |||
| 1425 | + | } | |||
| 1426 | + | ||||
| 1427 | + | if needsStackConversion { | |||
| 1428 | + | // Convert leaf node to stacked container | |||
| 1429 | + | originalPane := parentNode.Pane | |||
| 1430 | + | originalTitle := c.contentCoord.GetTitle(input.ParentPaneID) | |||
| 1431 | + | if originalTitle == "" { | |||
| 1432 | + | originalTitle = originalPane.Title | |||
| 1433 | + | } | |||
| 1434 | + | if originalTitle == "" { | |||
| 1435 | + | originalTitle = "Untitled" | |||
| 1436 | + | } | |||
| 1437 | + | originalPane.Title = originalTitle | |||
| 1438 | + | ||||
| 1439 | + | originalPaneChild := &entity.PaneNode{ | |||
| 1440 | + | ID: parentNode.ID + "_0", | |||
| 1441 | + | Pane: originalPane, | |||
| 1442 | + | Parent: parentNode, | |||
| 1443 | + | } | |||
| 1444 | + | ||||
| 1445 | + | parentNode.Pane = nil | |||
| 1446 | + | parentNode.IsStacked = true | |||
| 1447 | + | parentNode.Children = []*entity.PaneNode{originalPaneChild} | |||
| 1448 | + | ||||
| 1449 | + | log.Debug(). | |||
| 1450 | + | Str("node_id", parentNode.ID). | |||
| 1451 | + | Str("original_pane", string(originalPane.ID)). | |||
| 1452 | + | Msg("converted leaf to stacked node for popup") | |||
| 1453 | + | } | |||
| 1454 | + | ||||
| 1455 | + | // Create new child node for the popup pane | |||
| 1456 | + | newChildNode := &entity.PaneNode{ | |||
| 1457 | + | ID: stackNode.ID + "_popup_" + string(input.PopupPane.ID), | |||
| 1458 | + | Pane: input.PopupPane, | |||
| 1459 | + | Parent: stackNode, | |||
| 1460 | + | } | |||
| 1461 | + | stackNode.Children = append(stackNode.Children, newChildNode) | |||
| 1462 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |||
| 1463 | + | ||||
| 1464 | + | // Update workspace active pane | |||
| 1465 | + | ws.ActivePaneID = input.PopupPane.ID | |||
| 1466 | + | ||||
| 1467 | + | // Create PaneView for the popup | |||
| 1468 | + | if wsView != nil { | |||
| 1469 | + | newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil) | |||
| 1470 | + | setupPaneViewHover(ctx, newPaneView, wsView) | |||
| 1471 | + | wsView.RegisterPaneView(input.PopupPane.ID, newPaneView) | |||
| 1472 | + | ||||
| 1473 | + | // Add to StackedView | |||
| 1474 | + | if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil { | |||
| 1475 | + | log.Error().Err(err).Msg("failed to add popup pane to stack") | |||
| 1476 | + | return err | |||
| 1477 | + | } | |||
| 1478 | + | ||||
| 1479 | + | // Attach WebView widget | |||
| 1480 | + | if input.WebView != nil { | |||
| 1481 | + | widget := c.contentCoord.WrapWidget(ctx, input.WebView) | |||
| 1482 | + | if widget != nil { | |||
| 1483 | + | newPaneView.SetWebViewWidget(widget) | |||
| 1484 | + | } | |||
| 1485 | + | } | |||
| 1486 | + | ||||
| 1487 | + | // Update active pane tracking | |||
| 1488 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 1489 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 1490 | + | } | |||
| 1491 | + | ||||
| 1492 | + | // Setup title bar click callback | |||
| 1493 | + | tr := wsView.TreeRenderer() | |||
| 1494 | + | if tr != nil { | |||
| 1495 | + | stackedView := tr.GetStackedViewForPane(string(input.ParentPaneID)) | |||
| 1496 | + | if stackedView != nil { | |||
| 1497 | + | capturedStackNode := stackNode | |||
| 1498 | + | stackedView.SetOnActivate(func(index int) { | |||
| 1499 | + | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | |||
| 1500 | + | }) | |||
| 1501 | + | } | |||
| 1502 | + | } | |||
| 1503 | + | } | |||
| 1504 | + | ||||
| 1505 | + | log.Info(). | |||
| 1506 | + | Str("popup_pane", string(input.PopupPane.ID)). | |||
| 1507 | + | Int("stack_size", len(stackNode.Children)). | |||
| 1508 | + | Msg("popup inserted as stacked pane") | |||
| 1509 | + | ||||
| 1510 | + | return nil | |||
| 1511 | + | } | |||
| 1512 | + | ||||
| 1513 | + | // insertPopupTabbed creates a new tab for the popup. | |||
| 1514 | + | func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input InsertPopupInput) error { | |||
| 1515 | + | log := logging.FromContext(ctx) | |||
| 1516 | + | ||||
| 1517 | + | if c.onCreatePopupTab == nil { | |||
| 1518 | + | log.Warn().Msg("tabbed popup behavior requested but no tab handler configured, falling back to split") | |||
| 1519 | + | return c.insertPopupSplit(ctx, input) | |||
| 1520 | + | } | |||
| 1521 | + | ||||
| 1522 | + | // Delegate to tab coordinator via callback | |||
| 1523 | + | if err := c.onCreatePopupTab(ctx, input); err != nil { | |||
| 1524 | + | return fmt.Errorf("create popup tab: %w", err) | |||
| 1525 | + | } | |||
| 1526 | + | ||||
| 1527 | + | log.Info(). | |||
| 1528 | + | Str("popup_pane", string(input.PopupPane.ID)). | |||
| 1529 | + | Msg("popup inserted as new tab") | |||
| 1530 | + | ||||
| 1531 | + | return nil | |||
| 1532 | + | } | |||