v0.29.0 21b3150

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

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