detached 5740d12

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
308308
309309 return nil
310310 }
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 nil309 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 {
6363 omniboxCfg component.OmniboxConfig
6464
6565 // 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
7172
7273 // ID generator for tabs/panes
7374 idCounter uint64
@@ -299,6 +300,10 @@ func (a *App) initCoordinators(ctx context.Context) {
299300 a.switchWorkspaceView(ctx, tab.ID)
300301 })
301302 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+ })
302307
303308 // Wire tab bar click handling to coordinator
304309 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
@@ -321,6 +326,36 @@ func (a *App) initCoordinators(ctx context.Context) {
321326 return a.tabCoord.Close(ctx)
322327 })
323328
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+
324359 // 4. Navigation Coordinator
325360 a.navCoord = coordinator.NewNavigationCoordinator(
326361 ctx,
@@ -471,6 +506,43 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
471506 return a.workspaceViews[activeTab.ID]
472507 }
473508
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+
474546 // switchWorkspaceView swaps the displayed workspace view for a tab.
475547 func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
476548 log := logging.FromContext(ctx)
@@ -63,11 +63,12 @@ type App struct {
63 omniboxCfg component.OmniboxConfig63 omniboxCfg component.OmniboxConfig
64 64
65 // Web content (managed by ContentCoordinator)65 // Web content (managed by ContentCoordinator)
66- pool *webkit.WebViewPool66+ pool *webkit.WebViewPool
67- injector *webkit.ContentInjector67+ webViewFactory *webkit.WebViewFactory
68- router *webkit.MessageRouter68+ injector *webkit.ContentInjector
69- settings *webkit.SettingsManager69+ router *webkit.MessageRouter
70- faviconCache *cache.FaviconCache70+ settings *webkit.SettingsManager
71+ faviconCache *cache.FaviconCache
71 72
72 // ID generator for tabs/panes73 // ID generator for tabs/panes
73 idCounter uint6474 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 coordinator308 // 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 Coordinator359 // 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 (
66 "github.com/bnema/dumber/internal/application/usecase"
77 "github.com/bnema/dumber/internal/domain/entity"
88 "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
910 "github.com/bnema/dumber/internal/logging"
1011 "github.com/bnema/dumber/internal/ui/component"
1112 "github.com/bnema/dumber/internal/ui/window"
@@ -19,9 +20,10 @@ type TabCoordinator struct {
1920 config *config.Config
2021
2122 // 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
2527 }
2628
2729 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -310,3 +312,58 @@ func (c *TabCoordinator) GetTabBar() *component.TabBar {
310312 }
311313 return nil
312314 }
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.Config20 config *config.Config
20 21
21 // Callbacks to avoid circular dependencies22 // 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 nil313 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 (
66
77 "github.com/bnema/dumber/internal/application/usecase"
88 "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
910 "github.com/bnema/dumber/internal/logging"
1011 "github.com/bnema/dumber/internal/ui/component"
1112 "github.com/bnema/dumber/internal/ui/focus"
@@ -21,9 +22,10 @@ type WorkspaceCoordinator struct {
2122 contentCoord *ContentCoordinator
2223
2324 // 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
2729 }
2830
2931 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
@@ -592,6 +594,117 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
592594 return nil
593595 }
594596
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+
595708 // doIncrementalClose performs incremental close by promoting sibling without rebuild.
596709 func (c *WorkspaceCoordinator) doIncrementalClose(
597710 ctx context.Context,
@@ -1138,3 +1251,282 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
11381251 Str("pane_id", string(clickedPaneID)).
11391252 Msg("switched active pane via title bar click")
11401253 }
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 *ContentCoordinator22 contentCoord *ContentCoordinator
22 23
23 // Callbacks to avoid circular dependencies24 // Callbacks to avoid circular dependencies
24- getActiveWS func() (*entity.Workspace, *component.WorkspaceView)25+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
25- generateID func() string26+ generateID func() string
26- onCloseLastPane func(ctx context.Context) error27+ 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 nil594 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close