v0.27.2 5960dd8

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

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