v0.23.2 bfd5eb4

pool.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"errors"
6	"sync"
7	"sync/atomic"
8	"time"
9
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/puregotk-webkit/webkit"
12	"github.com/jwijenbergh/puregotk/v4/glib"
13)
14
15// ErrPoolClosed is returned when operations are attempted on a closed pool.
16var ErrPoolClosed = errors.New("webview pool is closed")
17
18// FilterApplier applies content filters to a UserContentManager.
19// This interface decouples the pool from the filtering package.
20type FilterApplier interface {
21	ApplyTo(ctx context.Context, ucm *webkit.UserContentManager)
22}
23
24// PoolConfig configures the WebView pool behavior.
25type PoolConfig struct {
26	// MinSize is the minimum number of warm WebViews to maintain.
27	MinSize int
28	// MaxSize is the maximum number of WebViews to keep in the pool.
29	MaxSize int
30	// PrewarmCount is the number of WebViews to pre-create on startup.
31	PrewarmCount int
32	// IdleTimeout is how long idle views stay in the pool before being destroyed.
33	IdleTimeout time.Duration
34}
35
36// DefaultPoolConfig returns sensible defaults for the pool.
37func DefaultPoolConfig() PoolConfig {
38	return PoolConfig{
39		MinSize:      2,
40		MaxSize:      8,
41		PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
42		IdleTimeout:  5 * time.Minute,
43	}
44}
45
46// WebViewPool manages a pool of pre-created WebViews for fast tab creation.
47type WebViewPool struct {
48	wkCtx    *WebKitContext
49	settings *SettingsManager
50	config   PoolConfig
51
52	pool          chan *WebView
53	injector      *ContentInjector
54	router        *MessageRouter
55	filterApplier FilterApplier // Optional content filter applier
56
57	// Background color for WebViews (eliminates white flash)
58	bg bgColor
59
60	closed atomic.Bool
61	wg     sync.WaitGroup
62}
63
64// NewWebViewPool creates a new WebView pool.
65func NewWebViewPool(
66	ctx context.Context,
67	wkCtx *WebKitContext,
68	settings *SettingsManager,
69	cfg PoolConfig,
70	injector *ContentInjector,
71	router *MessageRouter,
72) *WebViewPool {
73	if cfg.MaxSize <= 0 {
74		cfg.MaxSize = 8
75	}
76	if cfg.MinSize <= 0 {
77		cfg.MinSize = 2
78	}
79	if cfg.MinSize > cfg.MaxSize {
80		cfg.MinSize = cfg.MaxSize
81	}
82	if ctx == nil {
83		ctx = context.Background()
84	}
85
86	p := &WebViewPool{
87		wkCtx:    wkCtx,
88		settings: settings,
89		config:   cfg,
90		pool:     make(chan *WebView, cfg.MaxSize),
91		injector: injector,
92		router:   router,
93	}
94
95	log := logging.FromContext(ctx)
96	log.Debug().Msg("creating webview pool")
97
98	return p
99}
100
101// SetFilterApplier sets the content filter applier.
102// Filters will be applied to all newly created WebViews.
103func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
104	p.filterApplier = applier
105}
106
107// SetBackgroundColor sets the background color for newly created WebViews.
108// This color is shown before content is painted, eliminating white flash.
109func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
110	p.bg.set(r, g, b, a)
111}
112
113// Acquire gets a WebView from the pool or creates a new one.
114func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
115	log := logging.FromContext(ctx)
116
117	if p.closed.Load() {
118		return nil, ErrPoolClosed
119	}
120
121	// Try to get from pool first (non-blocking)
122	select {
123	case wv := <-p.pool:
124		if wv != nil && !wv.IsDestroyed() {
125			if r, g, b, a := p.bg.get(); a > 0 {
126				wv.SetBackgroundColor(r, g, b, a)
127			}
128			wv.inner.AddCssClass("webview-themed")
129			// Keep pooled WebViews hidden until we explicitly reveal them.
130			wv.inner.SetVisible(false)
131
132			// Ensure frontend is attached even if this WebView was pooled before injection was configured.
133			_ = wv.AttachFrontend(ctx, p.injector, p.router)
134			// Apply filters if available (may not have been applied during prewarm)
135			if p.filterApplier != nil {
136				p.filterApplier.ApplyTo(ctx, wv.ucm)
137			}
138			log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
139			return wv, nil
140		}
141		// Fall through to create new if the pooled one was destroyed
142	default:
143		// Pool empty
144	}
145
146	// Pool empty or pooled view was destroyed, create new
147	log.Debug().Msg("pool empty, creating new webview")
148	return p.createWebView(ctx)
149}
150
151func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
152	log := logging.FromContext(ctx)
153
154	wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA())
155	if err != nil {
156		return nil, err
157	}
158
159	// Add CSS class for theme background styling (prevents white flash)
160	wv.inner.AddCssClass("webview-themed")
161
162	// Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
163	wv.inner.SetVisible(false)
164
165	if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
166		log.Warn().Err(err).Msg("failed to attach frontend to new webview")
167	}
168
169	// Apply content filters if configured
170	if p.filterApplier != nil {
171		p.filterApplier.ApplyTo(ctx, wv.ucm)
172	}
173
174	return wv, nil
175}
176
177// Release returns a WebView to the pool.
178// For safety, we always destroy and never pool WebViews that have been used.
179// This prevents crashes from GLib signals firing after the widget tree is modified.
180// The pool is only used for prewarmed WebViews that haven't been attached to UI yet.
181func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
182	log := logging.FromContext(ctx)
183
184	if wv == nil || wv.IsDestroyed() || p.closed.Load() {
185		if wv != nil && !wv.IsDestroyed() {
186			wv.Destroy()
187		}
188		return
189	}
190
191	// CRITICAL: Always destroy used WebViews instead of pooling them.
192	// GLib signals are connected with closures that can fire asynchronously
193	// even after we clear Go callbacks. Loading about:blank triggers signals
194	// that can crash if the widget tree has been modified.
195	//
196	// The pool is designed for prewarmed WebViews only, not for recycling
197	// WebViews that have been attached to the UI.
198	log.Debug().Uint64("id", uint64(wv.ID())).Msg("destroying webview (not pooling used views)")
199	wv.Destroy()
200}
201
202// PrewarmFirst creates exactly one WebView synchronously.
203// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
204// This is the most impactful optimization for cold start since WebView creation
205// is the heaviest operation (spawns WebKit web process).
206// Returns error if WebView creation fails; caller should log and continue.
207func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
208	if p.closed.Load() {
209		return ErrPoolClosed
210	}
211	if len(p.pool) > 0 {
212		return nil // Already have at least one
213	}
214
215	log := logging.FromContext(ctx)
216	wv, err := p.createWebView(ctx)
217	if err != nil {
218		return err
219	}
220
221	select {
222	case p.pool <- wv:
223		log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
224	default:
225		wv.Destroy() // Pool somehow full (shouldn't happen)
226	}
227	return nil
228}
229
230// Prewarm creates WebViews synchronously to populate the pool.
231// Must be called from the GTK main thread (after GTK application is initialized).
232func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
233	if count <= 0 {
234		count = p.config.PrewarmCount
235	}
236	if count <= 0 {
237		return
238	}
239	p.prewarmSync(ctx, count)
240}
241
242// PrewarmAsync schedules WebView creation on the GTK idle loop.
243// This avoids blocking startup (especially cold-start navigation) while still
244// warming up WebViews for subsequent tab creation.
245func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
246	log := logging.FromContext(ctx)
247
248	if count <= 0 {
249		count = p.config.PrewarmCount
250	}
251	if count <= 0 {
252		return
253	}
254	if p.closed.Load() {
255		return
256	}
257
258	log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm")
259
260	p.wg.Add(1)
261	var doneOnce sync.Once
262	remaining := count
263
264	var schedule func()
265	schedule = func() {
266		// Stop early if the caller canceled the operation.
267		if err := ctx.Err(); err != nil {
268			log.Debug().Err(err).Msg("async webview pool prewarm canceled")
269			doneOnce.Do(p.wg.Done)
270			return
271		}
272
273		cb := glib.SourceFunc(func(_ uintptr) bool {
274			// Stop if the caller canceled the operation.
275			if err := ctx.Err(); err != nil {
276				log.Debug().Err(err).Msg("async webview pool prewarm canceled")
277				doneOnce.Do(p.wg.Done)
278				return false
279			}
280
281			// Stop if pool is closing/closed.
282			if p.closed.Load() {
283				doneOnce.Do(p.wg.Done)
284				return false
285			}
286
287			// Stop early if we reached capacity.
288			if len(p.pool) >= p.config.MaxSize {
289				doneOnce.Do(p.wg.Done)
290				return false
291			}
292
293			wv, err := p.createWebView(ctx)
294			if err != nil {
295				log.Warn().Err(err).Msg("failed to prewarm webview")
296			} else {
297				select {
298				case p.pool <- wv:
299					log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
300				default:
301					wv.Destroy()
302				}
303			}
304
305			remaining--
306			if remaining <= 0 {
307				log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete")
308				doneOnce.Do(p.wg.Done)
309				return false
310			}
311
312			schedule()
313			return false
314		})
315		glib.IdleAdd(&cb, 0)
316	}
317
318	schedule()
319}
320
321// prewarmSync creates WebViews synchronously.
322func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
323	log := logging.FromContext(ctx)
324
325	for i := 0; i < count && !p.closed.Load(); i++ {
326		// Check if pool is already at capacity
327		if len(p.pool) >= p.config.MaxSize {
328			break
329		}
330
331		wv, err := p.createWebView(ctx)
332		if err != nil {
333			log.Warn().Err(err).Msg("failed to prewarm webview")
334			continue
335		}
336
337		// Try to add to pool
338		select {
339		case p.pool <- wv:
340			log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
341		default:
342			// Pool full, destroy
343			wv.Destroy()
344		}
345
346		// Small delay between creations to avoid overwhelming the system
347		time.Sleep(50 * time.Millisecond)
348	}
349}
350
351// Size returns the current number of WebViews in the pool.
352func (p *WebViewPool) Size() int {
353	return len(p.pool)
354}
355
356// RefreshScripts clears and re-injects scripts to all pooled WebViews.
357// This is needed after adw.Init() to update dark mode preference that was
358// injected with the wrong value during early prewarming.
359func (p *WebViewPool) RefreshScripts(ctx context.Context) {
360	if p.closed.Load() || p.injector == nil {
361		return
362	}
363
364	log := logging.FromContext(ctx)
365
366	// Drain and refresh each WebView, then put it back
367	count := len(p.pool)
368	if count == 0 {
369		return
370	}
371
372	refreshed := 0
373refreshLoop:
374	for i := 0; i < count; i++ {
375		select {
376		case wv := <-p.pool:
377			if wv == nil || wv.IsDestroyed() {
378				continue
379			}
380
381			// Clear existing scripts and re-inject with updated values
382			if wv.ucm != nil {
383				wv.ucm.RemoveAllScripts()
384				wv.ucm.RemoveAllStyleSheets()
385				p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
386			}
387
388			// Put back in pool
389			select {
390			case p.pool <- wv:
391				refreshed++
392			default:
393				wv.Destroy()
394			}
395		default:
396			// Pool was modified during iteration, stop
397			break refreshLoop
398		}
399	}
400
401	log.Debug().
402		Int("refreshed", refreshed).
403		Bool("prefers_dark", p.injector.PrefersDark()).
404		Msg("refreshed scripts in pooled webviews")
405}
406
407// Close shuts down the pool and destroys all pooled WebViews.
408func (p *WebViewPool) Close(ctx context.Context) {
409	if p.closed.Swap(true) {
410		return // Already closed
411	}
412
413	log := logging.FromContext(ctx)
414
415	// Wait for any background operations
416	p.wg.Wait()
417
418	// Drain and destroy all pooled views
419	close(p.pool)
420	for wv := range p.pool {
421		if wv != nil && !wv.IsDestroyed() {
422			wv.Destroy()
423		}
424	}
425
426	log.Debug().Msg("webview pool closed")
427}

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