detached f7d1190

feat: listen for browser launch requests in ui app

commit
f7d1190648600182b6a996ef2f54ab0f720fc304
author
brice · · 2026-04-14 15:51 UTC
parent
68837f5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +267 -29
M cmd/dumber/main.go +3 -2
@@ -781,8 +781,9 @@ func buildUIDependencies(
781781 LaunchBrowserURL: func(uri string) {
782782 browserLauncher.LaunchURL(ctx, uri)
783783 },
784- ConfigMigrator: config.NewMigrator(),
785- HandlerDeps: *handlerDeps,
784+ BrowserLaunchRelay: browserLaunchRelay,
785+ ConfigMigrator: config.NewMigrator(),
786+ HandlerDeps: *handlerDeps,
786787 }
787788
788789 return uiDeps, nil
@@ -781,8 +781,9 @@ func buildUIDependencies(
781 LaunchBrowserURL: func(uri string) {781 LaunchBrowserURL: func(uri string) {
782 browserLauncher.LaunchURL(ctx, uri)782 browserLauncher.LaunchURL(ctx, uri)
783 },783 },
784- ConfigMigrator: config.NewMigrator(),784+ BrowserLaunchRelay: browserLaunchRelay,
785- HandlerDeps: *handlerDeps,785+ ConfigMigrator: config.NewMigrator(),
786+ HandlerDeps: *handlerDeps,
786 }787 }
787 788
788 return uiDeps, nil789 return uiDeps, nil
M internal/ui/app.go +151 -14
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "io"
78 "os"
89 "path/filepath"
910 "strings"
@@ -67,6 +68,7 @@ type App struct {
6768
6869 browserWindows map[string]*browserWindow
6970 browserWindowFactory func(context.Context, string) (*browserWindow, error)
71+ tabCreationWindow *browserWindow
7072 dispatchOnMainThread func(func())
7173
7274 // State
@@ -83,6 +85,7 @@ type App struct {
8385 // Pane management (used by coordinators)
8486 panesUC *usecase.ManagePanesUseCase
8587 workspaceViews map[entity.TabID]*component.WorkspaceView
88+ windowForTab map[entity.TabID]*browserWindow
8689 widgetFactory layout.WidgetFactory
8790 stackedPaneMgr *component.StackedPaneManager
8891
@@ -146,7 +149,9 @@ type App struct {
146149 deferredInitFn func()
147150
148151 // lifecycle
149- cancel context.CancelCauseFunc
152+ cancel context.CancelCauseFunc
153+ browserLaunchRelayOnce sync.Once
154+ browserLaunchRelayCloser io.Closer
150155 }
151156
152157 type floatingWorkspaceSession struct {
@@ -183,6 +188,7 @@ func New(deps *Dependencies) (*App, error) {
183188 tabsUC: deps.TabsUC,
184189 panesUC: deps.PanesUC,
185190 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
191+ windowForTab: make(map[entity.TabID]*browserWindow),
186192 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
187193 browserWindows: make(map[string]*browserWindow),
188194 dispatchOnMainThread: func(fn func()) { fn() },
@@ -282,7 +288,7 @@ func (a *App) onActivate(ctx context.Context) {
282288 // synchronous prewarming that delays the first navigation on cold start.
283289 a.setupPoolBackgroundColor(ctx)
284290
285- if _, err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {
291+ if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {
286292 log.Error().Err(err).Msg("failed to create main window")
287293 return
288294 }
@@ -451,6 +457,120 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
451457 return browserWindow, nil
452458 }
453459
460+func (a *App) ensureWindowForTabMap() {
461+ if a.windowForTab == nil {
462+ a.windowForTab = make(map[entity.TabID]*browserWindow)
463+ }
464+}
465+
466+func (a *App) browserWindowForMainWindow(mainWindow *window.MainWindow) *browserWindow {
467+ if mainWindow == nil {
468+ return nil
469+ }
470+ for _, bw := range a.browserWindows {
471+ if bw != nil && bw.mainWindow == mainWindow {
472+ return bw
473+ }
474+ }
475+ return nil
476+}
477+
478+func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {
479+ if a.windowForTab != nil {
480+ if bw := a.windowForTab[tabID]; bw != nil {
481+ return bw
482+ }
483+ }
484+ if a.tabCreationWindow != nil {
485+ return a.tabCreationWindow
486+ }
487+ return a.browserWindowForMainWindow(a.mainWindow)
488+}
489+
490+func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) {
491+ if bw == nil {
492+ return
493+ }
494+ a.ensureWindowForTabMap()
495+ a.windowForTab[tabID] = bw
496+}
497+
498+func (a *App) startBrowserLaunchRelayListener(ctx context.Context) {
499+ if a == nil || a.deps == nil || a.deps.BrowserLaunchRelay == nil {
500+ return
501+ }
502+ a.browserLaunchRelayOnce.Do(func() {
503+ closer, err := a.deps.BrowserLaunchRelay.Listen(ctx, a)
504+ if err != nil {
505+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to start browser launch relay listener")
506+ return
507+ }
508+ a.browserLaunchRelayCloser = closer
509+ })
510+}
511+
512+func (a *App) closeBrowserLaunchRelayListener() {
513+ if a.browserLaunchRelayCloser == nil {
514+ return
515+ }
516+ _ = a.browserLaunchRelayCloser.Close()
517+ a.browserLaunchRelayCloser = nil
518+}
519+
520+func (a *App) openFreshWindow(ctx context.Context, url string) error {
521+ created, err := a.createBrowserWindow(ctx, url)
522+ if err != nil {
523+ return err
524+ }
525+ a.registerBrowserWindow(created)
526+
527+ if a.tabs == nil || a.tabs.Count() == 0 {
528+ return nil
529+ }
530+
531+ if a.tabCoord != nil && a.widgetFactory != nil {
532+ a.tabCreationWindow = created
533+ defer func() {
534+ a.tabCreationWindow = nil
535+ }()
536+
537+ tab, err := a.tabCoord.Create(ctx, url)
538+ if err != nil {
539+ return err
540+ }
541+ a.setBrowserWindowForTab(tab.ID, created)
542+ if created.mainWindow != nil {
543+ created.mainWindow.Show()
544+ }
545+ return nil
546+ }
547+
548+ if a.tabsUC == nil {
549+ return nil
550+ }
551+
552+ output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{
553+ TabList: a.tabs,
554+ InitialURL: url,
555+ })
556+ if err != nil {
557+ return err
558+ }
559+ a.setBrowserWindowForTab(output.Tab.ID, created)
560+ if a.widgetFactory != nil {
561+ a.tabCreationWindow = created
562+ defer func() {
563+ a.tabCreationWindow = nil
564+ }()
565+ a.createWorkspaceView(ctx, output.Tab)
566+ a.switchWorkspaceView(ctx, output.Tab.ID)
567+ }
568+ if created.mainWindow != nil {
569+ created.mainWindow.Show()
570+ }
571+ return nil
572+}
573+
454574 func (a *App) initialWindowURL() string {
455575 if a.deps != nil && a.deps.InitialURL != "" {
456576 return a.deps.InitialURL
@@ -1504,6 +1624,7 @@ func (a *App) finalizeActivation(ctx context.Context) {
15041624 if a.mainWindow != nil {
15051625 a.mainWindow.Show()
15061626 }
1627+ a.startBrowserLaunchRelayListener(ctx)
15071628 log.Info().Msg("main window displayed")
15081629
15091630 if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
@@ -1616,6 +1737,7 @@ func (a *App) onShutdown(ctx context.Context) {
16161737 log.Warn().Err(err).Msg("failed to close idle inhibitor")
16171738 }
16181739 }
1740+ a.closeBrowserLaunchRelayListener()
16191741
16201742 log.Info().Msg("application shutdown complete")
16211743 }
@@ -1793,7 +1915,7 @@ func (a *App) initCoordinators(ctx context.Context) {
17931915
17941916 // Wire window title updates when active pane's title changes
17951917 a.contentCoord.SetOnWindowTitleChanged(func(title string) {
1796- a.updateWindowTitle(title)
1918+ a.updateWindowTitle(title, a.browserWindowForTab(a.tabs.ActiveTabID))
17971919 })
17981920
17991921 // Wire pane URI updates for session snapshots (searches all tabs)
@@ -2043,8 +2165,11 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int {
20432165
20442166 // updateWindowTitle updates the window title with the given page title.
20452167 // Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
2046-func (a *App) updateWindowTitle(pageTitle string) {
2047- if a.mainWindow == nil {
2168+func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
2169+ if target == nil {
2170+ target = a.browserWindowForMainWindow(a.mainWindow)
2171+ }
2172+ if target == nil || target.mainWindow == nil {
20482173 return
20492174 }
20502175
@@ -2052,18 +2177,23 @@ func (a *App) updateWindowTitle(pageTitle string) {
20522177 if pageTitle != "" {
20532178 title = pageTitle + " - Dumber"
20542179 }
2055- a.mainWindow.SetTitle(title)
2180+ target.mainWindow.SetTitle(title)
20562181 }
20572182
20582183 // updateWindowTitleFromActivePane updates the window title based on the current active pane.
2059-func (a *App) updateWindowTitleFromActivePane() {
2060- ws := a.activeWorkspace()
2184+func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
2185+ var ws *entity.Workspace
2186+ if tab := a.tabs.Find(tabID); tab != nil {
2187+ ws = tab.Workspace
2188+ } else {
2189+ ws = a.activeWorkspace()
2190+ }
20612191 if ws == nil || a.contentCoord == nil {
2062- a.updateWindowTitle("")
2192+ a.updateWindowTitle("", a.browserWindowForTab(tabID))
20632193 return
20642194 }
20652195 title := a.contentCoord.GetTitle(ws.ActivePaneID)
2066- a.updateWindowTitle(title)
2196+ a.updateWindowTitle(title, a.browserWindowForTab(tabID))
20672197 }
20682198
20692199 // handleModeChange is called when the input mode changes.
@@ -2176,14 +2306,19 @@ func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
21762306 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
21772307 a.createWorkspaceViewWithoutAttach(ctx, tab)
21782308
2309+ target := a.browserWindowForTab(tab.ID)
2310+ if target != nil {
2311+ a.setBrowserWindowForTab(tab.ID, target)
2312+ }
2313+
21792314 // Attach to content area
21802315 wsView := a.workspaceViews[tab.ID]
2181- if wsView != nil && a.mainWindow != nil {
2316+ if wsView != nil && target != nil && target.mainWindow != nil {
21822317 widget := wsView.Widget()
21832318 if widget != nil {
21842319 gtkWidget := widget.GtkWidget()
21852320 if gtkWidget != nil {
2186- a.mainWindow.SetContent(gtkWidget)
2321+ target.mainWindow.SetContent(gtkWidget)
21872322 }
21882323 }
21892324 }
@@ -2362,12 +2497,14 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
23622497 }
23632498
23642499 // Swap content (MainWindow.SetContent now properly removes old content)
2365- if a.mainWindow != nil {
2500+ if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
2501+ target.mainWindow.SetContent(gtkWidget)
2502+ } else if a.mainWindow != nil {
23662503 a.mainWindow.SetContent(gtkWidget)
23672504 }
23682505
23692506 // Update window title with the new active pane's title
2370- a.updateWindowTitleFromActivePane()
2507+ a.updateWindowTitleFromActivePane(tabID)
23712508 a.syncFloatingFocus()
23722509
23732510 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "io"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
9 "strings"10 "strings"
@@ -67,6 +68,7 @@ type App struct {
67 68
68 browserWindows map[string]*browserWindow69 browserWindows map[string]*browserWindow
69 browserWindowFactory func(context.Context, string) (*browserWindow, error)70 browserWindowFactory func(context.Context, string) (*browserWindow, error)
71+ tabCreationWindow *browserWindow
70 dispatchOnMainThread func(func())72 dispatchOnMainThread func(func())
71 73
72 // State74 // State
@@ -83,6 +85,7 @@ type App struct {
83 // Pane management (used by coordinators)85 // Pane management (used by coordinators)
84 panesUC *usecase.ManagePanesUseCase86 panesUC *usecase.ManagePanesUseCase
85 workspaceViews map[entity.TabID]*component.WorkspaceView87 workspaceViews map[entity.TabID]*component.WorkspaceView
88+ windowForTab map[entity.TabID]*browserWindow
86 widgetFactory layout.WidgetFactory89 widgetFactory layout.WidgetFactory
87 stackedPaneMgr *component.StackedPaneManager90 stackedPaneMgr *component.StackedPaneManager
88 91
@@ -146,7 +149,9 @@ type App struct {
146 deferredInitFn func()149 deferredInitFn func()
147 150
148 // lifecycle151 // lifecycle
149- cancel context.CancelCauseFunc152+ cancel context.CancelCauseFunc
153+ browserLaunchRelayOnce sync.Once
154+ browserLaunchRelayCloser io.Closer
150 }155 }
151 156
152 type floatingWorkspaceSession struct {157 type floatingWorkspaceSession struct {
@@ -183,6 +188,7 @@ func New(deps *Dependencies) (*App, error) {
183 tabsUC: deps.TabsUC,188 tabsUC: deps.TabsUC,
184 panesUC: deps.PanesUC,189 panesUC: deps.PanesUC,
185 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),190 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
191+ windowForTab: make(map[entity.TabID]*browserWindow),
186 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),192 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
187 browserWindows: make(map[string]*browserWindow),193 browserWindows: make(map[string]*browserWindow),
188 dispatchOnMainThread: func(fn func()) { fn() },194 dispatchOnMainThread: func(fn func()) { fn() },
@@ -282,7 +288,7 @@ func (a *App) onActivate(ctx context.Context) {
282 // synchronous prewarming that delays the first navigation on cold start.288 // synchronous prewarming that delays the first navigation on cold start.
283 a.setupPoolBackgroundColor(ctx)289 a.setupPoolBackgroundColor(ctx)
284 290
285- if _, err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {291+ if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {
286 log.Error().Err(err).Msg("failed to create main window")292 log.Error().Err(err).Msg("failed to create main window")
287 return293 return
288 }294 }
@@ -451,6 +457,120 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
451 return browserWindow, nil457 return browserWindow, nil
452 }458 }
453 459
460+func (a *App) ensureWindowForTabMap() {
461+ if a.windowForTab == nil {
462+ a.windowForTab = make(map[entity.TabID]*browserWindow)
463+ }
464+}
465+
466+func (a *App) browserWindowForMainWindow(mainWindow *window.MainWindow) *browserWindow {
467+ if mainWindow == nil {
468+ return nil
469+ }
470+ for _, bw := range a.browserWindows {
471+ if bw != nil && bw.mainWindow == mainWindow {
472+ return bw
473+ }
474+ }
475+ return nil
476+}
477+
478+func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {
479+ if a.windowForTab != nil {
480+ if bw := a.windowForTab[tabID]; bw != nil {
481+ return bw
482+ }
483+ }
484+ if a.tabCreationWindow != nil {
485+ return a.tabCreationWindow
486+ }
487+ return a.browserWindowForMainWindow(a.mainWindow)
488+}
489+
490+func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) {
491+ if bw == nil {
492+ return
493+ }
494+ a.ensureWindowForTabMap()
495+ a.windowForTab[tabID] = bw
496+}
497+
498+func (a *App) startBrowserLaunchRelayListener(ctx context.Context) {
499+ if a == nil || a.deps == nil || a.deps.BrowserLaunchRelay == nil {
500+ return
501+ }
502+ a.browserLaunchRelayOnce.Do(func() {
503+ closer, err := a.deps.BrowserLaunchRelay.Listen(ctx, a)
504+ if err != nil {
505+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to start browser launch relay listener")
506+ return
507+ }
508+ a.browserLaunchRelayCloser = closer
509+ })
510+}
511+
512+func (a *App) closeBrowserLaunchRelayListener() {
513+ if a.browserLaunchRelayCloser == nil {
514+ return
515+ }
516+ _ = a.browserLaunchRelayCloser.Close()
517+ a.browserLaunchRelayCloser = nil
518+}
519+
520+func (a *App) openFreshWindow(ctx context.Context, url string) error {
521+ created, err := a.createBrowserWindow(ctx, url)
522+ if err != nil {
523+ return err
524+ }
525+ a.registerBrowserWindow(created)
526+
527+ if a.tabs == nil || a.tabs.Count() == 0 {
528+ return nil
529+ }
530+
531+ if a.tabCoord != nil && a.widgetFactory != nil {
532+ a.tabCreationWindow = created
533+ defer func() {
534+ a.tabCreationWindow = nil
535+ }()
536+
537+ tab, err := a.tabCoord.Create(ctx, url)
538+ if err != nil {
539+ return err
540+ }
541+ a.setBrowserWindowForTab(tab.ID, created)
542+ if created.mainWindow != nil {
543+ created.mainWindow.Show()
544+ }
545+ return nil
546+ }
547+
548+ if a.tabsUC == nil {
549+ return nil
550+ }
551+
552+ output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{
553+ TabList: a.tabs,
554+ InitialURL: url,
555+ })
556+ if err != nil {
557+ return err
558+ }
559+ a.setBrowserWindowForTab(output.Tab.ID, created)
560+ if a.widgetFactory != nil {
561+ a.tabCreationWindow = created
562+ defer func() {
563+ a.tabCreationWindow = nil
564+ }()
565+ a.createWorkspaceView(ctx, output.Tab)
566+ a.switchWorkspaceView(ctx, output.Tab.ID)
567+ }
568+ if created.mainWindow != nil {
569+ created.mainWindow.Show()
570+ }
571+ return nil
572+}
573+
454 func (a *App) initialWindowURL() string {574 func (a *App) initialWindowURL() string {
455 if a.deps != nil && a.deps.InitialURL != "" {575 if a.deps != nil && a.deps.InitialURL != "" {
456 return a.deps.InitialURL576 return a.deps.InitialURL
@@ -1504,6 +1624,7 @@ func (a *App) finalizeActivation(ctx context.Context) {
1504 if a.mainWindow != nil {1624 if a.mainWindow != nil {
1505 a.mainWindow.Show()1625 a.mainWindow.Show()
1506 }1626 }
1627+ a.startBrowserLaunchRelayListener(ctx)
1507 log.Info().Msg("main window displayed")1628 log.Info().Msg("main window displayed")
1508 1629
1509 if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {1630 if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
@@ -1616,6 +1737,7 @@ func (a *App) onShutdown(ctx context.Context) {
1616 log.Warn().Err(err).Msg("failed to close idle inhibitor")1737 log.Warn().Err(err).Msg("failed to close idle inhibitor")
1617 }1738 }
1618 }1739 }
1740+ a.closeBrowserLaunchRelayListener()
1619 1741
1620 log.Info().Msg("application shutdown complete")1742 log.Info().Msg("application shutdown complete")
1621 }1743 }
@@ -1793,7 +1915,7 @@ func (a *App) initCoordinators(ctx context.Context) {
1793 1915
1794 // Wire window title updates when active pane's title changes1916 // Wire window title updates when active pane's title changes
1795 a.contentCoord.SetOnWindowTitleChanged(func(title string) {1917 a.contentCoord.SetOnWindowTitleChanged(func(title string) {
1796- a.updateWindowTitle(title)1918+ a.updateWindowTitle(title, a.browserWindowForTab(a.tabs.ActiveTabID))
1797 })1919 })
1798 1920
1799 // Wire pane URI updates for session snapshots (searches all tabs)1921 // Wire pane URI updates for session snapshots (searches all tabs)
@@ -2043,8 +2165,11 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int {
2043 2165
2044 // updateWindowTitle updates the window title with the given page title.2166 // updateWindowTitle updates the window title with the given page title.
2045 // Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.2167 // Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
2046-func (a *App) updateWindowTitle(pageTitle string) {2168+func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
2047- if a.mainWindow == nil {2169+ if target == nil {
2170+ target = a.browserWindowForMainWindow(a.mainWindow)
2171+ }
2172+ if target == nil || target.mainWindow == nil {
2048 return2173 return
2049 }2174 }
2050 2175
@@ -2052,18 +2177,23 @@ func (a *App) updateWindowTitle(pageTitle string) {
2052 if pageTitle != "" {2177 if pageTitle != "" {
2053 title = pageTitle + " - Dumber"2178 title = pageTitle + " - Dumber"
2054 }2179 }
2055- a.mainWindow.SetTitle(title)2180+ target.mainWindow.SetTitle(title)
2056 }2181 }
2057 2182
2058 // updateWindowTitleFromActivePane updates the window title based on the current active pane.2183 // updateWindowTitleFromActivePane updates the window title based on the current active pane.
2059-func (a *App) updateWindowTitleFromActivePane() {2184+func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
2060- ws := a.activeWorkspace()2185+ var ws *entity.Workspace
2186+ if tab := a.tabs.Find(tabID); tab != nil {
2187+ ws = tab.Workspace
2188+ } else {
2189+ ws = a.activeWorkspace()
2190+ }
2061 if ws == nil || a.contentCoord == nil {2191 if ws == nil || a.contentCoord == nil {
2062- a.updateWindowTitle("")2192+ a.updateWindowTitle("", a.browserWindowForTab(tabID))
2063 return2193 return
2064 }2194 }
2065 title := a.contentCoord.GetTitle(ws.ActivePaneID)2195 title := a.contentCoord.GetTitle(ws.ActivePaneID)
2066- a.updateWindowTitle(title)2196+ a.updateWindowTitle(title, a.browserWindowForTab(tabID))
2067 }2197 }
2068 2198
2069 // handleModeChange is called when the input mode changes.2199 // handleModeChange is called when the input mode changes.
@@ -2176,14 +2306,19 @@ func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
2176 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {2306 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
2177 a.createWorkspaceViewWithoutAttach(ctx, tab)2307 a.createWorkspaceViewWithoutAttach(ctx, tab)
2178 2308
2309+ target := a.browserWindowForTab(tab.ID)
2310+ if target != nil {
2311+ a.setBrowserWindowForTab(tab.ID, target)
2312+ }
2313+
2179 // Attach to content area2314 // Attach to content area
2180 wsView := a.workspaceViews[tab.ID]2315 wsView := a.workspaceViews[tab.ID]
2181- if wsView != nil && a.mainWindow != nil {2316+ if wsView != nil && target != nil && target.mainWindow != nil {
2182 widget := wsView.Widget()2317 widget := wsView.Widget()
2183 if widget != nil {2318 if widget != nil {
2184 gtkWidget := widget.GtkWidget()2319 gtkWidget := widget.GtkWidget()
2185 if gtkWidget != nil {2320 if gtkWidget != nil {
2186- a.mainWindow.SetContent(gtkWidget)2321+ target.mainWindow.SetContent(gtkWidget)
2187 }2322 }
2188 }2323 }
2189 }2324 }
@@ -2362,12 +2497,14 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
2362 }2497 }
2363 2498
2364 // Swap content (MainWindow.SetContent now properly removes old content)2499 // Swap content (MainWindow.SetContent now properly removes old content)
2365- if a.mainWindow != nil {2500+ if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
2501+ target.mainWindow.SetContent(gtkWidget)
2502+ } else if a.mainWindow != nil {
2366 a.mainWindow.SetContent(gtkWidget)2503 a.mainWindow.SetContent(gtkWidget)
2367 }2504 }
2368 2505
2369 // Update window title with the new active pane's title2506 // Update window title with the new active pane's title
2370- a.updateWindowTitleFromActivePane()2507+ a.updateWindowTitleFromActivePane(tabID)
2371 a.syncFloatingFocus()2508 a.syncFloatingFocus()
2372 2509
2373 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")2510 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
A internal/ui/app_browser_launch_test.go +99 -0
@@ -0,0 +1,99 @@
1+package ui
2+
3+import (
4+ "context"
5+ "io"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+)
13+
14+type testBrowserLaunchRelay struct {
15+ listenCalls int
16+ closer *testCloser
17+}
18+
19+func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {
20+ return false, nil
21+}
22+
23+func (r *testBrowserLaunchRelay) Listen(_ context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
24+ r.listenCalls++
25+ _ = opener
26+ return r.closer, nil
27+}
28+
29+type testCloser struct {
30+ closed bool
31+}
32+
33+func (c *testCloser) Close() error {
34+ c.closed = true
35+ return nil
36+}
37+
38+func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) {
39+ t.Helper()
40+
41+ rv := reflect.ValueOf(deps).Elem()
42+ fv := rv.FieldByName(field)
43+ if !fv.IsValid() {
44+ t.Fatalf("Dependencies missing %s field", field)
45+ }
46+ fv.Set(reflect.ValueOf(value))
47+}
48+
49+func windowForTabCount(t *testing.T, app *App) int {
50+ t.Helper()
51+
52+ rv := reflect.ValueOf(app).Elem()
53+ fv := rv.FieldByName("windowForTab")
54+ if !fv.IsValid() {
55+ t.Fatalf("App missing windowForTab field")
56+ }
57+ return fv.Len()
58+}
59+
60+func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
61+ relay := &testBrowserLaunchRelay{closer: &testCloser{}}
62+ deps := &Dependencies{}
63+ setDependencyField(t, deps, "BrowserLaunchRelay", relay)
64+
65+ app := &App{deps: deps, cancel: func(error) {}}
66+
67+ app.finalizeActivation(context.Background())
68+ app.finalizeActivation(context.Background())
69+
70+ if relay.listenCalls != 1 {
71+ t.Fatalf("Listen calls = %d, want 1", relay.listenCalls)
72+ }
73+
74+ app.onShutdown(context.Background())
75+
76+ if !relay.closer.closed {
77+ t.Fatalf("relay listener closer was not closed")
78+ }
79+}
80+
81+func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
82+ created := &browserWindow{id: "window-1"}
83+ app := &App{
84+ tabs: entity.NewTabList(),
85+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
86+ browserWindows: make(map[string]*browserWindow),
87+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
88+ return created, nil
89+ },
90+ }
91+ app.tabs.Add(entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
92+
93+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
94+ t.Fatalf("OpenFreshWindow returned error: %v", err)
95+ }
96+ if got := windowForTabCount(t, app); got != 1 {
97+ t.Fatalf("windowForTab length = %d, want 1", got)
98+ }
99+}
@@ -0,0 +1,99 @@
1+package ui
2+
3+import (
4+ "context"
5+ "io"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+)
13+
14+type testBrowserLaunchRelay struct {
15+ listenCalls int
16+ closer *testCloser
17+}
18+
19+func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {
20+ return false, nil
21+}
22+
23+func (r *testBrowserLaunchRelay) Listen(_ context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
24+ r.listenCalls++
25+ _ = opener
26+ return r.closer, nil
27+}
28+
29+type testCloser struct {
30+ closed bool
31+}
32+
33+func (c *testCloser) Close() error {
34+ c.closed = true
35+ return nil
36+}
37+
38+func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) {
39+ t.Helper()
40+
41+ rv := reflect.ValueOf(deps).Elem()
42+ fv := rv.FieldByName(field)
43+ if !fv.IsValid() {
44+ t.Fatalf("Dependencies missing %s field", field)
45+ }
46+ fv.Set(reflect.ValueOf(value))
47+}
48+
49+func windowForTabCount(t *testing.T, app *App) int {
50+ t.Helper()
51+
52+ rv := reflect.ValueOf(app).Elem()
53+ fv := rv.FieldByName("windowForTab")
54+ if !fv.IsValid() {
55+ t.Fatalf("App missing windowForTab field")
56+ }
57+ return fv.Len()
58+}
59+
60+func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
61+ relay := &testBrowserLaunchRelay{closer: &testCloser{}}
62+ deps := &Dependencies{}
63+ setDependencyField(t, deps, "BrowserLaunchRelay", relay)
64+
65+ app := &App{deps: deps, cancel: func(error) {}}
66+
67+ app.finalizeActivation(context.Background())
68+ app.finalizeActivation(context.Background())
69+
70+ if relay.listenCalls != 1 {
71+ t.Fatalf("Listen calls = %d, want 1", relay.listenCalls)
72+ }
73+
74+ app.onShutdown(context.Background())
75+
76+ if !relay.closer.closed {
77+ t.Fatalf("relay listener closer was not closed")
78+ }
79+}
80+
81+func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
82+ created := &browserWindow{id: "window-1"}
83+ app := &App{
84+ tabs: entity.NewTabList(),
85+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
86+ browserWindows: make(map[string]*browserWindow),
87+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
88+ return created, nil
89+ },
90+ }
91+ app.tabs.Add(entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
92+
93+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
94+ t.Fatalf("OpenFreshWindow returned error: %v", err)
95+ }
96+ if got := windowForTabCount(t, app); got != 1 {
97+ t.Fatalf("windowForTab length = %d, want 1", got)
98+ }
99+}
M internal/ui/browser_window.go +8 -8
@@ -26,6 +26,11 @@ func (a *App) removeBrowserWindow(id string) {
2626 if id == "" || a.browserWindows == nil {
2727 return
2828 }
29+ for tabID, bw := range a.windowForTab {
30+ if bw != nil && bw.id == id {
31+ delete(a.windowForTab, tabID)
32+ }
33+ }
2934 if bw := a.browserWindows[id]; bw != nil && bw.mainWindow == a.mainWindow {
3035 delete(a.browserWindows, id)
3136 for _, remaining := range a.browserWindows {
@@ -40,21 +45,16 @@ func (a *App) removeBrowserWindow(id string) {
4045 delete(a.browserWindows, id)
4146 }
4247
43-func (a *App) OpenFreshWindow(ctx context.Context, url string) (*browserWindow, error) {
48+func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
4449 dispatch := a.dispatchOnMainThread
4550 if dispatch == nil {
4651 dispatch = func(fn func()) { fn() }
4752 }
4853
49- var created *browserWindow
5054 var openErr error
5155 dispatch(func() {
52- created, openErr = a.createBrowserWindow(ctx, url)
53- if openErr != nil {
54- return
55- }
56- a.registerBrowserWindow(created)
56+ openErr = a.openFreshWindow(ctx, url)
5757 })
5858
59- return created, openErr
59+ return openErr
6060 }
@@ -26,6 +26,11 @@ func (a *App) removeBrowserWindow(id string) {
26 if id == "" || a.browserWindows == nil {26 if id == "" || a.browserWindows == nil {
27 return27 return
28 }28 }
29+ for tabID, bw := range a.windowForTab {
30+ if bw != nil && bw.id == id {
31+ delete(a.windowForTab, tabID)
32+ }
33+ }
29 if bw := a.browserWindows[id]; bw != nil && bw.mainWindow == a.mainWindow {34 if bw := a.browserWindows[id]; bw != nil && bw.mainWindow == a.mainWindow {
30 delete(a.browserWindows, id)35 delete(a.browserWindows, id)
31 for _, remaining := range a.browserWindows {36 for _, remaining := range a.browserWindows {
@@ -40,21 +45,16 @@ func (a *App) removeBrowserWindow(id string) {
40 delete(a.browserWindows, id)45 delete(a.browserWindows, id)
41 }46 }
42 47
43-func (a *App) OpenFreshWindow(ctx context.Context, url string) (*browserWindow, error) {48+func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
44 dispatch := a.dispatchOnMainThread49 dispatch := a.dispatchOnMainThread
45 if dispatch == nil {50 if dispatch == nil {
46 dispatch = func(fn func()) { fn() }51 dispatch = func(fn func()) { fn() }
47 }52 }
48 53
49- var created *browserWindow
50 var openErr error54 var openErr error
51 dispatch(func() {55 dispatch(func() {
52- created, openErr = a.createBrowserWindow(ctx, url)56+ openErr = a.openFreshWindow(ctx, url)
53- if openErr != nil {
54- return
55- }
56- a.registerBrowserWindow(created)
57 })57 })
58 58
59- return created, openErr59+ return openErr
60 }60 }
M internal/ui/browser_window_test.go +4 -5
@@ -90,8 +90,7 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
9090 return &browserWindow{id: "window-1", initialURL: url}, nil
9191 }
9292
93- window, err := app.OpenFreshWindow(context.Background(), "https://example.com")
94- if err != nil {
93+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
9594 t.Fatalf("OpenFreshWindow returned error: %v", err)
9695 }
9796 if !dispatched {
@@ -103,10 +102,10 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
103102 if got := len(app.browserWindows); got != 1 {
104103 t.Fatalf("browserWindows length = %d, want 1", got)
105104 }
106- if got := app.browserWindows[window.id]; got != window {
105+ if got := app.browserWindows["window-1"]; got == nil {
107106 t.Fatalf("browser window was not tracked")
108107 }
109- if window.initialURL != "https://example.com" {
110- t.Fatalf("browser window initialURL = %q, want %q", window.initialURL, "https://example.com")
108+ if app.browserWindows["window-1"].initialURL != "https://example.com" {
109+ t.Fatalf("browser window initialURL = %q, want %q", app.browserWindows["window-1"].initialURL, "https://example.com")
111110 }
112111 }
@@ -90,8 +90,7 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
90 return &browserWindow{id: "window-1", initialURL: url}, nil90 return &browserWindow{id: "window-1", initialURL: url}, nil
91 }91 }
92 92
93- window, err := app.OpenFreshWindow(context.Background(), "https://example.com")93+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
94- if err != nil {
95 t.Fatalf("OpenFreshWindow returned error: %v", err)94 t.Fatalf("OpenFreshWindow returned error: %v", err)
96 }95 }
97 if !dispatched {96 if !dispatched {
@@ -103,10 +102,10 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
103 if got := len(app.browserWindows); got != 1 {102 if got := len(app.browserWindows); got != 1 {
104 t.Fatalf("browserWindows length = %d, want 1", got)103 t.Fatalf("browserWindows length = %d, want 1", got)
105 }104 }
106- if got := app.browserWindows[window.id]; got != window {105+ if got := app.browserWindows["window-1"]; got == nil {
107 t.Fatalf("browser window was not tracked")106 t.Fatalf("browser window was not tracked")
108 }107 }
109- if window.initialURL != "https://example.com" {108+ if app.browserWindows["window-1"].initialURL != "https://example.com" {
110- t.Fatalf("browser window initialURL = %q, want %q", window.initialURL, "https://example.com")109+ t.Fatalf("browser window initialURL = %q, want %q", app.browserWindows["window-1"].initialURL, "https://example.com")
111 }110 }
112 }111 }
M internal/ui/deps.go +2 -0
@@ -104,6 +104,8 @@ type Dependencies struct {
104104 // LaunchBrowserURL opens a URL in a new dumber browser window.
105105 // Optional: if nil, standalone omnibox web navigations are logged only.
106106 LaunchBrowserURL func(uri string)
107+ // BrowserLaunchRelay listens for in-process browser launch requests.
108+ BrowserLaunchRelay port.BrowserLaunchRelay
107109 }
108110
109111 // Validate checks that all required dependencies are set.
@@ -104,6 +104,8 @@ type Dependencies struct {
104 // LaunchBrowserURL opens a URL in a new dumber browser window.104 // LaunchBrowserURL opens a URL in a new dumber browser window.
105 // Optional: if nil, standalone omnibox web navigations are logged only.105 // Optional: if nil, standalone omnibox web navigations are logged only.
106 LaunchBrowserURL func(uri string)106 LaunchBrowserURL func(uri string)
107+ // BrowserLaunchRelay listens for in-process browser launch requests.
108+ BrowserLaunchRelay port.BrowserLaunchRelay
107 }109 }
108 110
109 // Validate checks that all required dependencies are set.111 // Validate checks that all required dependencies are set.

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