detached 9a59c25

refactor(coordinator): extract content.go into coordinator/content/ package

Split 2269-line content.go into focused files:
- coordinator.go: struct, constructor, setters, state helpers
- lifecycle.go: WebView creation, release, widget wrapping
- navigation.go: load events, URI tracking, SPA navigation
- appearance.go: theme CSS injection, script refresh
- title.go: title/favicon management
- popup.go: popup creation/handling (absorbed popup_types.go)
- oauth.go: OAuth popup flow (absorbed coordinator/oauth.go)
- callbacks.go: callback wiring, crash, permissions
- bulk.go: bulk operations (filters, settings, scripts)

Rename ContentCoordinator -> content.Coordinator.
Move popup_types.go and oauth.go into the new package.
commit
9a59c25b7b1845c3622ba14604973a2beda99224
author
brice · · 2026-03-15 17:11 UTC
parent
4cbf000
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +2767 -2681
M internal/ui/app.go +12 -11
@@ -27,6 +27,7 @@ import (
2727 "github.com/bnema/dumber/internal/ui/adapter"
2828 "github.com/bnema/dumber/internal/ui/component"
2929 "github.com/bnema/dumber/internal/ui/coordinator"
30+ "github.com/bnema/dumber/internal/ui/coordinator/content"
3031 "github.com/bnema/dumber/internal/ui/dialog"
3132 "github.com/bnema/dumber/internal/ui/dispatcher"
3233 "github.com/bnema/dumber/internal/ui/focus"
@@ -68,7 +69,7 @@ type App struct {
6869 tabsUC *usecase.ManageTabsUseCase
6970
7071 // Coordinators (new architecture)
71- contentCoord *coordinator.ContentCoordinator
72+ contentCoord *content.Coordinator
7273 tabCoord *coordinator.TabCoordinator
7374 wsCoord *coordinator.WorkspaceCoordinator
7475 navCoord *coordinator.NavigationCoordinator
@@ -103,7 +104,7 @@ type App struct {
103104 engine port.Engine
104105 wkEngine *webkit.Engine
105106
106- // Web content (managed by ContentCoordinator)
107+ // Web content (managed by content.Coordinator)
107108 pool *webkit.WebViewPool
108109 webViewFactory *webkit.WebViewFactory
109110 injector *webkit.ContentInjector
@@ -1434,7 +1435,7 @@ func (a *App) runAfterFirstLoadStarted(fn func()) {
14341435 a.deferredInitFn = fn
14351436 }
14361437
1437-// triggerDeferredInit is called from ContentCoordinator on first load_started.
1438+// triggerDeferredInit is called from content.Coordinator on first load_started.
14381439 // It runs deferred initialization at LOW priority so navigation continues unblocked.
14391440 func (a *App) triggerDeferredInit(ctx context.Context) {
14401441 a.deferredInitOnce.Do(func() {
@@ -1522,7 +1523,7 @@ func (a *App) initCoordinators(ctx context.Context) {
15221523 a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
15231524
15241525 // 1. Content Coordinator (no dependencies on other coordinators)
1525- a.contentCoord = coordinator.NewContentCoordinator(
1526+ a.contentCoord = content.NewCoordinator(
15261527 ctx,
15271528 a.pool,
15281529 a.widgetFactory,
@@ -1622,14 +1623,14 @@ func (a *App) initCoordinators(ctx context.Context) {
16221623 &a.deps.Config.Workspace.Popups,
16231624 a.generateID,
16241625 )
1625- a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1626+ a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
16261627 return a.wsCoord.InsertPopup(ctx, input)
16271628 })
16281629 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
16291630 return a.wsCoord.ClosePaneByID(ctx, paneID)
16301631 })
16311632 // Wire tabbed popup behavior to create new tabs
1632- a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1633+ a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input content.InsertPopupInput) error {
16331634 // Create a new tab with the popup pane
16341635 tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
16351636 if err != nil {
@@ -1761,15 +1762,15 @@ func (a *App) wireWebRTCPermissionIndicator() {
17611762 }
17621763 log := logging.FromContext(ctx)
17631764
1764- a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) {
1765+ a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state content.PermissionActivityState) {
17651766 a.webrtcIndicator.SetOrigin(origin)
17661767
17671768 switch state {
1768- case coordinator.PermissionActivityRequesting:
1769+ case content.PermissionActivityRequesting:
17691770 a.webrtcIndicator.MarkRequesting(permTypes)
1770- case coordinator.PermissionActivityAllowed:
1771+ case content.PermissionActivityAllowed:
17711772 a.webrtcIndicator.MarkAllowed(permTypes)
1772- case coordinator.PermissionActivityBlocked:
1773+ case content.PermissionActivityBlocked:
17731774 a.webrtcIndicator.MarkBlocked(permTypes)
17741775 }
17751776
@@ -1794,7 +1795,7 @@ func (a *App) wireWebRTCPermissionIndicator() {
17941795 // Allowed/requesting → deny future requests.
17951796 // Blocked → allow future requests.
17961797 decision := entity.PermissionDenied
1797- if state == string(coordinator.PermissionActivityBlocked) {
1798+ if state == string(content.PermissionActivityBlocked) {
17981799 decision = entity.PermissionGranted
17991800 }
18001801 if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
@@ -27,6 +27,7 @@ import (
27 "github.com/bnema/dumber/internal/ui/adapter"27 "github.com/bnema/dumber/internal/ui/adapter"
28 "github.com/bnema/dumber/internal/ui/component"28 "github.com/bnema/dumber/internal/ui/component"
29 "github.com/bnema/dumber/internal/ui/coordinator"29 "github.com/bnema/dumber/internal/ui/coordinator"
30+ "github.com/bnema/dumber/internal/ui/coordinator/content"
30 "github.com/bnema/dumber/internal/ui/dialog"31 "github.com/bnema/dumber/internal/ui/dialog"
31 "github.com/bnema/dumber/internal/ui/dispatcher"32 "github.com/bnema/dumber/internal/ui/dispatcher"
32 "github.com/bnema/dumber/internal/ui/focus"33 "github.com/bnema/dumber/internal/ui/focus"
@@ -68,7 +69,7 @@ type App struct {
68 tabsUC *usecase.ManageTabsUseCase69 tabsUC *usecase.ManageTabsUseCase
69 70
70 // Coordinators (new architecture)71 // Coordinators (new architecture)
71- contentCoord *coordinator.ContentCoordinator72+ contentCoord *content.Coordinator
72 tabCoord *coordinator.TabCoordinator73 tabCoord *coordinator.TabCoordinator
73 wsCoord *coordinator.WorkspaceCoordinator74 wsCoord *coordinator.WorkspaceCoordinator
74 navCoord *coordinator.NavigationCoordinator75 navCoord *coordinator.NavigationCoordinator
@@ -103,7 +104,7 @@ type App struct {
103 engine port.Engine104 engine port.Engine
104 wkEngine *webkit.Engine105 wkEngine *webkit.Engine
105 106
106- // Web content (managed by ContentCoordinator)107+ // Web content (managed by content.Coordinator)
107 pool *webkit.WebViewPool108 pool *webkit.WebViewPool
108 webViewFactory *webkit.WebViewFactory109 webViewFactory *webkit.WebViewFactory
109 injector *webkit.ContentInjector110 injector *webkit.ContentInjector
@@ -1434,7 +1435,7 @@ func (a *App) runAfterFirstLoadStarted(fn func()) {
1434 a.deferredInitFn = fn1435 a.deferredInitFn = fn
1435 }1436 }
1436 1437
1437-// triggerDeferredInit is called from ContentCoordinator on first load_started.1438+// triggerDeferredInit is called from content.Coordinator on first load_started.
1438 // It runs deferred initialization at LOW priority so navigation continues unblocked.1439 // It runs deferred initialization at LOW priority so navigation continues unblocked.
1439 func (a *App) triggerDeferredInit(ctx context.Context) {1440 func (a *App) triggerDeferredInit(ctx context.Context) {
1440 a.deferredInitOnce.Do(func() {1441 a.deferredInitOnce.Do(func() {
@@ -1522,7 +1523,7 @@ func (a *App) initCoordinators(ctx context.Context) {
1522 a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)1523 a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
1523 1524
1524 // 1. Content Coordinator (no dependencies on other coordinators)1525 // 1. Content Coordinator (no dependencies on other coordinators)
1525- a.contentCoord = coordinator.NewContentCoordinator(1526+ a.contentCoord = content.NewCoordinator(
1526 ctx,1527 ctx,
1527 a.pool,1528 a.pool,
1528 a.widgetFactory,1529 a.widgetFactory,
@@ -1622,14 +1623,14 @@ func (a *App) initCoordinators(ctx context.Context) {
1622 &a.deps.Config.Workspace.Popups,1623 &a.deps.Config.Workspace.Popups,
1623 a.generateID,1624 a.generateID,
1624 )1625 )
1625- a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {1626+ a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
1626 return a.wsCoord.InsertPopup(ctx, input)1627 return a.wsCoord.InsertPopup(ctx, input)
1627 })1628 })
1628 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {1629 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
1629 return a.wsCoord.ClosePaneByID(ctx, paneID)1630 return a.wsCoord.ClosePaneByID(ctx, paneID)
1630 })1631 })
1631 // Wire tabbed popup behavior to create new tabs1632 // Wire tabbed popup behavior to create new tabs
1632- a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {1633+ a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input content.InsertPopupInput) error {
1633 // Create a new tab with the popup pane1634 // Create a new tab with the popup pane
1634 tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)1635 tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
1635 if err != nil {1636 if err != nil {
@@ -1761,15 +1762,15 @@ func (a *App) wireWebRTCPermissionIndicator() {
1761 }1762 }
1762 log := logging.FromContext(ctx)1763 log := logging.FromContext(ctx)
1763 1764
1764- a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) {1765+ a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state content.PermissionActivityState) {
1765 a.webrtcIndicator.SetOrigin(origin)1766 a.webrtcIndicator.SetOrigin(origin)
1766 1767
1767 switch state {1768 switch state {
1768- case coordinator.PermissionActivityRequesting:1769+ case content.PermissionActivityRequesting:
1769 a.webrtcIndicator.MarkRequesting(permTypes)1770 a.webrtcIndicator.MarkRequesting(permTypes)
1770- case coordinator.PermissionActivityAllowed:1771+ case content.PermissionActivityAllowed:
1771 a.webrtcIndicator.MarkAllowed(permTypes)1772 a.webrtcIndicator.MarkAllowed(permTypes)
1772- case coordinator.PermissionActivityBlocked:1773+ case content.PermissionActivityBlocked:
1773 a.webrtcIndicator.MarkBlocked(permTypes)1774 a.webrtcIndicator.MarkBlocked(permTypes)
1774 }1775 }
1775 1776
@@ -1794,7 +1795,7 @@ func (a *App) wireWebRTCPermissionIndicator() {
1794 // Allowed/requesting → deny future requests.1795 // Allowed/requesting → deny future requests.
1795 // Blocked → allow future requests.1796 // Blocked → allow future requests.
1796 decision := entity.PermissionDenied1797 decision := entity.PermissionDenied
1797- if state == string(coordinator.PermissionActivityBlocked) {1798+ if state == string(content.PermissionActivityBlocked) {
1798 decision = entity.PermissionGranted1799 decision = entity.PermissionGranted
1799 }1800 }
1800 if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {1801 if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
D internal/ui/coordinator/content.go +0 -2316 large diff (2316 lines changed): collapsed
A internal/ui/coordinator/content/appearance.go +290 -0
@@ -0,0 +1,290 @@
1+package content
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ aboutBlankURI = "about:blank"
17+ crashPageURI = "dumb://home/crash"
18+ logURLMaxLen = 80
19+ oauthParentRefreshDebounce = 200 * time.Millisecond
20+
21+ // Dark theme background color (#0a0a0b) as float32 RGBA values
22+ darkBgR = 0.039
23+ darkBgG = 0.039
24+ darkBgB = 0.043
25+ darkBgA = 1.0
26+)
27+
28+// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
29+// This is necessary because user scripts only run on navigation.
30+func (c *Coordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
31+ log := logging.FromContext(ctx)
32+ if cssText == "" {
33+ return
34+ }
35+
36+ c.setCurrentTheme(prefersDark, cssText)
37+
38+ script, err := buildWebUIThemeScript(prefersDark, cssText)
39+ if err != nil {
40+ log.Warn().Err(err).Msg("failed to build WebUI theme script")
41+ return
42+ }
43+
44+ for paneID, wv := range c.webViews {
45+ if wv == nil || wv.IsDestroyed() {
46+ continue
47+ }
48+ if c.shouldDeferAppearance(wv) {
49+ c.queueThemeApply(paneID, prefersDark, cssText)
50+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
51+ continue
52+ }
53+ c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
54+ }
55+}
56+
57+func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
58+ cssJSON, err := json.Marshal(cssText)
59+ if err != nil {
60+ return "", err
61+ }
62+
63+ script := fmt.Sprintf(`(function(){
64+ try {
65+ var cssText = %s;
66+ var prefersDark = %t;
67+
68+ // Keep the global flag in sync
69+ window.__dumber_gtk_prefers_dark = prefersDark;
70+
71+ // Update dark/light class
72+ if (prefersDark) {
73+ document.documentElement.classList.add('dark');
74+ document.documentElement.classList.remove('light');
75+ } else {
76+ document.documentElement.classList.add('light');
77+ document.documentElement.classList.remove('dark');
78+ }
79+
80+ // Update or insert theme style
81+ var style = document.querySelector('style[data-dumber-theme-vars]');
82+ if (!style) {
83+ style = document.createElement('style');
84+ style.setAttribute('data-dumber-theme-vars', '');
85+ (document.head || document.documentElement).appendChild(style);
86+ }
87+ style.textContent = cssText;
88+
89+ // Notify any running WebUI that theme changed
90+ try {
91+ window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
92+ detail: { prefersDark: prefersDark }
93+ }));
94+ } catch (e) {
95+ // ignore
96+ }
97+
98+ // Keep color-scheme consistent
99+ var meta = document.querySelector('meta[name="color-scheme"]');
100+ if (!meta) {
101+ meta = document.createElement('meta');
102+ meta.name = 'color-scheme';
103+ document.documentElement.appendChild(meta);
104+ }
105+ meta.content = prefersDark ? 'dark light' : 'light dark';
106+ } catch (e) {
107+ console.error('[dumber] failed to apply theme', e);
108+ }
109+})();`, string(cssJSON), prefersDark)
110+
111+ return script, nil
112+}
113+
114+func (c *Coordinator) applyWebUITheme(
115+ ctx context.Context,
116+ paneID entity.PaneID,
117+ wv *webkit.WebView,
118+ script string,
119+ prefersDark bool,
120+) {
121+ if wv == nil || wv.IsDestroyed() {
122+ return
123+ }
124+ uri := wv.URI()
125+ if !strings.HasPrefix(uri, "dumb://") {
126+ return
127+ }
128+ wv.RunJavaScript(ctx, script, "")
129+ logging.FromContext(ctx).
130+ Debug().
131+ Str("pane_id", string(paneID)).
132+ Str("uri", uri).
133+ Bool("prefers_dark", prefersDark).
134+ Msg("applied WebUI theme")
135+}
136+
137+func (c *Coordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
138+ c.appearanceMu.Lock()
139+ if c.pendingThemePanes == nil {
140+ c.pendingThemePanes = make(map[entity.PaneID]bool)
141+ }
142+ c.pendingThemePanes[paneID] = true
143+ c.pendingThemeUpdate = pendingThemeUpdate{
144+ prefersDark: prefersDark,
145+ cssText: cssText,
146+ }
147+ c.hasPendingThemeUpdate = true
148+ c.appearanceMu.Unlock()
149+}
150+
151+func (c *Coordinator) setCurrentTheme(prefersDark bool, cssText string) {
152+ c.appearanceMu.Lock()
153+ c.currentTheme = pendingThemeUpdate{
154+ prefersDark: prefersDark,
155+ cssText: cssText,
156+ }
157+ c.hasCurrentTheme = true
158+ c.appearanceMu.Unlock()
159+}
160+
161+func (c *Coordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
162+ c.appearanceMu.Lock()
163+ defer c.appearanceMu.Unlock()
164+
165+ if !c.hasCurrentTheme {
166+ return pendingThemeUpdate{}, false
167+ }
168+ return c.currentTheme, true
169+}
170+
171+func (c *Coordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
172+ c.appearanceMu.Lock()
173+ defer c.appearanceMu.Unlock()
174+
175+ if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
176+ return pendingThemeUpdate{}, false
177+ }
178+ delete(c.pendingThemePanes, paneID)
179+ update := c.pendingThemeUpdate
180+ if len(c.pendingThemePanes) == 0 {
181+ c.hasPendingThemeUpdate = false
182+ }
183+ return update, true
184+}
185+
186+func (c *Coordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
187+ update, ok := c.takePendingThemeApply(paneID)
188+ if !ok {
189+ return false
190+ }
191+
192+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
193+ if err != nil {
194+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
195+ return false
196+ }
197+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
198+ return true
199+}
200+
201+func (c *Coordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
202+ update, ok := c.getCurrentTheme()
203+ if !ok || update.cssText == "" {
204+ return false
205+ }
206+
207+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
208+ if err != nil {
209+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
210+ return false
211+ }
212+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
213+ return true
214+}
215+
216+func (c *Coordinator) queueScriptRefresh(paneID entity.PaneID) {
217+ c.appearanceMu.Lock()
218+ if c.pendingScriptRefresh == nil {
219+ c.pendingScriptRefresh = make(map[entity.PaneID]bool)
220+ }
221+ c.pendingScriptRefresh[paneID] = true
222+ c.appearanceMu.Unlock()
223+}
224+
225+func (c *Coordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
226+ c.appearanceMu.Lock()
227+ defer c.appearanceMu.Unlock()
228+
229+ if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
230+ return false
231+ }
232+ delete(c.pendingScriptRefresh, paneID)
233+ return true
234+}
235+
236+func (c *Coordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
237+ if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
238+ return
239+ }
240+ if !c.takePendingScriptRefresh(paneID) {
241+ return
242+ }
243+ if c.injector == nil {
244+ return
245+ }
246+ c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
247+}
248+
249+func (c *Coordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
250+ if wv == nil || wv.IsDestroyed() {
251+ return false
252+ }
253+ if wv.IsLoading() {
254+ return true
255+ }
256+ return wv.EstimatedProgress() < 1.0
257+}
258+
259+func (c *Coordinator) refreshInjectedScripts(
260+ ctx context.Context,
261+ injector *webkit.ContentInjector,
262+ paneID entity.PaneID,
263+ wv *webkit.WebView,
264+) {
265+ if injector == nil || wv == nil || wv.IsDestroyed() {
266+ return
267+ }
268+ ucm := wv.UserContentManager()
269+ if ucm == nil {
270+ return
271+ }
272+ ucm.RemoveAllScripts()
273+ ucm.RemoveAllStyleSheets()
274+ injector.InjectScripts(ctx, ucm, wv.ID())
275+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
276+}
277+
278+func (c *Coordinator) clearPendingAppearance(paneID entity.PaneID) {
279+ c.appearanceMu.Lock()
280+ if c.pendingScriptRefresh != nil {
281+ delete(c.pendingScriptRefresh, paneID)
282+ }
283+ if c.pendingThemePanes != nil {
284+ delete(c.pendingThemePanes, paneID)
285+ if len(c.pendingThemePanes) == 0 {
286+ c.hasPendingThemeUpdate = false
287+ }
288+ }
289+ c.appearanceMu.Unlock()
290+}
@@ -0,0 +1,290 @@
1+package content
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ aboutBlankURI = "about:blank"
17+ crashPageURI = "dumb://home/crash"
18+ logURLMaxLen = 80
19+ oauthParentRefreshDebounce = 200 * time.Millisecond
20+
21+ // Dark theme background color (#0a0a0b) as float32 RGBA values
22+ darkBgR = 0.039
23+ darkBgG = 0.039
24+ darkBgB = 0.043
25+ darkBgA = 1.0
26+)
27+
28+// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
29+// This is necessary because user scripts only run on navigation.
30+func (c *Coordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
31+ log := logging.FromContext(ctx)
32+ if cssText == "" {
33+ return
34+ }
35+
36+ c.setCurrentTheme(prefersDark, cssText)
37+
38+ script, err := buildWebUIThemeScript(prefersDark, cssText)
39+ if err != nil {
40+ log.Warn().Err(err).Msg("failed to build WebUI theme script")
41+ return
42+ }
43+
44+ for paneID, wv := range c.webViews {
45+ if wv == nil || wv.IsDestroyed() {
46+ continue
47+ }
48+ if c.shouldDeferAppearance(wv) {
49+ c.queueThemeApply(paneID, prefersDark, cssText)
50+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
51+ continue
52+ }
53+ c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
54+ }
55+}
56+
57+func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
58+ cssJSON, err := json.Marshal(cssText)
59+ if err != nil {
60+ return "", err
61+ }
62+
63+ script := fmt.Sprintf(`(function(){
64+ try {
65+ var cssText = %s;
66+ var prefersDark = %t;
67+
68+ // Keep the global flag in sync
69+ window.__dumber_gtk_prefers_dark = prefersDark;
70+
71+ // Update dark/light class
72+ if (prefersDark) {
73+ document.documentElement.classList.add('dark');
74+ document.documentElement.classList.remove('light');
75+ } else {
76+ document.documentElement.classList.add('light');
77+ document.documentElement.classList.remove('dark');
78+ }
79+
80+ // Update or insert theme style
81+ var style = document.querySelector('style[data-dumber-theme-vars]');
82+ if (!style) {
83+ style = document.createElement('style');
84+ style.setAttribute('data-dumber-theme-vars', '');
85+ (document.head || document.documentElement).appendChild(style);
86+ }
87+ style.textContent = cssText;
88+
89+ // Notify any running WebUI that theme changed
90+ try {
91+ window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
92+ detail: { prefersDark: prefersDark }
93+ }));
94+ } catch (e) {
95+ // ignore
96+ }
97+
98+ // Keep color-scheme consistent
99+ var meta = document.querySelector('meta[name="color-scheme"]');
100+ if (!meta) {
101+ meta = document.createElement('meta');
102+ meta.name = 'color-scheme';
103+ document.documentElement.appendChild(meta);
104+ }
105+ meta.content = prefersDark ? 'dark light' : 'light dark';
106+ } catch (e) {
107+ console.error('[dumber] failed to apply theme', e);
108+ }
109+})();`, string(cssJSON), prefersDark)
110+
111+ return script, nil
112+}
113+
114+func (c *Coordinator) applyWebUITheme(
115+ ctx context.Context,
116+ paneID entity.PaneID,
117+ wv *webkit.WebView,
118+ script string,
119+ prefersDark bool,
120+) {
121+ if wv == nil || wv.IsDestroyed() {
122+ return
123+ }
124+ uri := wv.URI()
125+ if !strings.HasPrefix(uri, "dumb://") {
126+ return
127+ }
128+ wv.RunJavaScript(ctx, script, "")
129+ logging.FromContext(ctx).
130+ Debug().
131+ Str("pane_id", string(paneID)).
132+ Str("uri", uri).
133+ Bool("prefers_dark", prefersDark).
134+ Msg("applied WebUI theme")
135+}
136+
137+func (c *Coordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
138+ c.appearanceMu.Lock()
139+ if c.pendingThemePanes == nil {
140+ c.pendingThemePanes = make(map[entity.PaneID]bool)
141+ }
142+ c.pendingThemePanes[paneID] = true
143+ c.pendingThemeUpdate = pendingThemeUpdate{
144+ prefersDark: prefersDark,
145+ cssText: cssText,
146+ }
147+ c.hasPendingThemeUpdate = true
148+ c.appearanceMu.Unlock()
149+}
150+
151+func (c *Coordinator) setCurrentTheme(prefersDark bool, cssText string) {
152+ c.appearanceMu.Lock()
153+ c.currentTheme = pendingThemeUpdate{
154+ prefersDark: prefersDark,
155+ cssText: cssText,
156+ }
157+ c.hasCurrentTheme = true
158+ c.appearanceMu.Unlock()
159+}
160+
161+func (c *Coordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
162+ c.appearanceMu.Lock()
163+ defer c.appearanceMu.Unlock()
164+
165+ if !c.hasCurrentTheme {
166+ return pendingThemeUpdate{}, false
167+ }
168+ return c.currentTheme, true
169+}
170+
171+func (c *Coordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
172+ c.appearanceMu.Lock()
173+ defer c.appearanceMu.Unlock()
174+
175+ if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
176+ return pendingThemeUpdate{}, false
177+ }
178+ delete(c.pendingThemePanes, paneID)
179+ update := c.pendingThemeUpdate
180+ if len(c.pendingThemePanes) == 0 {
181+ c.hasPendingThemeUpdate = false
182+ }
183+ return update, true
184+}
185+
186+func (c *Coordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
187+ update, ok := c.takePendingThemeApply(paneID)
188+ if !ok {
189+ return false
190+ }
191+
192+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
193+ if err != nil {
194+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
195+ return false
196+ }
197+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
198+ return true
199+}
200+
201+func (c *Coordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
202+ update, ok := c.getCurrentTheme()
203+ if !ok || update.cssText == "" {
204+ return false
205+ }
206+
207+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
208+ if err != nil {
209+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
210+ return false
211+ }
212+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
213+ return true
214+}
215+
216+func (c *Coordinator) queueScriptRefresh(paneID entity.PaneID) {
217+ c.appearanceMu.Lock()
218+ if c.pendingScriptRefresh == nil {
219+ c.pendingScriptRefresh = make(map[entity.PaneID]bool)
220+ }
221+ c.pendingScriptRefresh[paneID] = true
222+ c.appearanceMu.Unlock()
223+}
224+
225+func (c *Coordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
226+ c.appearanceMu.Lock()
227+ defer c.appearanceMu.Unlock()
228+
229+ if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
230+ return false
231+ }
232+ delete(c.pendingScriptRefresh, paneID)
233+ return true
234+}
235+
236+func (c *Coordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
237+ if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
238+ return
239+ }
240+ if !c.takePendingScriptRefresh(paneID) {
241+ return
242+ }
243+ if c.injector == nil {
244+ return
245+ }
246+ c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
247+}
248+
249+func (c *Coordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
250+ if wv == nil || wv.IsDestroyed() {
251+ return false
252+ }
253+ if wv.IsLoading() {
254+ return true
255+ }
256+ return wv.EstimatedProgress() < 1.0
257+}
258+
259+func (c *Coordinator) refreshInjectedScripts(
260+ ctx context.Context,
261+ injector *webkit.ContentInjector,
262+ paneID entity.PaneID,
263+ wv *webkit.WebView,
264+) {
265+ if injector == nil || wv == nil || wv.IsDestroyed() {
266+ return
267+ }
268+ ucm := wv.UserContentManager()
269+ if ucm == nil {
270+ return
271+ }
272+ ucm.RemoveAllScripts()
273+ ucm.RemoveAllStyleSheets()
274+ injector.InjectScripts(ctx, ucm, wv.ID())
275+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
276+}
277+
278+func (c *Coordinator) clearPendingAppearance(paneID entity.PaneID) {
279+ c.appearanceMu.Lock()
280+ if c.pendingScriptRefresh != nil {
281+ delete(c.pendingScriptRefresh, paneID)
282+ }
283+ if c.pendingThemePanes != nil {
284+ delete(c.pendingThemePanes, paneID)
285+ if len(c.pendingThemePanes) == 0 {
286+ c.hasPendingThemeUpdate = false
287+ }
288+ }
289+ c.appearanceMu.Unlock()
290+}
A internal/ui/coordinator/content/bulk.go +65 -0
@@ -0,0 +1,65 @@
1+package content
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/webkit"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// ApplyFiltersToAll applies content filters to all active webviews.
11+// Called when filters become available after webviews were already created.
12+func (c *Coordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
13+ log := logging.FromContext(ctx)
14+
15+ for paneID, wv := range c.snapshotWebViews() {
16+ if wv != nil && !wv.IsDestroyed() {
17+ applier.ApplyTo(ctx, wv.UserContentManager())
18+ log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")
19+ }
20+ }
21+}
22+
23+// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
24+func (c *Coordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
25+ log := logging.FromContext(ctx)
26+ if sm == nil {
27+ return
28+ }
29+
30+ for paneID, wv := range c.snapshotWebViews() {
31+ if wv == nil || wv.IsDestroyed() {
32+ continue
33+ }
34+ sm.ApplyToWebView(ctx, wv.Widget())
35+ log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
36+ }
37+}
38+
39+// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
40+//
41+// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
42+// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
43+// the scripts so future navigations pick up the latest values.
44+// Script refresh is deferred for any WebView that is currently loading to avoid
45+// removing scripts mid-navigation.
46+func (c *Coordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
47+ log := logging.FromContext(ctx)
48+ if injector == nil {
49+ return
50+ }
51+
52+ c.injector = injector
53+ for paneID, wv := range c.webViews {
54+ if wv == nil || wv.IsDestroyed() {
55+ continue
56+ }
57+ if c.shouldDeferAppearance(wv) {
58+ c.queueScriptRefresh(paneID)
59+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
60+ continue
61+ }
62+
63+ c.refreshInjectedScripts(ctx, injector, paneID, wv)
64+ }
65+}
@@ -0,0 +1,65 @@
1+package content
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/webkit"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// ApplyFiltersToAll applies content filters to all active webviews.
11+// Called when filters become available after webviews were already created.
12+func (c *Coordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
13+ log := logging.FromContext(ctx)
14+
15+ for paneID, wv := range c.snapshotWebViews() {
16+ if wv != nil && !wv.IsDestroyed() {
17+ applier.ApplyTo(ctx, wv.UserContentManager())
18+ log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")
19+ }
20+ }
21+}
22+
23+// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
24+func (c *Coordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
25+ log := logging.FromContext(ctx)
26+ if sm == nil {
27+ return
28+ }
29+
30+ for paneID, wv := range c.snapshotWebViews() {
31+ if wv == nil || wv.IsDestroyed() {
32+ continue
33+ }
34+ sm.ApplyToWebView(ctx, wv.Widget())
35+ log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
36+ }
37+}
38+
39+// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
40+//
41+// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
42+// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
43+// the scripts so future navigations pick up the latest values.
44+// Script refresh is deferred for any WebView that is currently loading to avoid
45+// removing scripts mid-navigation.
46+func (c *Coordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
47+ log := logging.FromContext(ctx)
48+ if injector == nil {
49+ return
50+ }
51+
52+ c.injector = injector
53+ for paneID, wv := range c.webViews {
54+ if wv == nil || wv.IsDestroyed() {
55+ continue
56+ }
57+ if c.shouldDeferAppearance(wv) {
58+ c.queueScriptRefresh(paneID)
59+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
60+ continue
61+ }
62+
63+ c.refreshInjectedScripts(ctx, injector, paneID, wv)
64+ }
65+}
A internal/ui/coordinator/content/callbacks.go +296 -0
@@ -0,0 +1,296 @@
1+package content
2+
3+import (
4+ "context"
5+ "net/url"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/logging"
12+ webkitlib "github.com/bnema/puregotk-webkit/webkit"
13+ "github.com/jwijenbergh/puregotk/v4/gdk"
14+)
15+
16+func shouldRenderCrashPage(reason webkitlib.WebProcessTerminationReason) bool {
17+ switch reason {
18+ case webkitlib.WebProcessCrashedValue, webkitlib.WebProcessExceededMemoryLimitValue:
19+ return true
20+ case webkitlib.WebProcessTerminatedByApiValue:
21+ return false
22+ default:
23+ return true
24+ }
25+}
26+
27+func extractOriginalURIFromCrashPage(uri string) string {
28+ if uri == "" {
29+ return ""
30+ }
31+
32+ parsed, err := url.Parse(uri)
33+ if err != nil {
34+ return uri
35+ }
36+
37+ if parsed.Scheme != "dumb" || parsed.Host != webkit.HomePath {
38+ return uri
39+ }
40+
41+ if strings.Trim(parsed.Path, "/") != "crash" {
42+ return uri
43+ }
44+
45+ original := strings.TrimSpace(parsed.Query().Get("url"))
46+ if original == "" {
47+ return ""
48+ }
49+ return original
50+}
51+
52+func buildCrashPageURI(originalURI string) string {
53+ if strings.TrimSpace(originalURI) == "" {
54+ return crashPageURI
55+ }
56+ query := url.Values{}
57+ query.Set("url", originalURI)
58+ return crashPageURI + "?" + query.Encode()
59+}
60+
61+// setupWebViewCallbacks configures standard callbacks and popup handling.
62+func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
63+ log := logging.FromContext(ctx)
64+
65+ // Title changes
66+ wv.OnTitleChanged = func(title string) {
67+ c.onTitleChanged(ctx, paneID, title)
68+ }
69+
70+ // Favicon changes - capture generation to detect stale callbacks after pool reuse
71+ faviconGen := wv.Generation()
72+ wv.OnFaviconChanged = func(favicon *gdk.Texture) {
73+ if wv.Generation() != faviconGen {
74+ return // WebView was reused, ignore stale signal
75+ }
76+ c.onFaviconChanged(ctx, paneID, wv, favicon)
77+ }
78+
79+ // Load events
80+ wv.OnLoadChanged = func(event webkit.LoadEvent) {
81+ switch event {
82+ case webkit.LoadStarted:
83+ c.onLoadStarted(paneID)
84+ case webkit.LoadCommitted:
85+ c.onLoadCommitted(ctx, paneID, wv)
86+ case webkit.LoadFinished:
87+ c.onLoadFinished(ctx, paneID, wv)
88+ }
89+ }
90+
91+ // Progress
92+ wv.OnProgressChanged = func(progress float64) {
93+ c.onProgressChanged(paneID, progress)
94+ }
95+
96+ // SPA navigation and external scheme handling
97+ wv.OnURIChanged = func(uri string) {
98+ c.handleURIChanged(ctx, paneID, wv, uri)
99+ }
100+
101+ // Middle-click / Ctrl+click handler for opening links in new pane
102+ wv.OnLinkMiddleClick = func(uri string) bool {
103+ return c.handleLinkMiddleClick(ctx, paneID, uri)
104+ }
105+
106+ // Link hover callback for status overlay
107+ wv.OnLinkHover = func(uri string) {
108+ c.onLinkHover(paneID, uri)
109+ }
110+
111+ wv.OnWebProcessTerminated = func(reason webkitlib.WebProcessTerminationReason, reasonLabel string, uri string) {
112+ originalURI := extractOriginalURIFromCrashPage(uri)
113+ if !shouldRenderCrashPage(reason) {
114+ log.Info().
115+ Str("pane_id", string(paneID)).
116+ Str("reason", reasonLabel).
117+ Str("uri", uri).
118+ Msg("web process termination handled without crash page")
119+ return
120+ }
121+
122+ crashURI := buildCrashPageURI(originalURI)
123+ log.Warn().
124+ Str("pane_id", string(paneID)).
125+ Str("reason", reasonLabel).
126+ Str("uri", uri).
127+ Str("crash_uri", crashURI).
128+ Msg("web process terminated, redirecting to crash page")
129+
130+ if err := wv.LoadURI(ctx, crashURI); err != nil {
131+ log.Error().
132+ Err(err).
133+ Str("pane_id", string(paneID)).
134+ Str("reason", reasonLabel).
135+ Str("uri", uri).
136+ Str("crash_uri", crashURI).
137+ Msg("failed to load crash page after web process termination")
138+ }
139+ }
140+
141+ // Permission request handler
142+ wv.OnPermissionRequest = func(origin string, permTypes []string, allow, deny func()) bool {
143+ return c.handlePermissionRequest(ctx, origin, permTypes, allow, deny)
144+ }
145+ // Fullscreen handlers for idle inhibition
146+ c.setupIdleInhibitionHandlers(ctx, paneID, wv)
147+
148+ // Popup handling for nested popups
149+ c.setupPopupHandling(ctx, paneID, wv)
150+}
151+
152+// handlePermissionRequest processes media permission requests from WebKit.
153+// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
154+func (c *Coordinator) handlePermissionRequest(
155+ ctx context.Context,
156+ origin string,
157+ permTypes []string,
158+ allow, deny func(),
159+) bool {
160+ log := logging.FromContext(ctx)
161+
162+ // Convert string permission types to entity types
163+ entityTypes := make([]entity.PermissionType, 0, len(permTypes))
164+ for _, pt := range permTypes {
165+ switch pt {
166+ case "microphone":
167+ entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
168+ case "camera":
169+ entityTypes = append(entityTypes, entity.PermissionTypeCamera)
170+ case "display":
171+ entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
172+ case "device_info":
173+ entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
174+ default:
175+ log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
176+ }
177+ }
178+
179+ if len(entityTypes) == 0 {
180+ log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
181+ deny()
182+ return true
183+ }
184+
185+ trackedTypes := filterWebRTCPermissionTypes(entityTypes)
186+ notifyActivity := func(state PermissionActivityState) {
187+ if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
188+ return
189+ }
190+ c.onPermissionActivity(origin, trackedTypes, state)
191+ }
192+
193+ notifyActivity(PermissionActivityRequesting)
194+
195+ wrappedAllow := func() {
196+ notifyActivity(PermissionActivityAllowed)
197+ allow()
198+ }
199+ wrappedDeny := func() {
200+ notifyActivity(PermissionActivityBlocked)
201+ deny()
202+ }
203+
204+ // Check if permission use case is available
205+ if c.permissionUC == nil {
206+ log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
207+ // Auto-allow display and device_info, deny others
208+ allAutoAllow := true
209+ for _, pt := range entityTypes {
210+ if !entity.IsAutoAllow(pt) {
211+ allAutoAllow = false
212+ break
213+ }
214+ }
215+ if allAutoAllow {
216+ wrappedAllow()
217+ } else {
218+ wrappedDeny()
219+ }
220+ return true
221+ }
222+
223+ // Delegate to use case
224+ callback := usecase.PermissionCallback{
225+ Allow: wrappedAllow,
226+ Deny: wrappedDeny,
227+ }
228+
229+ c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
230+ return true
231+}
232+
233+func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
234+ filtered := make([]entity.PermissionType, 0, len(types))
235+ for _, permType := range types {
236+ switch permType {
237+ case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
238+ filtered = append(filtered, permType)
239+ }
240+ }
241+ return filtered
242+}
243+
244+// setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
245+// Idle is inhibited when:
246+// - The webview enters fullscreen mode (e.g., fullscreen video)
247+// - The webview is playing audio (e.g., video/music playback)
248+// The inhibitor uses refcounting, so both can be active simultaneously.
249+func (c *Coordinator) setupIdleInhibitionHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
250+ log := logging.FromContext(ctx)
251+
252+ if wv == nil {
253+ return
254+ }
255+
256+ // Fullscreen handling
257+ wv.OnEnterFullscreen = func() bool {
258+ if c.idleInhibitor != nil {
259+ if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
260+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
261+ }
262+ }
263+ if c.onFullscreenChanged != nil {
264+ c.onFullscreenChanged(true)
265+ }
266+ return false // Allow fullscreen
267+ }
268+
269+ wv.OnLeaveFullscreen = func() bool {
270+ if c.idleInhibitor != nil {
271+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
272+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
273+ }
274+ }
275+ if c.onFullscreenChanged != nil {
276+ c.onFullscreenChanged(false)
277+ }
278+ return false // Allow leaving fullscreen
279+ }
280+
281+ // Audio playback handling
282+ wv.OnAudioStateChanged = func(playing bool) {
283+ if c.idleInhibitor == nil {
284+ return
285+ }
286+ if playing {
287+ if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
288+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
289+ }
290+ } else {
291+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
292+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
293+ }
294+ }
295+ }
296+}
@@ -0,0 +1,296 @@
1+package content
2+
3+import (
4+ "context"
5+ "net/url"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/logging"
12+ webkitlib "github.com/bnema/puregotk-webkit/webkit"
13+ "github.com/jwijenbergh/puregotk/v4/gdk"
14+)
15+
16+func shouldRenderCrashPage(reason webkitlib.WebProcessTerminationReason) bool {
17+ switch reason {
18+ case webkitlib.WebProcessCrashedValue, webkitlib.WebProcessExceededMemoryLimitValue:
19+ return true
20+ case webkitlib.WebProcessTerminatedByApiValue:
21+ return false
22+ default:
23+ return true
24+ }
25+}
26+
27+func extractOriginalURIFromCrashPage(uri string) string {
28+ if uri == "" {
29+ return ""
30+ }
31+
32+ parsed, err := url.Parse(uri)
33+ if err != nil {
34+ return uri
35+ }
36+
37+ if parsed.Scheme != "dumb" || parsed.Host != webkit.HomePath {
38+ return uri
39+ }
40+
41+ if strings.Trim(parsed.Path, "/") != "crash" {
42+ return uri
43+ }
44+
45+ original := strings.TrimSpace(parsed.Query().Get("url"))
46+ if original == "" {
47+ return ""
48+ }
49+ return original
50+}
51+
52+func buildCrashPageURI(originalURI string) string {
53+ if strings.TrimSpace(originalURI) == "" {
54+ return crashPageURI
55+ }
56+ query := url.Values{}
57+ query.Set("url", originalURI)
58+ return crashPageURI + "?" + query.Encode()
59+}
60+
61+// setupWebViewCallbacks configures standard callbacks and popup handling.
62+func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
63+ log := logging.FromContext(ctx)
64+
65+ // Title changes
66+ wv.OnTitleChanged = func(title string) {
67+ c.onTitleChanged(ctx, paneID, title)
68+ }
69+
70+ // Favicon changes - capture generation to detect stale callbacks after pool reuse
71+ faviconGen := wv.Generation()
72+ wv.OnFaviconChanged = func(favicon *gdk.Texture) {
73+ if wv.Generation() != faviconGen {
74+ return // WebView was reused, ignore stale signal
75+ }
76+ c.onFaviconChanged(ctx, paneID, wv, favicon)
77+ }
78+
79+ // Load events
80+ wv.OnLoadChanged = func(event webkit.LoadEvent) {
81+ switch event {
82+ case webkit.LoadStarted:
83+ c.onLoadStarted(paneID)
84+ case webkit.LoadCommitted:
85+ c.onLoadCommitted(ctx, paneID, wv)
86+ case webkit.LoadFinished:
87+ c.onLoadFinished(ctx, paneID, wv)
88+ }
89+ }
90+
91+ // Progress
92+ wv.OnProgressChanged = func(progress float64) {
93+ c.onProgressChanged(paneID, progress)
94+ }
95+
96+ // SPA navigation and external scheme handling
97+ wv.OnURIChanged = func(uri string) {
98+ c.handleURIChanged(ctx, paneID, wv, uri)
99+ }
100+
101+ // Middle-click / Ctrl+click handler for opening links in new pane
102+ wv.OnLinkMiddleClick = func(uri string) bool {
103+ return c.handleLinkMiddleClick(ctx, paneID, uri)
104+ }
105+
106+ // Link hover callback for status overlay
107+ wv.OnLinkHover = func(uri string) {
108+ c.onLinkHover(paneID, uri)
109+ }
110+
111+ wv.OnWebProcessTerminated = func(reason webkitlib.WebProcessTerminationReason, reasonLabel string, uri string) {
112+ originalURI := extractOriginalURIFromCrashPage(uri)
113+ if !shouldRenderCrashPage(reason) {
114+ log.Info().
115+ Str("pane_id", string(paneID)).
116+ Str("reason", reasonLabel).
117+ Str("uri", uri).
118+ Msg("web process termination handled without crash page")
119+ return
120+ }
121+
122+ crashURI := buildCrashPageURI(originalURI)
123+ log.Warn().
124+ Str("pane_id", string(paneID)).
125+ Str("reason", reasonLabel).
126+ Str("uri", uri).
127+ Str("crash_uri", crashURI).
128+ Msg("web process terminated, redirecting to crash page")
129+
130+ if err := wv.LoadURI(ctx, crashURI); err != nil {
131+ log.Error().
132+ Err(err).
133+ Str("pane_id", string(paneID)).
134+ Str("reason", reasonLabel).
135+ Str("uri", uri).
136+ Str("crash_uri", crashURI).
137+ Msg("failed to load crash page after web process termination")
138+ }
139+ }
140+
141+ // Permission request handler
142+ wv.OnPermissionRequest = func(origin string, permTypes []string, allow, deny func()) bool {
143+ return c.handlePermissionRequest(ctx, origin, permTypes, allow, deny)
144+ }
145+ // Fullscreen handlers for idle inhibition
146+ c.setupIdleInhibitionHandlers(ctx, paneID, wv)
147+
148+ // Popup handling for nested popups
149+ c.setupPopupHandling(ctx, paneID, wv)
150+}
151+
152+// handlePermissionRequest processes media permission requests from WebKit.
153+// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
154+func (c *Coordinator) handlePermissionRequest(
155+ ctx context.Context,
156+ origin string,
157+ permTypes []string,
158+ allow, deny func(),
159+) bool {
160+ log := logging.FromContext(ctx)
161+
162+ // Convert string permission types to entity types
163+ entityTypes := make([]entity.PermissionType, 0, len(permTypes))
164+ for _, pt := range permTypes {
165+ switch pt {
166+ case "microphone":
167+ entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
168+ case "camera":
169+ entityTypes = append(entityTypes, entity.PermissionTypeCamera)
170+ case "display":
171+ entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
172+ case "device_info":
173+ entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
174+ default:
175+ log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
176+ }
177+ }
178+
179+ if len(entityTypes) == 0 {
180+ log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
181+ deny()
182+ return true
183+ }
184+
185+ trackedTypes := filterWebRTCPermissionTypes(entityTypes)
186+ notifyActivity := func(state PermissionActivityState) {
187+ if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
188+ return
189+ }
190+ c.onPermissionActivity(origin, trackedTypes, state)
191+ }
192+
193+ notifyActivity(PermissionActivityRequesting)
194+
195+ wrappedAllow := func() {
196+ notifyActivity(PermissionActivityAllowed)
197+ allow()
198+ }
199+ wrappedDeny := func() {
200+ notifyActivity(PermissionActivityBlocked)
201+ deny()
202+ }
203+
204+ // Check if permission use case is available
205+ if c.permissionUC == nil {
206+ log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
207+ // Auto-allow display and device_info, deny others
208+ allAutoAllow := true
209+ for _, pt := range entityTypes {
210+ if !entity.IsAutoAllow(pt) {
211+ allAutoAllow = false
212+ break
213+ }
214+ }
215+ if allAutoAllow {
216+ wrappedAllow()
217+ } else {
218+ wrappedDeny()
219+ }
220+ return true
221+ }
222+
223+ // Delegate to use case
224+ callback := usecase.PermissionCallback{
225+ Allow: wrappedAllow,
226+ Deny: wrappedDeny,
227+ }
228+
229+ c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
230+ return true
231+}
232+
233+func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
234+ filtered := make([]entity.PermissionType, 0, len(types))
235+ for _, permType := range types {
236+ switch permType {
237+ case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
238+ filtered = append(filtered, permType)
239+ }
240+ }
241+ return filtered
242+}
243+
244+// setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
245+// Idle is inhibited when:
246+// - The webview enters fullscreen mode (e.g., fullscreen video)
247+// - The webview is playing audio (e.g., video/music playback)
248+// The inhibitor uses refcounting, so both can be active simultaneously.
249+func (c *Coordinator) setupIdleInhibitionHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
250+ log := logging.FromContext(ctx)
251+
252+ if wv == nil {
253+ return
254+ }
255+
256+ // Fullscreen handling
257+ wv.OnEnterFullscreen = func() bool {
258+ if c.idleInhibitor != nil {
259+ if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
260+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
261+ }
262+ }
263+ if c.onFullscreenChanged != nil {
264+ c.onFullscreenChanged(true)
265+ }
266+ return false // Allow fullscreen
267+ }
268+
269+ wv.OnLeaveFullscreen = func() bool {
270+ if c.idleInhibitor != nil {
271+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
272+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
273+ }
274+ }
275+ if c.onFullscreenChanged != nil {
276+ c.onFullscreenChanged(false)
277+ }
278+ return false // Allow leaving fullscreen
279+ }
280+
281+ // Audio playback handling
282+ wv.OnAudioStateChanged = func(playing bool) {
283+ if c.idleInhibitor == nil {
284+ return
285+ }
286+ if playing {
287+ if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
288+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
289+ }
290+ } else {
291+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
292+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
293+ }
294+ }
295+ }
296+}
R internal/ui/coordinator/content_active_override_test.go → internal/ui/coordinator/content/content_active_override_test.go +7 -7 renamed, 81% similar
@@ -1,4 +1,4 @@
1-package coordinator
1+package content
22
33 import (
44 "context"
@@ -11,13 +11,13 @@ import (
1111 "github.com/bnema/dumber/internal/ui/component"
1212 )
1313
14-func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
14+func TestCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
1515 t.Parallel()
1616
1717 mainWV := &webkit.WebView{}
1818 floatingWV := &webkit.WebView{}
1919
20- c := &ContentCoordinator{
20+ c := &Coordinator{
2121 webViews: map[entity.PaneID]*webkit.WebView{
2222 "main-pane": mainWV,
2323 "floating-pane": floatingWV,
@@ -30,12 +30,12 @@ func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
3030 assert.Equal(t, entity.PaneID("floating-pane"), c.ActivePaneID(context.Background()))
3131 }
3232
33-func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
33+func TestCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
3434 t.Parallel()
3535
3636 mainWV := &webkit.WebView{}
3737
38- c := &ContentCoordinator{
38+ c := &Coordinator{
3939 webViews: map[entity.PaneID]*webkit.WebView{
4040 "main-pane": mainWV,
4141 },
@@ -48,7 +48,7 @@ func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
4848 assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))
4949 }
5050
51-func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {
51+func TestCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {
5252 t.Parallel()
5353
5454 ctx := context.Background()
@@ -56,7 +56,7 @@ func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *t
5656 mainPane := entity.NewPane(entity.PaneID("main-pane"))
5757 ws := entity.NewWorkspace(entity.WorkspaceID("ws-1"), mainPane)
5858
59- c := &ContentCoordinator{
59+ c := &Coordinator{
6060 webViews: map[entity.PaneID]*webkit.WebView{
6161 mainPane.ID: mainWV,
6262 },
@@ -1,4 +1,4 @@
1-package coordinator1+package content
2 2
3 import (3 import (
4 "context"4 "context"
@@ -11,13 +11,13 @@ import (
11 "github.com/bnema/dumber/internal/ui/component"11 "github.com/bnema/dumber/internal/ui/component"
12 )12 )
13 13
14-func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {14+func TestCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
15 t.Parallel()15 t.Parallel()
16 16
17 mainWV := &webkit.WebView{}17 mainWV := &webkit.WebView{}
18 floatingWV := &webkit.WebView{}18 floatingWV := &webkit.WebView{}
19 19
20- c := &ContentCoordinator{20+ c := &Coordinator{
21 webViews: map[entity.PaneID]*webkit.WebView{21 webViews: map[entity.PaneID]*webkit.WebView{
22 "main-pane": mainWV,22 "main-pane": mainWV,
23 "floating-pane": floatingWV,23 "floating-pane": floatingWV,
@@ -30,12 +30,12 @@ func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
30 assert.Equal(t, entity.PaneID("floating-pane"), c.ActivePaneID(context.Background()))30 assert.Equal(t, entity.PaneID("floating-pane"), c.ActivePaneID(context.Background()))
31 }31 }
32 32
33-func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {33+func TestCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
34 t.Parallel()34 t.Parallel()
35 35
36 mainWV := &webkit.WebView{}36 mainWV := &webkit.WebView{}
37 37
38- c := &ContentCoordinator{38+ c := &Coordinator{
39 webViews: map[entity.PaneID]*webkit.WebView{39 webViews: map[entity.PaneID]*webkit.WebView{
40 "main-pane": mainWV,40 "main-pane": mainWV,
41 },41 },
@@ -48,7 +48,7 @@ func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
48 assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))48 assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))
49 }49 }
50 50
51-func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {51+func TestCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {
52 t.Parallel()52 t.Parallel()
53 53
54 ctx := context.Background()54 ctx := context.Background()
@@ -56,7 +56,7 @@ func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *t
56 mainPane := entity.NewPane(entity.PaneID("main-pane"))56 mainPane := entity.NewPane(entity.PaneID("main-pane"))
57 ws := entity.NewWorkspace(entity.WorkspaceID("ws-1"), mainPane)57 ws := entity.NewWorkspace(entity.WorkspaceID("ws-1"), mainPane)
58 58
59- c := &ContentCoordinator{59+ c := &Coordinator{
60 webViews: map[entity.PaneID]*webkit.WebView{60 webViews: map[entity.PaneID]*webkit.WebView{
61 mainPane.ID: mainWV,61 mainPane.ID: mainWV,
62 },62 },
R internal/ui/coordinator/content_reveal_test.go → internal/ui/coordinator/content/content_reveal_test.go +9 -9 renamed, 94% similar
@@ -1,4 +1,4 @@
1-package coordinator
1+package content
22
33 import (
44 "context"
@@ -16,7 +16,7 @@ import (
1616 // without needing to mock WebKit dependencies.
1717
1818 func TestMarkPendingReveal_SetsState(t *testing.T) {
19- c := &ContentCoordinator{
19+ c := &Coordinator{
2020 pendingReveal: make(map[entity.PaneID]bool),
2121 }
2222 paneID := entity.PaneID("pane-1")
@@ -29,7 +29,7 @@ func TestMarkPendingReveal_SetsState(t *testing.T) {
2929 }
3030
3131 func TestClearPendingReveal_RemovesState(t *testing.T) {
32- c := &ContentCoordinator{
32+ c := &Coordinator{
3333 pendingReveal: make(map[entity.PaneID]bool),
3434 }
3535 paneID := entity.PaneID("pane-1")
@@ -47,7 +47,7 @@ func TestClearPendingReveal_RemovesState(t *testing.T) {
4747
4848 func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
4949 callbackCalled := false
50- c := &ContentCoordinator{
50+ c := &Coordinator{
5151 pendingReveal: make(map[entity.PaneID]bool),
5252 webViews: make(map[entity.PaneID]*webkit.WebView),
5353 onWebViewShown: func(paneID entity.PaneID) {
@@ -63,7 +63,7 @@ func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
6363 }
6464
6565 func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
66- c := &ContentCoordinator{
66+ c := &Coordinator{
6767 pendingReveal: make(map[entity.PaneID]bool),
6868 webViews: make(map[entity.PaneID]*webkit.WebView),
6969 }
@@ -82,7 +82,7 @@ func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
8282
8383 func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
8484 revealCount := 0
85- c := &ContentCoordinator{
85+ c := &Coordinator{
8686 pendingReveal: make(map[entity.PaneID]bool),
8787 webViews: make(map[entity.PaneID]*webkit.WebView),
8888 onWebViewShown: func(paneID entity.PaneID) {
@@ -109,7 +109,7 @@ func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
109109 }
110110
111111 func TestPendingReveal_ConcurrentAccess(t *testing.T) {
112- c := &ContentCoordinator{
112+ c := &Coordinator{
113113 pendingReveal: make(map[entity.PaneID]bool),
114114 webViews: make(map[entity.PaneID]*webkit.WebView),
115115 }
@@ -144,7 +144,7 @@ func TestPendingReveal_ConcurrentAccess(t *testing.T) {
144144 }
145145
146146 func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
147- c := &ContentCoordinator{
147+ c := &Coordinator{
148148 pendingReveal: make(map[entity.PaneID]bool),
149149 }
150150
@@ -161,7 +161,7 @@ func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
161161 }
162162
163163 func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {
164- c := &ContentCoordinator{
164+ c := &Coordinator{
165165 pendingReveal: make(map[entity.PaneID]bool),
166166 }
167167
@@ -1,4 +1,4 @@
1-package coordinator1+package content
2 2
3 import (3 import (
4 "context"4 "context"
@@ -16,7 +16,7 @@ import (
16 // without needing to mock WebKit dependencies.16 // without needing to mock WebKit dependencies.
17 17
18 func TestMarkPendingReveal_SetsState(t *testing.T) {18 func TestMarkPendingReveal_SetsState(t *testing.T) {
19- c := &ContentCoordinator{19+ c := &Coordinator{
20 pendingReveal: make(map[entity.PaneID]bool),20 pendingReveal: make(map[entity.PaneID]bool),
21 }21 }
22 paneID := entity.PaneID("pane-1")22 paneID := entity.PaneID("pane-1")
@@ -29,7 +29,7 @@ func TestMarkPendingReveal_SetsState(t *testing.T) {
29 }29 }
30 30
31 func TestClearPendingReveal_RemovesState(t *testing.T) {31 func TestClearPendingReveal_RemovesState(t *testing.T) {
32- c := &ContentCoordinator{32+ c := &Coordinator{
33 pendingReveal: make(map[entity.PaneID]bool),33 pendingReveal: make(map[entity.PaneID]bool),
34 }34 }
35 paneID := entity.PaneID("pane-1")35 paneID := entity.PaneID("pane-1")
@@ -47,7 +47,7 @@ func TestClearPendingReveal_RemovesState(t *testing.T) {
47 47
48 func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {48 func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
49 callbackCalled := false49 callbackCalled := false
50- c := &ContentCoordinator{50+ c := &Coordinator{
51 pendingReveal: make(map[entity.PaneID]bool),51 pendingReveal: make(map[entity.PaneID]bool),
52 webViews: make(map[entity.PaneID]*webkit.WebView),52 webViews: make(map[entity.PaneID]*webkit.WebView),
53 onWebViewShown: func(paneID entity.PaneID) {53 onWebViewShown: func(paneID entity.PaneID) {
@@ -63,7 +63,7 @@ func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
63 }63 }
64 64
65 func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {65 func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
66- c := &ContentCoordinator{66+ c := &Coordinator{
67 pendingReveal: make(map[entity.PaneID]bool),67 pendingReveal: make(map[entity.PaneID]bool),
68 webViews: make(map[entity.PaneID]*webkit.WebView),68 webViews: make(map[entity.PaneID]*webkit.WebView),
69 }69 }
@@ -82,7 +82,7 @@ func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
82 82
83 func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {83 func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
84 revealCount := 084 revealCount := 0
85- c := &ContentCoordinator{85+ c := &Coordinator{
86 pendingReveal: make(map[entity.PaneID]bool),86 pendingReveal: make(map[entity.PaneID]bool),
87 webViews: make(map[entity.PaneID]*webkit.WebView),87 webViews: make(map[entity.PaneID]*webkit.WebView),
88 onWebViewShown: func(paneID entity.PaneID) {88 onWebViewShown: func(paneID entity.PaneID) {
@@ -109,7 +109,7 @@ func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
109 }109 }
110 110
111 func TestPendingReveal_ConcurrentAccess(t *testing.T) {111 func TestPendingReveal_ConcurrentAccess(t *testing.T) {
112- c := &ContentCoordinator{112+ c := &Coordinator{
113 pendingReveal: make(map[entity.PaneID]bool),113 pendingReveal: make(map[entity.PaneID]bool),
114 webViews: make(map[entity.PaneID]*webkit.WebView),114 webViews: make(map[entity.PaneID]*webkit.WebView),
115 }115 }
@@ -144,7 +144,7 @@ func TestPendingReveal_ConcurrentAccess(t *testing.T) {
144 }144 }
145 145
146 func TestMarkPendingReveal_MultiplePanes(t *testing.T) {146 func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
147- c := &ContentCoordinator{147+ c := &Coordinator{
148 pendingReveal: make(map[entity.PaneID]bool),148 pendingReveal: make(map[entity.PaneID]bool),
149 }149 }
150 150
@@ -161,7 +161,7 @@ func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
161 }161 }
162 162
163 func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {163 func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {
164- c := &ContentCoordinator{164+ c := &Coordinator{
165 pendingReveal: make(map[entity.PaneID]bool),165 pendingReveal: make(map[entity.PaneID]bool),
166 }166 }
167 167
A internal/ui/coordinator/content/coordinator.go +301 -0
@@ -0,0 +1,301 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/bnema/dumber/internal/infrastructure/webkit"
13+ "github.com/bnema/dumber/internal/logging"
14+ "github.com/bnema/dumber/internal/ui/adapter"
15+ "github.com/bnema/dumber/internal/ui/component"
16+ "github.com/bnema/dumber/internal/ui/input"
17+ "github.com/bnema/dumber/internal/ui/layout"
18+)
19+
20+// Coordinator manages WebView lifecycle, title tracking, and content attachment.
21+type Coordinator struct {
22+ pool *webkit.WebViewPool
23+ widgetFactory layout.WidgetFactory
24+ faviconAdapter *adapter.FaviconAdapter
25+ zoomUC *usecase.ManageZoomUseCase
26+ permissionUC *usecase.HandlePermissionUseCase
27+ injector *webkit.ContentInjector
28+
29+ webViews map[entity.PaneID]*webkit.WebView
30+ webViewsMu sync.RWMutex
31+
32+ activePaneOverride entity.PaneID
33+ activePaneOverrideMu sync.RWMutex
34+
35+ paneTitles map[entity.PaneID]string
36+ titleMu sync.RWMutex
37+
38+ // Track original navigation URLs to handle cross-domain redirects
39+ // e.g., google.fr → google.com: cache favicon under both domains
40+ navOrigins map[entity.PaneID]string
41+ navOriginMu sync.RWMutex
42+
43+ // Callback to get active workspace state (avoids circular dependency)
44+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
45+
46+ // Callback when title changes (for history persistence)
47+ onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
48+
49+ // Callback when page is committed (for history recording)
50+ onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
51+
52+ // Callback when pane URI changes (for session snapshots)
53+ onPaneURIUpdated func(paneID entity.PaneID, url string)
54+
55+ // Callback when active pane title changes (for window title updates)
56+ onWindowTitleChanged func(title string)
57+
58+ // Callback when media permission activity changes (requesting/allowed/blocked).
59+ onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState)
60+
61+ // Callback when the active pane commits a navigation (new page loading).
62+ onActiveNavigationCommitted func(uri string)
63+
64+ // Callback when the WebView becomes visible (first real commit)
65+ onWebViewShown func(paneID entity.PaneID)
66+
67+ revealMu sync.Mutex
68+ pendingReveal map[entity.PaneID]bool
69+
70+ appearanceMu sync.Mutex
71+ pendingScriptRefresh map[entity.PaneID]bool
72+ pendingThemePanes map[entity.PaneID]bool
73+ pendingThemeUpdate pendingThemeUpdate
74+ hasPendingThemeUpdate bool
75+ currentTheme pendingThemeUpdate
76+ hasCurrentTheme bool
77+
78+ // Gesture action handler for mouse button navigation
79+ gestureActionHandler input.ActionHandler
80+
81+ // Popup handling
82+ factory *webkit.WebViewFactory
83+ popupConfig *config.PopupBehaviorConfig
84+ pendingPopups map[port.WebViewID]*PendingPopup
85+ popupOAuth map[port.WebViewID]*popupOAuthState
86+ popupRefresh map[entity.PaneID]*time.Timer
87+ popupMu sync.RWMutex
88+
89+ // Callback to insert popup into workspace (avoids circular dependency)
90+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
91+
92+ // Callback to close a pane when popup closes
93+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
94+
95+ // ID generator for popup panes
96+ generateID func() string
97+
98+ // Idle inhibitor for fullscreen video playback
99+ idleInhibitor port.IdleInhibitor
100+
101+ // Callback when fullscreen state changes (for hiding/showing tab bar)
102+ onFullscreenChanged func(entering bool)
103+
104+ // Callback when WebView gains focus (for accent picker text input targeting)
105+ onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
106+
107+ // Callback for first load_started event (triggers deferred initialization)
108+ onFirstLoadStarted func()
109+ loadStartedOnce sync.Once
110+}
111+
112+type pendingThemeUpdate struct {
113+ prefersDark bool
114+ cssText string
115+}
116+
117+type popupOAuthState struct {
118+ ParentPaneID entity.PaneID
119+ CallbackURI string
120+ Success bool
121+ Error bool
122+ Seen bool
123+}
124+
125+// PermissionActivityState represents the visible state for media permission activity.
126+type PermissionActivityState string
127+
128+const (
129+ PermissionActivityRequesting PermissionActivityState = "requesting"
130+ PermissionActivityAllowed PermissionActivityState = "allowed"
131+ PermissionActivityBlocked PermissionActivityState = "blocked"
132+)
133+
134+// NewCoordinator creates a new Coordinator.
135+func NewCoordinator(
136+ ctx context.Context,
137+ pool *webkit.WebViewPool,
138+ widgetFactory layout.WidgetFactory,
139+ faviconAdapter *adapter.FaviconAdapter,
140+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
141+ zoomUC *usecase.ManageZoomUseCase,
142+ permissionUC *usecase.HandlePermissionUseCase,
143+) *Coordinator {
144+ log := logging.FromContext(ctx)
145+ log.Debug().Msg("creating content coordinator")
146+
147+ return &Coordinator{
148+ pool: pool,
149+ widgetFactory: widgetFactory,
150+ faviconAdapter: faviconAdapter,
151+ zoomUC: zoomUC,
152+ permissionUC: permissionUC,
153+ webViews: make(map[entity.PaneID]*webkit.WebView),
154+ paneTitles: make(map[entity.PaneID]string),
155+ navOrigins: make(map[entity.PaneID]string),
156+ pendingReveal: make(map[entity.PaneID]bool),
157+ getActiveWS: getActiveWS,
158+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
159+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
160+ popupRefresh: make(map[entity.PaneID]*time.Timer),
161+ }
162+}
163+
164+// SetOnTitleUpdated sets the callback for title changes (for history persistence).
165+func (c *Coordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
166+ c.onTitleUpdated = fn
167+}
168+
169+// SetOnHistoryRecord sets the callback for recording history on page commit.
170+func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
171+ c.onHistoryRecord = fn
172+}
173+
174+// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
175+func (c *Coordinator) SetOnPermissionActivity(
176+ fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState),
177+) {
178+ c.onPermissionActivity = fn
179+}
180+
181+// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
182+func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(uri string)) {
183+ c.onActiveNavigationCommitted = fn
184+}
185+
186+// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
187+func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
188+ c.onPaneURIUpdated = fn
189+}
190+
191+// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
192+func (c *Coordinator) SetOnWindowTitleChanged(fn func(title string)) {
193+ c.onWindowTitleChanged = fn
194+}
195+
196+// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
197+func (c *Coordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
198+ c.onWebViewShown = fn
199+}
200+
201+// SetGestureActionHandler sets the callback for mouse button navigation gestures.
202+func (c *Coordinator) SetGestureActionHandler(handler input.ActionHandler) {
203+ c.gestureActionHandler = handler
204+}
205+
206+// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
207+func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
208+ c.idleInhibitor = inhibitor
209+}
210+
211+// SetOnFullscreenChanged sets the callback for fullscreen state changes.
212+func (c *Coordinator) SetOnFullscreenChanged(fn func(entering bool)) {
213+ c.onFullscreenChanged = fn
214+}
215+
216+// SetOnWebViewFocused sets the callback for when a WebView gains focus.
217+func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) {
218+ c.onWebViewFocused = fn
219+}
220+
221+// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
222+// This is used to trigger deferred initialization after the initial load_uri()
223+// has been processed by the GTK main loop.
224+func (c *Coordinator) SetOnFirstLoadStarted(fn func()) {
225+ c.onFirstLoadStarted = fn
226+}
227+
228+// ActivePaneID returns the currently active pane ID used by navigation.
229+func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {
230+ if paneID, ok := c.activePaneOverrideID(); ok {
231+ return paneID
232+ }
233+ if c.getActiveWS == nil {
234+ return ""
235+ }
236+
237+ ws, _ := c.getActiveWS()
238+ if ws == nil {
239+ return ""
240+ }
241+ return ws.ActivePaneID
242+}
243+
244+// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
245+func (c *Coordinator) SetActivePaneOverride(paneID entity.PaneID) {
246+ c.activePaneOverrideMu.Lock()
247+ c.activePaneOverride = paneID
248+ c.activePaneOverrideMu.Unlock()
249+}
250+
251+// ClearActivePaneOverride removes any forced active pane override.
252+func (c *Coordinator) ClearActivePaneOverride() {
253+ c.activePaneOverrideMu.Lock()
254+ c.activePaneOverride = ""
255+ c.activePaneOverrideMu.Unlock()
256+}
257+
258+func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
259+ c.activePaneOverrideMu.RLock()
260+ defer c.activePaneOverrideMu.RUnlock()
261+ if c.activePaneOverride == "" {
262+ return "", false
263+ }
264+ return c.activePaneOverride, true
265+}
266+
267+func (c *Coordinator) webViewCount() int {
268+ c.webViewsMu.RLock()
269+ defer c.webViewsMu.RUnlock()
270+ return len(c.webViews)
271+}
272+
273+func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) *webkit.WebView {
274+ c.webViewsMu.RLock()
275+ defer c.webViewsMu.RUnlock()
276+ return c.webViews[paneID]
277+}
278+
279+func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv *webkit.WebView) {
280+ c.webViewsMu.Lock()
281+ c.webViews[paneID] = wv
282+ c.webViewsMu.Unlock()
283+}
284+
285+func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) *webkit.WebView {
286+ c.webViewsMu.Lock()
287+ defer c.webViewsMu.Unlock()
288+ wv := c.webViews[paneID]
289+ delete(c.webViews, paneID)
290+ return wv
291+}
292+
293+func (c *Coordinator) snapshotWebViews() map[entity.PaneID]*webkit.WebView {
294+ c.webViewsMu.RLock()
295+ defer c.webViewsMu.RUnlock()
296+ snapshot := make(map[entity.PaneID]*webkit.WebView, len(c.webViews))
297+ for paneID, wv := range c.webViews {
298+ snapshot[paneID] = wv
299+ }
300+ return snapshot
301+}
@@ -0,0 +1,301 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/bnema/dumber/internal/infrastructure/webkit"
13+ "github.com/bnema/dumber/internal/logging"
14+ "github.com/bnema/dumber/internal/ui/adapter"
15+ "github.com/bnema/dumber/internal/ui/component"
16+ "github.com/bnema/dumber/internal/ui/input"
17+ "github.com/bnema/dumber/internal/ui/layout"
18+)
19+
20+// Coordinator manages WebView lifecycle, title tracking, and content attachment.
21+type Coordinator struct {
22+ pool *webkit.WebViewPool
23+ widgetFactory layout.WidgetFactory
24+ faviconAdapter *adapter.FaviconAdapter
25+ zoomUC *usecase.ManageZoomUseCase
26+ permissionUC *usecase.HandlePermissionUseCase
27+ injector *webkit.ContentInjector
28+
29+ webViews map[entity.PaneID]*webkit.WebView
30+ webViewsMu sync.RWMutex
31+
32+ activePaneOverride entity.PaneID
33+ activePaneOverrideMu sync.RWMutex
34+
35+ paneTitles map[entity.PaneID]string
36+ titleMu sync.RWMutex
37+
38+ // Track original navigation URLs to handle cross-domain redirects
39+ // e.g., google.fr → google.com: cache favicon under both domains
40+ navOrigins map[entity.PaneID]string
41+ navOriginMu sync.RWMutex
42+
43+ // Callback to get active workspace state (avoids circular dependency)
44+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
45+
46+ // Callback when title changes (for history persistence)
47+ onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
48+
49+ // Callback when page is committed (for history recording)
50+ onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
51+
52+ // Callback when pane URI changes (for session snapshots)
53+ onPaneURIUpdated func(paneID entity.PaneID, url string)
54+
55+ // Callback when active pane title changes (for window title updates)
56+ onWindowTitleChanged func(title string)
57+
58+ // Callback when media permission activity changes (requesting/allowed/blocked).
59+ onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState)
60+
61+ // Callback when the active pane commits a navigation (new page loading).
62+ onActiveNavigationCommitted func(uri string)
63+
64+ // Callback when the WebView becomes visible (first real commit)
65+ onWebViewShown func(paneID entity.PaneID)
66+
67+ revealMu sync.Mutex
68+ pendingReveal map[entity.PaneID]bool
69+
70+ appearanceMu sync.Mutex
71+ pendingScriptRefresh map[entity.PaneID]bool
72+ pendingThemePanes map[entity.PaneID]bool
73+ pendingThemeUpdate pendingThemeUpdate
74+ hasPendingThemeUpdate bool
75+ currentTheme pendingThemeUpdate
76+ hasCurrentTheme bool
77+
78+ // Gesture action handler for mouse button navigation
79+ gestureActionHandler input.ActionHandler
80+
81+ // Popup handling
82+ factory *webkit.WebViewFactory
83+ popupConfig *config.PopupBehaviorConfig
84+ pendingPopups map[port.WebViewID]*PendingPopup
85+ popupOAuth map[port.WebViewID]*popupOAuthState
86+ popupRefresh map[entity.PaneID]*time.Timer
87+ popupMu sync.RWMutex
88+
89+ // Callback to insert popup into workspace (avoids circular dependency)
90+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
91+
92+ // Callback to close a pane when popup closes
93+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
94+
95+ // ID generator for popup panes
96+ generateID func() string
97+
98+ // Idle inhibitor for fullscreen video playback
99+ idleInhibitor port.IdleInhibitor
100+
101+ // Callback when fullscreen state changes (for hiding/showing tab bar)
102+ onFullscreenChanged func(entering bool)
103+
104+ // Callback when WebView gains focus (for accent picker text input targeting)
105+ onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
106+
107+ // Callback for first load_started event (triggers deferred initialization)
108+ onFirstLoadStarted func()
109+ loadStartedOnce sync.Once
110+}
111+
112+type pendingThemeUpdate struct {
113+ prefersDark bool
114+ cssText string
115+}
116+
117+type popupOAuthState struct {
118+ ParentPaneID entity.PaneID
119+ CallbackURI string
120+ Success bool
121+ Error bool
122+ Seen bool
123+}
124+
125+// PermissionActivityState represents the visible state for media permission activity.
126+type PermissionActivityState string
127+
128+const (
129+ PermissionActivityRequesting PermissionActivityState = "requesting"
130+ PermissionActivityAllowed PermissionActivityState = "allowed"
131+ PermissionActivityBlocked PermissionActivityState = "blocked"
132+)
133+
134+// NewCoordinator creates a new Coordinator.
135+func NewCoordinator(
136+ ctx context.Context,
137+ pool *webkit.WebViewPool,
138+ widgetFactory layout.WidgetFactory,
139+ faviconAdapter *adapter.FaviconAdapter,
140+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
141+ zoomUC *usecase.ManageZoomUseCase,
142+ permissionUC *usecase.HandlePermissionUseCase,
143+) *Coordinator {
144+ log := logging.FromContext(ctx)
145+ log.Debug().Msg("creating content coordinator")
146+
147+ return &Coordinator{
148+ pool: pool,
149+ widgetFactory: widgetFactory,
150+ faviconAdapter: faviconAdapter,
151+ zoomUC: zoomUC,
152+ permissionUC: permissionUC,
153+ webViews: make(map[entity.PaneID]*webkit.WebView),
154+ paneTitles: make(map[entity.PaneID]string),
155+ navOrigins: make(map[entity.PaneID]string),
156+ pendingReveal: make(map[entity.PaneID]bool),
157+ getActiveWS: getActiveWS,
158+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
159+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
160+ popupRefresh: make(map[entity.PaneID]*time.Timer),
161+ }
162+}
163+
164+// SetOnTitleUpdated sets the callback for title changes (for history persistence).
165+func (c *Coordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
166+ c.onTitleUpdated = fn
167+}
168+
169+// SetOnHistoryRecord sets the callback for recording history on page commit.
170+func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
171+ c.onHistoryRecord = fn
172+}
173+
174+// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
175+func (c *Coordinator) SetOnPermissionActivity(
176+ fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState),
177+) {
178+ c.onPermissionActivity = fn
179+}
180+
181+// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
182+func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(uri string)) {
183+ c.onActiveNavigationCommitted = fn
184+}
185+
186+// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
187+func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
188+ c.onPaneURIUpdated = fn
189+}
190+
191+// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
192+func (c *Coordinator) SetOnWindowTitleChanged(fn func(title string)) {
193+ c.onWindowTitleChanged = fn
194+}
195+
196+// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
197+func (c *Coordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
198+ c.onWebViewShown = fn
199+}
200+
201+// SetGestureActionHandler sets the callback for mouse button navigation gestures.
202+func (c *Coordinator) SetGestureActionHandler(handler input.ActionHandler) {
203+ c.gestureActionHandler = handler
204+}
205+
206+// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
207+func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
208+ c.idleInhibitor = inhibitor
209+}
210+
211+// SetOnFullscreenChanged sets the callback for fullscreen state changes.
212+func (c *Coordinator) SetOnFullscreenChanged(fn func(entering bool)) {
213+ c.onFullscreenChanged = fn
214+}
215+
216+// SetOnWebViewFocused sets the callback for when a WebView gains focus.
217+func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) {
218+ c.onWebViewFocused = fn
219+}
220+
221+// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
222+// This is used to trigger deferred initialization after the initial load_uri()
223+// has been processed by the GTK main loop.
224+func (c *Coordinator) SetOnFirstLoadStarted(fn func()) {
225+ c.onFirstLoadStarted = fn
226+}
227+
228+// ActivePaneID returns the currently active pane ID used by navigation.
229+func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {
230+ if paneID, ok := c.activePaneOverrideID(); ok {
231+ return paneID
232+ }
233+ if c.getActiveWS == nil {
234+ return ""
235+ }
236+
237+ ws, _ := c.getActiveWS()
238+ if ws == nil {
239+ return ""
240+ }
241+ return ws.ActivePaneID
242+}
243+
244+// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
245+func (c *Coordinator) SetActivePaneOverride(paneID entity.PaneID) {
246+ c.activePaneOverrideMu.Lock()
247+ c.activePaneOverride = paneID
248+ c.activePaneOverrideMu.Unlock()
249+}
250+
251+// ClearActivePaneOverride removes any forced active pane override.
252+func (c *Coordinator) ClearActivePaneOverride() {
253+ c.activePaneOverrideMu.Lock()
254+ c.activePaneOverride = ""
255+ c.activePaneOverrideMu.Unlock()
256+}
257+
258+func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
259+ c.activePaneOverrideMu.RLock()
260+ defer c.activePaneOverrideMu.RUnlock()
261+ if c.activePaneOverride == "" {
262+ return "", false
263+ }
264+ return c.activePaneOverride, true
265+}
266+
267+func (c *Coordinator) webViewCount() int {
268+ c.webViewsMu.RLock()
269+ defer c.webViewsMu.RUnlock()
270+ return len(c.webViews)
271+}
272+
273+func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) *webkit.WebView {
274+ c.webViewsMu.RLock()
275+ defer c.webViewsMu.RUnlock()
276+ return c.webViews[paneID]
277+}
278+
279+func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv *webkit.WebView) {
280+ c.webViewsMu.Lock()
281+ c.webViews[paneID] = wv
282+ c.webViewsMu.Unlock()
283+}
284+
285+func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) *webkit.WebView {
286+ c.webViewsMu.Lock()
287+ defer c.webViewsMu.Unlock()
288+ wv := c.webViews[paneID]
289+ delete(c.webViews, paneID)
290+ return wv
291+}
292+
293+func (c *Coordinator) snapshotWebViews() map[entity.PaneID]*webkit.WebView {
294+ c.webViewsMu.RLock()
295+ defer c.webViewsMu.RUnlock()
296+ snapshot := make(map[entity.PaneID]*webkit.WebView, len(c.webViews))
297+ for paneID, wv := range c.webViews {
298+ snapshot[paneID] = wv
299+ }
300+ return snapshot
301+}
A internal/ui/coordinator/content/lifecycle.go +205 -0
@@ -0,0 +1,205 @@
1+package content
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/webkit"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/input"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+)
14+
15+// EnsureWebView acquires or reuses a WebView for the given pane.
16+func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
17+ log := logging.FromContext(ctx)
18+
19+ if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() {
20+ return wv, nil
21+ }
22+
23+ if c.pool == nil {
24+ return nil, fmt.Errorf("webview pool not configured")
25+ }
26+
27+ // Mark tab_created on first webview (first tab)
28+ if c.webViewCount() == 0 {
29+ logging.Trace().Mark("tab_created")
30+ }
31+
32+ wv, err := c.pool.Acquire(ctx)
33+ if err != nil {
34+ return nil, err
35+ }
36+ logging.Trace().Mark("webview_acquired")
37+
38+ c.setWebViewLocked(paneID, wv)
39+ c.setupWebViewCallbacks(ctx, paneID, wv)
40+
41+ log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
42+ return wv, nil
43+}
44+
45+// ReleaseWebView returns the WebView for a pane to the pool.
46+func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
47+ log := logging.FromContext(ctx)
48+
49+ wv := c.deleteWebViewLocked(paneID)
50+ if wv == nil {
51+ return
52+ }
53+ c.clearPendingAppearance(paneID)
54+
55+ // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
56+ // we must release the inhibition before destroying the webview.
57+ // Otherwise the D-Bus inhibit request stays active forever.
58+ if c.idleInhibitor != nil {
59+ if wv.IsFullscreen() {
60+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
61+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
62+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
63+ }
64+ }
65+ if wv.IsPlayingAudio() {
66+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
67+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
68+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
69+ }
70+ }
71+ }
72+
73+ // Clean up title tracking
74+ c.titleMu.Lock()
75+ delete(c.paneTitles, paneID)
76+ c.titleMu.Unlock()
77+
78+ // Clean up navigation origin tracking
79+ c.navOriginMu.Lock()
80+ delete(c.navOrigins, paneID)
81+ c.navOriginMu.Unlock()
82+
83+ if c.pool != nil {
84+ c.pool.Release(ctx, wv)
85+ } else {
86+ wv.Destroy()
87+ }
88+
89+ log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
90+}
91+
92+// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
93+func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
94+ log := logging.FromContext(ctx)
95+
96+ if ws == nil || wsView == nil || c.widgetFactory == nil {
97+ return
98+ }
99+
100+ for _, pane := range ws.AllPanes() {
101+ if pane == nil {
102+ continue
103+ }
104+
105+ wv, err := c.EnsureWebView(ctx, pane.ID)
106+ if err != nil {
107+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
108+ continue
109+ }
110+
111+ // Load the pane's URI if set and different from current
112+ if pane.URI != "" && pane.URI != wv.URI() {
113+ if err := wv.LoadURI(ctx, pane.URI); err != nil {
114+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
115+ }
116+ }
117+
118+ widget := c.WrapWidget(ctx, wv)
119+ if widget == nil {
120+ continue
121+ }
122+
123+ if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
124+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
125+ }
126+ logging.Trace().Mark("webview_attached")
127+ }
128+}
129+
130+// WrapWidget converts a WebView to a layout.Widget for embedding.
131+// It also attaches gesture handlers for mouse button navigation.
132+func (c *Coordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
133+ log := logging.FromContext(ctx)
134+
135+ if wv == nil || c.widgetFactory == nil {
136+ log.Debug().Msg("cannot wrap nil webview or factory")
137+ return nil
138+ }
139+
140+ gtkView := wv.Widget()
141+ if gtkView == nil {
142+ return nil
143+ }
144+
145+ widget := c.widgetFactory.WrapWidget(&gtkView.Widget)
146+
147+ // Attach gesture handler for mouse button 8/9 navigation
148+ if widget != nil {
149+ gestureHandler := input.NewGestureHandler(ctx)
150+ // Pass WebView directly to preserve user gesture context (like Epiphany)
151+ gestureHandler.SetNavigator(wv)
152+ // Keep callback as fallback
153+ if c.gestureActionHandler != nil {
154+ gestureHandler.SetOnAction(c.gestureActionHandler)
155+ }
156+ gestureHandler.AttachTo(widget.GtkWidget())
157+ log.Debug().Msg("gesture handler attached to webview with direct navigator")
158+ }
159+
160+ return widget
161+}
162+
163+// ActiveWebView returns the WebView for the active pane.
164+func (c *Coordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
165+ log := logging.FromContext(ctx)
166+
167+ if paneID, ok := c.activePaneOverrideID(); ok {
168+ wv := c.getWebViewLocked(paneID)
169+ if wv != nil {
170+ return wv
171+ }
172+ log.Debug().Str("pane_id", string(paneID)).Msg("active pane override has no webview, falling back")
173+ }
174+ if c.getActiveWS == nil {
175+ log.Debug().Msg("no active workspace resolver")
176+ return nil
177+ }
178+
179+ ws, _ := c.getActiveWS()
180+ if ws == nil {
181+ log.Debug().Msg("no active workspace")
182+ return nil
183+ }
184+
185+ pane := ws.ActivePane()
186+ if pane == nil || pane.Pane == nil {
187+ log.Debug().Msg("no active pane")
188+ return nil
189+ }
190+
191+ return c.getWebViewLocked(pane.Pane.ID)
192+}
193+
194+// GetWebView returns the WebView for a specific pane.
195+func (c *Coordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
196+ return c.getWebViewLocked(paneID)
197+}
198+
199+// RegisterPopupWebView registers a popup WebView that was created externally.
200+// This is used when popup tabs are created and the WebView needs to be tracked.
201+func (c *Coordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
202+ if wv != nil && paneID != "" {
203+ c.setWebViewLocked(paneID, wv)
204+ }
205+}
@@ -0,0 +1,205 @@
1+package content
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/webkit"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/input"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+)
14+
15+// EnsureWebView acquires or reuses a WebView for the given pane.
16+func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
17+ log := logging.FromContext(ctx)
18+
19+ if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() {
20+ return wv, nil
21+ }
22+
23+ if c.pool == nil {
24+ return nil, fmt.Errorf("webview pool not configured")
25+ }
26+
27+ // Mark tab_created on first webview (first tab)
28+ if c.webViewCount() == 0 {
29+ logging.Trace().Mark("tab_created")
30+ }
31+
32+ wv, err := c.pool.Acquire(ctx)
33+ if err != nil {
34+ return nil, err
35+ }
36+ logging.Trace().Mark("webview_acquired")
37+
38+ c.setWebViewLocked(paneID, wv)
39+ c.setupWebViewCallbacks(ctx, paneID, wv)
40+
41+ log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
42+ return wv, nil
43+}
44+
45+// ReleaseWebView returns the WebView for a pane to the pool.
46+func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
47+ log := logging.FromContext(ctx)
48+
49+ wv := c.deleteWebViewLocked(paneID)
50+ if wv == nil {
51+ return
52+ }
53+ c.clearPendingAppearance(paneID)
54+
55+ // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
56+ // we must release the inhibition before destroying the webview.
57+ // Otherwise the D-Bus inhibit request stays active forever.
58+ if c.idleInhibitor != nil {
59+ if wv.IsFullscreen() {
60+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
61+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
62+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
63+ }
64+ }
65+ if wv.IsPlayingAudio() {
66+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
67+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
68+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
69+ }
70+ }
71+ }
72+
73+ // Clean up title tracking
74+ c.titleMu.Lock()
75+ delete(c.paneTitles, paneID)
76+ c.titleMu.Unlock()
77+
78+ // Clean up navigation origin tracking
79+ c.navOriginMu.Lock()
80+ delete(c.navOrigins, paneID)
81+ c.navOriginMu.Unlock()
82+
83+ if c.pool != nil {
84+ c.pool.Release(ctx, wv)
85+ } else {
86+ wv.Destroy()
87+ }
88+
89+ log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
90+}
91+
92+// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
93+func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
94+ log := logging.FromContext(ctx)
95+
96+ if ws == nil || wsView == nil || c.widgetFactory == nil {
97+ return
98+ }
99+
100+ for _, pane := range ws.AllPanes() {
101+ if pane == nil {
102+ continue
103+ }
104+
105+ wv, err := c.EnsureWebView(ctx, pane.ID)
106+ if err != nil {
107+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
108+ continue
109+ }
110+
111+ // Load the pane's URI if set and different from current
112+ if pane.URI != "" && pane.URI != wv.URI() {
113+ if err := wv.LoadURI(ctx, pane.URI); err != nil {
114+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
115+ }
116+ }
117+
118+ widget := c.WrapWidget(ctx, wv)
119+ if widget == nil {
120+ continue
121+ }
122+
123+ if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
124+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
125+ }
126+ logging.Trace().Mark("webview_attached")
127+ }
128+}
129+
130+// WrapWidget converts a WebView to a layout.Widget for embedding.
131+// It also attaches gesture handlers for mouse button navigation.
132+func (c *Coordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
133+ log := logging.FromContext(ctx)
134+
135+ if wv == nil || c.widgetFactory == nil {
136+ log.Debug().Msg("cannot wrap nil webview or factory")
137+ return nil
138+ }
139+
140+ gtkView := wv.Widget()
141+ if gtkView == nil {
142+ return nil
143+ }
144+
145+ widget := c.widgetFactory.WrapWidget(&gtkView.Widget)
146+
147+ // Attach gesture handler for mouse button 8/9 navigation
148+ if widget != nil {
149+ gestureHandler := input.NewGestureHandler(ctx)
150+ // Pass WebView directly to preserve user gesture context (like Epiphany)
151+ gestureHandler.SetNavigator(wv)
152+ // Keep callback as fallback
153+ if c.gestureActionHandler != nil {
154+ gestureHandler.SetOnAction(c.gestureActionHandler)
155+ }
156+ gestureHandler.AttachTo(widget.GtkWidget())
157+ log.Debug().Msg("gesture handler attached to webview with direct navigator")
158+ }
159+
160+ return widget
161+}
162+
163+// ActiveWebView returns the WebView for the active pane.
164+func (c *Coordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
165+ log := logging.FromContext(ctx)
166+
167+ if paneID, ok := c.activePaneOverrideID(); ok {
168+ wv := c.getWebViewLocked(paneID)
169+ if wv != nil {
170+ return wv
171+ }
172+ log.Debug().Str("pane_id", string(paneID)).Msg("active pane override has no webview, falling back")
173+ }
174+ if c.getActiveWS == nil {
175+ log.Debug().Msg("no active workspace resolver")
176+ return nil
177+ }
178+
179+ ws, _ := c.getActiveWS()
180+ if ws == nil {
181+ log.Debug().Msg("no active workspace")
182+ return nil
183+ }
184+
185+ pane := ws.ActivePane()
186+ if pane == nil || pane.Pane == nil {
187+ log.Debug().Msg("no active pane")
188+ return nil
189+ }
190+
191+ return c.getWebViewLocked(pane.Pane.ID)
192+}
193+
194+// GetWebView returns the WebView for a specific pane.
195+func (c *Coordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
196+ return c.getWebViewLocked(paneID)
197+}
198+
199+// RegisterPopupWebView registers a popup WebView that was created externally.
200+// This is used when popup tabs are created and the WebView needs to be tracked.
201+func (c *Coordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
202+ if wv != nil && paneID != "" {
203+ c.setWebViewLocked(paneID, wv)
204+ }
205+}
A internal/ui/coordinator/content/navigation.go +329 -0
@@ -0,0 +1,329 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ urlutil "github.com/bnema/dumber/internal/domain/url"
10+ "github.com/bnema/dumber/internal/infrastructure/desktop"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// onLoadCommitted re-applies zoom when page content starts loading and records history.
16+// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
17+// History is recorded here because the URI is guaranteed to be correct after commit.
18+// Also shows the WebView widget (it's hidden during creation to avoid white flash).
19+func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
20+ log := logging.FromContext(ctx)
21+ logging.Trace().Mark("load_committed")
22+
23+ uri := wv.URI()
24+ if uri == "" {
25+ return
26+ }
27+
28+ // Set appropriate background color based on page type to prevent dark background bleeding.
29+ switch {
30+ case strings.HasPrefix(uri, "dumb://"):
31+ // Internal pages: apply themed background
32+ theme, ok := c.getCurrentTheme()
33+ if ok && theme.prefersDark {
34+ wv.SetBackgroundColor(darkBgR, darkBgG, darkBgB, darkBgA)
35+ } else {
36+ wv.ResetBackgroundToDefault()
37+ }
38+ case strings.HasPrefix(uri, "about:"):
39+ // Keep pool background (no action)
40+ default:
41+ // External pages: white background
42+ wv.ResetBackgroundToDefault()
43+ }
44+
45+ // Show the WebView now that content is being painted
46+ // (WebViews are hidden on creation to avoid white flash)
47+ // Skip showing if this is about:blank but the pane is loading a different URL
48+ // This prevents the brief flash of about:blank during initial navigation
49+ shouldShow := true
50+ if uri == aboutBlankURI {
51+ // Get the pane's intended URI from the workspace
52+ ws, _ := c.getActiveWS()
53+ if ws != nil {
54+ if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
55+ // Don't show about:blank if the pane is supposed to load a different URL
56+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
57+ shouldShow = false
58+ log.Debug().
59+ Str("pane_id", string(paneID)).
60+ Str("pane_uri", paneNode.Pane.URI).
61+ Msg("skipping webview show for about:blank (pane loading different URL)")
62+ }
63+ }
64+ }
65+ }
66+
67+ if !shouldShow {
68+ // Avoid updating UI/domain state to about:blank when we know the pane is
69+ // navigating to a different URL. This prevents the omnibox/window title from
70+ // briefly showing about:blank on cold start.
71+ c.clearPendingReveal(paneID)
72+ return
73+ }
74+
75+ if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
76+ c.applyCurrentTheme(ctx, paneID, wv)
77+ }
78+
79+ c.markPendingReveal(paneID)
80+ if wv.EstimatedProgress() > 0 {
81+ c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
82+ }
83+
84+ // Update domain model with current URI for session snapshots
85+ c.updatePaneURI(paneID, uri)
86+
87+ // Sync StackedView title bar with the WebView's current title.
88+ // This keeps the stacked title bar up-to-date immediately on navigation,
89+ // before the asynchronous notify::title signal fires.
90+ if title := wv.Title(); title != "" {
91+ c.syncStackedTitle(ctx, paneID, title)
92+ }
93+
94+ // Record history - URI is guaranteed to be correct at LoadCommitted
95+ if c.onHistoryRecord != nil {
96+ c.onHistoryRecord(ctx, paneID, uri)
97+ }
98+
99+ // Resolve favicon for stacked title bar (fills gap when WebKit doesn't emit notify::favicon)
100+ c.resolveCommittedFavicon(ctx, paneID, wv)
101+
102+ // Notify active pane navigation for permission indicator reset.
103+ c.notifyActiveNavigation(paneID, uri)
104+
105+ // Apply zoom
106+ if c.zoomUC == nil {
107+ return
108+ }
109+
110+ domain, err := usecase.ExtractDomain(uri)
111+ if err != nil {
112+ return
113+ }
114+
115+ _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
116+}
117+
118+func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
119+ if c.onActiveNavigationCommitted == nil {
120+ return
121+ }
122+ ws, _ := c.getActiveWS()
123+ if ws != nil && ws.ActivePaneID == paneID {
124+ c.onActiveNavigationCommitted(uri)
125+ }
126+}
127+
128+func (c *Coordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
129+ if wv == nil || wv.IsDestroyed() {
130+ return false
131+ }
132+ if wv.URI() != aboutBlankURI {
133+ return false
134+ }
135+ ws, _ := c.getActiveWS()
136+ if ws == nil {
137+ return false
138+ }
139+ paneNode := ws.FindPane(paneID)
140+ if paneNode == nil || paneNode.Pane == nil {
141+ return false
142+ }
143+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
144+ return true
145+ }
146+ return false
147+}
148+
149+// onSPANavigation records history when URL changes via JavaScript (History API).
150+// This handles SPA navigation like YouTube search, where the URL changes without a page load.
151+func (c *Coordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
152+ // Update domain model with current URI for session snapshots
153+ c.updatePaneURI(paneID, uri)
154+
155+ // Record history for SPA navigation
156+ if c.onHistoryRecord != nil {
157+ c.onHistoryRecord(ctx, paneID, uri)
158+ }
159+}
160+
161+// updatePaneURI updates the pane's URI in the domain model.
162+// This is called on navigation so that session snapshots capture the current URL.
163+func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
164+ if c.onPaneURIUpdated != nil {
165+ c.onPaneURIUpdated(paneID, uri)
166+ }
167+}
168+
169+// onLoadStarted shows the progress bar when page loading begins.
170+func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
171+ logging.Trace().Mark("load_started")
172+
173+ // Trigger deferred initialization on first load_started.
174+ // This ensures non-critical init runs after initial navigation starts.
175+ c.loadStartedOnce.Do(func() {
176+ if c.onFirstLoadStarted != nil {
177+ c.onFirstLoadStarted()
178+ }
179+ })
180+
181+ _, wsView := c.getActiveWS()
182+ if wsView == nil {
183+ return
184+ }
185+
186+ paneView := wsView.GetPaneView(paneID)
187+ if paneView != nil {
188+ paneView.SetLoading(true)
189+ }
190+}
191+
192+// onLoadFinished hides the progress bar when page loading completes.
193+func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
194+ _, wsView := c.getActiveWS()
195+ if wsView == nil {
196+ return
197+ }
198+
199+ paneView := wsView.GetPaneView(paneID)
200+ if paneView != nil {
201+ paneView.SetLoading(false)
202+ }
203+
204+ c.revealIfPending(context.Background(), paneID, "", "load-finished")
205+ if c.shouldSkipAboutBlankAppearance(paneID, wv) {
206+ return
207+ }
208+ c.applyPendingThemeUpdate(ctx, paneID, wv)
209+ c.refreshPendingScripts(ctx, paneID, wv)
210+}
211+
212+// onProgressChanged updates the progress bar with current load progress.
213+func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
214+ if progress > 0 {
215+ c.revealIfPending(context.Background(), paneID, "", "progress")
216+ }
217+
218+ _, wsView := c.getActiveWS()
219+ if wsView == nil {
220+ return
221+ }
222+
223+ paneView := wsView.GetPaneView(paneID)
224+ if paneView != nil {
225+ paneView.SetLoadProgress(progress)
226+ }
227+}
228+
229+func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {
230+ c.revealMu.Lock()
231+ c.pendingReveal[paneID] = true
232+ c.revealMu.Unlock()
233+}
234+
235+func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {
236+ c.revealMu.Lock()
237+ delete(c.pendingReveal, paneID)
238+ c.revealMu.Unlock()
239+}
240+
241+func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
242+ c.revealMu.Lock()
243+ pending := c.pendingReveal[paneID]
244+ if pending {
245+ delete(c.pendingReveal, paneID)
246+ }
247+ c.revealMu.Unlock()
248+
249+ if !pending {
250+ return
251+ }
252+
253+ wv := c.getWebViewLocked(paneID)
254+ if wv == nil || wv.IsDestroyed() {
255+ return
256+ }
257+
258+ if inner := wv.Widget(); inner != nil {
259+ inner.SetVisible(true)
260+ logging.FromContext(ctx).
261+ Debug().
262+ Str("pane_id", string(paneID)).
263+ Str("uri", uri).
264+ Str("reason", reason).
265+ Msg("webview revealed")
266+ }
267+
268+ // Mark first_paint and finish startup trace
269+ logging.Trace().Mark("first_paint")
270+ logging.Trace().Finish()
271+
272+ if c.onWebViewShown != nil {
273+ c.onWebViewShown(paneID)
274+ }
275+}
276+
277+// onLinkHover updates the link status overlay when hovering over links.
278+func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
279+ _, wsView := c.getActiveWS()
280+ if wsView == nil {
281+ return
282+ }
283+
284+ paneView := wsView.GetPaneView(paneID)
285+ if paneView == nil {
286+ return
287+ }
288+
289+ if uri != "" {
290+ paneView.ShowLinkStatus(uri)
291+ } else {
292+ paneView.HideLinkStatus()
293+ }
294+}
295+
296+// handleURIChanged handles URI changes from WebKit, including external scheme detection
297+// and SPA navigation tracking.
298+func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView, uri string) {
299+ if uri == "" {
300+ return
301+ }
302+
303+ log := logging.FromContext(ctx)
304+
305+ // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
306+ // These are typically triggered by JavaScript redirects (window.location)
307+ isExternal := urlutil.IsExternalScheme(uri)
308+
309+ if isExternal {
310+ log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
311+
312+ // Launch externally
313+ desktop.LaunchExternalURL(uri)
314+
315+ // Stop loading to prevent WebKit from showing an error page
316+ // The page stays on the previous URL before the JS redirect
317+ _ = wv.Stop(ctx)
318+
319+ // Navigate back to avoid stale URI in omnibox/history
320+ if wv.CanGoBack() {
321+ _ = wv.GoBack(ctx)
322+ }
323+ return
324+ }
325+
326+ if !wv.IsLoading() {
327+ c.onSPANavigation(ctx, paneID, uri)
328+ }
329+}
@@ -0,0 +1,329 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ urlutil "github.com/bnema/dumber/internal/domain/url"
10+ "github.com/bnema/dumber/internal/infrastructure/desktop"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// onLoadCommitted re-applies zoom when page content starts loading and records history.
16+// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
17+// History is recorded here because the URI is guaranteed to be correct after commit.
18+// Also shows the WebView widget (it's hidden during creation to avoid white flash).
19+func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
20+ log := logging.FromContext(ctx)
21+ logging.Trace().Mark("load_committed")
22+
23+ uri := wv.URI()
24+ if uri == "" {
25+ return
26+ }
27+
28+ // Set appropriate background color based on page type to prevent dark background bleeding.
29+ switch {
30+ case strings.HasPrefix(uri, "dumb://"):
31+ // Internal pages: apply themed background
32+ theme, ok := c.getCurrentTheme()
33+ if ok && theme.prefersDark {
34+ wv.SetBackgroundColor(darkBgR, darkBgG, darkBgB, darkBgA)
35+ } else {
36+ wv.ResetBackgroundToDefault()
37+ }
38+ case strings.HasPrefix(uri, "about:"):
39+ // Keep pool background (no action)
40+ default:
41+ // External pages: white background
42+ wv.ResetBackgroundToDefault()
43+ }
44+
45+ // Show the WebView now that content is being painted
46+ // (WebViews are hidden on creation to avoid white flash)
47+ // Skip showing if this is about:blank but the pane is loading a different URL
48+ // This prevents the brief flash of about:blank during initial navigation
49+ shouldShow := true
50+ if uri == aboutBlankURI {
51+ // Get the pane's intended URI from the workspace
52+ ws, _ := c.getActiveWS()
53+ if ws != nil {
54+ if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
55+ // Don't show about:blank if the pane is supposed to load a different URL
56+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
57+ shouldShow = false
58+ log.Debug().
59+ Str("pane_id", string(paneID)).
60+ Str("pane_uri", paneNode.Pane.URI).
61+ Msg("skipping webview show for about:blank (pane loading different URL)")
62+ }
63+ }
64+ }
65+ }
66+
67+ if !shouldShow {
68+ // Avoid updating UI/domain state to about:blank when we know the pane is
69+ // navigating to a different URL. This prevents the omnibox/window title from
70+ // briefly showing about:blank on cold start.
71+ c.clearPendingReveal(paneID)
72+ return
73+ }
74+
75+ if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
76+ c.applyCurrentTheme(ctx, paneID, wv)
77+ }
78+
79+ c.markPendingReveal(paneID)
80+ if wv.EstimatedProgress() > 0 {
81+ c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
82+ }
83+
84+ // Update domain model with current URI for session snapshots
85+ c.updatePaneURI(paneID, uri)
86+
87+ // Sync StackedView title bar with the WebView's current title.
88+ // This keeps the stacked title bar up-to-date immediately on navigation,
89+ // before the asynchronous notify::title signal fires.
90+ if title := wv.Title(); title != "" {
91+ c.syncStackedTitle(ctx, paneID, title)
92+ }
93+
94+ // Record history - URI is guaranteed to be correct at LoadCommitted
95+ if c.onHistoryRecord != nil {
96+ c.onHistoryRecord(ctx, paneID, uri)
97+ }
98+
99+ // Resolve favicon for stacked title bar (fills gap when WebKit doesn't emit notify::favicon)
100+ c.resolveCommittedFavicon(ctx, paneID, wv)
101+
102+ // Notify active pane navigation for permission indicator reset.
103+ c.notifyActiveNavigation(paneID, uri)
104+
105+ // Apply zoom
106+ if c.zoomUC == nil {
107+ return
108+ }
109+
110+ domain, err := usecase.ExtractDomain(uri)
111+ if err != nil {
112+ return
113+ }
114+
115+ _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
116+}
117+
118+func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
119+ if c.onActiveNavigationCommitted == nil {
120+ return
121+ }
122+ ws, _ := c.getActiveWS()
123+ if ws != nil && ws.ActivePaneID == paneID {
124+ c.onActiveNavigationCommitted(uri)
125+ }
126+}
127+
128+func (c *Coordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
129+ if wv == nil || wv.IsDestroyed() {
130+ return false
131+ }
132+ if wv.URI() != aboutBlankURI {
133+ return false
134+ }
135+ ws, _ := c.getActiveWS()
136+ if ws == nil {
137+ return false
138+ }
139+ paneNode := ws.FindPane(paneID)
140+ if paneNode == nil || paneNode.Pane == nil {
141+ return false
142+ }
143+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
144+ return true
145+ }
146+ return false
147+}
148+
149+// onSPANavigation records history when URL changes via JavaScript (History API).
150+// This handles SPA navigation like YouTube search, where the URL changes without a page load.
151+func (c *Coordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
152+ // Update domain model with current URI for session snapshots
153+ c.updatePaneURI(paneID, uri)
154+
155+ // Record history for SPA navigation
156+ if c.onHistoryRecord != nil {
157+ c.onHistoryRecord(ctx, paneID, uri)
158+ }
159+}
160+
161+// updatePaneURI updates the pane's URI in the domain model.
162+// This is called on navigation so that session snapshots capture the current URL.
163+func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
164+ if c.onPaneURIUpdated != nil {
165+ c.onPaneURIUpdated(paneID, uri)
166+ }
167+}
168+
169+// onLoadStarted shows the progress bar when page loading begins.
170+func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
171+ logging.Trace().Mark("load_started")
172+
173+ // Trigger deferred initialization on first load_started.
174+ // This ensures non-critical init runs after initial navigation starts.
175+ c.loadStartedOnce.Do(func() {
176+ if c.onFirstLoadStarted != nil {
177+ c.onFirstLoadStarted()
178+ }
179+ })
180+
181+ _, wsView := c.getActiveWS()
182+ if wsView == nil {
183+ return
184+ }
185+
186+ paneView := wsView.GetPaneView(paneID)
187+ if paneView != nil {
188+ paneView.SetLoading(true)
189+ }
190+}
191+
192+// onLoadFinished hides the progress bar when page loading completes.
193+func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
194+ _, wsView := c.getActiveWS()
195+ if wsView == nil {
196+ return
197+ }
198+
199+ paneView := wsView.GetPaneView(paneID)
200+ if paneView != nil {
201+ paneView.SetLoading(false)
202+ }
203+
204+ c.revealIfPending(context.Background(), paneID, "", "load-finished")
205+ if c.shouldSkipAboutBlankAppearance(paneID, wv) {
206+ return
207+ }
208+ c.applyPendingThemeUpdate(ctx, paneID, wv)
209+ c.refreshPendingScripts(ctx, paneID, wv)
210+}
211+
212+// onProgressChanged updates the progress bar with current load progress.
213+func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
214+ if progress > 0 {
215+ c.revealIfPending(context.Background(), paneID, "", "progress")
216+ }
217+
218+ _, wsView := c.getActiveWS()
219+ if wsView == nil {
220+ return
221+ }
222+
223+ paneView := wsView.GetPaneView(paneID)
224+ if paneView != nil {
225+ paneView.SetLoadProgress(progress)
226+ }
227+}
228+
229+func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {
230+ c.revealMu.Lock()
231+ c.pendingReveal[paneID] = true
232+ c.revealMu.Unlock()
233+}
234+
235+func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {
236+ c.revealMu.Lock()
237+ delete(c.pendingReveal, paneID)
238+ c.revealMu.Unlock()
239+}
240+
241+func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
242+ c.revealMu.Lock()
243+ pending := c.pendingReveal[paneID]
244+ if pending {
245+ delete(c.pendingReveal, paneID)
246+ }
247+ c.revealMu.Unlock()
248+
249+ if !pending {
250+ return
251+ }
252+
253+ wv := c.getWebViewLocked(paneID)
254+ if wv == nil || wv.IsDestroyed() {
255+ return
256+ }
257+
258+ if inner := wv.Widget(); inner != nil {
259+ inner.SetVisible(true)
260+ logging.FromContext(ctx).
261+ Debug().
262+ Str("pane_id", string(paneID)).
263+ Str("uri", uri).
264+ Str("reason", reason).
265+ Msg("webview revealed")
266+ }
267+
268+ // Mark first_paint and finish startup trace
269+ logging.Trace().Mark("first_paint")
270+ logging.Trace().Finish()
271+
272+ if c.onWebViewShown != nil {
273+ c.onWebViewShown(paneID)
274+ }
275+}
276+
277+// onLinkHover updates the link status overlay when hovering over links.
278+func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
279+ _, wsView := c.getActiveWS()
280+ if wsView == nil {
281+ return
282+ }
283+
284+ paneView := wsView.GetPaneView(paneID)
285+ if paneView == nil {
286+ return
287+ }
288+
289+ if uri != "" {
290+ paneView.ShowLinkStatus(uri)
291+ } else {
292+ paneView.HideLinkStatus()
293+ }
294+}
295+
296+// handleURIChanged handles URI changes from WebKit, including external scheme detection
297+// and SPA navigation tracking.
298+func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView, uri string) {
299+ if uri == "" {
300+ return
301+ }
302+
303+ log := logging.FromContext(ctx)
304+
305+ // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
306+ // These are typically triggered by JavaScript redirects (window.location)
307+ isExternal := urlutil.IsExternalScheme(uri)
308+
309+ if isExternal {
310+ log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
311+
312+ // Launch externally
313+ desktop.LaunchExternalURL(uri)
314+
315+ // Stop loading to prevent WebKit from showing an error page
316+ // The page stays on the previous URL before the JS redirect
317+ _ = wv.Stop(ctx)
318+
319+ // Navigate back to avoid stale URI in omnibox/history
320+ if wv.CanGoBack() {
321+ _ = wv.GoBack(ctx)
322+ }
323+ return
324+ }
325+
326+ if !wv.IsLoading() {
327+ c.onSPANavigation(ctx, paneID, uri)
328+ }
329+}
A internal/ui/coordinator/content/oauth.go +413 -0
@@ -0,0 +1,413 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/jwijenbergh/puregotk/v4/glib"
14+)
15+
16+// oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
17+// These appear in authorization URLs and callback redirects.
18+var oauthFlowPatterns = []string{
19+ // OAuth flow indicators
20+ "oauth",
21+ "authorize",
22+ "authorization",
23+ "auth/",
24+ "/auth",
25+ "login",
26+ "signin",
27+ "sign-in",
28+ // OpenID Connect
29+ "oidc",
30+ "openid",
31+ // Callback/redirect endpoints
32+ "callback",
33+ "redirect",
34+ "/cb",
35+}
36+
37+// oauthCallbackPatterns are URL query parameters that indicate an OAuth callback.
38+// These appear when the OAuth provider redirects back to the application.
39+var oauthCallbackPatterns = []string{
40+ // Success parameters
41+ "code=",
42+ "access_token=",
43+ "id_token=",
44+ "token_type=",
45+ "refresh_token=",
46+ // Error parameters
47+ "error=",
48+ "error_description=",
49+ "error_uri=",
50+}
51+
52+// oauthRequestPatterns are query parameters found in OAuth authorization requests.
53+var oauthRequestPatterns = []string{
54+ "response_type=",
55+ "client_id=",
56+ "redirect_uri=",
57+ "scope=",
58+ "nonce=",
59+}
60+
61+// IsOAuthURL checks if the URL is related to an OAuth flow.
62+// This includes authorization endpoints, login pages, and callback URLs.
63+func IsOAuthURL(url string) bool {
64+ if url == "" {
65+ return false
66+ }
67+ lower := strings.ToLower(url)
68+
69+ // Check for OAuth flow patterns in URL path
70+ for _, pattern := range oauthFlowPatterns {
71+ if strings.Contains(lower, pattern) {
72+ return true
73+ }
74+ }
75+
76+ // Check for OAuth request parameters
77+ for _, pattern := range oauthRequestPatterns {
78+ if strings.Contains(lower, pattern) {
79+ return true
80+ }
81+ }
82+
83+ return false
84+}
85+
86+// IsOAuthCallback checks if the URL is an OAuth callback with response parameters.
87+// This indicates the OAuth flow has completed (successfully or with error).
88+func IsOAuthCallback(url string) bool {
89+ if url == "" {
90+ return false
91+ }
92+ lower := strings.ToLower(url)
93+
94+ // Must contain callback patterns (code=, access_token=, error=, etc.)
95+ for _, pattern := range oauthCallbackPatterns {
96+ if strings.Contains(lower, pattern) {
97+ return true
98+ }
99+ }
100+
101+ return false
102+}
103+
104+// ShouldAutoClose determines if a popup at this URL should auto-close.
105+// Returns true for OAuth callbacks that indicate flow completion.
106+//
107+// This is comprehensive detection that handles:
108+// - Success: code=, access_token=, id_token=
109+// - Errors: error=, error_description=
110+// - Various OAuth providers (Google, GitHub, Auth0, etc.)
111+func ShouldAutoClose(url string) bool {
112+ return IsOAuthCallback(url)
113+}
114+
115+// shouldForceCloseOnSafetyTimeout determines if a popup should be force-closed
116+// when the OAuth safety timeout is reached.
117+//
118+// Safety-timeout force close is disabled for stability. OAuth popups close on
119+// callback detection or manual user action.
120+func shouldForceCloseOnSafetyTimeout(url string) bool {
121+ _ = url
122+ return false
123+}
124+
125+// IsOAuthSuccess checks if the callback indicates successful authentication.
126+func IsOAuthSuccess(url string) bool {
127+ if url == "" {
128+ return false
129+ }
130+ lower := strings.ToLower(url)
131+
132+ // Check for success indicators
133+ successPatterns := []string{
134+ "code=",
135+ "access_token=",
136+ "id_token=",
137+ }
138+
139+ for _, pattern := range successPatterns {
140+ if strings.Contains(lower, pattern) {
141+ // Make sure it's not an error response
142+ if !strings.Contains(lower, "error=") {
143+ return true
144+ }
145+ }
146+ }
147+
148+ return false
149+}
150+
151+// IsOAuthError checks if the callback indicates an authentication error.
152+func IsOAuthError(url string) bool {
153+ if url == "" {
154+ return false
155+ }
156+ lower := strings.ToLower(url)
157+ return strings.Contains(lower, "error=")
158+}
159+
160+func composeOnClose(existing, next func()) func() {
161+ if existing == nil {
162+ return next
163+ }
164+ if next == nil {
165+ return existing
166+ }
167+ return func() {
168+ existing()
169+ next()
170+ }
171+}
172+
173+func composeOnURIChanged(existing, next func(string)) func(string) {
174+ if existing == nil {
175+ return next
176+ }
177+ if next == nil {
178+ return existing
179+ }
180+ return func(uri string) {
181+ existing(uri)
182+ next(uri)
183+ }
184+}
185+
186+func composeOnLoadChanged(
187+ existing func(port.LoadEvent),
188+ next func(port.LoadEvent),
189+) func(port.LoadEvent) {
190+ if existing == nil {
191+ return next
192+ }
193+ if next == nil {
194+ return existing
195+ }
196+ return func(event port.LoadEvent) {
197+ existing(event)
198+ next(event)
199+ }
200+}
201+
202+// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
203+// It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)
204+// For providers using postMessage (like Google Sign-In), we rely on the provider calling
205+// window.close() which triggers WebKit's close signal.
206+// A long safety timeout (30s) catches popups that get stuck.
207+func (c *Coordinator) setupOAuthAutoClose(
208+ ctx context.Context,
209+ paneID entity.PaneID,
210+ popupID port.WebViewID,
211+ wv *webkit.WebView,
212+) {
213+ log := logging.FromContext(ctx)
214+
215+ // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
216+ var safetyTimer *time.Timer
217+ var safetyTimerMu sync.Mutex
218+ var cancelSafetyTimerOnce sync.Once
219+ var requestCloseOnce sync.Once
220+ const oauthSafetyTimeout = 30 * time.Second
221+ const oauthCloseDelay = 500 * time.Millisecond
222+
223+ startSafetyTimer := func() {
224+ safetyTimerMu.Lock()
225+ defer safetyTimerMu.Unlock()
226+ if safetyTimer != nil {
227+ safetyTimer.Stop()
228+ }
229+ safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
230+ cb := glib.SourceFunc(func(_ uintptr) bool {
231+ if wv != nil && !wv.IsDestroyed() {
232+ uri := wv.URI()
233+ if shouldForceCloseOnSafetyTimeout(uri) {
234+ log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
235+ wv.Close()
236+ return false
237+ }
238+ log.Debug().
239+ Str("pane", string(paneID)).
240+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
241+ Msg("oauth safety timeout reached during active auth flow, skipping forced close")
242+ }
243+ return false
244+ })
245+ glib.IdleAdd(&cb, 0)
246+ })
247+ }
248+
249+ cancelSafetyTimer := func() {
250+ cancelSafetyTimerOnce.Do(func() {
251+ safetyTimerMu.Lock()
252+ defer safetyTimerMu.Unlock()
253+ if safetyTimer != nil {
254+ safetyTimer.Stop()
255+ safetyTimer = nil
256+ }
257+ })
258+ }
259+
260+ requestOAuthClose := func(uri string, reason string) {
261+ c.capturePopupOAuthState(popupID, uri)
262+ cancelSafetyTimer()
263+ log.Info().
264+ Str("pane", string(paneID)).
265+ Str("reason", reason).
266+ Msg("oauth callback detected, closing")
267+ requestCloseOnce.Do(func() {
268+ time.AfterFunc(oauthCloseDelay, func() {
269+ cb := glib.SourceFunc(func(_ uintptr) bool {
270+ if wv != nil && !wv.IsDestroyed() {
271+ wv.Close()
272+ }
273+ return false
274+ })
275+ glib.IdleAdd(&cb, 0)
276+ })
277+ })
278+ }
279+
280+ // Start safety timer immediately.
281+ startSafetyTimer()
282+
283+ // Wrap OnURIChanged to check for OAuth callbacks.
284+ wv.OnURIChanged = composeOnURIChanged(wv.OnURIChanged, func(uri string) {
285+ if ShouldAutoClose(uri) {
286+ requestOAuthClose(uri, "uri_changed")
287+ }
288+ })
289+
290+ // Monitor load events for URL-based detection.
291+ wv.OnLoadChanged = composeOnLoadChanged(wv.OnLoadChanged, func(event webkit.LoadEvent) {
292+ if event == webkit.LoadCommitted {
293+ uri := wv.URI()
294+ if ShouldAutoClose(uri) {
295+ requestOAuthClose(uri, "load_committed")
296+ }
297+ }
298+ })
299+
300+ // Cancel safety timer on any close path.
301+ wv.OnClose = composeOnClose(func() {
302+ cancelSafetyTimer()
303+ }, wv.OnClose)
304+}
305+
306+func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
307+ c.popupMu.Lock()
308+ defer c.popupMu.Unlock()
309+ if c.popupOAuth == nil {
310+ c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
311+ }
312+ c.popupOAuth[popupID] = &popupOAuthState{
313+ ParentPaneID: parentPaneID,
314+ }
315+}
316+
317+func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
318+ c.popupMu.Lock()
319+ defer c.popupMu.Unlock()
320+
321+ state, ok := c.popupOAuth[popupID]
322+ if !ok {
323+ return
324+ }
325+
326+ state.Seen = true
327+ state.CallbackURI = uri
328+ state.Success = IsOAuthSuccess(uri)
329+ state.Error = IsOAuthError(uri)
330+}
331+
332+func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
333+ log := logging.FromContext(ctx)
334+
335+ c.popupMu.Lock()
336+ state, ok := c.popupOAuth[popupID]
337+ if ok {
338+ delete(c.popupOAuth, popupID)
339+ }
340+ c.popupMu.Unlock()
341+
342+ if !ok || state == nil || !state.Seen {
343+ return
344+ }
345+
346+ log.Debug().
347+ Uint64("popup_id", uint64(popupID)).
348+ Str("parent_pane_id", string(state.ParentPaneID)).
349+ Bool("oauth_success", state.Success).
350+ Bool("oauth_error", state.Error).
351+ Msg("popup oauth result captured on close")
352+
353+ if !state.Success || state.ParentPaneID == "" {
354+ return
355+ }
356+
357+ c.scheduleParentPaneRefresh(ctx, state.ParentPaneID, popupID)
358+}
359+
360+func (c *Coordinator) scheduleParentPaneRefresh(
361+ ctx context.Context,
362+ parentPaneID entity.PaneID,
363+ popupID port.WebViewID,
364+) {
365+ c.popupMu.Lock()
366+ if c.popupRefresh == nil {
367+ c.popupRefresh = make(map[entity.PaneID]*time.Timer)
368+ }
369+ if existing := c.popupRefresh[parentPaneID]; existing != nil {
370+ existing.Stop()
371+ }
372+ c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
373+ c.popupMu.Lock()
374+ delete(c.popupRefresh, parentPaneID)
375+ c.popupMu.Unlock()
376+ cb := glib.SourceFunc(func(_ uintptr) bool {
377+ c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
378+ return false
379+ })
380+ glib.IdleAdd(&cb, 0)
381+ })
382+ c.popupMu.Unlock()
383+}
384+
385+func (c *Coordinator) refreshPaneAfterOAuth(
386+ ctx context.Context,
387+ parentPaneID entity.PaneID,
388+ popupID port.WebViewID,
389+) {
390+ log := logging.FromContext(ctx)
391+ wv := c.getWebViewLocked(parentPaneID)
392+ if wv == nil || wv.IsDestroyed() {
393+ log.Debug().
394+ Str("parent_pane_id", string(parentPaneID)).
395+ Uint64("popup_id", uint64(popupID)).
396+ Msg("skipping parent pane refresh after oauth close: parent webview unavailable")
397+ return
398+ }
399+
400+ if err := wv.Reload(ctx); err != nil {
401+ log.Warn().
402+ Err(err).
403+ Str("parent_pane_id", string(parentPaneID)).
404+ Uint64("popup_id", uint64(popupID)).
405+ Msg("failed parent pane refresh after oauth popup close")
406+ return
407+ }
408+
409+ log.Info().
410+ Str("parent_pane_id", string(parentPaneID)).
411+ Uint64("popup_id", uint64(popupID)).
412+ Msg("refreshed parent pane after oauth popup success")
413+}
@@ -0,0 +1,413 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/jwijenbergh/puregotk/v4/glib"
14+)
15+
16+// oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
17+// These appear in authorization URLs and callback redirects.
18+var oauthFlowPatterns = []string{
19+ // OAuth flow indicators
20+ "oauth",
21+ "authorize",
22+ "authorization",
23+ "auth/",
24+ "/auth",
25+ "login",
26+ "signin",
27+ "sign-in",
28+ // OpenID Connect
29+ "oidc",
30+ "openid",
31+ // Callback/redirect endpoints
32+ "callback",
33+ "redirect",
34+ "/cb",
35+}
36+
37+// oauthCallbackPatterns are URL query parameters that indicate an OAuth callback.
38+// These appear when the OAuth provider redirects back to the application.
39+var oauthCallbackPatterns = []string{
40+ // Success parameters
41+ "code=",
42+ "access_token=",
43+ "id_token=",
44+ "token_type=",
45+ "refresh_token=",
46+ // Error parameters
47+ "error=",
48+ "error_description=",
49+ "error_uri=",
50+}
51+
52+// oauthRequestPatterns are query parameters found in OAuth authorization requests.
53+var oauthRequestPatterns = []string{
54+ "response_type=",
55+ "client_id=",
56+ "redirect_uri=",
57+ "scope=",
58+ "nonce=",
59+}
60+
61+// IsOAuthURL checks if the URL is related to an OAuth flow.
62+// This includes authorization endpoints, login pages, and callback URLs.
63+func IsOAuthURL(url string) bool {
64+ if url == "" {
65+ return false
66+ }
67+ lower := strings.ToLower(url)
68+
69+ // Check for OAuth flow patterns in URL path
70+ for _, pattern := range oauthFlowPatterns {
71+ if strings.Contains(lower, pattern) {
72+ return true
73+ }
74+ }
75+
76+ // Check for OAuth request parameters
77+ for _, pattern := range oauthRequestPatterns {
78+ if strings.Contains(lower, pattern) {
79+ return true
80+ }
81+ }
82+
83+ return false
84+}
85+
86+// IsOAuthCallback checks if the URL is an OAuth callback with response parameters.
87+// This indicates the OAuth flow has completed (successfully or with error).
88+func IsOAuthCallback(url string) bool {
89+ if url == "" {
90+ return false
91+ }
92+ lower := strings.ToLower(url)
93+
94+ // Must contain callback patterns (code=, access_token=, error=, etc.)
95+ for _, pattern := range oauthCallbackPatterns {
96+ if strings.Contains(lower, pattern) {
97+ return true
98+ }
99+ }
100+
101+ return false
102+}
103+
104+// ShouldAutoClose determines if a popup at this URL should auto-close.
105+// Returns true for OAuth callbacks that indicate flow completion.
106+//
107+// This is comprehensive detection that handles:
108+// - Success: code=, access_token=, id_token=
109+// - Errors: error=, error_description=
110+// - Various OAuth providers (Google, GitHub, Auth0, etc.)
111+func ShouldAutoClose(url string) bool {
112+ return IsOAuthCallback(url)
113+}
114+
115+// shouldForceCloseOnSafetyTimeout determines if a popup should be force-closed
116+// when the OAuth safety timeout is reached.
117+//
118+// Safety-timeout force close is disabled for stability. OAuth popups close on
119+// callback detection or manual user action.
120+func shouldForceCloseOnSafetyTimeout(url string) bool {
121+ _ = url
122+ return false
123+}
124+
125+// IsOAuthSuccess checks if the callback indicates successful authentication.
126+func IsOAuthSuccess(url string) bool {
127+ if url == "" {
128+ return false
129+ }
130+ lower := strings.ToLower(url)
131+
132+ // Check for success indicators
133+ successPatterns := []string{
134+ "code=",
135+ "access_token=",
136+ "id_token=",
137+ }
138+
139+ for _, pattern := range successPatterns {
140+ if strings.Contains(lower, pattern) {
141+ // Make sure it's not an error response
142+ if !strings.Contains(lower, "error=") {
143+ return true
144+ }
145+ }
146+ }
147+
148+ return false
149+}
150+
151+// IsOAuthError checks if the callback indicates an authentication error.
152+func IsOAuthError(url string) bool {
153+ if url == "" {
154+ return false
155+ }
156+ lower := strings.ToLower(url)
157+ return strings.Contains(lower, "error=")
158+}
159+
160+func composeOnClose(existing, next func()) func() {
161+ if existing == nil {
162+ return next
163+ }
164+ if next == nil {
165+ return existing
166+ }
167+ return func() {
168+ existing()
169+ next()
170+ }
171+}
172+
173+func composeOnURIChanged(existing, next func(string)) func(string) {
174+ if existing == nil {
175+ return next
176+ }
177+ if next == nil {
178+ return existing
179+ }
180+ return func(uri string) {
181+ existing(uri)
182+ next(uri)
183+ }
184+}
185+
186+func composeOnLoadChanged(
187+ existing func(port.LoadEvent),
188+ next func(port.LoadEvent),
189+) func(port.LoadEvent) {
190+ if existing == nil {
191+ return next
192+ }
193+ if next == nil {
194+ return existing
195+ }
196+ return func(event port.LoadEvent) {
197+ existing(event)
198+ next(event)
199+ }
200+}
201+
202+// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
203+// It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)
204+// For providers using postMessage (like Google Sign-In), we rely on the provider calling
205+// window.close() which triggers WebKit's close signal.
206+// A long safety timeout (30s) catches popups that get stuck.
207+func (c *Coordinator) setupOAuthAutoClose(
208+ ctx context.Context,
209+ paneID entity.PaneID,
210+ popupID port.WebViewID,
211+ wv *webkit.WebView,
212+) {
213+ log := logging.FromContext(ctx)
214+
215+ // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
216+ var safetyTimer *time.Timer
217+ var safetyTimerMu sync.Mutex
218+ var cancelSafetyTimerOnce sync.Once
219+ var requestCloseOnce sync.Once
220+ const oauthSafetyTimeout = 30 * time.Second
221+ const oauthCloseDelay = 500 * time.Millisecond
222+
223+ startSafetyTimer := func() {
224+ safetyTimerMu.Lock()
225+ defer safetyTimerMu.Unlock()
226+ if safetyTimer != nil {
227+ safetyTimer.Stop()
228+ }
229+ safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
230+ cb := glib.SourceFunc(func(_ uintptr) bool {
231+ if wv != nil && !wv.IsDestroyed() {
232+ uri := wv.URI()
233+ if shouldForceCloseOnSafetyTimeout(uri) {
234+ log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
235+ wv.Close()
236+ return false
237+ }
238+ log.Debug().
239+ Str("pane", string(paneID)).
240+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
241+ Msg("oauth safety timeout reached during active auth flow, skipping forced close")
242+ }
243+ return false
244+ })
245+ glib.IdleAdd(&cb, 0)
246+ })
247+ }
248+
249+ cancelSafetyTimer := func() {
250+ cancelSafetyTimerOnce.Do(func() {
251+ safetyTimerMu.Lock()
252+ defer safetyTimerMu.Unlock()
253+ if safetyTimer != nil {
254+ safetyTimer.Stop()
255+ safetyTimer = nil
256+ }
257+ })
258+ }
259+
260+ requestOAuthClose := func(uri string, reason string) {
261+ c.capturePopupOAuthState(popupID, uri)
262+ cancelSafetyTimer()
263+ log.Info().
264+ Str("pane", string(paneID)).
265+ Str("reason", reason).
266+ Msg("oauth callback detected, closing")
267+ requestCloseOnce.Do(func() {
268+ time.AfterFunc(oauthCloseDelay, func() {
269+ cb := glib.SourceFunc(func(_ uintptr) bool {
270+ if wv != nil && !wv.IsDestroyed() {
271+ wv.Close()
272+ }
273+ return false
274+ })
275+ glib.IdleAdd(&cb, 0)
276+ })
277+ })
278+ }
279+
280+ // Start safety timer immediately.
281+ startSafetyTimer()
282+
283+ // Wrap OnURIChanged to check for OAuth callbacks.
284+ wv.OnURIChanged = composeOnURIChanged(wv.OnURIChanged, func(uri string) {
285+ if ShouldAutoClose(uri) {
286+ requestOAuthClose(uri, "uri_changed")
287+ }
288+ })
289+
290+ // Monitor load events for URL-based detection.
291+ wv.OnLoadChanged = composeOnLoadChanged(wv.OnLoadChanged, func(event webkit.LoadEvent) {
292+ if event == webkit.LoadCommitted {
293+ uri := wv.URI()
294+ if ShouldAutoClose(uri) {
295+ requestOAuthClose(uri, "load_committed")
296+ }
297+ }
298+ })
299+
300+ // Cancel safety timer on any close path.
301+ wv.OnClose = composeOnClose(func() {
302+ cancelSafetyTimer()
303+ }, wv.OnClose)
304+}
305+
306+func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
307+ c.popupMu.Lock()
308+ defer c.popupMu.Unlock()
309+ if c.popupOAuth == nil {
310+ c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
311+ }
312+ c.popupOAuth[popupID] = &popupOAuthState{
313+ ParentPaneID: parentPaneID,
314+ }
315+}
316+
317+func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
318+ c.popupMu.Lock()
319+ defer c.popupMu.Unlock()
320+
321+ state, ok := c.popupOAuth[popupID]
322+ if !ok {
323+ return
324+ }
325+
326+ state.Seen = true
327+ state.CallbackURI = uri
328+ state.Success = IsOAuthSuccess(uri)
329+ state.Error = IsOAuthError(uri)
330+}
331+
332+func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
333+ log := logging.FromContext(ctx)
334+
335+ c.popupMu.Lock()
336+ state, ok := c.popupOAuth[popupID]
337+ if ok {
338+ delete(c.popupOAuth, popupID)
339+ }
340+ c.popupMu.Unlock()
341+
342+ if !ok || state == nil || !state.Seen {
343+ return
344+ }
345+
346+ log.Debug().
347+ Uint64("popup_id", uint64(popupID)).
348+ Str("parent_pane_id", string(state.ParentPaneID)).
349+ Bool("oauth_success", state.Success).
350+ Bool("oauth_error", state.Error).
351+ Msg("popup oauth result captured on close")
352+
353+ if !state.Success || state.ParentPaneID == "" {
354+ return
355+ }
356+
357+ c.scheduleParentPaneRefresh(ctx, state.ParentPaneID, popupID)
358+}
359+
360+func (c *Coordinator) scheduleParentPaneRefresh(
361+ ctx context.Context,
362+ parentPaneID entity.PaneID,
363+ popupID port.WebViewID,
364+) {
365+ c.popupMu.Lock()
366+ if c.popupRefresh == nil {
367+ c.popupRefresh = make(map[entity.PaneID]*time.Timer)
368+ }
369+ if existing := c.popupRefresh[parentPaneID]; existing != nil {
370+ existing.Stop()
371+ }
372+ c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
373+ c.popupMu.Lock()
374+ delete(c.popupRefresh, parentPaneID)
375+ c.popupMu.Unlock()
376+ cb := glib.SourceFunc(func(_ uintptr) bool {
377+ c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
378+ return false
379+ })
380+ glib.IdleAdd(&cb, 0)
381+ })
382+ c.popupMu.Unlock()
383+}
384+
385+func (c *Coordinator) refreshPaneAfterOAuth(
386+ ctx context.Context,
387+ parentPaneID entity.PaneID,
388+ popupID port.WebViewID,
389+) {
390+ log := logging.FromContext(ctx)
391+ wv := c.getWebViewLocked(parentPaneID)
392+ if wv == nil || wv.IsDestroyed() {
393+ log.Debug().
394+ Str("parent_pane_id", string(parentPaneID)).
395+ Uint64("popup_id", uint64(popupID)).
396+ Msg("skipping parent pane refresh after oauth close: parent webview unavailable")
397+ return
398+ }
399+
400+ if err := wv.Reload(ctx); err != nil {
401+ log.Warn().
402+ Err(err).
403+ Str("parent_pane_id", string(parentPaneID)).
404+ Uint64("popup_id", uint64(popupID)).
405+ Msg("failed parent pane refresh after oauth popup close")
406+ return
407+ }
408+
409+ log.Info().
410+ Str("parent_pane_id", string(parentPaneID)).
411+ Uint64("popup_id", uint64(popupID)).
412+ Msg("refreshed parent pane after oauth popup success")
413+}
R internal/ui/coordinator/oauth_test.go → internal/ui/coordinator/content/oauth_test.go +3 -3 renamed, 98% similar
@@ -1,4 +1,4 @@
1-package coordinator
1+package content
22
33 import (
44 "context"
@@ -52,7 +52,7 @@ func TestHandlePopupOAuthClose_SuccessSchedulesParentRefresh(t *testing.T) {
5252 parentPaneID := entity.PaneID("parent-pane")
5353 popupID := port.WebViewID(101)
5454
55- c := &ContentCoordinator{
55+ c := &Coordinator{
5656 webViews: make(map[entity.PaneID]*webkit.WebView),
5757 popupOAuth: make(map[port.WebViewID]*popupOAuthState),
5858 popupRefresh: make(map[entity.PaneID]*time.Timer),
@@ -81,7 +81,7 @@ func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) {
8181 parentPaneID := entity.PaneID("parent-pane")
8282 popupID := port.WebViewID(102)
8383
84- c := &ContentCoordinator{
84+ c := &Coordinator{
8585 webViews: make(map[entity.PaneID]*webkit.WebView),
8686 popupOAuth: make(map[port.WebViewID]*popupOAuthState),
8787 popupRefresh: make(map[entity.PaneID]*time.Timer),
@@ -1,4 +1,4 @@
1-package coordinator1+package content
2 2
3 import (3 import (
4 "context"4 "context"
@@ -52,7 +52,7 @@ func TestHandlePopupOAuthClose_SuccessSchedulesParentRefresh(t *testing.T) {
52 parentPaneID := entity.PaneID("parent-pane")52 parentPaneID := entity.PaneID("parent-pane")
53 popupID := port.WebViewID(101)53 popupID := port.WebViewID(101)
54 54
55- c := &ContentCoordinator{55+ c := &Coordinator{
56 webViews: make(map[entity.PaneID]*webkit.WebView),56 webViews: make(map[entity.PaneID]*webkit.WebView),
57 popupOAuth: make(map[port.WebViewID]*popupOAuthState),57 popupOAuth: make(map[port.WebViewID]*popupOAuthState),
58 popupRefresh: make(map[entity.PaneID]*time.Timer),58 popupRefresh: make(map[entity.PaneID]*time.Timer),
@@ -81,7 +81,7 @@ func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) {
81 parentPaneID := entity.PaneID("parent-pane")81 parentPaneID := entity.PaneID("parent-pane")
82 popupID := port.WebViewID(102)82 popupID := port.WebViewID(102)
83 83
84- c := &ContentCoordinator{84+ c := &Coordinator{
85 webViews: make(map[entity.PaneID]*webkit.WebView),85 webViews: make(map[entity.PaneID]*webkit.WebView),
86 popupOAuth: make(map[port.WebViewID]*popupOAuthState),86 popupOAuth: make(map[port.WebViewID]*popupOAuthState),
87 popupRefresh: make(map[entity.PaneID]*time.Timer),87 popupRefresh: make(map[entity.PaneID]*time.Timer),
A internal/ui/coordinator/content/popup.go +504 -0
@@ -0,0 +1,504 @@
1+package content
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// PopupType indicates whether the popup was triggered by a link or JavaScript.
16+type PopupType int
17+
18+const (
19+ // PopupTypeTab represents a popup from target="_blank" links.
20+ // These are typically user-initiated and open like new tabs.
21+ PopupTypeTab PopupType = iota
22+ // PopupTypePopup represents a popup from window.open() JavaScript calls.
23+ // These may have different placement behavior than tab-like popups.
24+ PopupTypePopup
25+)
26+
27+// String returns a human-readable name for the popup type.
28+func (t PopupType) String() string {
29+ switch t {
30+ case PopupTypeTab:
31+ return "tab"
32+ case PopupTypePopup:
33+ return "popup"
34+ default:
35+ return "unknown"
36+ }
37+}
38+
39+// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
40+func DetectPopupType(frameName string) PopupType {
41+ // _blank is the standard indicator for "open in new tab/window"
42+ if frameName == "_blank" {
43+ return PopupTypeTab
44+ }
45+ // Empty or custom frame names indicate JS window.open()
46+ return PopupTypePopup
47+}
48+
49+// PendingPopup tracks a popup WebView that has been created but not yet
50+// inserted into the workspace. This is used during the three-phase popup
51+// lifecycle: create → ready-to-show → (insert into workspace).
52+type PendingPopup struct {
53+ // WebView is the related WebView created in the "create" phase.
54+ // It shares cookies/session with the parent for OAuth support.
55+ WebView port.WebView
56+
57+ // ParentPaneID is the pane that spawned this popup.
58+ ParentPaneID entity.PaneID
59+
60+ // ParentWebViewID is the WebView ID of the parent (for lookup).
61+ ParentWebViewID port.WebViewID
62+
63+ // TargetURI is the initial URL the popup will load.
64+ TargetURI string
65+
66+ // FrameName is the target frame name from the navigation action.
67+ // "_blank" indicates a tab-like popup, other values indicate JS popups.
68+ FrameName string
69+
70+ // IsUserGesture indicates if the popup was triggered by user action
71+ // (click) vs script-initiated (e.g., popup after timeout).
72+ IsUserGesture bool
73+
74+ // PopupType categorizes this as a tab-like or JS popup.
75+ PopupType PopupType
76+
77+ // CreatedAt is when the popup was created (for timeout handling).
78+ CreatedAt time.Time
79+}
80+
81+// InsertPopupInput contains the data needed to insert a popup into the workspace.
82+type InsertPopupInput struct {
83+ // ParentPaneID is the pane that spawned this popup.
84+ ParentPaneID entity.PaneID
85+
86+ // PopupPane is the pre-created pane entity for the popup.
87+ // It should have IsRelated=true and ParentPaneID set.
88+ PopupPane *entity.Pane
89+
90+ // WebView is the related WebView for the popup.
91+ WebView port.WebView
92+
93+ // Behavior determines how the popup is inserted (split/stacked/tabbed).
94+ Behavior config.PopupBehavior
95+
96+ // Placement specifies direction for split behavior (right/left/top/bottom).
97+ Placement string
98+
99+ // PopupType indicates if this is a tab-like or JS popup.
100+ PopupType PopupType
101+
102+ // TargetURI is the URL to load in the popup.
103+ TargetURI string
104+}
105+
106+// GetBehavior returns the appropriate behavior based on popup type and config.
107+func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {
108+ if cfg == nil {
109+ return config.PopupBehaviorSplit // Default
110+ }
111+
112+ switch popupType {
113+ case PopupTypeTab:
114+ // Tab-like popups (_blank) use blank_target_behavior
115+ switch cfg.BlankTargetBehavior {
116+ case "split":
117+ return config.PopupBehaviorSplit
118+ case "stacked":
119+ return config.PopupBehaviorStacked
120+ case "tabbed":
121+ return config.PopupBehaviorTabbed
122+ default:
123+ return config.PopupBehaviorStacked // Default for _blank
124+ }
125+ case PopupTypePopup:
126+ // JS popups use the main behavior setting
127+ return cfg.Behavior
128+ default:
129+ return cfg.Behavior
130+ }
131+}
132+
133+// SetPopupConfig configures popup handling.
134+func (c *Coordinator) SetPopupConfig(
135+ factory *webkit.WebViewFactory,
136+ popupConfig *config.PopupBehaviorConfig,
137+ generateID func() string,
138+) {
139+ c.factory = factory
140+ c.popupConfig = popupConfig
141+ c.generateID = generateID
142+}
143+
144+// SetOnInsertPopup sets the callback to insert popups into the workspace.
145+func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
146+ c.onInsertPopup = fn
147+}
148+
149+// SetOnClosePane sets the callback to close a pane when its popup closes.
150+func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
151+ c.onClosePane = fn
152+}
153+
154+// setupPopupHandling wires the popup create signal for a WebView.
155+// This should be called after acquiring a WebView for a pane.
156+func (c *Coordinator) setupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
157+ log := logging.FromContext(ctx)
158+
159+ if wv == nil {
160+ return
161+ }
162+
163+ // Wire the OnCreate callback for popup requests
164+ wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
165+ return c.handlePopupCreate(ctx, paneID, wv, req)
166+ }
167+
168+ log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
169+}
170+
171+// createPopupPane creates a new pane entity for a popup window.
172+func (c *Coordinator) createPopupPane(
173+ popupID port.WebViewID,
174+ parentPaneID entity.PaneID,
175+ targetURI string,
176+) (entity.PaneID, *entity.Pane) {
177+ var paneID entity.PaneID
178+ if c.generateID != nil {
179+ paneID = entity.PaneID(c.generateID())
180+ } else {
181+ paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
182+ }
183+
184+ popupPane := entity.NewPane(paneID)
185+ popupPane.WindowType = entity.WindowPopup
186+ popupPane.IsRelated = true
187+ popupPane.ParentPaneID = &parentPaneID
188+ popupPane.URI = targetURI
189+
190+ return paneID, popupPane
191+}
192+
193+// handlePopupCreate handles the WebKit "create" signal for popup windows.
194+// Returns a new related WebView if popup is allowed, nil to block.
195+//
196+// IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
197+// signal handler returns. Otherwise WebKit won't establish window.opener.
198+// The WebView stays hidden until ready-to-show signal is received.
199+func (c *Coordinator) handlePopupCreate(
200+ ctx context.Context,
201+ parentPaneID entity.PaneID,
202+ parentWV *webkit.WebView,
203+ req webkit.PopupRequest,
204+) *webkit.WebView {
205+ log := logging.FromContext(ctx)
206+
207+ log.Debug().
208+ Str("parent_pane", string(parentPaneID)).
209+ Str("target_uri", req.TargetURI).
210+ Str("frame_name", req.FrameName).
211+ Bool("user_gesture", req.IsUserGesture).
212+ Msg("popup create request")
213+
214+ // Check if popups are enabled
215+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
216+ log.Debug().Msg("popups disabled by config, blocking")
217+ return nil
218+ }
219+
220+ // Check if factory is available
221+ if c.factory == nil {
222+ log.Warn().Msg("no webview factory, cannot create popup")
223+ return nil
224+ }
225+
226+ // Create related WebView for session sharing (created hidden)
227+ parentID := parentWV.ID()
228+ popupWV, err := c.factory.CreateRelated(ctx, parentID)
229+ if err != nil {
230+ log.Error().Err(err).Msg("failed to create related webview for popup")
231+ return nil
232+ }
233+
234+ // Detect popup type
235+ popupType := DetectPopupType(req.FrameName)
236+ popupID := popupWV.ID()
237+
238+ // Determine behavior from config
239+ behavior := GetBehavior(popupType, c.popupConfig)
240+ placement := "right"
241+ if c.popupConfig != nil {
242+ placement = c.popupConfig.Placement
243+ }
244+
245+ // Create popup pane entity
246+ paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
247+
248+ // Check OAuth configuration
249+ hasConfig := c.popupConfig != nil
250+ oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
251+ isOAuth := IsOAuthURL(req.TargetURI)
252+ log.Debug().
253+ Bool("has_config", hasConfig).
254+ Bool("oauth_enabled", oauthEnabled).
255+ Bool("is_oauth", isOAuth).
256+ Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
257+ Msg("popup OAuth check")
258+
259+ // Insert into workspace IMMEDIATELY (WebView stays hidden)
260+ // This is required for WebKit to establish window.opener relationship
261+ if c.onInsertPopup != nil {
262+ popupInput := InsertPopupInput{
263+ ParentPaneID: parentPaneID,
264+ PopupPane: popupPane,
265+ WebView: popupWV,
266+ Behavior: behavior,
267+ Placement: placement,
268+ PopupType: popupType,
269+ TargetURI: req.TargetURI,
270+ }
271+
272+ if err := c.onInsertPopup(ctx, popupInput); err != nil {
273+ log.Error().Err(err).Msg("failed to insert popup into workspace")
274+ popupWV.Destroy()
275+ return nil
276+ }
277+ }
278+
279+ // Register WebView in our map (after successful insertion)
280+ c.setWebViewLocked(paneID, popupWV)
281+
282+ // Setup standard callbacks (after successful insertion to avoid leak)
283+ c.setupWebViewCallbacks(ctx, paneID, popupWV)
284+
285+ // Setup OAuth auto-close if configured
286+ if hasConfig && oauthEnabled && isOAuth {
287+ popupPane.AutoClose = true
288+ c.trackOAuthPopup(popupID, parentPaneID)
289+ c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
290+ log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
291+ }
292+
293+ // Store pending popup for ready-to-show handling (just visibility now)
294+ pending := &PendingPopup{
295+ WebView: popupWV,
296+ ParentPaneID: parentPaneID,
297+ ParentWebViewID: parentID,
298+ TargetURI: req.TargetURI,
299+ FrameName: req.FrameName,
300+ IsUserGesture: req.IsUserGesture,
301+ PopupType: popupType,
302+ CreatedAt: time.Now(),
303+ }
304+
305+ c.popupMu.Lock()
306+ c.pendingPopups[popupID] = pending
307+ c.popupMu.Unlock()
308+
309+ // Wire ready-to-show (now just for visibility) and close signals
310+ popupWV.OnReadyToShow = func() {
311+ c.handlePopupReadyToShow(ctx, popupID)
312+ }
313+ popupWV.OnClose = composeOnClose(popupWV.OnClose, func() {
314+ c.handlePopupClose(ctx, popupID)
315+ })
316+
317+ log.Info().
318+ Uint64("popup_id", uint64(popupID)).
319+ Str("pane_id", string(paneID)).
320+ Str("popup_type", popupType.String()).
321+ Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
322+ Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
323+
324+ return popupWV
325+}
326+
327+// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
328+// The popup WebView was already inserted into the workspace (hidden) during
329+// the create signal. This handler just makes it visible.
330+func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
331+ log := logging.FromContext(ctx)
332+
333+ // Get pending popup
334+ c.popupMu.Lock()
335+ pending, ok := c.pendingPopups[popupID]
336+ if ok {
337+ delete(c.pendingPopups, popupID)
338+ }
339+ c.popupMu.Unlock()
340+
341+ if !ok || pending == nil {
342+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
343+ return
344+ }
345+
346+ log.Debug().
347+ Uint64("popup_id", uint64(popupID)).
348+ Str("popup_type", pending.PopupType.String()).
349+ Msg("popup ready to show - making visible")
350+
351+ // Make the WebView visible now that it's ready
352+ if pending.WebView != nil {
353+ pending.WebView.Show()
354+ }
355+
356+ log.Info().
357+ Uint64("popup_id", uint64(popupID)).
358+ Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
359+ Msg("popup now visible")
360+}
361+
362+// handlePopupClose handles the WebKit "close" signal for popup windows.
363+func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
364+ log := logging.FromContext(ctx)
365+ log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
366+
367+ // Check if still pending (never shown)
368+ c.popupMu.Lock()
369+ pending, wasPending := c.pendingPopups[popupID]
370+ if wasPending {
371+ delete(c.pendingPopups, popupID)
372+ }
373+ c.popupMu.Unlock()
374+
375+ if wasPending && pending != nil {
376+ c.handlePopupOAuthClose(ctx, popupID)
377+ // Was never shown, just destroy
378+ pending.WebView.Destroy()
379+ log.Debug().Msg("destroyed pending popup that was never shown")
380+ return
381+ }
382+
383+ // Find pane by WebView ID
384+ var paneID entity.PaneID
385+ for pid, wv := range c.webViews {
386+ if wv != nil && wv.ID() == popupID {
387+ paneID = pid
388+ break
389+ }
390+ }
391+
392+ if paneID == "" {
393+ c.handlePopupOAuthClose(ctx, popupID)
394+ log.Warn().Msg("popup close: could not find pane for webview")
395+ return
396+ }
397+
398+ // Close the pane in workspace (this removes the UI element)
399+ if c.onClosePane != nil {
400+ if err := c.onClosePane(ctx, paneID); err != nil {
401+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
402+ }
403+ }
404+
405+ c.handlePopupOAuthClose(ctx, popupID)
406+
407+ // Release the WebView (this will clean up tracking)
408+ c.ReleaseWebView(ctx, paneID)
409+
410+ log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
411+}
412+
413+// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
414+// Opens the link in a new pane using blank_target_behavior config.
415+func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
416+ log := logging.FromContext(ctx)
417+
418+ log.Info().
419+ Str("parent_pane", string(parentPaneID)).
420+ Str("uri", uri).
421+ Msg("middle-click/ctrl+click on link")
422+
423+ // Check if popups are enabled
424+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
425+ log.Debug().Msg("popups disabled by config, ignoring middle-click")
426+ return false
427+ }
428+
429+ // Check if factory is available
430+ if c.factory == nil {
431+ log.Warn().Msg("no webview factory, cannot handle middle-click")
432+ return false
433+ }
434+
435+ // Create a new WebView (regular, not related - just opening a link)
436+ newWV, err := c.factory.Create(ctx)
437+ if err != nil {
438+ log.Error().Err(err).Msg("failed to create webview for middle-click")
439+ return false
440+ }
441+
442+ // Generate pane ID
443+ var paneID entity.PaneID
444+ if c.generateID != nil {
445+ paneID = entity.PaneID(c.generateID())
446+ } else {
447+ paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
448+ }
449+
450+ // Create pane entity - uses blank_target_behavior
451+ newPane := entity.NewPane(paneID)
452+ newPane.WindowType = entity.WindowPopup
453+ newPane.URI = uri
454+
455+ // Register WebView
456+ c.setWebViewLocked(paneID, newWV)
457+
458+ // Setup callbacks
459+ c.setupWebViewCallbacks(ctx, paneID, newWV)
460+
461+ // Get behavior from config (same as _blank links)
462+ behavior := config.PopupBehaviorStacked // default
463+ if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
464+ behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
465+ }
466+ placement := "right"
467+ if c.popupConfig != nil {
468+ placement = c.popupConfig.Placement
469+ }
470+
471+ // Request insertion into workspace
472+ if c.onInsertPopup != nil {
473+ popupInput := InsertPopupInput{
474+ ParentPaneID: parentPaneID,
475+ PopupPane: newPane,
476+ WebView: newWV,
477+ Behavior: behavior,
478+ Placement: placement,
479+ PopupType: PopupTypeTab, // Treat like _blank
480+ TargetURI: uri,
481+ }
482+
483+ if err := c.onInsertPopup(ctx, popupInput); err != nil {
484+ log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
485+ // Clean up on failure
486+ delete(c.webViews, paneID)
487+ newWV.Destroy()
488+ return false
489+ }
490+ }
491+
492+ // Load the URI after insertion
493+ if err := newWV.LoadURI(ctx, uri); err != nil {
494+ log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
495+ }
496+
497+ log.Info().
498+ Str("pane_id", string(paneID)).
499+ Str("behavior", string(behavior)).
500+ Str("uri", uri).
501+ Msg("middle-click link opened in new pane")
502+
503+ return true
504+}
@@ -0,0 +1,504 @@
1+package content
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// PopupType indicates whether the popup was triggered by a link or JavaScript.
16+type PopupType int
17+
18+const (
19+ // PopupTypeTab represents a popup from target="_blank" links.
20+ // These are typically user-initiated and open like new tabs.
21+ PopupTypeTab PopupType = iota
22+ // PopupTypePopup represents a popup from window.open() JavaScript calls.
23+ // These may have different placement behavior than tab-like popups.
24+ PopupTypePopup
25+)
26+
27+// String returns a human-readable name for the popup type.
28+func (t PopupType) String() string {
29+ switch t {
30+ case PopupTypeTab:
31+ return "tab"
32+ case PopupTypePopup:
33+ return "popup"
34+ default:
35+ return "unknown"
36+ }
37+}
38+
39+// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
40+func DetectPopupType(frameName string) PopupType {
41+ // _blank is the standard indicator for "open in new tab/window"
42+ if frameName == "_blank" {
43+ return PopupTypeTab
44+ }
45+ // Empty or custom frame names indicate JS window.open()
46+ return PopupTypePopup
47+}
48+
49+// PendingPopup tracks a popup WebView that has been created but not yet
50+// inserted into the workspace. This is used during the three-phase popup
51+// lifecycle: create → ready-to-show → (insert into workspace).
52+type PendingPopup struct {
53+ // WebView is the related WebView created in the "create" phase.
54+ // It shares cookies/session with the parent for OAuth support.
55+ WebView port.WebView
56+
57+ // ParentPaneID is the pane that spawned this popup.
58+ ParentPaneID entity.PaneID
59+
60+ // ParentWebViewID is the WebView ID of the parent (for lookup).
61+ ParentWebViewID port.WebViewID
62+
63+ // TargetURI is the initial URL the popup will load.
64+ TargetURI string
65+
66+ // FrameName is the target frame name from the navigation action.
67+ // "_blank" indicates a tab-like popup, other values indicate JS popups.
68+ FrameName string
69+
70+ // IsUserGesture indicates if the popup was triggered by user action
71+ // (click) vs script-initiated (e.g., popup after timeout).
72+ IsUserGesture bool
73+
74+ // PopupType categorizes this as a tab-like or JS popup.
75+ PopupType PopupType
76+
77+ // CreatedAt is when the popup was created (for timeout handling).
78+ CreatedAt time.Time
79+}
80+
81+// InsertPopupInput contains the data needed to insert a popup into the workspace.
82+type InsertPopupInput struct {
83+ // ParentPaneID is the pane that spawned this popup.
84+ ParentPaneID entity.PaneID
85+
86+ // PopupPane is the pre-created pane entity for the popup.
87+ // It should have IsRelated=true and ParentPaneID set.
88+ PopupPane *entity.Pane
89+
90+ // WebView is the related WebView for the popup.
91+ WebView port.WebView
92+
93+ // Behavior determines how the popup is inserted (split/stacked/tabbed).
94+ Behavior config.PopupBehavior
95+
96+ // Placement specifies direction for split behavior (right/left/top/bottom).
97+ Placement string
98+
99+ // PopupType indicates if this is a tab-like or JS popup.
100+ PopupType PopupType
101+
102+ // TargetURI is the URL to load in the popup.
103+ TargetURI string
104+}
105+
106+// GetBehavior returns the appropriate behavior based on popup type and config.
107+func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {
108+ if cfg == nil {
109+ return config.PopupBehaviorSplit // Default
110+ }
111+
112+ switch popupType {
113+ case PopupTypeTab:
114+ // Tab-like popups (_blank) use blank_target_behavior
115+ switch cfg.BlankTargetBehavior {
116+ case "split":
117+ return config.PopupBehaviorSplit
118+ case "stacked":
119+ return config.PopupBehaviorStacked
120+ case "tabbed":
121+ return config.PopupBehaviorTabbed
122+ default:
123+ return config.PopupBehaviorStacked // Default for _blank
124+ }
125+ case PopupTypePopup:
126+ // JS popups use the main behavior setting
127+ return cfg.Behavior
128+ default:
129+ return cfg.Behavior
130+ }
131+}
132+
133+// SetPopupConfig configures popup handling.
134+func (c *Coordinator) SetPopupConfig(
135+ factory *webkit.WebViewFactory,
136+ popupConfig *config.PopupBehaviorConfig,
137+ generateID func() string,
138+) {
139+ c.factory = factory
140+ c.popupConfig = popupConfig
141+ c.generateID = generateID
142+}
143+
144+// SetOnInsertPopup sets the callback to insert popups into the workspace.
145+func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
146+ c.onInsertPopup = fn
147+}
148+
149+// SetOnClosePane sets the callback to close a pane when its popup closes.
150+func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
151+ c.onClosePane = fn
152+}
153+
154+// setupPopupHandling wires the popup create signal for a WebView.
155+// This should be called after acquiring a WebView for a pane.
156+func (c *Coordinator) setupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
157+ log := logging.FromContext(ctx)
158+
159+ if wv == nil {
160+ return
161+ }
162+
163+ // Wire the OnCreate callback for popup requests
164+ wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
165+ return c.handlePopupCreate(ctx, paneID, wv, req)
166+ }
167+
168+ log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
169+}
170+
171+// createPopupPane creates a new pane entity for a popup window.
172+func (c *Coordinator) createPopupPane(
173+ popupID port.WebViewID,
174+ parentPaneID entity.PaneID,
175+ targetURI string,
176+) (entity.PaneID, *entity.Pane) {
177+ var paneID entity.PaneID
178+ if c.generateID != nil {
179+ paneID = entity.PaneID(c.generateID())
180+ } else {
181+ paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
182+ }
183+
184+ popupPane := entity.NewPane(paneID)
185+ popupPane.WindowType = entity.WindowPopup
186+ popupPane.IsRelated = true
187+ popupPane.ParentPaneID = &parentPaneID
188+ popupPane.URI = targetURI
189+
190+ return paneID, popupPane
191+}
192+
193+// handlePopupCreate handles the WebKit "create" signal for popup windows.
194+// Returns a new related WebView if popup is allowed, nil to block.
195+//
196+// IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
197+// signal handler returns. Otherwise WebKit won't establish window.opener.
198+// The WebView stays hidden until ready-to-show signal is received.
199+func (c *Coordinator) handlePopupCreate(
200+ ctx context.Context,
201+ parentPaneID entity.PaneID,
202+ parentWV *webkit.WebView,
203+ req webkit.PopupRequest,
204+) *webkit.WebView {
205+ log := logging.FromContext(ctx)
206+
207+ log.Debug().
208+ Str("parent_pane", string(parentPaneID)).
209+ Str("target_uri", req.TargetURI).
210+ Str("frame_name", req.FrameName).
211+ Bool("user_gesture", req.IsUserGesture).
212+ Msg("popup create request")
213+
214+ // Check if popups are enabled
215+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
216+ log.Debug().Msg("popups disabled by config, blocking")
217+ return nil
218+ }
219+
220+ // Check if factory is available
221+ if c.factory == nil {
222+ log.Warn().Msg("no webview factory, cannot create popup")
223+ return nil
224+ }
225+
226+ // Create related WebView for session sharing (created hidden)
227+ parentID := parentWV.ID()
228+ popupWV, err := c.factory.CreateRelated(ctx, parentID)
229+ if err != nil {
230+ log.Error().Err(err).Msg("failed to create related webview for popup")
231+ return nil
232+ }
233+
234+ // Detect popup type
235+ popupType := DetectPopupType(req.FrameName)
236+ popupID := popupWV.ID()
237+
238+ // Determine behavior from config
239+ behavior := GetBehavior(popupType, c.popupConfig)
240+ placement := "right"
241+ if c.popupConfig != nil {
242+ placement = c.popupConfig.Placement
243+ }
244+
245+ // Create popup pane entity
246+ paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
247+
248+ // Check OAuth configuration
249+ hasConfig := c.popupConfig != nil
250+ oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
251+ isOAuth := IsOAuthURL(req.TargetURI)
252+ log.Debug().
253+ Bool("has_config", hasConfig).
254+ Bool("oauth_enabled", oauthEnabled).
255+ Bool("is_oauth", isOAuth).
256+ Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
257+ Msg("popup OAuth check")
258+
259+ // Insert into workspace IMMEDIATELY (WebView stays hidden)
260+ // This is required for WebKit to establish window.opener relationship
261+ if c.onInsertPopup != nil {
262+ popupInput := InsertPopupInput{
263+ ParentPaneID: parentPaneID,
264+ PopupPane: popupPane,
265+ WebView: popupWV,
266+ Behavior: behavior,
267+ Placement: placement,
268+ PopupType: popupType,
269+ TargetURI: req.TargetURI,
270+ }
271+
272+ if err := c.onInsertPopup(ctx, popupInput); err != nil {
273+ log.Error().Err(err).Msg("failed to insert popup into workspace")
274+ popupWV.Destroy()
275+ return nil
276+ }
277+ }
278+
279+ // Register WebView in our map (after successful insertion)
280+ c.setWebViewLocked(paneID, popupWV)
281+
282+ // Setup standard callbacks (after successful insertion to avoid leak)
283+ c.setupWebViewCallbacks(ctx, paneID, popupWV)
284+
285+ // Setup OAuth auto-close if configured
286+ if hasConfig && oauthEnabled && isOAuth {
287+ popupPane.AutoClose = true
288+ c.trackOAuthPopup(popupID, parentPaneID)
289+ c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
290+ log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
291+ }
292+
293+ // Store pending popup for ready-to-show handling (just visibility now)
294+ pending := &PendingPopup{
295+ WebView: popupWV,
296+ ParentPaneID: parentPaneID,
297+ ParentWebViewID: parentID,
298+ TargetURI: req.TargetURI,
299+ FrameName: req.FrameName,
300+ IsUserGesture: req.IsUserGesture,
301+ PopupType: popupType,
302+ CreatedAt: time.Now(),
303+ }
304+
305+ c.popupMu.Lock()
306+ c.pendingPopups[popupID] = pending
307+ c.popupMu.Unlock()
308+
309+ // Wire ready-to-show (now just for visibility) and close signals
310+ popupWV.OnReadyToShow = func() {
311+ c.handlePopupReadyToShow(ctx, popupID)
312+ }
313+ popupWV.OnClose = composeOnClose(popupWV.OnClose, func() {
314+ c.handlePopupClose(ctx, popupID)
315+ })
316+
317+ log.Info().
318+ Uint64("popup_id", uint64(popupID)).
319+ Str("pane_id", string(paneID)).
320+ Str("popup_type", popupType.String()).
321+ Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
322+ Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
323+
324+ return popupWV
325+}
326+
327+// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
328+// The popup WebView was already inserted into the workspace (hidden) during
329+// the create signal. This handler just makes it visible.
330+func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
331+ log := logging.FromContext(ctx)
332+
333+ // Get pending popup
334+ c.popupMu.Lock()
335+ pending, ok := c.pendingPopups[popupID]
336+ if ok {
337+ delete(c.pendingPopups, popupID)
338+ }
339+ c.popupMu.Unlock()
340+
341+ if !ok || pending == nil {
342+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
343+ return
344+ }
345+
346+ log.Debug().
347+ Uint64("popup_id", uint64(popupID)).
348+ Str("popup_type", pending.PopupType.String()).
349+ Msg("popup ready to show - making visible")
350+
351+ // Make the WebView visible now that it's ready
352+ if pending.WebView != nil {
353+ pending.WebView.Show()
354+ }
355+
356+ log.Info().
357+ Uint64("popup_id", uint64(popupID)).
358+ Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
359+ Msg("popup now visible")
360+}
361+
362+// handlePopupClose handles the WebKit "close" signal for popup windows.
363+func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
364+ log := logging.FromContext(ctx)
365+ log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
366+
367+ // Check if still pending (never shown)
368+ c.popupMu.Lock()
369+ pending, wasPending := c.pendingPopups[popupID]
370+ if wasPending {
371+ delete(c.pendingPopups, popupID)
372+ }
373+ c.popupMu.Unlock()
374+
375+ if wasPending && pending != nil {
376+ c.handlePopupOAuthClose(ctx, popupID)
377+ // Was never shown, just destroy
378+ pending.WebView.Destroy()
379+ log.Debug().Msg("destroyed pending popup that was never shown")
380+ return
381+ }
382+
383+ // Find pane by WebView ID
384+ var paneID entity.PaneID
385+ for pid, wv := range c.webViews {
386+ if wv != nil && wv.ID() == popupID {
387+ paneID = pid
388+ break
389+ }
390+ }
391+
392+ if paneID == "" {
393+ c.handlePopupOAuthClose(ctx, popupID)
394+ log.Warn().Msg("popup close: could not find pane for webview")
395+ return
396+ }
397+
398+ // Close the pane in workspace (this removes the UI element)
399+ if c.onClosePane != nil {
400+ if err := c.onClosePane(ctx, paneID); err != nil {
401+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
402+ }
403+ }
404+
405+ c.handlePopupOAuthClose(ctx, popupID)
406+
407+ // Release the WebView (this will clean up tracking)
408+ c.ReleaseWebView(ctx, paneID)
409+
410+ log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
411+}
412+
413+// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
414+// Opens the link in a new pane using blank_target_behavior config.
415+func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
416+ log := logging.FromContext(ctx)
417+
418+ log.Info().
419+ Str("parent_pane", string(parentPaneID)).
420+ Str("uri", uri).
421+ Msg("middle-click/ctrl+click on link")
422+
423+ // Check if popups are enabled
424+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
425+ log.Debug().Msg("popups disabled by config, ignoring middle-click")
426+ return false
427+ }
428+
429+ // Check if factory is available
430+ if c.factory == nil {
431+ log.Warn().Msg("no webview factory, cannot handle middle-click")
432+ return false
433+ }
434+
435+ // Create a new WebView (regular, not related - just opening a link)
436+ newWV, err := c.factory.Create(ctx)
437+ if err != nil {
438+ log.Error().Err(err).Msg("failed to create webview for middle-click")
439+ return false
440+ }
441+
442+ // Generate pane ID
443+ var paneID entity.PaneID
444+ if c.generateID != nil {
445+ paneID = entity.PaneID(c.generateID())
446+ } else {
447+ paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
448+ }
449+
450+ // Create pane entity - uses blank_target_behavior
451+ newPane := entity.NewPane(paneID)
452+ newPane.WindowType = entity.WindowPopup
453+ newPane.URI = uri
454+
455+ // Register WebView
456+ c.setWebViewLocked(paneID, newWV)
457+
458+ // Setup callbacks
459+ c.setupWebViewCallbacks(ctx, paneID, newWV)
460+
461+ // Get behavior from config (same as _blank links)
462+ behavior := config.PopupBehaviorStacked // default
463+ if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
464+ behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
465+ }
466+ placement := "right"
467+ if c.popupConfig != nil {
468+ placement = c.popupConfig.Placement
469+ }
470+
471+ // Request insertion into workspace
472+ if c.onInsertPopup != nil {
473+ popupInput := InsertPopupInput{
474+ ParentPaneID: parentPaneID,
475+ PopupPane: newPane,
476+ WebView: newWV,
477+ Behavior: behavior,
478+ Placement: placement,
479+ PopupType: PopupTypeTab, // Treat like _blank
480+ TargetURI: uri,
481+ }
482+
483+ if err := c.onInsertPopup(ctx, popupInput); err != nil {
484+ log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
485+ // Clean up on failure
486+ delete(c.webViews, paneID)
487+ newWV.Destroy()
488+ return false
489+ }
490+ }
491+
492+ // Load the URI after insertion
493+ if err := newWV.LoadURI(ctx, uri); err != nil {
494+ log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
495+ }
496+
497+ log.Info().
498+ Str("pane_id", string(paneID)).
499+ Str("behavior", string(behavior)).
500+ Str("uri", uri).
501+ Msg("middle-click link opened in new pane")
502+
503+ return true
504+}
A internal/ui/coordinator/content/title.go +316 -0
@@ -0,0 +1,316 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/webkit"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/adapter"
11+ "github.com/bnema/dumber/internal/ui/layout"
12+ "github.com/jwijenbergh/puregotk/v4/gdk"
13+ "github.com/jwijenbergh/puregotk/v4/glib"
14+)
15+
16+// GetTitle returns the current title for a pane.
17+func (c *Coordinator) GetTitle(paneID entity.PaneID) string {
18+ c.titleMu.RLock()
19+ defer c.titleMu.RUnlock()
20+ return c.paneTitles[paneID]
21+}
22+
23+// onTitleChanged updates title tracking when a WebView's title changes.
24+func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
25+ log := logging.FromContext(ctx)
26+
27+ // Update title map
28+ c.titleMu.Lock()
29+ c.paneTitles[paneID] = title
30+ c.titleMu.Unlock()
31+
32+ // Update domain model and check if this is the active pane
33+ isActivePaneTitle := false
34+ ws, wsView := c.getActiveWS()
35+ if ws != nil {
36+ paneNode := ws.FindPane(paneID)
37+ if paneNode != nil && paneNode.Pane != nil {
38+ paneNode.Pane.Title = title
39+ }
40+ // Check if this pane is the active one
41+ if ws.ActivePaneID == paneID {
42+ isActivePaneTitle = true
43+ }
44+ }
45+
46+ // Update StackedView title bar if this pane is in a stack
47+ if wsView != nil {
48+ tr := wsView.TreeRenderer()
49+ if tr != nil {
50+ stackedView := tr.GetStackedViewForPane(string(paneID))
51+ if stackedView != nil {
52+ c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
53+ }
54+ }
55+ }
56+
57+ // Notify history persistence (get URL from WebView)
58+ if c.onTitleUpdated != nil {
59+ if wv := c.getWebViewLocked(paneID); wv != nil {
60+ currentURI := wv.URI()
61+ if currentURI != "" && title != "" {
62+ c.onTitleUpdated(ctx, paneID, currentURI, title)
63+ }
64+ }
65+ }
66+
67+ // Notify window title update if this is the active pane
68+ if isActivePaneTitle && c.onWindowTitleChanged != nil {
69+ c.onWindowTitleChanged(title)
70+ }
71+
72+ log.Debug().
73+ Str("pane_id", string(paneID)).
74+ Str("title", title).
75+ Msg("pane title updated")
76+}
77+
78+// updateStackedPaneTitle updates the title of a pane in a StackedView.
79+func (c *Coordinator) updateStackedPaneTitle(
80+ ctx context.Context,
81+ sv *layout.StackedView,
82+ paneID entity.PaneID,
83+ title string,
84+) {
85+ log := logging.FromContext(ctx)
86+
87+ // Find the pane's index directly in the StackedView
88+ index := sv.FindPaneIndex(string(paneID))
89+ if index < 0 {
90+ log.Debug().
91+ Str("pane_id", string(paneID)).
92+ Msg("pane not found in StackedView for title update")
93+ return
94+ }
95+
96+ if err := sv.UpdateTitle(index, title); err != nil {
97+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
98+ }
99+}
100+
101+// syncStackedTitle updates the stacked title bar for a pane if it's in a stack.
102+// Called from onLoadCommitted to keep titles in sync during navigation.
103+func (c *Coordinator) syncStackedTitle(ctx context.Context, paneID entity.PaneID, title string) {
104+ _, wsView := c.getActiveWS()
105+ if wsView == nil {
106+ return
107+ }
108+ tr := wsView.TreeRenderer()
109+ if tr == nil {
110+ return
111+ }
112+ if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
113+ c.updateStackedPaneTitle(ctx, sv, paneID, title)
114+ }
115+}
116+
117+// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
118+func (c *Coordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, emittingWV *webkit.WebView, favicon *gdk.Texture) {
119+ log := logging.FromContext(ctx)
120+
121+ // Verify this WebView is still bound to the expected pane
122+ currentWV := c.getWebViewLocked(paneID)
123+ if currentWV == nil || currentWV != emittingWV {
124+ log.Debug().
125+ Str("pane_id", string(paneID)).
126+ Msg("ignoring favicon change from unbound webview")
127+ return
128+ }
129+
130+ uri := emittingWV.URI()
131+
132+ // Update favicon cache with domain key (handles cross-domain redirects)
133+ if c.faviconAdapter != nil && favicon != nil && uri != "" {
134+ c.navOriginMu.RLock()
135+ originURL := c.navOrigins[paneID]
136+ c.navOriginMu.RUnlock()
137+ c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
138+ }
139+
140+ // Update StackedView favicon if this pane is in a stack
141+ c.updateStackedFaviconForPane(ctx, paneID, favicon)
142+
143+ log.Debug().
144+ Str("pane_id", string(paneID)).
145+ Str("uri", uri).
146+ Bool("has_favicon", favicon != nil).
147+ Msg("pane favicon updated")
148+}
149+
150+// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
151+func (c *Coordinator) updateStackedPaneFavicon(
152+ ctx context.Context,
153+ sv *layout.StackedView,
154+ paneID entity.PaneID,
155+ favicon *gdk.Texture,
156+) {
157+ log := logging.FromContext(ctx)
158+
159+ // Find the pane's index directly in the StackedView
160+ index := sv.FindPaneIndex(string(paneID))
161+ if index < 0 {
162+ log.Debug().
163+ Str("pane_id", string(paneID)).
164+ Msg("pane not found in StackedView for favicon update")
165+ return
166+ }
167+
168+ if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
169+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
170+ }
171+}
172+
173+// updateStackedFaviconForPane updates the stacked title bar favicon for a pane.
174+func (c *Coordinator) updateStackedFaviconForPane(ctx context.Context, paneID entity.PaneID, texture *gdk.Texture) {
175+ _, wsView := c.getActiveWS()
176+ if wsView == nil {
177+ return
178+ }
179+ tr := wsView.TreeRenderer()
180+ if tr == nil {
181+ return
182+ }
183+ if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
184+ c.updateStackedPaneFavicon(ctx, sv, paneID, texture)
185+ }
186+}
187+
188+// resolveCommittedFavicon ensures the stacked title bar has a favicon after navigation commits.
189+// First checks if WebKit already has a favicon for the page (common for subpath URLs).
190+// Falls back to the full GetOrFetch pipeline (cache + WebKit DB + DuckDuckGo API).
191+// This closes the gap where title bars relied solely on the notify::favicon signal.
192+func (c *Coordinator) resolveCommittedFavicon(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
193+ if c.faviconAdapter == nil || wv == nil {
194+ return
195+ }
196+
197+ uri := wv.URI()
198+ if uri == "" || strings.HasPrefix(uri, "about:") || strings.HasPrefix(uri, "dumb://") {
199+ return
200+ }
201+
202+ log := logging.FromContext(ctx)
203+
204+ // Check if we already have a texture cached for this domain
205+ if texture := c.faviconAdapter.GetTextureByURL(uri); texture != nil {
206+ // Already cached - just ensure the stacked view is updated
207+ c.updateStackedFaviconForPane(ctx, paneID, texture)
208+ return
209+ }
210+
211+ // Check if WebKit already has a favicon for this page
212+ if icon := wv.Favicon(); icon != nil {
213+ log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("using existing WebKit favicon for committed page")
214+ // Store and update through the normal path
215+ c.navOriginMu.RLock()
216+ originURL := c.navOrigins[paneID]
217+ c.navOriginMu.RUnlock()
218+ c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, icon)
219+ c.updateStackedFaviconForPane(ctx, paneID, icon)
220+ return
221+ }
222+
223+ // Fall back to GetOrFetch (checks service cache, WebKit DB, then DuckDuckGo API)
224+ // Capture current generation to guard against stale callbacks
225+ gen := wv.Generation()
226+ capturedURI := uri
227+ c.faviconAdapter.GetOrFetch(ctx, uri, func(texture *gdk.Texture) {
228+ // Skip nil results — a nil means "couldn't resolve", not "no favicon".
229+ // Without this guard, a late nil callback can overwrite a good favicon
230+ // that was already set by an earlier onFaviconChanged signal.
231+ if texture == nil {
232+ return
233+ }
234+ // Verify WebView is still bound to pane and hasn't been reused
235+ if wv.Generation() != gen {
236+ return
237+ }
238+ currentWV := c.getWebViewLocked(paneID)
239+ if currentWV != wv {
240+ return
241+ }
242+ // Verify the WebView hasn't navigated away since we started the fetch
243+ if currentWV.URI() != capturedURI {
244+ return
245+ }
246+ c.updateStackedFaviconForPane(ctx, paneID, texture)
247+ })
248+}
249+
250+// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
251+func (c *Coordinator) FaviconAdapter() *adapter.FaviconAdapter {
252+ return c.faviconAdapter
253+}
254+
255+// SetNavigationOrigin records the original URL before navigation starts.
256+// This allows caching favicons under both original and final domains
257+// when cross-domain redirects occur (e.g., google.fr → google.com).
258+func (c *Coordinator) SetNavigationOrigin(paneID entity.PaneID, uri string) {
259+ c.navOriginMu.Lock()
260+ c.navOrigins[paneID] = uri
261+ c.navOriginMu.Unlock()
262+}
263+
264+// PreloadCachedFavicon checks the favicon cache and updates the stacked pane
265+// title bar immediately if a cached favicon exists for the URL.
266+// This provides instant favicon display without waiting for WebKit.
267+func (c *Coordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) {
268+ if c.faviconAdapter == nil || uri == "" {
269+ return
270+ }
271+ select {
272+ case <-ctx.Done():
273+ return
274+ default:
275+ }
276+
277+ // Check memory and disk cache (no external fetch)
278+ texture := c.faviconAdapter.PreloadFromCache(uri)
279+ select {
280+ case <-ctx.Done():
281+ return
282+ default:
283+ }
284+
285+ // Update stacked pane favicon on GTK main loop.
286+ cb := glib.SourceFunc(func(_ uintptr) bool {
287+ select {
288+ case <-ctx.Done():
289+ return false
290+ default:
291+ }
292+ if c.getActiveWS == nil {
293+ return false
294+ }
295+ // Only update if we found a cached texture.
296+ // If nil, let resolveCommittedFavicon handle it after load commits.
297+ if texture == nil {
298+ return false
299+ }
300+ _, wsView := c.getActiveWS()
301+ if wsView == nil {
302+ return false
303+ }
304+ tr := wsView.TreeRenderer()
305+ if tr == nil {
306+ return false
307+ }
308+ stackedView := tr.GetStackedViewForPane(string(paneID))
309+ if stackedView == nil {
310+ return false
311+ }
312+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
313+ return false
314+ })
315+ glib.IdleAdd(&cb, 0)
316+}
@@ -0,0 +1,316 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/webkit"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/adapter"
11+ "github.com/bnema/dumber/internal/ui/layout"
12+ "github.com/jwijenbergh/puregotk/v4/gdk"
13+ "github.com/jwijenbergh/puregotk/v4/glib"
14+)
15+
16+// GetTitle returns the current title for a pane.
17+func (c *Coordinator) GetTitle(paneID entity.PaneID) string {
18+ c.titleMu.RLock()
19+ defer c.titleMu.RUnlock()
20+ return c.paneTitles[paneID]
21+}
22+
23+// onTitleChanged updates title tracking when a WebView's title changes.
24+func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
25+ log := logging.FromContext(ctx)
26+
27+ // Update title map
28+ c.titleMu.Lock()
29+ c.paneTitles[paneID] = title
30+ c.titleMu.Unlock()
31+
32+ // Update domain model and check if this is the active pane
33+ isActivePaneTitle := false
34+ ws, wsView := c.getActiveWS()
35+ if ws != nil {
36+ paneNode := ws.FindPane(paneID)
37+ if paneNode != nil && paneNode.Pane != nil {
38+ paneNode.Pane.Title = title
39+ }
40+ // Check if this pane is the active one
41+ if ws.ActivePaneID == paneID {
42+ isActivePaneTitle = true
43+ }
44+ }
45+
46+ // Update StackedView title bar if this pane is in a stack
47+ if wsView != nil {
48+ tr := wsView.TreeRenderer()
49+ if tr != nil {
50+ stackedView := tr.GetStackedViewForPane(string(paneID))
51+ if stackedView != nil {
52+ c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
53+ }
54+ }
55+ }
56+
57+ // Notify history persistence (get URL from WebView)
58+ if c.onTitleUpdated != nil {
59+ if wv := c.getWebViewLocked(paneID); wv != nil {
60+ currentURI := wv.URI()
61+ if currentURI != "" && title != "" {
62+ c.onTitleUpdated(ctx, paneID, currentURI, title)
63+ }
64+ }
65+ }
66+
67+ // Notify window title update if this is the active pane
68+ if isActivePaneTitle && c.onWindowTitleChanged != nil {
69+ c.onWindowTitleChanged(title)
70+ }
71+
72+ log.Debug().
73+ Str("pane_id", string(paneID)).
74+ Str("title", title).
75+ Msg("pane title updated")
76+}
77+
78+// updateStackedPaneTitle updates the title of a pane in a StackedView.
79+func (c *Coordinator) updateStackedPaneTitle(
80+ ctx context.Context,
81+ sv *layout.StackedView,
82+ paneID entity.PaneID,
83+ title string,
84+) {
85+ log := logging.FromContext(ctx)
86+
87+ // Find the pane's index directly in the StackedView
88+ index := sv.FindPaneIndex(string(paneID))
89+ if index < 0 {
90+ log.Debug().
91+ Str("pane_id", string(paneID)).
92+ Msg("pane not found in StackedView for title update")
93+ return
94+ }
95+
96+ if err := sv.UpdateTitle(index, title); err != nil {
97+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
98+ }
99+}
100+
101+// syncStackedTitle updates the stacked title bar for a pane if it's in a stack.
102+// Called from onLoadCommitted to keep titles in sync during navigation.
103+func (c *Coordinator) syncStackedTitle(ctx context.Context, paneID entity.PaneID, title string) {
104+ _, wsView := c.getActiveWS()
105+ if wsView == nil {
106+ return
107+ }
108+ tr := wsView.TreeRenderer()
109+ if tr == nil {
110+ return
111+ }
112+ if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
113+ c.updateStackedPaneTitle(ctx, sv, paneID, title)
114+ }
115+}
116+
117+// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
118+func (c *Coordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, emittingWV *webkit.WebView, favicon *gdk.Texture) {
119+ log := logging.FromContext(ctx)
120+
121+ // Verify this WebView is still bound to the expected pane
122+ currentWV := c.getWebViewLocked(paneID)
123+ if currentWV == nil || currentWV != emittingWV {
124+ log.Debug().
125+ Str("pane_id", string(paneID)).
126+ Msg("ignoring favicon change from unbound webview")
127+ return
128+ }
129+
130+ uri := emittingWV.URI()
131+
132+ // Update favicon cache with domain key (handles cross-domain redirects)
133+ if c.faviconAdapter != nil && favicon != nil && uri != "" {
134+ c.navOriginMu.RLock()
135+ originURL := c.navOrigins[paneID]
136+ c.navOriginMu.RUnlock()
137+ c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
138+ }
139+
140+ // Update StackedView favicon if this pane is in a stack
141+ c.updateStackedFaviconForPane(ctx, paneID, favicon)
142+
143+ log.Debug().
144+ Str("pane_id", string(paneID)).
145+ Str("uri", uri).
146+ Bool("has_favicon", favicon != nil).
147+ Msg("pane favicon updated")
148+}
149+
150+// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
151+func (c *Coordinator) updateStackedPaneFavicon(
152+ ctx context.Context,
153+ sv *layout.StackedView,
154+ paneID entity.PaneID,
155+ favicon *gdk.Texture,
156+) {
157+ log := logging.FromContext(ctx)
158+
159+ // Find the pane's index directly in the StackedView
160+ index := sv.FindPaneIndex(string(paneID))
161+ if index < 0 {
162+ log.Debug().
163+ Str("pane_id", string(paneID)).
164+ Msg("pane not found in StackedView for favicon update")
165+ return
166+ }
167+
168+ if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
169+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
170+ }
171+}
172+
173+// updateStackedFaviconForPane updates the stacked title bar favicon for a pane.
174+func (c *Coordinator) updateStackedFaviconForPane(ctx context.Context, paneID entity.PaneID, texture *gdk.Texture) {
175+ _, wsView := c.getActiveWS()
176+ if wsView == nil {
177+ return
178+ }
179+ tr := wsView.TreeRenderer()
180+ if tr == nil {
181+ return
182+ }
183+ if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
184+ c.updateStackedPaneFavicon(ctx, sv, paneID, texture)
185+ }
186+}
187+
188+// resolveCommittedFavicon ensures the stacked title bar has a favicon after navigation commits.
189+// First checks if WebKit already has a favicon for the page (common for subpath URLs).
190+// Falls back to the full GetOrFetch pipeline (cache + WebKit DB + DuckDuckGo API).
191+// This closes the gap where title bars relied solely on the notify::favicon signal.
192+func (c *Coordinator) resolveCommittedFavicon(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
193+ if c.faviconAdapter == nil || wv == nil {
194+ return
195+ }
196+
197+ uri := wv.URI()
198+ if uri == "" || strings.HasPrefix(uri, "about:") || strings.HasPrefix(uri, "dumb://") {
199+ return
200+ }
201+
202+ log := logging.FromContext(ctx)
203+
204+ // Check if we already have a texture cached for this domain
205+ if texture := c.faviconAdapter.GetTextureByURL(uri); texture != nil {
206+ // Already cached - just ensure the stacked view is updated
207+ c.updateStackedFaviconForPane(ctx, paneID, texture)
208+ return
209+ }
210+
211+ // Check if WebKit already has a favicon for this page
212+ if icon := wv.Favicon(); icon != nil {
213+ log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("using existing WebKit favicon for committed page")
214+ // Store and update through the normal path
215+ c.navOriginMu.RLock()
216+ originURL := c.navOrigins[paneID]
217+ c.navOriginMu.RUnlock()
218+ c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, icon)
219+ c.updateStackedFaviconForPane(ctx, paneID, icon)
220+ return
221+ }
222+
223+ // Fall back to GetOrFetch (checks service cache, WebKit DB, then DuckDuckGo API)
224+ // Capture current generation to guard against stale callbacks
225+ gen := wv.Generation()
226+ capturedURI := uri
227+ c.faviconAdapter.GetOrFetch(ctx, uri, func(texture *gdk.Texture) {
228+ // Skip nil results — a nil means "couldn't resolve", not "no favicon".
229+ // Without this guard, a late nil callback can overwrite a good favicon
230+ // that was already set by an earlier onFaviconChanged signal.
231+ if texture == nil {
232+ return
233+ }
234+ // Verify WebView is still bound to pane and hasn't been reused
235+ if wv.Generation() != gen {
236+ return
237+ }
238+ currentWV := c.getWebViewLocked(paneID)
239+ if currentWV != wv {
240+ return
241+ }
242+ // Verify the WebView hasn't navigated away since we started the fetch
243+ if currentWV.URI() != capturedURI {
244+ return
245+ }
246+ c.updateStackedFaviconForPane(ctx, paneID, texture)
247+ })
248+}
249+
250+// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
251+func (c *Coordinator) FaviconAdapter() *adapter.FaviconAdapter {
252+ return c.faviconAdapter
253+}
254+
255+// SetNavigationOrigin records the original URL before navigation starts.
256+// This allows caching favicons under both original and final domains
257+// when cross-domain redirects occur (e.g., google.fr → google.com).
258+func (c *Coordinator) SetNavigationOrigin(paneID entity.PaneID, uri string) {
259+ c.navOriginMu.Lock()
260+ c.navOrigins[paneID] = uri
261+ c.navOriginMu.Unlock()
262+}
263+
264+// PreloadCachedFavicon checks the favicon cache and updates the stacked pane
265+// title bar immediately if a cached favicon exists for the URL.
266+// This provides instant favicon display without waiting for WebKit.
267+func (c *Coordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) {
268+ if c.faviconAdapter == nil || uri == "" {
269+ return
270+ }
271+ select {
272+ case <-ctx.Done():
273+ return
274+ default:
275+ }
276+
277+ // Check memory and disk cache (no external fetch)
278+ texture := c.faviconAdapter.PreloadFromCache(uri)
279+ select {
280+ case <-ctx.Done():
281+ return
282+ default:
283+ }
284+
285+ // Update stacked pane favicon on GTK main loop.
286+ cb := glib.SourceFunc(func(_ uintptr) bool {
287+ select {
288+ case <-ctx.Done():
289+ return false
290+ default:
291+ }
292+ if c.getActiveWS == nil {
293+ return false
294+ }
295+ // Only update if we found a cached texture.
296+ // If nil, let resolveCommittedFavicon handle it after load commits.
297+ if texture == nil {
298+ return false
299+ }
300+ _, wsView := c.getActiveWS()
301+ if wsView == nil {
302+ return false
303+ }
304+ tr := wsView.TreeRenderer()
305+ if tr == nil {
306+ return false
307+ }
308+ stackedView := tr.GetStackedViewForPane(string(paneID))
309+ if stackedView == nil {
310+ return false
311+ }
312+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
313+ return false
314+ })
315+ glib.IdleAdd(&cb, 0)
316+}
M internal/ui/coordinator/navigation.go +3 -2
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/application/usecase"
1010 "github.com/bnema/dumber/internal/domain/entity"
1111 "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/coordinator/content"
1213 )
1314
1415 // OmniboxProvider provides access to omnibox operations.
@@ -22,7 +23,7 @@ type OmniboxProvider interface {
2223 type NavigationCoordinator struct {
2324 contextProvider func() context.Context
2425 navigateUC *usecase.NavigateUseCase
25- contentCoord *ContentCoordinator
26+ contentCoord *content.Coordinator
2627 omniboxProvider OmniboxProvider
2728 }
2829
@@ -32,7 +33,7 @@ const faviconPreloadTimeout = 300 * time.Millisecond
3233 func NewNavigationCoordinator(
3334 ctx context.Context,
3435 navigateUC *usecase.NavigateUseCase,
35- contentCoord *ContentCoordinator,
36+ contentCoord *content.Coordinator,
3637 ) *NavigationCoordinator {
3738 log := logging.FromContext(ctx)
3839 log.Debug().Msg("creating navigation coordinator")
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/coordinator/content"
12 )13 )
13 14
14 // OmniboxProvider provides access to omnibox operations.15 // OmniboxProvider provides access to omnibox operations.
@@ -22,7 +23,7 @@ type OmniboxProvider interface {
22 type NavigationCoordinator struct {23 type NavigationCoordinator struct {
23 contextProvider func() context.Context24 contextProvider func() context.Context
24 navigateUC *usecase.NavigateUseCase25 navigateUC *usecase.NavigateUseCase
25- contentCoord *ContentCoordinator26+ contentCoord *content.Coordinator
26 omniboxProvider OmniboxProvider27 omniboxProvider OmniboxProvider
27 }28 }
28 29
@@ -32,7 +33,7 @@ const faviconPreloadTimeout = 300 * time.Millisecond
32 func NewNavigationCoordinator(33 func NewNavigationCoordinator(
33 ctx context.Context,34 ctx context.Context,
34 navigateUC *usecase.NavigateUseCase,35 navigateUC *usecase.NavigateUseCase,
35- contentCoord *ContentCoordinator,36+ contentCoord *content.Coordinator,
36 ) *NavigationCoordinator {37 ) *NavigationCoordinator {
37 log := logging.FromContext(ctx)38 log := logging.FromContext(ctx)
38 log.Debug().Msg("creating navigation coordinator")39 log.Debug().Msg("creating navigation coordinator")
D internal/ui/coordinator/oauth.go +0 -193
@@ -1,193 +0,0 @@
1-package coordinator
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/dumber/internal/application/port"
7-)
8-
9-// oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
10-// These appear in authorization URLs and callback redirects.
11-var oauthFlowPatterns = []string{
12- // OAuth flow indicators
13- "oauth",
14- "authorize",
15- "authorization",
16- "auth/",
17- "/auth",
18- "login",
19- "signin",
20- "sign-in",
21- // OpenID Connect
22- "oidc",
23- "openid",
24- // Callback/redirect endpoints
25- "callback",
26- "redirect",
27- "/cb",
28-}
29-
30-// oauthCallbackPatterns are URL query parameters that indicate an OAuth callback.
31-// These appear when the OAuth provider redirects back to the application.
32-var oauthCallbackPatterns = []string{
33- // Success parameters
34- "code=",
35- "access_token=",
36- "id_token=",
37- "token_type=",
38- "refresh_token=",
39- // Error parameters
40- "error=",
41- "error_description=",
42- "error_uri=",
43-}
44-
45-// oauthRequestPatterns are query parameters found in OAuth authorization requests.
46-var oauthRequestPatterns = []string{
47- "response_type=",
48- "client_id=",
49- "redirect_uri=",
50- "scope=",
51- "nonce=",
52-}
53-
54-// IsOAuthURL checks if the URL is related to an OAuth flow.
55-// This includes authorization endpoints, login pages, and callback URLs.
56-func IsOAuthURL(url string) bool {
57- if url == "" {
58- return false
59- }
60- lower := strings.ToLower(url)
61-
62- // Check for OAuth flow patterns in URL path
63- for _, pattern := range oauthFlowPatterns {
64- if strings.Contains(lower, pattern) {
65- return true
66- }
67- }
68-
69- // Check for OAuth request parameters
70- for _, pattern := range oauthRequestPatterns {
71- if strings.Contains(lower, pattern) {
72- return true
73- }
74- }
75-
76- return false
77-}
78-
79-// IsOAuthCallback checks if the URL is an OAuth callback with response parameters.
80-// This indicates the OAuth flow has completed (successfully or with error).
81-func IsOAuthCallback(url string) bool {
82- if url == "" {
83- return false
84- }
85- lower := strings.ToLower(url)
86-
87- // Must contain callback patterns (code=, access_token=, error=, etc.)
88- for _, pattern := range oauthCallbackPatterns {
89- if strings.Contains(lower, pattern) {
90- return true
91- }
92- }
93-
94- return false
95-}
96-
97-// ShouldAutoClose determines if a popup at this URL should auto-close.
98-// Returns true for OAuth callbacks that indicate flow completion.
99-//
100-// This is comprehensive detection that handles:
101-// - Success: code=, access_token=, id_token=
102-// - Errors: error=, error_description=
103-// - Various OAuth providers (Google, GitHub, Auth0, etc.)
104-func ShouldAutoClose(url string) bool {
105- return IsOAuthCallback(url)
106-}
107-
108-// shouldForceCloseOnSafetyTimeout determines if a popup should be force-closed
109-// when the OAuth safety timeout is reached.
110-//
111-// Safety-timeout force close is disabled for stability. OAuth popups close on
112-// callback detection or manual user action.
113-func shouldForceCloseOnSafetyTimeout(url string) bool {
114- _ = url
115- return false
116-}
117-
118-// IsOAuthSuccess checks if the callback indicates successful authentication.
119-func IsOAuthSuccess(url string) bool {
120- if url == "" {
121- return false
122- }
123- lower := strings.ToLower(url)
124-
125- // Check for success indicators
126- successPatterns := []string{
127- "code=",
128- "access_token=",
129- "id_token=",
130- }
131-
132- for _, pattern := range successPatterns {
133- if strings.Contains(lower, pattern) {
134- // Make sure it's not an error response
135- if !strings.Contains(lower, "error=") {
136- return true
137- }
138- }
139- }
140-
141- return false
142-}
143-
144-// IsOAuthError checks if the callback indicates an authentication error.
145-func IsOAuthError(url string) bool {
146- if url == "" {
147- return false
148- }
149- lower := strings.ToLower(url)
150- return strings.Contains(lower, "error=")
151-}
152-
153-func composeOnClose(existing, next func()) func() {
154- if existing == nil {
155- return next
156- }
157- if next == nil {
158- return existing
159- }
160- return func() {
161- existing()
162- next()
163- }
164-}
165-
166-func composeOnURIChanged(existing, next func(string)) func(string) {
167- if existing == nil {
168- return next
169- }
170- if next == nil {
171- return existing
172- }
173- return func(uri string) {
174- existing(uri)
175- next(uri)
176- }
177-}
178-
179-func composeOnLoadChanged(
180- existing func(port.LoadEvent),
181- next func(port.LoadEvent),
182-) func(port.LoadEvent) {
183- if existing == nil {
184- return next
185- }
186- if next == nil {
187- return existing
188- }
189- return func(event port.LoadEvent) {
190- existing(event)
191- next(event)
192- }
193-}
@@ -1,193 +0,0 @@
1-package coordinator
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/dumber/internal/application/port"
7-)
8-
9-// oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
10-// These appear in authorization URLs and callback redirects.
11-var oauthFlowPatterns = []string{
12- // OAuth flow indicators
13- "oauth",
14- "authorize",
15- "authorization",
16- "auth/",
17- "/auth",
18- "login",
19- "signin",
20- "sign-in",
21- // OpenID Connect
22- "oidc",
23- "openid",
24- // Callback/redirect endpoints
25- "callback",
26- "redirect",
27- "/cb",
28-}
29-
30-// oauthCallbackPatterns are URL query parameters that indicate an OAuth callback.
31-// These appear when the OAuth provider redirects back to the application.
32-var oauthCallbackPatterns = []string{
33- // Success parameters
34- "code=",
35- "access_token=",
36- "id_token=",
37- "token_type=",
38- "refresh_token=",
39- // Error parameters
40- "error=",
41- "error_description=",
42- "error_uri=",
43-}
44-
45-// oauthRequestPatterns are query parameters found in OAuth authorization requests.
46-var oauthRequestPatterns = []string{
47- "response_type=",
48- "client_id=",
49- "redirect_uri=",
50- "scope=",
51- "nonce=",
52-}
53-
54-// IsOAuthURL checks if the URL is related to an OAuth flow.
55-// This includes authorization endpoints, login pages, and callback URLs.
56-func IsOAuthURL(url string) bool {
57- if url == "" {
58- return false
59- }
60- lower := strings.ToLower(url)
61-
62- // Check for OAuth flow patterns in URL path
63- for _, pattern := range oauthFlowPatterns {
64- if strings.Contains(lower, pattern) {
65- return true
66- }
67- }
68-
69- // Check for OAuth request parameters
70- for _, pattern := range oauthRequestPatterns {
71- if strings.Contains(lower, pattern) {
72- return true
73- }
74- }
75-
76- return false
77-}
78-
79-// IsOAuthCallback checks if the URL is an OAuth callback with response parameters.
80-// This indicates the OAuth flow has completed (successfully or with error).
81-func IsOAuthCallback(url string) bool {
82- if url == "" {
83- return false
84- }
85- lower := strings.ToLower(url)
86-
87- // Must contain callback patterns (code=, access_token=, error=, etc.)
88- for _, pattern := range oauthCallbackPatterns {
89- if strings.Contains(lower, pattern) {
90- return true
91- }
92- }
93-
94- return false
95-}
96-
97-// ShouldAutoClose determines if a popup at this URL should auto-close.
98-// Returns true for OAuth callbacks that indicate flow completion.
99-//
100-// This is comprehensive detection that handles:
101-// - Success: code=, access_token=, id_token=
102-// - Errors: error=, error_description=
103-// - Various OAuth providers (Google, GitHub, Auth0, etc.)
104-func ShouldAutoClose(url string) bool {
105- return IsOAuthCallback(url)
106-}
107-
108-// shouldForceCloseOnSafetyTimeout determines if a popup should be force-closed
109-// when the OAuth safety timeout is reached.
110-//
111-// Safety-timeout force close is disabled for stability. OAuth popups close on
112-// callback detection or manual user action.
113-func shouldForceCloseOnSafetyTimeout(url string) bool {
114- _ = url
115- return false
116-}
117-
118-// IsOAuthSuccess checks if the callback indicates successful authentication.
119-func IsOAuthSuccess(url string) bool {
120- if url == "" {
121- return false
122- }
123- lower := strings.ToLower(url)
124-
125- // Check for success indicators
126- successPatterns := []string{
127- "code=",
128- "access_token=",
129- "id_token=",
130- }
131-
132- for _, pattern := range successPatterns {
133- if strings.Contains(lower, pattern) {
134- // Make sure it's not an error response
135- if !strings.Contains(lower, "error=") {
136- return true
137- }
138- }
139- }
140-
141- return false
142-}
143-
144-// IsOAuthError checks if the callback indicates an authentication error.
145-func IsOAuthError(url string) bool {
146- if url == "" {
147- return false
148- }
149- lower := strings.ToLower(url)
150- return strings.Contains(lower, "error=")
151-}
152-
153-func composeOnClose(existing, next func()) func() {
154- if existing == nil {
155- return next
156- }
157- if next == nil {
158- return existing
159- }
160- return func() {
161- existing()
162- next()
163- }
164-}
165-
166-func composeOnURIChanged(existing, next func(string)) func(string) {
167- if existing == nil {
168- return next
169- }
170- if next == nil {
171- return existing
172- }
173- return func(uri string) {
174- existing(uri)
175- next(uri)
176- }
177-}
178-
179-func composeOnLoadChanged(
180- existing func(port.LoadEvent),
181- next func(port.LoadEvent),
182-) func(port.LoadEvent) {
183- if existing == nil {
184- return next
185- }
186- if next == nil {
187- return existing
188- }
189- return func(event port.LoadEvent) {
190- existing(event)
191- next(event)
192- }
193-}
D internal/ui/coordinator/popup_types.go +0 -127
@@ -1,127 +0,0 @@
1-package coordinator
2-
3-import (
4- "time"
5-
6- "github.com/bnema/dumber/internal/application/port"
7- "github.com/bnema/dumber/internal/domain/entity"
8- "github.com/bnema/dumber/internal/infrastructure/config"
9-)
10-
11-// PopupType indicates whether the popup was triggered by a link or JavaScript.
12-type PopupType int
13-
14-const (
15- // PopupTypeTab represents a popup from target="_blank" links.
16- // These are typically user-initiated and open like new tabs.
17- PopupTypeTab PopupType = iota
18- // PopupTypePopup represents a popup from window.open() JavaScript calls.
19- // These may have different placement behavior than tab-like popups.
20- PopupTypePopup
21-)
22-
23-// String returns a human-readable name for the popup type.
24-func (t PopupType) String() string {
25- switch t {
26- case PopupTypeTab:
27- return "tab"
28- case PopupTypePopup:
29- return "popup"
30- default:
31- return "unknown"
32- }
33-}
34-
35-// PendingPopup tracks a popup WebView that has been created but not yet
36-// inserted into the workspace. This is used during the three-phase popup
37-// lifecycle: create → ready-to-show → (insert into workspace).
38-type PendingPopup struct {
39- // WebView is the related WebView created in the "create" phase.
40- // It shares cookies/session with the parent for OAuth support.
41- WebView port.WebView
42-
43- // ParentPaneID is the pane that spawned this popup.
44- ParentPaneID entity.PaneID
45-
46- // ParentWebViewID is the WebView ID of the parent (for lookup).
47- ParentWebViewID port.WebViewID
48-
49- // TargetURI is the initial URL the popup will load.
50- TargetURI string
51-
52- // FrameName is the target frame name from the navigation action.
53- // "_blank" indicates a tab-like popup, other values indicate JS popups.
54- FrameName string
55-
56- // IsUserGesture indicates if the popup was triggered by user action
57- // (click) vs script-initiated (e.g., popup after timeout).
58- IsUserGesture bool
59-
60- // PopupType categorizes this as a tab-like or JS popup.
61- PopupType PopupType
62-
63- // CreatedAt is when the popup was created (for timeout handling).
64- CreatedAt time.Time
65-}
66-
67-// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
68-func DetectPopupType(frameName string) PopupType {
69- // _blank is the standard indicator for "open in new tab/window"
70- if frameName == "_blank" {
71- return PopupTypeTab
72- }
73- // Empty or custom frame names indicate JS window.open()
74- return PopupTypePopup
75-}
76-
77-// InsertPopupInput contains the data needed to insert a popup into the workspace.
78-type InsertPopupInput struct {
79- // ParentPaneID is the pane that spawned this popup.
80- ParentPaneID entity.PaneID
81-
82- // PopupPane is the pre-created pane entity for the popup.
83- // It should have IsRelated=true and ParentPaneID set.
84- PopupPane *entity.Pane
85-
86- // WebView is the related WebView for the popup.
87- WebView port.WebView
88-
89- // Behavior determines how the popup is inserted (split/stacked/tabbed).
90- Behavior config.PopupBehavior
91-
92- // Placement specifies direction for split behavior (right/left/top/bottom).
93- Placement string
94-
95- // PopupType indicates if this is a tab-like or JS popup.
96- PopupType PopupType
97-
98- // TargetURI is the URL to load in the popup.
99- TargetURI string
100-}
101-
102-// GetBehavior returns the appropriate behavior based on popup type and config.
103-func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {
104- if cfg == nil {
105- return config.PopupBehaviorSplit // Default
106- }
107-
108- switch popupType {
109- case PopupTypeTab:
110- // Tab-like popups (_blank) use blank_target_behavior
111- switch cfg.BlankTargetBehavior {
112- case "split":
113- return config.PopupBehaviorSplit
114- case "stacked":
115- return config.PopupBehaviorStacked
116- case "tabbed":
117- return config.PopupBehaviorTabbed
118- default:
119- return config.PopupBehaviorStacked // Default for _blank
120- }
121- case PopupTypePopup:
122- // JS popups use the main behavior setting
123- return cfg.Behavior
124- default:
125- return cfg.Behavior
126- }
127-}
@@ -1,127 +0,0 @@
1-package coordinator
2-
3-import (
4- "time"
5-
6- "github.com/bnema/dumber/internal/application/port"
7- "github.com/bnema/dumber/internal/domain/entity"
8- "github.com/bnema/dumber/internal/infrastructure/config"
9-)
10-
11-// PopupType indicates whether the popup was triggered by a link or JavaScript.
12-type PopupType int
13-
14-const (
15- // PopupTypeTab represents a popup from target="_blank" links.
16- // These are typically user-initiated and open like new tabs.
17- PopupTypeTab PopupType = iota
18- // PopupTypePopup represents a popup from window.open() JavaScript calls.
19- // These may have different placement behavior than tab-like popups.
20- PopupTypePopup
21-)
22-
23-// String returns a human-readable name for the popup type.
24-func (t PopupType) String() string {
25- switch t {
26- case PopupTypeTab:
27- return "tab"
28- case PopupTypePopup:
29- return "popup"
30- default:
31- return "unknown"
32- }
33-}
34-
35-// PendingPopup tracks a popup WebView that has been created but not yet
36-// inserted into the workspace. This is used during the three-phase popup
37-// lifecycle: create → ready-to-show → (insert into workspace).
38-type PendingPopup struct {
39- // WebView is the related WebView created in the "create" phase.
40- // It shares cookies/session with the parent for OAuth support.
41- WebView port.WebView
42-
43- // ParentPaneID is the pane that spawned this popup.
44- ParentPaneID entity.PaneID
45-
46- // ParentWebViewID is the WebView ID of the parent (for lookup).
47- ParentWebViewID port.WebViewID
48-
49- // TargetURI is the initial URL the popup will load.
50- TargetURI string
51-
52- // FrameName is the target frame name from the navigation action.
53- // "_blank" indicates a tab-like popup, other values indicate JS popups.
54- FrameName string
55-
56- // IsUserGesture indicates if the popup was triggered by user action
57- // (click) vs script-initiated (e.g., popup after timeout).
58- IsUserGesture bool
59-
60- // PopupType categorizes this as a tab-like or JS popup.
61- PopupType PopupType
62-
63- // CreatedAt is when the popup was created (for timeout handling).
64- CreatedAt time.Time
65-}
66-
67-// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
68-func DetectPopupType(frameName string) PopupType {
69- // _blank is the standard indicator for "open in new tab/window"
70- if frameName == "_blank" {
71- return PopupTypeTab
72- }
73- // Empty or custom frame names indicate JS window.open()
74- return PopupTypePopup
75-}
76-
77-// InsertPopupInput contains the data needed to insert a popup into the workspace.
78-type InsertPopupInput struct {
79- // ParentPaneID is the pane that spawned this popup.
80- ParentPaneID entity.PaneID
81-
82- // PopupPane is the pre-created pane entity for the popup.
83- // It should have IsRelated=true and ParentPaneID set.
84- PopupPane *entity.Pane
85-
86- // WebView is the related WebView for the popup.
87- WebView port.WebView
88-
89- // Behavior determines how the popup is inserted (split/stacked/tabbed).
90- Behavior config.PopupBehavior
91-
92- // Placement specifies direction for split behavior (right/left/top/bottom).
93- Placement string
94-
95- // PopupType indicates if this is a tab-like or JS popup.
96- PopupType PopupType
97-
98- // TargetURI is the URL to load in the popup.
99- TargetURI string
100-}
101-
102-// GetBehavior returns the appropriate behavior based on popup type and config.
103-func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {
104- if cfg == nil {
105- return config.PopupBehaviorSplit // Default
106- }
107-
108- switch popupType {
109- case PopupTypeTab:
110- // Tab-like popups (_blank) use blank_target_behavior
111- switch cfg.BlankTargetBehavior {
112- case "split":
113- return config.PopupBehaviorSplit
114- case "stacked":
115- return config.PopupBehaviorStacked
116- case "tabbed":
117- return config.PopupBehaviorTabbed
118- default:
119- return config.PopupBehaviorStacked // Default for _blank
120- }
121- case PopupTypePopup:
122- // JS popups use the main behavior setting
123- return cfg.Behavior
124- default:
125- return cfg.Behavior
126- }
127-}
M internal/ui/coordinator/workspace.go +14 -13
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/dumber/internal/infrastructure/config"
1212 "github.com/bnema/dumber/internal/logging"
1313 "github.com/bnema/dumber/internal/ui/component"
14+ "github.com/bnema/dumber/internal/ui/coordinator/content"
1415 "github.com/bnema/dumber/internal/ui/focus"
1516 "github.com/bnema/dumber/internal/ui/layout"
1617 "github.com/rs/zerolog"
@@ -27,15 +28,15 @@ type WorkspaceCoordinator struct {
2728 focusMgr *focus.Manager
2829 stackedPaneMgr *component.StackedPaneManager
2930 widgetFactory layout.WidgetFactory
30- contentCoord *ContentCoordinator
31+ contentCoord *content.Coordinator
3132
3233 // Callbacks to avoid circular dependencies
3334 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
3435 generateID func() string
3536 onCloseLastPane func(ctx context.Context) error
36- onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior
37- onStateChanged func() // For session snapshots
38- onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks
37+ onCreatePopupTab func(ctx context.Context, input content.InsertPopupInput) error // For tabbed popup behavior
38+ onStateChanged func() // For session snapshots
39+ onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks
3940 }
4041
4142 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
@@ -44,7 +45,7 @@ type WorkspaceCoordinatorConfig struct {
4445 FocusMgr *focus.Manager
4546 StackedPaneMgr *component.StackedPaneManager
4647 WidgetFactory layout.WidgetFactory
47- ContentCoord *ContentCoordinator
48+ ContentCoord *content.Coordinator
4849 GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)
4950 GenerateID func() string
5051 }
@@ -1180,7 +1181,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
11801181 // Release the closing pane's webview
11811182 c.contentCoord.ReleaseWebView(ctx, closingPaneID)
11821183
1183- // Sync remaining title bars with current titles from ContentCoordinator.
1184+ // Sync remaining title bars with current titles from content.Coordinator.
11841185 // The domain model (stackNode.Children) has already been updated by the use case,
11851186 // so we iterate the remaining children and update their titles in the UI.
11861187 c.syncStackedPaneTitles(ctx, stackedView, stackNode)
@@ -1756,13 +1757,13 @@ func (c *WorkspaceCoordinator) SetupStackedPaneCallbacks(ctx context.Context, ws
17561757
17571758 // SetOnCreatePopupTab sets the callback for creating popup tabs.
17581759 // This is used when popup behavior is "tabbed".
1759-func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input InsertPopupInput) error) {
1760+func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input content.InsertPopupInput) error) {
17601761 c.onCreatePopupTab = fn
17611762 }
17621763
17631764 // InsertPopup inserts a popup pane into the workspace based on the specified behavior.
17641765 // Supports split, stacked, and tabbed behaviors.
1765-func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopupInput) error {
1766+func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input content.InsertPopupInput) error {
17661767 log := logging.FromContext(ctx)
17671768
17681769 log.Debug().
@@ -1787,7 +1788,7 @@ func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopu
17871788 }
17881789
17891790 // insertPopupSplit inserts a popup as a split pane adjacent to the parent.
1790-func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input InsertPopupInput) error {
1791+func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input content.InsertPopupInput) error {
17911792 log := logging.FromContext(ctx)
17921793
17931794 ws, wsView := c.getActiveWS()
@@ -1882,7 +1883,7 @@ func (c *WorkspaceCoordinator) resolvePopupSplitWidget(
18821883 func (c *WorkspaceCoordinator) attachPopupWebView(
18831884 ctx context.Context,
18841885 wsView *component.WorkspaceView,
1885- input InsertPopupInput,
1886+ input content.InsertPopupInput,
18861887 ) {
18871888 if wsView == nil || input.WebView == nil {
18881889 return
@@ -1902,7 +1903,7 @@ func (c *WorkspaceCoordinator) attachPopupWebView(
19021903
19031904 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
19041905 // Uses CreateStack and AddToStack use cases for proper domain model management.
1905-func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
1906+func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input content.InsertPopupInput) error {
19061907 log := logging.FromContext(ctx)
19071908
19081909 ws, wsView := c.getActiveWS()
@@ -2040,7 +2041,7 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
20402041
20412042 func (c *WorkspaceCoordinator) attachPopupPaneView(
20422043 ctx context.Context,
2043- input InsertPopupInput,
2044+ input content.InsertPopupInput,
20442045 wsView *component.WorkspaceView,
20452046 stackNode *entity.PaneNode,
20462047 log *zerolog.Logger,
@@ -2085,7 +2086,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
20852086 }
20862087
20872088 // insertPopupTabbed creates a new tab for the popup.
2088-func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input InsertPopupInput) error {
2089+func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input content.InsertPopupInput) error {
20892090 log := logging.FromContext(ctx)
20902091
20912092 if c.onCreatePopupTab == nil {
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/dumber/internal/infrastructure/config"11 "github.com/bnema/dumber/internal/infrastructure/config"
12 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/ui/component"13 "github.com/bnema/dumber/internal/ui/component"
14+ "github.com/bnema/dumber/internal/ui/coordinator/content"
14 "github.com/bnema/dumber/internal/ui/focus"15 "github.com/bnema/dumber/internal/ui/focus"
15 "github.com/bnema/dumber/internal/ui/layout"16 "github.com/bnema/dumber/internal/ui/layout"
16 "github.com/rs/zerolog"17 "github.com/rs/zerolog"
@@ -27,15 +28,15 @@ type WorkspaceCoordinator struct {
27 focusMgr *focus.Manager28 focusMgr *focus.Manager
28 stackedPaneMgr *component.StackedPaneManager29 stackedPaneMgr *component.StackedPaneManager
29 widgetFactory layout.WidgetFactory30 widgetFactory layout.WidgetFactory
30- contentCoord *ContentCoordinator31+ contentCoord *content.Coordinator
31 32
32 // Callbacks to avoid circular dependencies33 // Callbacks to avoid circular dependencies
33 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)34 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
34 generateID func() string35 generateID func() string
35 onCloseLastPane func(ctx context.Context) error36 onCloseLastPane func(ctx context.Context) error
36- onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior37+ onCreatePopupTab func(ctx context.Context, input content.InsertPopupInput) error // For tabbed popup behavior
37- onStateChanged func() // For session snapshots38+ onStateChanged func() // For session snapshots
38- onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks39+ onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks
39 }40 }
40 41
41 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.42 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
@@ -44,7 +45,7 @@ type WorkspaceCoordinatorConfig struct {
44 FocusMgr *focus.Manager45 FocusMgr *focus.Manager
45 StackedPaneMgr *component.StackedPaneManager46 StackedPaneMgr *component.StackedPaneManager
46 WidgetFactory layout.WidgetFactory47 WidgetFactory layout.WidgetFactory
47- ContentCoord *ContentCoordinator48+ ContentCoord *content.Coordinator
48 GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)49 GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)
49 GenerateID func() string50 GenerateID func() string
50 }51 }
@@ -1180,7 +1181,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
1180 // Release the closing pane's webview1181 // Release the closing pane's webview
1181 c.contentCoord.ReleaseWebView(ctx, closingPaneID)1182 c.contentCoord.ReleaseWebView(ctx, closingPaneID)
1182 1183
1183- // Sync remaining title bars with current titles from ContentCoordinator.1184+ // Sync remaining title bars with current titles from content.Coordinator.
1184 // The domain model (stackNode.Children) has already been updated by the use case,1185 // The domain model (stackNode.Children) has already been updated by the use case,
1185 // so we iterate the remaining children and update their titles in the UI.1186 // so we iterate the remaining children and update their titles in the UI.
1186 c.syncStackedPaneTitles(ctx, stackedView, stackNode)1187 c.syncStackedPaneTitles(ctx, stackedView, stackNode)
@@ -1756,13 +1757,13 @@ func (c *WorkspaceCoordinator) SetupStackedPaneCallbacks(ctx context.Context, ws
1756 1757
1757 // SetOnCreatePopupTab sets the callback for creating popup tabs.1758 // SetOnCreatePopupTab sets the callback for creating popup tabs.
1758 // This is used when popup behavior is "tabbed".1759 // This is used when popup behavior is "tabbed".
1759-func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input InsertPopupInput) error) {1760+func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input content.InsertPopupInput) error) {
1760 c.onCreatePopupTab = fn1761 c.onCreatePopupTab = fn
1761 }1762 }
1762 1763
1763 // InsertPopup inserts a popup pane into the workspace based on the specified behavior.1764 // InsertPopup inserts a popup pane into the workspace based on the specified behavior.
1764 // Supports split, stacked, and tabbed behaviors.1765 // Supports split, stacked, and tabbed behaviors.
1765-func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopupInput) error {1766+func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input content.InsertPopupInput) error {
1766 log := logging.FromContext(ctx)1767 log := logging.FromContext(ctx)
1767 1768
1768 log.Debug().1769 log.Debug().
@@ -1787,7 +1788,7 @@ func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopu
1787 }1788 }
1788 1789
1789 // insertPopupSplit inserts a popup as a split pane adjacent to the parent.1790 // insertPopupSplit inserts a popup as a split pane adjacent to the parent.
1790-func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input InsertPopupInput) error {1791+func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input content.InsertPopupInput) error {
1791 log := logging.FromContext(ctx)1792 log := logging.FromContext(ctx)
1792 1793
1793 ws, wsView := c.getActiveWS()1794 ws, wsView := c.getActiveWS()
@@ -1882,7 +1883,7 @@ func (c *WorkspaceCoordinator) resolvePopupSplitWidget(
1882 func (c *WorkspaceCoordinator) attachPopupWebView(1883 func (c *WorkspaceCoordinator) attachPopupWebView(
1883 ctx context.Context,1884 ctx context.Context,
1884 wsView *component.WorkspaceView,1885 wsView *component.WorkspaceView,
1885- input InsertPopupInput,1886+ input content.InsertPopupInput,
1886 ) {1887 ) {
1887 if wsView == nil || input.WebView == nil {1888 if wsView == nil || input.WebView == nil {
1888 return1889 return
@@ -1902,7 +1903,7 @@ func (c *WorkspaceCoordinator) attachPopupWebView(
1902 1903
1903 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.1904 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
1904 // Uses CreateStack and AddToStack use cases for proper domain model management.1905 // Uses CreateStack and AddToStack use cases for proper domain model management.
1905-func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {1906+func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input content.InsertPopupInput) error {
1906 log := logging.FromContext(ctx)1907 log := logging.FromContext(ctx)
1907 1908
1908 ws, wsView := c.getActiveWS()1909 ws, wsView := c.getActiveWS()
@@ -2040,7 +2041,7 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
2040 2041
2041 func (c *WorkspaceCoordinator) attachPopupPaneView(2042 func (c *WorkspaceCoordinator) attachPopupPaneView(
2042 ctx context.Context,2043 ctx context.Context,
2043- input InsertPopupInput,2044+ input content.InsertPopupInput,
2044 wsView *component.WorkspaceView,2045 wsView *component.WorkspaceView,
2045 stackNode *entity.PaneNode,2046 stackNode *entity.PaneNode,
2046 log *zerolog.Logger,2047 log *zerolog.Logger,
@@ -2085,7 +2086,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
2085 }2086 }
2086 2087
2087 // insertPopupTabbed creates a new tab for the popup.2088 // insertPopupTabbed creates a new tab for the popup.
2088-func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input InsertPopupInput) error {2089+func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input content.InsertPopupInput) error {
2089 log := logging.FromContext(ctx)2090 log := logging.FromContext(ctx)
2090 2091
2091 if c.onCreatePopupTab == nil {2092 if c.onCreatePopupTab == nil {

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