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/puregotk/v4/gtk"
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).
19func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.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
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(uri)
128 }
129 return
130 }
131 ws, _ := c.getActiveWS()
132 if ws != nil && ws.ActivePaneID == paneID {
133 c.onActiveNavigationCommitted(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 if wsView == nil {
192 return
193 }
194
195 paneView := wsView.GetPaneView(paneID)
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) {
203 _, wsView := c.getActiveWS()
204 if wsView == nil {
205 return
206 }
207
208 paneView := wsView.GetPaneView(paneID)
209 if paneView != nil {
210 paneView.SetLoading(false)
211 }
212
213 c.revealIfPending(ctx, paneID, "", "load-finished")
214 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
215 return
216 }
217 c.applyPendingThemeUpdate(ctx, paneID, wv)
218 c.refreshPendingScripts(ctx, paneID, wv)
219}
220
221// onProgressChanged updates the progress bar with current load progress.
222func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
223 if progress > 0 {
224 c.revealIfPending(context.Background(), paneID, "", "progress")
225 }
226
227 _, wsView := c.getActiveWS()
228 if wsView == nil {
229 return
230 }
231
232 paneView := wsView.GetPaneView(paneID)
233 if paneView != nil {
234 paneView.SetLoadProgress(progress)
235 }
236}
237
238func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {
239 c.revealMu.Lock()
240 c.pendingReveal[paneID] = true
241 c.revealMu.Unlock()
242}
243
244func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {
245 c.revealMu.Lock()
246 delete(c.pendingReveal, paneID)
247 c.revealMu.Unlock()
248}
249
250func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
251 c.revealMu.Lock()
252 pending := c.pendingReveal[paneID]
253 if pending {
254 delete(c.pendingReveal, paneID)
255 }
256 c.revealMu.Unlock()
257
258 if !pending {
259 return
260 }
261
262 wv := c.getWebViewLocked(paneID)
263 if wv == nil || wv.IsDestroyed() {
264 return
265 }
266
267 // Use NativeWidgetProvider to make the widget visible (engine-agnostic)
268 if nwp, ok := wv.(port.NativeWidgetProvider); ok {
269 if ptr := nwp.NativeWidget(); ptr != 0 {
270 gtkWidget := >k.Widget{}
271 gtkWidget.Ptr = ptr
272 gtkWidget.SetVisible(true)
273 logging.FromContext(ctx).
274 Debug().
275 Str("pane_id", string(paneID)).
276 Str("uri", uri).
277 Str("reason", reason).
278 Msg("webview revealed")
279 }
280 }
281
282 // Mark first_paint and finish startup trace
283 logging.Trace().Mark("first_paint")
284 logging.Trace().Finish()
285
286 if c.onWebViewShown != nil {
287 c.onWebViewShown(paneID)
288 }
289}
290
291// onLinkHover updates the link status overlay when hovering over links.
292func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
293 _, wsView := c.getActiveWS()
294 if wsView == nil {
295 return
296 }
297
298 paneView := wsView.GetPaneView(paneID)
299 if paneView == nil {
300 return
301 }
302
303 if uri != "" {
304 paneView.ShowLinkStatus(uri)
305 } else {
306 paneView.HideLinkStatus()
307 }
308}
309
310// handleURIChanged handles URI changes from WebKit, including external scheme detection
311// and SPA navigation tracking.
312func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
313 if uri == "" {
314 return
315 }
316
317 log := logging.FromContext(ctx)
318
319 // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
320 // These are typically triggered by JavaScript redirects (window.location)
321 isExternal := urlutil.IsExternalScheme(uri)
322
323 if isExternal {
324 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
325
326 // Launch externally via injected callback
327 if c.onLaunchExternalURL != nil {
328 c.onLaunchExternalURL(uri)
329 } else {
330 log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external URL not launched: no handler registered")
331 }
332
333 // Stop loading to prevent WebKit from showing an error page
334 // The page stays on the previous URL before the JS redirect
335 if err := wv.Stop(ctx); err != nil {
336 log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for external URL")
337 }
338
339 // Navigate back to avoid stale URI in omnibox/history
340 if wv.CanGoBack() {
341 if err := wv.GoBack(ctx); err != nil {
342 log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after external URL")
343 }
344 }
345 return
346 }
347
348 if !wv.IsLoading() {
349 c.onSPANavigation(ctx, paneID, uri)
350 }
351}