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) {
21 log := logging.FromContext(ctx)
22 logging.Trace().Mark("load_committed")
23
24 uri := wv.URI()
25 if uri == "" {
26 return
27 }
28
29 // Set appropriate background color based on page type to prevent dark background bleeding.
30 switch {
31 case strings.HasPrefix(uri, "dumb://"):
32 // Internal pages: apply themed background
33 theme, ok := c.getCurrentTheme()
34 if ok && theme.prefersDark {
35 wv.SetBackgroundColor(darkBgR, darkBgG, darkBgB, darkBgA)
36 } else {
37 wv.ResetBackgroundToDefault()
38 }
39 case strings.HasPrefix(uri, "about:"):
40 // Keep pool background (no action)
41 default:
42 // External pages: white background
43 wv.ResetBackgroundToDefault()
44 }
45
46 // Show the WebView now that content is being painted
47 // (WebViews are hidden on creation to avoid white flash)
48 // Skip showing if this is about:blank but the pane is loading a different URL
49 // This prevents the brief flash of about:blank during initial navigation
50 shouldShow := true
51 if uri == aboutBlankURI {
52 // Get the pane's intended URI from the workspace
53 ws, _ := c.getActiveWS()
54 if ws != nil {
55 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
56 // Don't show about:blank if the pane is supposed to load a different URL
57 if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
58 shouldShow = false
59 log.Debug().
60 Str("pane_id", string(paneID)).
61 Str("pane_uri", paneNode.Pane.URI).
62 Msg("skipping webview show for about:blank (pane loading different URL)")
63 }
64 }
65 }
66 }
67
68 if !shouldShow {
69 // Avoid updating UI/domain state to about:blank when we know the pane is
70 // navigating to a different URL. This prevents the omnibox/window title from
71 // briefly showing about:blank on cold start.
72 c.clearPendingReveal(paneID)
73 return
74 }
75
76 if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
77 c.applyCurrentTheme(ctx, paneID, wv)
78 }
79
80 c.markPendingReveal(paneID)
81 c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
82
83 // Update domain model with current URI for session snapshots
84 c.updatePaneURI(paneID, uri)
85
86 // Sync StackedView title bar with the WebView's current title.
87 // This keeps the stacked title bar up-to-date immediately on navigation,
88 // before the asynchronous notify::title signal fires.
89 if title := wv.Title(); title != "" {
90 c.syncStackedTitle(ctx, paneID, title)
91 }
92
93 // Record history - URI is guaranteed to be correct at LoadCommitted
94 if c.onHistoryRecord != nil {
95 c.onHistoryRecord(ctx, paneID, uri)
96 }
97
98 // Resolve favicon for stacked title bar (fills gap when WebKit doesn't emit notify::favicon)
99 c.resolveCommittedFavicon(ctx, paneID, wv)
100
101 // Notify active pane navigation for permission indicator reset.
102 c.notifyActiveNavigation(paneID, uri)
103
104 // Apply zoom
105 if c.zoomUC == nil {
106 return
107 }
108
109 zoomKey, err := usecase.ExtractZoomKey(uri)
110 if err != nil {
111 log.Debug().Err(err).Str("uri", uri).Msg("skipping zoom application: cannot extract zoom key")
112 return
113 }
114
115 _ = c.zoomUC.ApplyToWebView(ctx, wv, zoomKey)
116}
117
118func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
119 if c.onActiveNavigationCommitted == nil {
120 return
121 }
122 c.activePaneOverrideMu.RLock()
123 override := c.activePaneOverride
124 c.activePaneOverrideMu.RUnlock()
125 if override != "" {
126 if override == paneID {
127 c.onActiveNavigationCommitted(paneID, uri)
128 }
129 return
130 }
131 ws, _ := c.getActiveWS()
132 if ws != nil && ws.ActivePaneID == paneID {
133 c.onActiveNavigationCommitted(paneID, uri)
134 }
135}
136
137func (c *Coordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv port.WebView) bool {
138 if wv == nil || wv.IsDestroyed() {
139 return false
140 }
141 if wv.URI() != aboutBlankURI {
142 return false
143 }
144 ws, _ := c.getActiveWS()
145 if ws == nil {
146 return false
147 }
148 paneNode := ws.FindPane(paneID)
149 if paneNode == nil || paneNode.Pane == nil {
150 return false
151 }
152 if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
153 return true
154 }
155 return false
156}
157
158// onSPANavigation records history when URL changes via JavaScript (History API).
159// This handles SPA navigation like YouTube search, where the URL changes without a page load.
160func (c *Coordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
161 // Update domain model with current URI for session snapshots
162 c.updatePaneURI(paneID, uri)
163
164 // Record history for SPA navigation
165 if c.onHistoryRecord != nil {
166 c.onHistoryRecord(ctx, paneID, uri)
167 }
168}
169
170// updatePaneURI updates the pane's URI in the domain model.
171// This is called on navigation so that session snapshots capture the current URL.
172func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
173 if c.onPaneURIUpdated != nil {
174 c.onPaneURIUpdated(paneID, uri)
175 }
176}
177
178// onLoadStarted shows the progress bar when page loading begins.
179func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
180 logging.Trace().Mark("load_started")
181
182 // Trigger deferred initialization on first load_started.
183 // This ensures non-critical init runs after initial navigation starts.
184 c.loadStartedOnce.Do(func() {
185 if c.onFirstLoadStarted != nil {
186 c.onFirstLoadStarted()
187 }
188 })
189
190 _, wsView := c.getActiveWS()
191 var paneView *component.PaneView
192 if wsView != nil {
193 paneView = wsView.GetPaneView(paneID)
194 }
195
196 if wsView == nil {
197 return
198 }
199 if paneView != nil {
200 paneView.SetLoading(true)
201 }
202}
203
204// onLoadFinished hides the progress bar when page loading completes.
205func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
206 _, wsView := c.getActiveWS()
207 var paneView *component.PaneView
208 if wsView != nil {
209 paneView = wsView.GetPaneView(paneID)
210 }
211
212 if wsView == nil {
213 return
214 }
215 if paneView != nil {
216 paneView.SetLoading(false)
217 }
218
219 c.revealIfPending(ctx, paneID, "", "load-finished")
220 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
221 return
222 }
223 c.applyPendingThemeUpdate(ctx, paneID, wv)
224 c.refreshPendingScripts(ctx, paneID, wv)
225}
226
227// onProgressChanged updates the progress bar with current load progress.
228func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
229 if progress > 0 {
230 c.revealIfPending(context.Background(), paneID, "", "progress")
231 }
232
233 _, wsView := c.getActiveWS()
234 var paneView *component.PaneView
235 if wsView != nil {
236 paneView = wsView.GetPaneView(paneID)
237 }
238
239 if wsView == nil {
240 return
241 }
242 if paneView != nil {
243 paneView.SetLoadProgress(progress)
244 }
245}
246
247func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {
248 c.revealMu.Lock()
249 c.pendingReveal[paneID] = true
250 c.revealMu.Unlock()
251}
252
253func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {
254 c.revealMu.Lock()
255 delete(c.pendingReveal, paneID)
256 c.revealMu.Unlock()
257}
258
259func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
260 c.revealMu.Lock()
261 pending := c.pendingReveal[paneID]
262 if pending {
263 delete(c.pendingReveal, paneID)
264 }
265 c.revealMu.Unlock()
266
267 if !pending {
268 return
269 }
270
271 wv := c.getWebViewLocked(paneID)
272 if wv == nil || wv.IsDestroyed() {
273 return
274 }
275
276 // Use NativeWidgetProvider to make the widget visible (engine-agnostic)
277 if nwp, ok := wv.(port.NativeWidgetProvider); ok {
278 if ptr := nwp.NativeWidget(); ptr != 0 {
279 gtkWidget := >k.Widget{}
280 gtkWidget.Ptr = ptr
281 gtkWidget.SetVisible(true)
282 logging.FromContext(ctx).
283 Debug().
284 Str("pane_id", string(paneID)).
285 Str("uri", uri).
286 Str("reason", reason).
287 Msg("webview revealed")
288 }
289 }
290
291 // Mark first_paint and finish startup trace
292 logging.Trace().Mark("first_paint")
293 logging.Trace().Finish()
294
295 if c.onWebViewShown != nil {
296 c.onWebViewShown(paneID)
297 }
298}
299
300// onLinkHover updates the link status overlay when hovering over links.
301func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
302 _, wsView := c.getActiveWS()
303 if wsView == nil {
304 return
305 }
306
307 paneView := wsView.GetPaneView(paneID)
308 if paneView == nil {
309 return
310 }
311
312 if uri != "" {
313 paneView.ShowLinkStatus(uri)
314 } else {
315 paneView.HideLinkStatus()
316 }
317}
318
319// handleURIChanged handles URI changes from WebKit, including external scheme detection
320// and SPA navigation tracking.
321func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
322 if uri == "" {
323 return
324 }
325
326 log := logging.FromContext(ctx)
327
328 // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
329 // These are typically triggered by JavaScript redirects (window.location)
330 isExternal := urlutil.IsExternalScheme(uri)
331
332 if isExternal {
333 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme URI change blocked")
334
335 // External schemes must only be launched from WebKit's navigation-policy path,
336 // where NavigationAction.IsUserGesture is available and verified. URI changes can
337 // be driven by page script or redirects, so this path only cleans up WebKit state.
338 if err := wv.Stop(ctx); err != nil {
339 log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for blocked external URL")
340 }
341
342 // Navigate back to avoid stale URI in omnibox/history.
343 if wv.CanGoBack() {
344 if err := wv.GoBack(ctx); err != nil {
345 log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after blocked external URL")
346 }
347 }
348 return
349 }
350
351 if !wv.IsLoading() {
352 c.onSPANavigation(ctx, paneID, uri)
353 }
354}