v0.30.0 eb3068f

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

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