1package content
2
3import (
4 "context"
5 "strings"
6
7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"
10 urlutil "github.com/bnema/dumber/internal/domain/url"
11 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/dumber/internal/ui/component"
13 "github.com/bnema/puregotk/v4/gtk"
14)
15
16// onLoadCommitted re-applies zoom when page content starts loading and records history.
17// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
18// History is recorded here because the URI is guaranteed to be correct after commit.
19// Also shows the WebView widget (it's hidden during creation to avoid white flash).
20func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) {
21 log := logging.FromContext(ctx)
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, identity)
72 return
73 }
74
75 if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
76 c.applyCurrentTheme(ctx, paneID, wv)
77 }
78
79 c.markPendingReveal(paneID, identity)
80 c.revealIfPending(ctx, paneID, wv, identity, uri, "progress-after-commit")
81
82 // Update domain model with current URI for session snapshots
83 c.updatePaneURI(paneID, uri)
84
85 // Sync StackedView title bar with the WebView's current title.
86 // This keeps the stacked title bar up-to-date immediately on navigation,
87 // before the asynchronous notify::title signal fires.
88 if title := wv.Title(); title != "" {
89 c.syncStackedTitle(ctx, paneID, title)
90 }
91
92 // Record history - URI is guaranteed to be correct at LoadCommitted
93 if c.onHistoryRecord != nil {
94 c.onHistoryRecord(ctx, paneID, uri)
95 }
96
97 // Resolve favicon for stacked title bar (fills gap when WebKit doesn't emit notify::favicon)
98 c.resolveCommittedFavicon(ctx, paneID, wv)
99
100 // Notify active pane navigation for permission indicator reset.
101 c.notifyActiveNavigation(paneID, uri)
102
103 // Apply zoom
104 if c.zoomUC == nil {
105 return
106 }
107
108 zoomKey, err := usecase.ExtractZoomKey(uri)
109 if err != nil {
110 log.Debug().Err(err).Str("uri", uri).Msg("skipping zoom application: cannot extract zoom key")
111 return
112 }
113
114 _ = c.zoomUC.ApplyToWebView(ctx, wv, zoomKey)
115}
116
117func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
118 if c.onActiveNavigationCommitted == nil {
119 return
120 }
121 c.activePaneOverrideMu.RLock()
122 override := c.activePaneOverride
123 c.activePaneOverrideMu.RUnlock()
124 if override != "" {
125 if override == paneID {
126 c.onActiveNavigationCommitted(paneID, uri)
127 }
128 return
129 }
130 ws, _ := c.getActiveWS()
131 if ws != nil && ws.ActivePaneID == paneID {
132 c.onActiveNavigationCommitted(paneID, uri)
133 }
134}
135
136func (c *Coordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv port.WebView) bool {
137 if wv == nil || wv.IsDestroyed() {
138 return false
139 }
140 if wv.URI() != aboutBlankURI {
141 return false
142 }
143 ws, _ := c.getActiveWS()
144 if ws == nil {
145 return false
146 }
147 paneNode := ws.FindPane(paneID)
148 if paneNode == nil || paneNode.Pane == nil {
149 return false
150 }
151 if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
152 return true
153 }
154 return false
155}
156
157// onSPANavigation records history when URL changes via JavaScript (History API).
158// This handles SPA navigation like YouTube search, where the URL changes without a page load.
159func (c *Coordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
160 // Update domain model with current URI for session snapshots
161 c.updatePaneURI(paneID, uri)
162
163 // Record history for SPA navigation
164 if c.onHistoryRecord != nil {
165 c.onHistoryRecord(ctx, paneID, uri)
166 }
167}
168
169// updatePaneURI updates the pane's URI in the domain model.
170// This is called on navigation so that session snapshots capture the current URL.
171func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
172 if c.onPaneURIUpdated != nil {
173 c.onPaneURIUpdated(paneID, uri)
174 }
175}
176
177// onLoadStarted shows the progress bar when page loading begins.
178func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
179 // Trigger deferred initialization on first load_started.
180 // This ensures non-critical init runs after initial navigation starts.
181 c.loadStartedOnce.Do(func() {
182 if c.onFirstLoadStarted != nil {
183 c.onFirstLoadStarted()
184 }
185 })
186
187 _, wsView := c.getActiveWS()
188 var paneView *component.PaneView
189 if wsView != nil {
190 paneView = wsView.GetPaneView(paneID)
191 }
192
193 if wsView == nil {
194 return
195 }
196 if paneView != nil {
197 paneView.SetLoading(true)
198 }
199}
200
201// onLoadFinished hides the progress bar when page loading completes.
202func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) {
203 _, wsView := c.getActiveWS()
204 var paneView *component.PaneView
205 if wsView != nil {
206 paneView = wsView.GetPaneView(paneID)
207 }
208
209 if wsView == nil {
210 return
211 }
212 if paneView != nil {
213 paneView.SetLoading(false)
214 }
215
216 c.revealIfPending(ctx, paneID, wv, identity, "", "load-finished")
217 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
218 return
219 }
220 c.applyPendingThemeUpdate(ctx, paneID, wv)
221 c.refreshPendingScripts(ctx, paneID, wv)
222}
223
224// onProgressChanged updates the progress bar with current load progress.
225func (c *Coordinator) onProgressChanged(paneID entity.PaneID, wv port.WebView, identity webViewIdentity, progress float64) {
226 if progress > 0 {
227 c.revealIfPending(context.Background(), paneID, wv, identity, "", "progress")
228 }
229
230 _, wsView := c.getActiveWS()
231 var paneView *component.PaneView
232 if wsView != nil {
233 paneView = wsView.GetPaneView(paneID)
234 }
235
236 if wsView == nil {
237 return
238 }
239 if paneView != nil {
240 paneView.SetLoadProgress(progress)
241 }
242}
243
244func (c *Coordinator) markPendingReveal(paneID entity.PaneID, identity webViewIdentity) {
245 c.webViewsMu.RLock()
246 c.revealMu.Lock()
247 defer c.revealMu.Unlock()
248 defer c.webViewsMu.RUnlock()
249 currentWebView := c.webViews[paneID]
250 if currentWebView == nil {
251 return
252 }
253 currentIdentity, ok := identityForWebView(currentWebView)
254 if !ok || currentIdentity != identity {
255 return
256 }
257 c.ensureRevealMapsLocked()
258 c.pendingReveal[paneID] = identity
259}
260
261func (c *Coordinator) clearPendingReveal(paneID entity.PaneID, identity webViewIdentity) {
262 c.webViewsMu.RLock()
263 c.revealMu.Lock()
264 defer c.revealMu.Unlock()
265 defer c.webViewsMu.RUnlock()
266 pendingIdentity, pending := c.pendingReveal[paneID]
267 if !pending || pendingIdentity != identity {
268 return
269 }
270 delete(c.pendingReveal, paneID)
271}
272
273// WebViewRevealed reports whether this pane's current WebView has completed a
274// reveal. State is identity-bound so a replacement cannot inherit it.
275func (c *Coordinator) WebViewRevealed(paneID entity.PaneID) bool {
276 return c.webViewRevealed(paneID, nil)
277}
278
279// webViewRevealed verifies the identity being attached when one is supplied.
280// This prevents a rebuild from applying a replacement's reveal state to a
281// previously read pooled WebView.
282func (c *Coordinator) webViewRevealed(paneID entity.PaneID, expected port.WebView) bool {
283 c.webViewsMu.RLock()
284 c.revealMu.Lock()
285 defer c.revealMu.Unlock()
286 defer c.webViewsMu.RUnlock()
287 current := c.webViews[paneID]
288 identity, ok := identityForWebView(current)
289 if !ok || expected != nil {
290 expectedIdentity, expectedOK := identityForWebView(expected)
291 if !expectedOK || expectedIdentity != identity {
292 return false
293 }
294 }
295 revealedIdentity, revealed := c.revealedWebViews[paneID]
296 return revealed && revealedIdentity == identity
297}
298
299func (c *Coordinator) markWebViewRevealed(paneID entity.PaneID, identity webViewIdentity) bool {
300 c.webViewsMu.RLock()
301 c.revealMu.Lock()
302 defer c.revealMu.Unlock()
303 defer c.webViewsMu.RUnlock()
304 currentIdentity, current := identityForWebView(c.webViews[paneID])
305 if !current || currentIdentity != identity {
306 return false
307 }
308 c.ensureRevealMapsLocked()
309 c.revealedWebViews[paneID] = identity
310 return true
311}
312
313func (c *Coordinator) revealIfPending(
314 ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity, uri, reason string,
315) {
316 // Mapping replacement and release acquire this gate too. No ordinary map
317 // lock is held across the native call, but the gate keeps the validated
318 // identity current until its widget visibility has been changed.
319 c.revealMutationMu.Lock()
320
321 c.webViewsMu.RLock()
322 c.revealMu.Lock()
323 pendingIdentity, pending := c.pendingReveal[paneID]
324 if !pending || pendingIdentity != identity {
325 c.revealMu.Unlock()
326 c.webViewsMu.RUnlock()
327 c.revealMutationMu.Unlock()
328 return
329 }
330 currentIdentity, current := identityForWebView(c.webViews[paneID])
331 if !current || currentIdentity != identity {
332 c.revealMu.Unlock()
333 c.webViewsMu.RUnlock()
334 c.revealMutationMu.Unlock()
335 return
336 }
337 delete(c.pendingReveal, paneID)
338 c.revealMu.Unlock()
339 c.webViewsMu.RUnlock()
340
341 if wv.IsDestroyed() {
342 c.revealMutationMu.Unlock()
343 return
344 }
345
346 if c.setWebViewVisible != nil {
347 c.setWebViewVisible(wv)
348 } else if nwp, ok := wv.(port.NativeWidgetProvider); ok {
349 if ptr := nwp.NativeWidget(); ptr != 0 {
350 gtkWidget := >k.Widget{}
351 gtkWidget.Ptr = ptr
352 gtkWidget.SetVisible(true)
353 logging.FromContext(ctx).
354 Debug().
355 Str("pane_id", string(paneID)).
356 Str("uri", uri).
357 Str("reason", reason).
358 Msg("webview revealed")
359 }
360 }
361
362 // Keep the presentation record in the same serialized operation as the
363 // native mutation. User callbacks run only after the gate is released.
364 revealed := c.markWebViewRevealed(paneID, identity)
365 c.revealMutationMu.Unlock()
366 if revealed && c.onWebViewShown != nil {
367 c.onWebViewShown(paneID)
368 }
369}
370
371// onLinkHover updates the link status overlay when hovering over links.
372func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
373 _, wsView := c.getActiveWS()
374 if wsView == nil {
375 return
376 }
377
378 paneView := wsView.GetPaneView(paneID)
379 if paneView == nil {
380 return
381 }
382
383 if uri != "" {
384 paneView.ShowLinkStatus(uri)
385 } else {
386 paneView.HideLinkStatus()
387 }
388}
389
390// handleURIChanged handles URI changes from WebKit, including external scheme detection
391// and SPA navigation tracking.
392func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
393 if uri == "" {
394 return
395 }
396
397 log := logging.FromContext(ctx)
398
399 // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
400 // These are typically triggered by JavaScript redirects (window.location)
401 isExternal := urlutil.IsExternalScheme(uri)
402
403 if isExternal {
404 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme URI change blocked")
405
406 // External schemes must only be launched from WebKit's navigation-policy path,
407 // where NavigationAction.IsUserGesture is available and verified. URI changes can
408 // be driven by page script or redirects, so this path only cleans up WebKit state.
409 if err := wv.Stop(ctx); err != nil {
410 log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for blocked external URL")
411 }
412
413 // Navigate back to avoid stale URI in omnibox/history.
414 if wv.CanGoBack() {
415 if err := wv.GoBack(ctx); err != nil {
416 log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after blocked external URL")
417 }
418 }
419 return
420 }
421
422 if !wv.IsLoading() {
423 c.onSPANavigation(ctx, paneID, uri)
424 }
425}