v0.28.0 3cb3770

pool.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"errors"
6	"os"
7	"strings"
8	"sync"
9	"sync/atomic"
10	"time"
11
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/puregotk-webkit/webkit"
14	"github.com/bnema/puregotk/v4/glib"
15)
16
17// ErrPoolClosed is returned when operations are attempted on a closed pool.
18var ErrPoolClosed = errors.New("webview pool is closed")
19
20// FilterApplier applies content filters to a UserContentManager.
21// This interface decouples the pool from the filtering package.
22type FilterApplier interface {
23	ApplyTo(ctx context.Context, ucm *webkit.UserContentManager)
24}
25
26// PoolConfig configures the WebView pool behavior.
27type PoolConfig struct {
28	// MinSize is the minimum number of warm WebViews to maintain.
29	MinSize int
30	// MaxSize is the maximum number of WebViews to keep in the pool.
31	MaxSize int
32	// PrewarmCount is the number of WebViews to pre-create on startup.
33	PrewarmCount int
34	// IdleTimeout is how long idle views stay in the pool before being destroyed.
35	IdleTimeout time.Duration
36	// ReusePolicy controls if/when released WebViews are returned to pool.
37	// Values: off, safe, aggressive.
38	ReusePolicy string
39	// TerminatePolicy controls whether Destroy() should terminate web process.
40	// Values: auto, always, never.
41	TerminatePolicy string
42}
43
44// DefaultPoolConfig returns sensible defaults for the pool.
45func DefaultPoolConfig() PoolConfig {
46	return PoolConfig{
47		MinSize:      2,
48		MaxSize:      8,
49		PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
50		IdleTimeout:  5 * time.Minute,
51	}
52}
53
54const (
55	reusePolicyOff        = "off"
56	reusePolicySafe       = "safe"
57	reusePolicyAggressive = "aggressive"
58)
59
60// WebViewPool manages a pool of pre-created WebViews for fast tab creation.
61type WebViewPool struct {
62	wkCtx    *WebKitContext
63	settings *SettingsManager
64	config   PoolConfig
65
66	pool          chan *WebView
67	poolMu        sync.Mutex
68	injector      *ContentInjector
69	router        *MessageRouter
70	filterApplier FilterApplier // Optional content filter applier
71
72	// Background color for WebViews (eliminates white flash)
73	bg bgColor
74
75	closed atomic.Bool
76	wg     sync.WaitGroup
77
78	reusePolicy     string
79	terminatePolicy string
80
81	acquireCount      atomic.Uint64
82	acquirePoolHits   atomic.Uint64
83	acquirePoolMisses atomic.Uint64
84	releaseCount      atomic.Uint64
85	releaseReused     atomic.Uint64
86	releaseDestroyed  atomic.Uint64
87	releaseDropped    atomic.Uint64
88	releaseTotalNanos atomic.Int64
89	acquireTotalNanos atomic.Int64
90}
91
92// NewWebViewPool creates a new WebView pool.
93func NewWebViewPool(
94	ctx context.Context,
95	wkCtx *WebKitContext,
96	settings *SettingsManager,
97	cfg PoolConfig,
98	injector *ContentInjector,
99	router *MessageRouter,
100) *WebViewPool {
101	if cfg.MaxSize <= 0 {
102		cfg.MaxSize = 8
103	}
104	if cfg.MinSize <= 0 {
105		cfg.MinSize = 2
106	}
107	if cfg.MinSize > cfg.MaxSize {
108		cfg.MinSize = cfg.MaxSize
109	}
110	cfg.ReusePolicy = resolveReusePolicy(cfg.ReusePolicy)
111	cfg.TerminatePolicy = resolveTerminatePolicy(cfg.TerminatePolicy)
112	if ctx == nil {
113		ctx = context.Background()
114	}
115
116	p := &WebViewPool{
117		wkCtx:           wkCtx,
118		settings:        settings,
119		config:          cfg,
120		pool:            make(chan *WebView, cfg.MaxSize),
121		injector:        injector,
122		router:          router,
123		reusePolicy:     cfg.ReusePolicy,
124		terminatePolicy: cfg.TerminatePolicy,
125	}
126
127	log := logging.FromContext(ctx)
128	log.Debug().
129		Str("reuse_policy", p.reusePolicy).
130		Str("terminate_policy", p.terminatePolicy).
131		Msg("creating webview pool")
132
133	return p
134}
135
136// SetFilterApplier sets the content filter applier.
137// Filters will be applied to all newly created WebViews.
138func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
139	p.filterApplier = applier
140}
141
142// SetBackgroundColor sets the background color for newly created WebViews.
143// This color is shown before content is painted, eliminating white flash.
144func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
145	p.bg.set(r, g, b, a)
146}
147
148// Acquire gets a WebView from the pool or creates a new one.
149func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
150	log := logging.FromContext(ctx)
151	start := time.Now()
152
153	if p.closed.Load() {
154		return nil, ErrPoolClosed
155	}
156
157	// Try to get from pool first (non-blocking)
158	select {
159	case wv := <-p.pool:
160		if wv != nil && !wv.IsDestroyed() {
161			if r, g, b, a := p.bg.get(); a > 0 {
162				wv.SetBackgroundColor(float64(r), float64(g), float64(b), float64(a))
163			}
164			wv.inner.AddCssClass("webview-themed")
165			// Keep pooled WebViews hidden until we explicitly reveal them.
166			wv.inner.SetVisible(false)
167
168			// Ensure frontend is attached even if this WebView was pooled before injection was configured.
169			_ = wv.AttachFrontend(ctx, p.injector, p.router)
170			// Apply filters if available (may not have been applied during prewarm)
171			if p.filterApplier != nil {
172				p.filterApplier.ApplyTo(ctx, wv.ucm)
173			}
174			wv.ReconnectSignals()
175			p.acquireCount.Add(1)
176			p.acquirePoolHits.Add(1)
177			p.acquireTotalNanos.Add(time.Since(start).Nanoseconds())
178			log.Debug().
179				Uint64("id", uint64(wv.ID())).
180				Dur("latency", time.Since(start)).
181				Int("pool_size", len(p.pool)).
182				Str("source", "pool").
183				Msg("acquired webview")
184			return wv, nil
185		}
186		// Fall through to create new if the pooled one was destroyed
187	default:
188		// Pool empty
189	}
190
191	// Pool empty or pooled view was destroyed, create new
192	log.Debug().Msg("pool empty, creating new webview")
193	wv, err := p.createWebView(ctx)
194	if err != nil {
195		return nil, err
196	}
197	p.acquireCount.Add(1)
198	p.acquirePoolMisses.Add(1)
199	p.acquireTotalNanos.Add(time.Since(start).Nanoseconds())
200
201	// Re-check after creation: if pool closed during createWebView, destroy
202	// the new WebView and return an error to avoid leaking resources.
203	if p.closed.Load() {
204		wv.DestroyWithPolicy(p.terminatePolicy)
205		return nil, ErrPoolClosed
206	}
207
208	log.Debug().
209		Uint64("id", uint64(wv.ID())).
210		Dur("latency", time.Since(start)).
211		Int("pool_size", len(p.pool)).
212		Str("source", "new").
213		Msg("acquired webview")
214	return wv, nil
215}
216
217func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
218	log := logging.FromContext(ctx)
219
220	wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA())
221	if err != nil {
222		return nil, err
223	}
224
225	// Add CSS class for theme background styling (prevents white flash)
226	wv.inner.AddCssClass("webview-themed")
227
228	// Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
229	wv.inner.SetVisible(false)
230
231	if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
232		log.Warn().Err(err).Msg("failed to attach frontend to new webview")
233	}
234
235	// Apply content filters if configured
236	if p.filterApplier != nil {
237		p.filterApplier.ApplyTo(ctx, wv.ucm)
238	}
239
240	return wv, nil
241}
242
243// Release returns a WebView to the pool when configured and safe.
244// Default behavior remains conservative (`reuse_policy=off`) and destroys on release.
245func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
246	log := logging.FromContext(ctx)
247	start := time.Now()
248
249	if wv == nil || wv.IsDestroyed() || p.closed.Load() {
250		if wv != nil && !wv.IsDestroyed() {
251			wv.DestroyWithPolicy(p.terminatePolicy)
252		}
253		p.releaseDropped.Add(1)
254		return
255	}
256
257	action := "destroyed"
258	if p.shouldReuseOnRelease(wv) {
259		wv.ResetForPoolReuse()
260		if p.tryPut(ctx, wv) {
261			action = "reused"
262			p.releaseReused.Add(1)
263		} else {
264			action = "destroyed_after_reuse_failed"
265			wv.DestroyWithPolicy(p.terminatePolicy)
266			p.releaseDestroyed.Add(1)
267		}
268	} else {
269		wv.DestroyWithPolicy(p.terminatePolicy)
270		p.releaseDestroyed.Add(1)
271	}
272	p.releaseCount.Add(1)
273	elapsed := time.Since(start)
274	p.releaseTotalNanos.Add(elapsed.Nanoseconds())
275	log.Debug().
276		Uint64("id", uint64(wv.ID())).
277		Str("action", action).
278		Str("reuse_policy", p.reusePolicy).
279		Str("terminate_policy", p.terminatePolicy).
280		Dur("latency", elapsed).
281		Int("pool_size", len(p.pool)).
282		Uint64("acquire_total", p.acquireCount.Load()).
283		Uint64("acquire_hits", p.acquirePoolHits.Load()).
284		Uint64("acquire_misses", p.acquirePoolMisses.Load()).
285		Uint64("release_total", p.releaseCount.Load()).
286		Uint64("release_reused", p.releaseReused.Load()).
287		Uint64("release_destroyed", p.releaseDestroyed.Load()).
288		Msg("released webview")
289}
290
291// PrewarmFirst creates exactly one WebView synchronously.
292// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
293// This is the most impactful optimization for cold start since WebView creation
294// is the heaviest operation (spawns WebKit web process).
295// Returns error if WebView creation fails; caller should log and continue.
296func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
297	if p.closed.Load() {
298		return ErrPoolClosed
299	}
300	if len(p.pool) > 0 {
301		return nil // Already have at least one
302	}
303
304	log := logging.FromContext(ctx)
305	wv, err := p.createWebView(ctx)
306	if err != nil {
307		return err
308	}
309
310	if p.tryPut(ctx, wv) {
311		log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
312		return nil
313	}
314	wv.DestroyWithPolicy(p.terminatePolicy) // Pool somehow full (shouldn't happen)
315	return nil
316}
317
318// Prewarm creates WebViews synchronously to populate the pool.
319// Must be called from the GTK main thread (after GTK application is initialized).
320func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
321	if count <= 0 {
322		count = p.config.PrewarmCount
323	}
324	if count <= 0 {
325		return
326	}
327	p.prewarmSync(ctx, count)
328}
329
330// PrewarmAsync schedules WebView creation on the GTK idle loop.
331// This avoids blocking startup (especially cold-start navigation) while still
332// warming up WebViews for subsequent tab creation.
333func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
334	log := logging.FromContext(ctx)
335
336	if count <= 0 {
337		count = p.config.PrewarmCount
338	}
339	if count <= 0 {
340		return
341	}
342	if p.closed.Load() {
343		return
344	}
345
346	log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm")
347
348	p.wg.Add(1)
349	var doneOnce sync.Once
350	remaining := count
351
352	var schedule func()
353	schedule = func() {
354		// Stop early if the caller canceled the operation.
355		if err := ctx.Err(); err != nil {
356			log.Debug().Err(err).Msg("async webview pool prewarm canceled")
357			doneOnce.Do(p.wg.Done)
358			return
359		}
360
361		cb := glib.SourceFunc(func(_ uintptr) bool {
362			// Stop if the caller canceled the operation.
363			if err := ctx.Err(); err != nil {
364				log.Debug().Err(err).Msg("async webview pool prewarm canceled")
365				doneOnce.Do(p.wg.Done)
366				return false
367			}
368
369			// Stop if pool is closing/closed.
370			if p.closed.Load() {
371				doneOnce.Do(p.wg.Done)
372				return false
373			}
374
375			// Stop early if we reached capacity.
376			if len(p.pool) >= p.config.MaxSize {
377				doneOnce.Do(p.wg.Done)
378				return false
379			}
380
381			wv, err := p.createWebView(ctx)
382			if err != nil {
383				log.Warn().Err(err).Msg("failed to prewarm webview")
384			} else {
385				if p.tryPut(ctx, wv) {
386					log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
387				} else {
388					wv.DestroyWithPolicy(p.terminatePolicy)
389				}
390			}
391
392			remaining--
393			if remaining <= 0 {
394				log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete")
395				doneOnce.Do(p.wg.Done)
396				return false
397			}
398
399			schedule()
400			return false
401		})
402		// Use LOW priority so initial navigation and other high-priority work runs first
403		glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
404	}
405
406	schedule()
407}
408
409// prewarmSync creates WebViews synchronously.
410func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
411	log := logging.FromContext(ctx)
412
413	for i := 0; i < count && !p.closed.Load(); i++ {
414		// Check if pool is already at capacity
415		if len(p.pool) >= p.config.MaxSize {
416			break
417		}
418
419		wv, err := p.createWebView(ctx)
420		if err != nil {
421			log.Warn().Err(err).Msg("failed to prewarm webview")
422			continue
423		}
424
425		// Try to add to pool
426		if p.tryPut(ctx, wv) {
427			log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
428		} else {
429			// Pool full, destroy
430			wv.DestroyWithPolicy(p.terminatePolicy)
431		}
432
433		// Small delay between creations to avoid overwhelming the system
434		time.Sleep(50 * time.Millisecond)
435	}
436}
437
438// Size returns the current number of WebViews in the pool.
439func (p *WebViewPool) Size() int {
440	return len(p.pool)
441}
442
443// RefreshScripts clears and re-injects scripts to all pooled WebViews.
444// This is needed after adw.Init() to update dark mode preference that was
445// injected with the wrong value during early prewarming.
446func (p *WebViewPool) RefreshScripts(ctx context.Context) {
447	if p.closed.Load() || p.injector == nil {
448		return
449	}
450
451	log := logging.FromContext(ctx)
452
453	// Drain and refresh each WebView, then put it back
454	count := len(p.pool)
455	if count == 0 {
456		return
457	}
458
459	refreshed := 0
460refreshLoop:
461	for i := 0; i < count; i++ {
462		select {
463		case wv := <-p.pool:
464			if wv == nil || wv.IsDestroyed() {
465				continue
466			}
467
468			// Clear existing scripts and re-inject with updated values
469			if wv.ucm != nil {
470				wv.ucm.RemoveAllScripts()
471				wv.ucm.RemoveAllStyleSheets()
472				p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
473			}
474
475			// Put back in pool
476			if p.tryPut(ctx, wv) {
477				refreshed++
478			} else {
479				wv.DestroyWithPolicy(p.terminatePolicy)
480			}
481		default:
482			// Pool was modified during iteration, stop
483			break refreshLoop
484		}
485	}
486
487	log.Debug().
488		Int("refreshed", refreshed).
489		Bool("prefers_dark", p.injector.PrefersDark()).
490		Msg("refreshed scripts in pooled webviews")
491}
492
493// Close shuts down the pool and destroys all pooled WebViews.
494func (p *WebViewPool) Close(ctx context.Context) {
495	if p.closed.Swap(true) {
496		return // Already closed
497	}
498
499	log := logging.FromContext(ctx)
500
501	// Wait for any background operations
502	p.wg.Wait()
503
504	// Drain and destroy all pooled views
505	p.poolMu.Lock()
506	close(p.pool)
507	p.poolMu.Unlock()
508	for wv := range p.pool {
509		if wv != nil && !wv.IsDestroyed() {
510			wv.Destroy()
511		}
512	}
513
514	log.Debug().Msg("webview pool closed")
515}
516
517func (p *WebViewPool) shouldReuseOnRelease(wv *WebView) bool {
518	if p.reusePolicy == reusePolicyOff || wv == nil || wv.IsDestroyed() || wv.IsRelated() {
519		return false
520	}
521	if len(p.pool) >= p.config.MaxSize {
522		return false
523	}
524	switch p.reusePolicy {
525	case reusePolicySafe:
526		// Safe mode: only reuse views that haven't navigated, avoiding
527		// back/forward/page context leaking across tabs.
528		return !wv.HasNavigationActivity()
529	case reusePolicyAggressive:
530		// Aggressive mode: reuse regardless of navigation activity.
531		// The view is reset via ResetForPoolReuse before being returned to the pool.
532		return true
533	default:
534		return false
535	}
536}
537
538func (p *WebViewPool) tryPut(ctx context.Context, wv *WebView) bool {
539	log := logging.FromContext(ctx)
540	p.poolMu.Lock()
541	defer p.poolMu.Unlock()
542	if p.closed.Load() {
543		return false
544	}
545	select {
546	case p.pool <- wv:
547		log.Debug().Uint64("id", uint64(wv.ID())).Int("pool_size", len(p.pool)).Msg("returned webview to pool")
548		return true
549	default:
550		return false
551	}
552}
553
554func resolveReusePolicy(raw string) string {
555	switch strings.ToLower(strings.TrimSpace(defaultIfEmpty(raw, os.Getenv("DUMBER_WEBVIEW_REUSE_POLICY")))) {
556	case reusePolicySafe:
557		return reusePolicySafe
558	case reusePolicyAggressive:
559		return reusePolicyAggressive
560	case reusePolicyOff, "":
561		return reusePolicyOff
562	default:
563		return reusePolicyOff
564	}
565}
566
567func defaultIfEmpty(value, fallback string) string {
568	if strings.TrimSpace(value) != "" {
569		return value
570	}
571	return fallback
572}

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