detached dd5a7db

Merge pull request #40 from bnema/feat/defer-webview-prewarm

perf(startup): defer webview pool prewarm
commit
dd5a7dbb9eb475e167beff0deb9edbd4676720c9
author
bnema · · 2025-12-27 08:47 UTC
committer
GitHub · · 2025-12-27 08:47 UTC
merge of
00c4c81 + 3c5cbdb · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +105 -8
M CHANGELOG.md +1 -0
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Fixed
8+- Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
89 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).
910
1011 ## [0.21.0] - 2025-12-26
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Fixed7 ### Fixed
8+- Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
8 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).9 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).
9 10
10 ## [0.21.0] - 2025-12-2611 ## [0.21.0] - 2025-12-26
M internal/infrastructure/webkit/pool.go +80 -0
@@ -8,6 +8,7 @@ import (
88
99 "github.com/bnema/dumber/internal/logging"
1010 "github.com/bnema/puregotk-webkit/webkit"
11+ "github.com/jwijenbergh/puregotk/v4/glib"
1112 )
1213
1314 // FilterApplier applies content filters to a UserContentManager.
@@ -207,6 +208,85 @@ func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
207208 p.prewarmSync(ctx, count)
208209 }
209210
211+// PrewarmAsync schedules WebView creation on the GTK idle loop.
212+// This avoids blocking startup (especially cold-start navigation) while still
213+// warming up WebViews for subsequent tab creation.
214+func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
215+ log := logging.FromContext(ctx)
216+
217+ if count <= 0 {
218+ count = p.config.PrewarmCount
219+ }
220+ if count <= 0 {
221+ return
222+ }
223+ if p.closed.Load() {
224+ return
225+ }
226+
227+ log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm")
228+
229+ p.wg.Add(1)
230+ var doneOnce sync.Once
231+ remaining := count
232+
233+ var schedule func()
234+ schedule = func() {
235+ // Stop early if the caller canceled the operation.
236+ if err := ctx.Err(); err != nil {
237+ log.Debug().Err(err).Msg("async webview pool prewarm canceled")
238+ doneOnce.Do(p.wg.Done)
239+ return
240+ }
241+
242+ cb := glib.SourceFunc(func(_ uintptr) bool {
243+ // Stop if the caller canceled the operation.
244+ if err := ctx.Err(); err != nil {
245+ log.Debug().Err(err).Msg("async webview pool prewarm canceled")
246+ doneOnce.Do(p.wg.Done)
247+ return false
248+ }
249+
250+ // Stop if pool is closing/closed.
251+ if p.closed.Load() {
252+ doneOnce.Do(p.wg.Done)
253+ return false
254+ }
255+
256+ // Stop early if we reached capacity.
257+ if len(p.pool) >= p.config.MaxSize {
258+ doneOnce.Do(p.wg.Done)
259+ return false
260+ }
261+
262+ wv, err := p.createWebView(ctx)
263+ if err != nil {
264+ log.Warn().Err(err).Msg("failed to prewarm webview")
265+ } else {
266+ select {
267+ case p.pool <- wv:
268+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
269+ default:
270+ wv.Destroy()
271+ }
272+ }
273+
274+ remaining--
275+ if remaining <= 0 {
276+ log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete")
277+ doneOnce.Do(p.wg.Done)
278+ return false
279+ }
280+
281+ schedule()
282+ return false
283+ })
284+ glib.IdleAdd(&cb, 0)
285+ }
286+
287+ schedule()
288+}
289+
210290 // prewarmSync creates WebViews synchronously.
211291 func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
212292 log := logging.FromContext(ctx)
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk-webkit/webkit"10 "github.com/bnema/puregotk-webkit/webkit"
11+ "github.com/jwijenbergh/puregotk/v4/glib"
11 )12 )
12 13
13 // FilterApplier applies content filters to a UserContentManager.14 // FilterApplier applies content filters to a UserContentManager.
@@ -207,6 +208,85 @@ func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
207 p.prewarmSync(ctx, count)208 p.prewarmSync(ctx, count)
208 }209 }
209 210
211+// PrewarmAsync schedules WebView creation on the GTK idle loop.
212+// This avoids blocking startup (especially cold-start navigation) while still
213+// warming up WebViews for subsequent tab creation.
214+func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
215+ log := logging.FromContext(ctx)
216+
217+ if count <= 0 {
218+ count = p.config.PrewarmCount
219+ }
220+ if count <= 0 {
221+ return
222+ }
223+ if p.closed.Load() {
224+ return
225+ }
226+
227+ log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm")
228+
229+ p.wg.Add(1)
230+ var doneOnce sync.Once
231+ remaining := count
232+
233+ var schedule func()
234+ schedule = func() {
235+ // Stop early if the caller canceled the operation.
236+ if err := ctx.Err(); err != nil {
237+ log.Debug().Err(err).Msg("async webview pool prewarm canceled")
238+ doneOnce.Do(p.wg.Done)
239+ return
240+ }
241+
242+ cb := glib.SourceFunc(func(_ uintptr) bool {
243+ // Stop if the caller canceled the operation.
244+ if err := ctx.Err(); err != nil {
245+ log.Debug().Err(err).Msg("async webview pool prewarm canceled")
246+ doneOnce.Do(p.wg.Done)
247+ return false
248+ }
249+
250+ // Stop if pool is closing/closed.
251+ if p.closed.Load() {
252+ doneOnce.Do(p.wg.Done)
253+ return false
254+ }
255+
256+ // Stop early if we reached capacity.
257+ if len(p.pool) >= p.config.MaxSize {
258+ doneOnce.Do(p.wg.Done)
259+ return false
260+ }
261+
262+ wv, err := p.createWebView(ctx)
263+ if err != nil {
264+ log.Warn().Err(err).Msg("failed to prewarm webview")
265+ } else {
266+ select {
267+ case p.pool <- wv:
268+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
269+ default:
270+ wv.Destroy()
271+ }
272+ }
273+
274+ remaining--
275+ if remaining <= 0 {
276+ log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete")
277+ doneOnce.Do(p.wg.Done)
278+ return false
279+ }
280+
281+ schedule()
282+ return false
283+ })
284+ glib.IdleAdd(&cb, 0)
285+ }
286+
287+ schedule()
288+}
289+
210 // prewarmSync creates WebViews synchronously.290 // prewarmSync creates WebViews synchronously.
211 func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {291 func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
212 log := logging.FromContext(ctx)292 log := logging.FromContext(ctx)
M internal/ui/app.go +24 -8
@@ -179,7 +179,9 @@ func (a *App) onActivate(ctx context.Context) {
179179 log.Debug().Msg("GTK application activated")
180180
181181 a.applyGTKColorSchemePreference(ctx)
182- a.prewarmWebViewPool(ctx)
182+ // Configure pool background color early (prevents white flash), but avoid
183+ // synchronous prewarming that delays the first navigation on cold start.
184+ a.setupPoolBackgroundColor(ctx)
183185
184186 if err := a.createMainWindow(ctx); err != nil {
185187 log.Error().Err(err).Msg("failed to create main window")
@@ -198,6 +200,8 @@ func (a *App) onActivate(ctx context.Context) {
198200 a.initSnapshotService(ctx)
199201 a.initUpdateCoordinator(ctx)
200202 a.createInitialTab(ctx)
203+ // Prewarm the remaining WebViews after the initial tab starts loading.
204+ a.prewarmWebViewPoolAsync(ctx)
201205 a.finalizeActivation(ctx)
202206 }
203207
@@ -223,19 +227,31 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
223227 Msg("set gtk-application-prefer-dark-theme")
224228 }
225229
226-func (a *App) prewarmWebViewPool(ctx context.Context) {
230+func (a *App) setupPoolBackgroundColor(ctx context.Context) {
231+ log := logging.FromContext(ctx)
232+
227233 // Set theme background color on pool to eliminate white flash.
228- // Must be done before prewarming so WebViews get the correct color.
229- if a.pool != nil && a.deps != nil && a.deps.Theme != nil {
230- r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
231- a.pool.SetBackgroundColor(r, g, b, alpha)
234+ // Must be done before any WebView creation so WebViews get the correct color.
235+ if a.pool == nil {
236+ log.Debug().Msg("webview pool not available; skipping background color setup")
237+ return
232238 }
239+ if a.deps == nil || a.deps.Theme == nil {
240+ log.Debug().Msg("theme not available; skipping webview pool background color setup")
241+ return
242+ }
243+
244+ r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
245+ a.pool.SetBackgroundColor(r, g, b, alpha)
246+ log.Debug().Msg("configured webview pool background color")
247+}
233248
234- // Prewarm WebView pool now that GTK is initialized.
249+func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
250+ // Prewarm WebView pool after startup so cold-start navigation is not blocked.
235251 if a.pool == nil {
236252 return
237253 }
238- a.pool.Prewarm(ctx, 0)
254+ a.pool.PrewarmAsync(ctx, 0)
239255 }
240256
241257 func (a *App) createMainWindow(ctx context.Context) error {
@@ -179,7 +179,9 @@ func (a *App) onActivate(ctx context.Context) {
179 log.Debug().Msg("GTK application activated")179 log.Debug().Msg("GTK application activated")
180 180
181 a.applyGTKColorSchemePreference(ctx)181 a.applyGTKColorSchemePreference(ctx)
182- a.prewarmWebViewPool(ctx)182+ // Configure pool background color early (prevents white flash), but avoid
183+ // synchronous prewarming that delays the first navigation on cold start.
184+ a.setupPoolBackgroundColor(ctx)
183 185
184 if err := a.createMainWindow(ctx); err != nil {186 if err := a.createMainWindow(ctx); err != nil {
185 log.Error().Err(err).Msg("failed to create main window")187 log.Error().Err(err).Msg("failed to create main window")
@@ -198,6 +200,8 @@ func (a *App) onActivate(ctx context.Context) {
198 a.initSnapshotService(ctx)200 a.initSnapshotService(ctx)
199 a.initUpdateCoordinator(ctx)201 a.initUpdateCoordinator(ctx)
200 a.createInitialTab(ctx)202 a.createInitialTab(ctx)
203+ // Prewarm the remaining WebViews after the initial tab starts loading.
204+ a.prewarmWebViewPoolAsync(ctx)
201 a.finalizeActivation(ctx)205 a.finalizeActivation(ctx)
202 }206 }
203 207
@@ -223,19 +227,31 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
223 Msg("set gtk-application-prefer-dark-theme")227 Msg("set gtk-application-prefer-dark-theme")
224 }228 }
225 229
226-func (a *App) prewarmWebViewPool(ctx context.Context) {230+func (a *App) setupPoolBackgroundColor(ctx context.Context) {
231+ log := logging.FromContext(ctx)
232+
227 // Set theme background color on pool to eliminate white flash.233 // Set theme background color on pool to eliminate white flash.
228- // Must be done before prewarming so WebViews get the correct color.234+ // Must be done before any WebView creation so WebViews get the correct color.
229- if a.pool != nil && a.deps != nil && a.deps.Theme != nil {235+ if a.pool == nil {
230- r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()236+ log.Debug().Msg("webview pool not available; skipping background color setup")
231- a.pool.SetBackgroundColor(r, g, b, alpha)237+ return
232 }238 }
239+ if a.deps == nil || a.deps.Theme == nil {
240+ log.Debug().Msg("theme not available; skipping webview pool background color setup")
241+ return
242+ }
243+
244+ r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
245+ a.pool.SetBackgroundColor(r, g, b, alpha)
246+ log.Debug().Msg("configured webview pool background color")
247+}
233 248
234- // Prewarm WebView pool now that GTK is initialized.249+func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
250+ // Prewarm WebView pool after startup so cold-start navigation is not blocked.
235 if a.pool == nil {251 if a.pool == nil {
236 return252 return
237 }253 }
238- a.pool.Prewarm(ctx, 0)254+ a.pool.PrewarmAsync(ctx, 0)
239 }255 }
240 256
241 func (a *App) createMainWindow(ctx context.Context) error {257 func (a *App) createMainWindow(ctx context.Context) error {

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