detached 11bef86

perf(browser): defer startup work and warm WebView pool

commit
11bef86e58fea5d42763241e4118384bac7a1499
author
bnema · · 2025-12-06 02:49 UTC
parent
4b825fa
signature
unsigned
5 files · +940 -22
M internal/app/browser/browser.go +77 -12
@@ -65,6 +65,10 @@ type BrowserApp struct {
6565 messageHandler *messaging.Handler
6666 shortcutHandler *ShortcutHandler
6767 windowShortcutHandler WindowShortcutHandlerInterface
68+
69+ // Performance optimization components
70+ webViewPool *WebViewPool // Pre-created WebViews for instant tab/pane creation
71+ prefetchManager *PrefetchManager // DNS prefetching for faster page loads
6872 }
6973
7074 // Run starts the browser application
@@ -130,8 +134,8 @@ func (app *BrowserApp) Initialize() error {
130134
131135 logging.Info(fmt.Sprintf("Config initialized"))
132136
133- // Detect keyboard layout
134- environment.DetectAndSetKeyboardLocale()
137+ // Detect keyboard layout in background (required for shortcuts)
138+ go environment.DetectAndSetKeyboardLocale()
135139
136140 // Initialize database (migrations run automatically in InitDB)
137141 database, err := db.InitDB(app.config.Database.Path)
@@ -142,10 +146,13 @@ func (app *BrowserApp) Initialize() error {
142146 app.queries = db.New(database)
143147 logging.Info(fmt.Sprintf("Database opened at %s", app.config.Database.Path))
144148
145- // Cleanup expired certificate validations
146- if err := webkit.CleanupExpiredCertificateValidations(); err != nil {
147- logging.Warn(fmt.Sprintf("Warning: failed to cleanup expired certificate validations: %v", err))
148- }
149+ // DEFERRED: Cleanup expired certificate validations in background
150+ go func() {
151+ time.Sleep(500 * time.Millisecond) // Low priority maintenance task
152+ if err := webkit.CleanupExpiredCertificateValidations(); err != nil {
153+ logging.Warn(fmt.Sprintf("Warning: failed to cleanup expired certificate validations: %v", err))
154+ }
155+ }()
149156
150157 // Initialize services
151158 app.parserService = services.NewParserService(app.config, app.queries)
@@ -161,9 +168,47 @@ func (app *BrowserApp) Initialize() error {
161168 app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService)
162169 app.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
163170
171+ // Initialize prefetch manager for DNS preloading
172+ app.prefetchManager = NewPrefetchManager(DefaultPrefetchConfig(), app.queries)
173+
174+ // Initialize WebView pool for instant tab/pane creation
175+ // Pool starts warming up after main window shows (see Run method)
176+ app.webViewPool = NewWebViewPool(2, 4, app.getWebViewConfig)
177+
164178 return nil
165179 }
166180
181+// getWebViewConfig returns the configuration for creating pooled WebViews
182+func (app *BrowserApp) getWebViewConfig() *webkit.Config {
183+ dataDir, _ := config.GetDataDir()
184+ cacheDir, _ := config.GetFilterCacheDir()
185+
186+ return &webkit.Config{
187+ EnableJavaScript: true,
188+ EnableWebGL: true,
189+ DefaultFontSize: app.config.Appearance.DefaultFontSize,
190+ MinimumFontSize: 8, // Reasonable default
191+ UserAgent: app.config.CodecPreferences.CustomUserAgent,
192+ DataDir: dataDir,
193+ CacheDir: cacheDir,
194+ EnablePageCache: true,
195+ EnableSmoothScrolling: true,
196+ CreateWindow: false, // Pool WebViews don't create their own window
197+ }
198+}
199+
200+// GetWebViewPool returns the WebView pool for creating new tabs/panes.
201+// Returns nil if pool is not initialized.
202+func (app *BrowserApp) GetWebViewPool() *WebViewPool {
203+ return app.webViewPool
204+}
205+
206+// GetPrefetchManager returns the prefetch manager for DNS preloading.
207+// Returns nil if prefetch manager is not initialized.
208+func (app *BrowserApp) GetPrefetchManager() *PrefetchManager {
209+ return app.prefetchManager
210+}
211+
167212 // loadCachesParallel loads all caches in parallel for fast startup.
168213 // Uses errgroup to coordinate parallel loads and capture any errors.
169214 // Target: <100ms for all caches combined.
@@ -235,12 +280,14 @@ func (app *BrowserApp) Run() {
235280 // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)
236281 webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)
237282
238- // Initialize content blocking BEFORE creating WebViews so the service is ready
239- // to register WebViews as they are created (tabs, workspaces, popups)
240- if err := app.setupContentBlocking(); err != nil {
241- logging.Warn(fmt.Sprintf("Warning: failed to setup content blocking: %v", err))
242- // Continue without content blocking
243- }
283+ // DEFERRED: Initialize content blocking AFTER window shows to speed up boot
284+ // Content filters load from cache quickly (~50ms) and apply to new navigations
285+ go func() {
286+ time.Sleep(100 * time.Millisecond) // Let window render first
287+ if err := app.setupContentBlocking(); err != nil {
288+ logging.Warn(fmt.Sprintf("Warning: failed to setup content blocking: %v", err))
289+ }
290+ }()
244291
245292 // Create and setup WebView (this also creates TabManager which creates more WebViews)
246293 if err := app.createWebView(); err != nil {
@@ -253,6 +300,17 @@ func (app *BrowserApp) Run() {
253300 logging.Warn(fmt.Sprintf("Warning: failed to register URI scheme handlers: %v", err))
254301 }
255302
303+ // POST-VISIBLE: Start WebView pool warmup for instant tab/pane creation
304+ if app.webViewPool != nil {
305+ app.webViewPool.Start()
306+ }
307+
308+ // POST-VISIBLE: Start DNS prefetching from history and common domains
309+ if app.prefetchManager != nil {
310+ app.prefetchManager.PrefetchCommonDomains()
311+ app.prefetchManager.PrefetchFromHistory(context.Background())
312+ }
313+
256314 // Handle browse command if present (must use active tab's navigation controller)
257315 if app.tabManager != nil {
258316 activeTab := app.tabManager.GetActiveTab()
@@ -278,6 +336,13 @@ func (app *BrowserApp) Run() {
278336 func (app *BrowserApp) cleanup() {
279337 logging.Info(fmt.Sprintf("Starting browser cleanup..."))
280338
339+ // Stop WebView pool first (destroys pre-created WebViews)
340+ if app.webViewPool != nil {
341+ logging.Info(fmt.Sprintf("Stopping WebView pool"))
342+ app.webViewPool.Stop()
343+ app.webViewPool = nil
344+ }
345+
281346 // Cleanup window shortcuts first
282347 if app.windowShortcutHandler != nil {
283348 logging.Info(fmt.Sprintf("Cleaning up window shortcuts"))
@@ -65,6 +65,10 @@ type BrowserApp struct {
65 messageHandler *messaging.Handler65 messageHandler *messaging.Handler
66 shortcutHandler *ShortcutHandler66 shortcutHandler *ShortcutHandler
67 windowShortcutHandler WindowShortcutHandlerInterface67 windowShortcutHandler WindowShortcutHandlerInterface
68+
69+ // Performance optimization components
70+ webViewPool *WebViewPool // Pre-created WebViews for instant tab/pane creation
71+ prefetchManager *PrefetchManager // DNS prefetching for faster page loads
68 }72 }
69 73
70 // Run starts the browser application74 // Run starts the browser application
@@ -130,8 +134,8 @@ func (app *BrowserApp) Initialize() error {
130 134
131 logging.Info(fmt.Sprintf("Config initialized"))135 logging.Info(fmt.Sprintf("Config initialized"))
132 136
133- // Detect keyboard layout137+ // Detect keyboard layout in background (required for shortcuts)
134- environment.DetectAndSetKeyboardLocale()138+ go environment.DetectAndSetKeyboardLocale()
135 139
136 // Initialize database (migrations run automatically in InitDB)140 // Initialize database (migrations run automatically in InitDB)
137 database, err := db.InitDB(app.config.Database.Path)141 database, err := db.InitDB(app.config.Database.Path)
@@ -142,10 +146,13 @@ func (app *BrowserApp) Initialize() error {
142 app.queries = db.New(database)146 app.queries = db.New(database)
143 logging.Info(fmt.Sprintf("Database opened at %s", app.config.Database.Path))147 logging.Info(fmt.Sprintf("Database opened at %s", app.config.Database.Path))
144 148
145- // Cleanup expired certificate validations149+ // DEFERRED: Cleanup expired certificate validations in background
146- if err := webkit.CleanupExpiredCertificateValidations(); err != nil {150+ go func() {
147- logging.Warn(fmt.Sprintf("Warning: failed to cleanup expired certificate validations: %v", err))151+ time.Sleep(500 * time.Millisecond) // Low priority maintenance task
148- }152+ if err := webkit.CleanupExpiredCertificateValidations(); err != nil {
153+ logging.Warn(fmt.Sprintf("Warning: failed to cleanup expired certificate validations: %v", err))
154+ }
155+ }()
149 156
150 // Initialize services157 // Initialize services
151 app.parserService = services.NewParserService(app.config, app.queries)158 app.parserService = services.NewParserService(app.config, app.queries)
@@ -161,9 +168,47 @@ func (app *BrowserApp) Initialize() error {
161 app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService)168 app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService)
162 app.messageHandler = messaging.NewHandler(app.parserService, app.browserService)169 app.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
163 170
171+ // Initialize prefetch manager for DNS preloading
172+ app.prefetchManager = NewPrefetchManager(DefaultPrefetchConfig(), app.queries)
173+
174+ // Initialize WebView pool for instant tab/pane creation
175+ // Pool starts warming up after main window shows (see Run method)
176+ app.webViewPool = NewWebViewPool(2, 4, app.getWebViewConfig)
177+
164 return nil178 return nil
165 }179 }
166 180
181+// getWebViewConfig returns the configuration for creating pooled WebViews
182+func (app *BrowserApp) getWebViewConfig() *webkit.Config {
183+ dataDir, _ := config.GetDataDir()
184+ cacheDir, _ := config.GetFilterCacheDir()
185+
186+ return &webkit.Config{
187+ EnableJavaScript: true,
188+ EnableWebGL: true,
189+ DefaultFontSize: app.config.Appearance.DefaultFontSize,
190+ MinimumFontSize: 8, // Reasonable default
191+ UserAgent: app.config.CodecPreferences.CustomUserAgent,
192+ DataDir: dataDir,
193+ CacheDir: cacheDir,
194+ EnablePageCache: true,
195+ EnableSmoothScrolling: true,
196+ CreateWindow: false, // Pool WebViews don't create their own window
197+ }
198+}
199+
200+// GetWebViewPool returns the WebView pool for creating new tabs/panes.
201+// Returns nil if pool is not initialized.
202+func (app *BrowserApp) GetWebViewPool() *WebViewPool {
203+ return app.webViewPool
204+}
205+
206+// GetPrefetchManager returns the prefetch manager for DNS preloading.
207+// Returns nil if prefetch manager is not initialized.
208+func (app *BrowserApp) GetPrefetchManager() *PrefetchManager {
209+ return app.prefetchManager
210+}
211+
167 // loadCachesParallel loads all caches in parallel for fast startup.212 // loadCachesParallel loads all caches in parallel for fast startup.
168 // Uses errgroup to coordinate parallel loads and capture any errors.213 // Uses errgroup to coordinate parallel loads and capture any errors.
169 // Target: <100ms for all caches combined.214 // Target: <100ms for all caches combined.
@@ -235,12 +280,14 @@ func (app *BrowserApp) Run() {
235 // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)280 // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)
236 webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)281 webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)
237 282
238- // Initialize content blocking BEFORE creating WebViews so the service is ready283+ // DEFERRED: Initialize content blocking AFTER window shows to speed up boot
239- // to register WebViews as they are created (tabs, workspaces, popups)284+ // Content filters load from cache quickly (~50ms) and apply to new navigations
240- if err := app.setupContentBlocking(); err != nil {285+ go func() {
241- logging.Warn(fmt.Sprintf("Warning: failed to setup content blocking: %v", err))286+ time.Sleep(100 * time.Millisecond) // Let window render first
242- // Continue without content blocking287+ if err := app.setupContentBlocking(); err != nil {
243- }288+ logging.Warn(fmt.Sprintf("Warning: failed to setup content blocking: %v", err))
289+ }
290+ }()
244 291
245 // Create and setup WebView (this also creates TabManager which creates more WebViews)292 // Create and setup WebView (this also creates TabManager which creates more WebViews)
246 if err := app.createWebView(); err != nil {293 if err := app.createWebView(); err != nil {
@@ -253,6 +300,17 @@ func (app *BrowserApp) Run() {
253 logging.Warn(fmt.Sprintf("Warning: failed to register URI scheme handlers: %v", err))300 logging.Warn(fmt.Sprintf("Warning: failed to register URI scheme handlers: %v", err))
254 }301 }
255 302
303+ // POST-VISIBLE: Start WebView pool warmup for instant tab/pane creation
304+ if app.webViewPool != nil {
305+ app.webViewPool.Start()
306+ }
307+
308+ // POST-VISIBLE: Start DNS prefetching from history and common domains
309+ if app.prefetchManager != nil {
310+ app.prefetchManager.PrefetchCommonDomains()
311+ app.prefetchManager.PrefetchFromHistory(context.Background())
312+ }
313+
256 // Handle browse command if present (must use active tab's navigation controller)314 // Handle browse command if present (must use active tab's navigation controller)
257 if app.tabManager != nil {315 if app.tabManager != nil {
258 activeTab := app.tabManager.GetActiveTab()316 activeTab := app.tabManager.GetActiveTab()
@@ -278,6 +336,13 @@ func (app *BrowserApp) Run() {
278 func (app *BrowserApp) cleanup() {336 func (app *BrowserApp) cleanup() {
279 logging.Info(fmt.Sprintf("Starting browser cleanup..."))337 logging.Info(fmt.Sprintf("Starting browser cleanup..."))
280 338
339+ // Stop WebView pool first (destroys pre-created WebViews)
340+ if app.webViewPool != nil {
341+ logging.Info(fmt.Sprintf("Stopping WebView pool"))
342+ app.webViewPool.Stop()
343+ app.webViewPool = nil
344+ }
345+
281 // Cleanup window shortcuts first346 // Cleanup window shortcuts first
282 if app.windowShortcutHandler != nil {347 if app.windowShortcutHandler != nil {
283 logging.Info(fmt.Sprintf("Cleaning up window shortcuts"))348 logging.Info(fmt.Sprintf("Cleaning up window shortcuts"))
A internal/app/browser/deferred_init.go +204 -0
@@ -0,0 +1,204 @@
1+// Package browser provides the main browser application components.
2+package browser
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+ "sync/atomic"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// DeferredState represents the state of a deferred initialization
15+type DeferredState int32
16+
17+const (
18+ DeferredStatePending DeferredState = iota
19+ DeferredStateInitializing
20+ DeferredStateReady
21+ DeferredStateFailed
22+)
23+
24+// DeferredInit manages lazy initialization of optional services.
25+// Services are only initialized when first accessed, reducing boot time.
26+type DeferredInit[T any] struct {
27+ name string
28+ initFunc func(ctx context.Context) (T, error)
29+ timeout time.Duration
30+
31+ mu sync.RWMutex
32+ state int32 // atomic: DeferredState
33+ value T
34+ err error
35+ initOnce sync.Once
36+}
37+
38+// NewDeferredInit creates a new deferred initializer.
39+// name: identifier for logging
40+// initFunc: function to create the value (called at most once)
41+// timeout: maximum time to wait for initialization (0 = no timeout)
42+func NewDeferredInit[T any](name string, initFunc func(ctx context.Context) (T, error), timeout time.Duration) *DeferredInit[T] {
43+ return &DeferredInit[T]{
44+ name: name,
45+ initFunc: initFunc,
46+ timeout: timeout,
47+ }
48+}
49+
50+// Get returns the initialized value, initializing it on first call.
51+// Subsequent calls return the cached value or error.
52+// Thread-safe: multiple goroutines can call Get() simultaneously.
53+func (d *DeferredInit[T]) Get(ctx context.Context) (T, error) {
54+ // Fast path: already initialized
55+ state := DeferredState(atomic.LoadInt32(&d.state))
56+ if state == DeferredStateReady {
57+ d.mu.RLock()
58+ v := d.value
59+ d.mu.RUnlock()
60+ return v, nil
61+ }
62+ if state == DeferredStateFailed {
63+ d.mu.RLock()
64+ err := d.err
65+ d.mu.RUnlock()
66+ var zero T
67+ return zero, err
68+ }
69+
70+ // Slow path: need to initialize
71+ d.initOnce.Do(func() {
72+ d.initialize(ctx)
73+ })
74+
75+ // Return result
76+ d.mu.RLock()
77+ defer d.mu.RUnlock()
78+
79+ if d.err != nil {
80+ var zero T
81+ return zero, d.err
82+ }
83+ return d.value, nil
84+}
85+
86+// initialize performs the actual initialization
87+func (d *DeferredInit[T]) initialize(ctx context.Context) {
88+ atomic.StoreInt32(&d.state, int32(DeferredStateInitializing))
89+ startTime := time.Now()
90+
91+ logging.Debug(fmt.Sprintf("[deferred] Initializing %s...", d.name))
92+
93+ // Apply timeout if configured
94+ if d.timeout > 0 {
95+ var cancel context.CancelFunc
96+ ctx, cancel = context.WithTimeout(ctx, d.timeout)
97+ defer cancel()
98+ }
99+
100+ value, err := d.initFunc(ctx)
101+
102+ d.mu.Lock()
103+ defer d.mu.Unlock()
104+
105+ elapsed := time.Since(startTime)
106+
107+ if err != nil {
108+ d.err = fmt.Errorf("deferred init %s failed: %w", d.name, err)
109+ atomic.StoreInt32(&d.state, int32(DeferredStateFailed))
110+ logging.Error(fmt.Sprintf("[deferred] %s initialization failed after %v: %v", d.name, elapsed, err))
111+ return
112+ }
113+
114+ d.value = value
115+ atomic.StoreInt32(&d.state, int32(DeferredStateReady))
116+ logging.Info(fmt.Sprintf("[deferred] %s initialized in %v", d.name, elapsed))
117+}
118+
119+// TryGet returns the value if already initialized, without blocking.
120+// Returns (value, true) if ready, or (zero, false) if not yet initialized.
121+func (d *DeferredInit[T]) TryGet() (T, bool) {
122+ state := DeferredState(atomic.LoadInt32(&d.state))
123+ if state != DeferredStateReady {
124+ var zero T
125+ return zero, false
126+ }
127+
128+ d.mu.RLock()
129+ v := d.value
130+ d.mu.RUnlock()
131+ return v, true
132+}
133+
134+// IsReady returns true if initialization has completed successfully
135+func (d *DeferredInit[T]) IsReady() bool {
136+ return DeferredState(atomic.LoadInt32(&d.state)) == DeferredStateReady
137+}
138+
139+// IsFailed returns true if initialization failed
140+func (d *DeferredInit[T]) IsFailed() bool {
141+ return DeferredState(atomic.LoadInt32(&d.state)) == DeferredStateFailed
142+}
143+
144+// State returns the current initialization state
145+func (d *DeferredInit[T]) State() DeferredState {
146+ return DeferredState(atomic.LoadInt32(&d.state))
147+}
148+
149+// Name returns the identifier of this deferred initializer
150+func (d *DeferredInit[T]) Name() string {
151+ return d.name
152+}
153+
154+// Error returns the initialization error, if any
155+func (d *DeferredInit[T]) Error() error {
156+ d.mu.RLock()
157+ defer d.mu.RUnlock()
158+ return d.err
159+}
160+
161+// DeferredInitGroup manages a collection of deferred initializers
162+type DeferredInitGroup struct {
163+ mu sync.RWMutex
164+ items map[string]interface{ State() DeferredState }
165+}
166+
167+// NewDeferredInitGroup creates a new group for tracking deferred initializers
168+func NewDeferredInitGroup() *DeferredInitGroup {
169+ return &DeferredInitGroup{
170+ items: make(map[string]interface{ State() DeferredState }),
171+ }
172+}
173+
174+// Register adds a deferred initializer to the group
175+func (g *DeferredInitGroup) Register(name string, item interface{ State() DeferredState }) {
176+ g.mu.Lock()
177+ defer g.mu.Unlock()
178+ g.items[name] = item
179+}
180+
181+// Stats returns statistics about all registered initializers
182+func (g *DeferredInitGroup) Stats() map[string]DeferredState {
183+ g.mu.RLock()
184+ defer g.mu.RUnlock()
185+
186+ stats := make(map[string]DeferredState, len(g.items))
187+ for name, item := range g.items {
188+ stats[name] = item.State()
189+ }
190+ return stats
191+}
192+
193+// AllReady returns true if all registered initializers are ready
194+func (g *DeferredInitGroup) AllReady() bool {
195+ g.mu.RLock()
196+ defer g.mu.RUnlock()
197+
198+ for _, item := range g.items {
199+ if item.State() != DeferredStateReady {
200+ return false
201+ }
202+ }
203+ return true
204+}
@@ -0,0 +1,204 @@
1+// Package browser provides the main browser application components.
2+package browser
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+ "sync/atomic"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// DeferredState represents the state of a deferred initialization
15+type DeferredState int32
16+
17+const (
18+ DeferredStatePending DeferredState = iota
19+ DeferredStateInitializing
20+ DeferredStateReady
21+ DeferredStateFailed
22+)
23+
24+// DeferredInit manages lazy initialization of optional services.
25+// Services are only initialized when first accessed, reducing boot time.
26+type DeferredInit[T any] struct {
27+ name string
28+ initFunc func(ctx context.Context) (T, error)
29+ timeout time.Duration
30+
31+ mu sync.RWMutex
32+ state int32 // atomic: DeferredState
33+ value T
34+ err error
35+ initOnce sync.Once
36+}
37+
38+// NewDeferredInit creates a new deferred initializer.
39+// name: identifier for logging
40+// initFunc: function to create the value (called at most once)
41+// timeout: maximum time to wait for initialization (0 = no timeout)
42+func NewDeferredInit[T any](name string, initFunc func(ctx context.Context) (T, error), timeout time.Duration) *DeferredInit[T] {
43+ return &DeferredInit[T]{
44+ name: name,
45+ initFunc: initFunc,
46+ timeout: timeout,
47+ }
48+}
49+
50+// Get returns the initialized value, initializing it on first call.
51+// Subsequent calls return the cached value or error.
52+// Thread-safe: multiple goroutines can call Get() simultaneously.
53+func (d *DeferredInit[T]) Get(ctx context.Context) (T, error) {
54+ // Fast path: already initialized
55+ state := DeferredState(atomic.LoadInt32(&d.state))
56+ if state == DeferredStateReady {
57+ d.mu.RLock()
58+ v := d.value
59+ d.mu.RUnlock()
60+ return v, nil
61+ }
62+ if state == DeferredStateFailed {
63+ d.mu.RLock()
64+ err := d.err
65+ d.mu.RUnlock()
66+ var zero T
67+ return zero, err
68+ }
69+
70+ // Slow path: need to initialize
71+ d.initOnce.Do(func() {
72+ d.initialize(ctx)
73+ })
74+
75+ // Return result
76+ d.mu.RLock()
77+ defer d.mu.RUnlock()
78+
79+ if d.err != nil {
80+ var zero T
81+ return zero, d.err
82+ }
83+ return d.value, nil
84+}
85+
86+// initialize performs the actual initialization
87+func (d *DeferredInit[T]) initialize(ctx context.Context) {
88+ atomic.StoreInt32(&d.state, int32(DeferredStateInitializing))
89+ startTime := time.Now()
90+
91+ logging.Debug(fmt.Sprintf("[deferred] Initializing %s...", d.name))
92+
93+ // Apply timeout if configured
94+ if d.timeout > 0 {
95+ var cancel context.CancelFunc
96+ ctx, cancel = context.WithTimeout(ctx, d.timeout)
97+ defer cancel()
98+ }
99+
100+ value, err := d.initFunc(ctx)
101+
102+ d.mu.Lock()
103+ defer d.mu.Unlock()
104+
105+ elapsed := time.Since(startTime)
106+
107+ if err != nil {
108+ d.err = fmt.Errorf("deferred init %s failed: %w", d.name, err)
109+ atomic.StoreInt32(&d.state, int32(DeferredStateFailed))
110+ logging.Error(fmt.Sprintf("[deferred] %s initialization failed after %v: %v", d.name, elapsed, err))
111+ return
112+ }
113+
114+ d.value = value
115+ atomic.StoreInt32(&d.state, int32(DeferredStateReady))
116+ logging.Info(fmt.Sprintf("[deferred] %s initialized in %v", d.name, elapsed))
117+}
118+
119+// TryGet returns the value if already initialized, without blocking.
120+// Returns (value, true) if ready, or (zero, false) if not yet initialized.
121+func (d *DeferredInit[T]) TryGet() (T, bool) {
122+ state := DeferredState(atomic.LoadInt32(&d.state))
123+ if state != DeferredStateReady {
124+ var zero T
125+ return zero, false
126+ }
127+
128+ d.mu.RLock()
129+ v := d.value
130+ d.mu.RUnlock()
131+ return v, true
132+}
133+
134+// IsReady returns true if initialization has completed successfully
135+func (d *DeferredInit[T]) IsReady() bool {
136+ return DeferredState(atomic.LoadInt32(&d.state)) == DeferredStateReady
137+}
138+
139+// IsFailed returns true if initialization failed
140+func (d *DeferredInit[T]) IsFailed() bool {
141+ return DeferredState(atomic.LoadInt32(&d.state)) == DeferredStateFailed
142+}
143+
144+// State returns the current initialization state
145+func (d *DeferredInit[T]) State() DeferredState {
146+ return DeferredState(atomic.LoadInt32(&d.state))
147+}
148+
149+// Name returns the identifier of this deferred initializer
150+func (d *DeferredInit[T]) Name() string {
151+ return d.name
152+}
153+
154+// Error returns the initialization error, if any
155+func (d *DeferredInit[T]) Error() error {
156+ d.mu.RLock()
157+ defer d.mu.RUnlock()
158+ return d.err
159+}
160+
161+// DeferredInitGroup manages a collection of deferred initializers
162+type DeferredInitGroup struct {
163+ mu sync.RWMutex
164+ items map[string]interface{ State() DeferredState }
165+}
166+
167+// NewDeferredInitGroup creates a new group for tracking deferred initializers
168+func NewDeferredInitGroup() *DeferredInitGroup {
169+ return &DeferredInitGroup{
170+ items: make(map[string]interface{ State() DeferredState }),
171+ }
172+}
173+
174+// Register adds a deferred initializer to the group
175+func (g *DeferredInitGroup) Register(name string, item interface{ State() DeferredState }) {
176+ g.mu.Lock()
177+ defer g.mu.Unlock()
178+ g.items[name] = item
179+}
180+
181+// Stats returns statistics about all registered initializers
182+func (g *DeferredInitGroup) Stats() map[string]DeferredState {
183+ g.mu.RLock()
184+ defer g.mu.RUnlock()
185+
186+ stats := make(map[string]DeferredState, len(g.items))
187+ for name, item := range g.items {
188+ stats[name] = item.State()
189+ }
190+ return stats
191+}
192+
193+// AllReady returns true if all registered initializers are ready
194+func (g *DeferredInitGroup) AllReady() bool {
195+ g.mu.RLock()
196+ defer g.mu.RUnlock()
197+
198+ for _, item := range g.items {
199+ if item.State() != DeferredStateReady {
200+ return false
201+ }
202+ }
203+ return true
204+}
A internal/app/browser/prefetch.go +306 -0
@@ -0,0 +1,306 @@
1+// Package browser provides the main browser application components.
2+package browser
3+
4+import (
5+ "context"
6+ "fmt"
7+ "net/url"
8+ "sync"
9+ "sync/atomic"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/db"
13+ "github.com/bnema/dumber/internal/logging"
14+ "github.com/bnema/dumber/pkg/webkit"
15+)
16+
17+// PrefetchManager handles DNS prefetching and resource preloading.
18+// Improves page load times by resolving DNS before navigation.
19+type PrefetchManager struct {
20+ mu sync.RWMutex
21+
22+ // Configuration
23+ enabled int32 // atomic: 1 = enabled, 0 = disabled
24+ maxPrefetches int // Maximum concurrent prefetches
25+ throttleDelay time.Duration // Minimum delay between prefetches for same domain
26+ maxCacheSize int // Maximum entries in recentPrefetches before cleanup
27+
28+ // State tracking
29+ recentPrefetches map[string]time.Time // domain -> last prefetch time
30+ prefetchCount int64
31+
32+ // Database access for history-based prefetching
33+ queries db.DatabaseQuerier
34+}
35+
36+// PrefetchConfig contains configuration for the prefetch manager
37+type PrefetchConfig struct {
38+ Enabled bool
39+ MaxPrefetches int
40+ ThrottleDelay time.Duration
41+}
42+
43+// DefaultPrefetchConfig returns sensible default configuration
44+func DefaultPrefetchConfig() PrefetchConfig {
45+ return PrefetchConfig{
46+ Enabled: true,
47+ MaxPrefetches: 10,
48+ ThrottleDelay: 100 * time.Millisecond,
49+ }
50+}
51+
52+// NewPrefetchManager creates a new prefetch manager
53+func NewPrefetchManager(cfg PrefetchConfig, queries db.DatabaseQuerier) *PrefetchManager {
54+ var enabled int32
55+ if cfg.Enabled {
56+ enabled = 1
57+ }
58+ return &PrefetchManager{
59+ enabled: enabled,
60+ maxPrefetches: cfg.MaxPrefetches,
61+ throttleDelay: cfg.ThrottleDelay,
62+ maxCacheSize: 500, // Limit cache to prevent unbounded growth
63+ recentPrefetches: make(map[string]time.Time),
64+ queries: queries,
65+ }
66+}
67+
68+// isEnabled returns true if prefetching is enabled (atomic read)
69+func (pm *PrefetchManager) isEnabled() bool {
70+ return atomic.LoadInt32(&pm.enabled) == 1
71+}
72+
73+// cleanupOldEntries removes old entries from the cache to prevent unbounded growth
74+func (pm *PrefetchManager) cleanupOldEntries() {
75+ // Only cleanup if we exceed max size
76+ if len(pm.recentPrefetches) <= pm.maxCacheSize {
77+ return
78+ }
79+
80+ cutoff := time.Now().Add(-10 * pm.throttleDelay)
81+ for hostname, lastTime := range pm.recentPrefetches {
82+ if lastTime.Before(cutoff) {
83+ delete(pm.recentPrefetches, hostname)
84+ }
85+ }
86+}
87+
88+// PrefetchDNS prefetches DNS for the given URL.
89+// Throttles requests to avoid excessive DNS queries.
90+func (pm *PrefetchManager) PrefetchDNS(rawURL string) {
91+ if !pm.isEnabled() || rawURL == "" {
92+ return
93+ }
94+
95+ // Extract hostname from URL
96+ hostname := pm.extractHostname(rawURL)
97+ if hostname == "" {
98+ return
99+ }
100+
101+ // Check throttle
102+ pm.mu.Lock()
103+ if lastPrefetch, exists := pm.recentPrefetches[hostname]; exists {
104+ if time.Since(lastPrefetch) < pm.throttleDelay {
105+ pm.mu.Unlock()
106+ return
107+ }
108+ }
109+ pm.recentPrefetches[hostname] = time.Now()
110+ pm.prefetchCount++
111+ pm.cleanupOldEntries() // Prevent unbounded growth
112+ pm.mu.Unlock()
113+
114+ // Perform DNS prefetch
115+ webkit.PrefetchDNS(hostname)
116+ logging.Debug(fmt.Sprintf("[prefetch] DNS prefetch for %s", hostname))
117+}
118+
119+// PrefetchDNSBatch prefetches DNS for multiple URLs
120+func (pm *PrefetchManager) PrefetchDNSBatch(urls []string) {
121+ if !pm.isEnabled() {
122+ return
123+ }
124+
125+ seen := make(map[string]bool)
126+ count := 0
127+
128+ for _, rawURL := range urls {
129+ if count >= pm.maxPrefetches {
130+ break
131+ }
132+
133+ hostname := pm.extractHostname(rawURL)
134+ if hostname == "" || seen[hostname] {
135+ continue
136+ }
137+ seen[hostname] = true
138+
139+ pm.PrefetchDNS(rawURL)
140+ count++
141+ }
142+
143+ if count > 0 {
144+ logging.Debug(fmt.Sprintf("[prefetch] Batch prefetched DNS for %d domains", count))
145+ }
146+}
147+
148+// PrefetchFromHistory prefetches DNS for frequently visited domains.
149+// Should be called after startup to warm DNS cache.
150+func (pm *PrefetchManager) PrefetchFromHistory(ctx context.Context) {
151+ if !pm.isEnabled() || pm.queries == nil {
152+ return
153+ }
154+
155+ // Get most visited URLs from history
156+ entries, err := pm.queries.GetMostVisited(ctx, int64(pm.maxPrefetches*2))
157+ if err != nil {
158+ logging.Warn(fmt.Sprintf("[prefetch] Failed to get history for prefetch: %v", err))
159+ return
160+ }
161+
162+ // Extract unique hostnames
163+ seen := make(map[string]bool)
164+ hostnames := make([]string, 0, pm.maxPrefetches)
165+
166+ for _, entry := range entries {
167+ hostname := pm.extractHostname(entry.Url)
168+ if hostname == "" || seen[hostname] {
169+ continue
170+ }
171+ seen[hostname] = true
172+ hostnames = append(hostnames, hostname)
173+
174+ if len(hostnames) >= pm.maxPrefetches {
175+ break
176+ }
177+ }
178+
179+ // Prefetch in background
180+ if len(hostnames) > 0 {
181+ go func() {
182+ for _, hostname := range hostnames {
183+ webkit.PrefetchDNS(hostname)
184+ pm.mu.Lock()
185+ pm.recentPrefetches[hostname] = time.Now()
186+ pm.prefetchCount++
187+ pm.mu.Unlock()
188+ }
189+ logging.Info(fmt.Sprintf("[prefetch] Prefetched DNS for %d domains from history", len(hostnames)))
190+ }()
191+ }
192+}
193+
194+// PrefetchCommonDomains prefetches DNS for commonly used CDNs and services.
195+// Call this at startup for faster loading of common resources.
196+func (pm *PrefetchManager) PrefetchCommonDomains() {
197+ if !pm.isEnabled() {
198+ return
199+ }
200+
201+ commonDomains := []string{
202+ // CDNs
203+ "cdnjs.cloudflare.com",
204+ "cdn.jsdelivr.net",
205+ "unpkg.com",
206+ "code.jquery.com",
207+ // Fonts
208+ "fonts.googleapis.com",
209+ "fonts.gstatic.com",
210+ // Common services
211+ "www.google.com",
212+ "www.googleapis.com",
213+ "api.github.com",
214+ "raw.githubusercontent.com",
215+ }
216+
217+ go func() {
218+ for _, domain := range commonDomains {
219+ webkit.PrefetchDNS(domain)
220+ pm.mu.Lock()
221+ pm.recentPrefetches[domain] = time.Now()
222+ pm.prefetchCount++
223+ pm.mu.Unlock()
224+ }
225+ logging.Info(fmt.Sprintf("[prefetch] Prefetched DNS for %d common domains", len(commonDomains)))
226+ }()
227+}
228+
229+// OnLinkHover should be called when user hovers over a link.
230+// Prefetches DNS for the link target.
231+func (pm *PrefetchManager) OnLinkHover(linkURL string) {
232+ pm.PrefetchDNS(linkURL)
233+}
234+
235+// extractHostname extracts the hostname from a URL string
236+func (pm *PrefetchManager) extractHostname(rawURL string) string {
237+ if rawURL == "" {
238+ return ""
239+ }
240+
241+ // Handle URLs without scheme
242+ if rawURL[0] != 'h' && rawURL[0] != 'H' {
243+ // Check if it looks like a hostname
244+ if !containsScheme(rawURL) {
245+ rawURL = "https://" + rawURL
246+ }
247+ }
248+
249+ parsed, err := url.Parse(rawURL)
250+ if err != nil {
251+ return ""
252+ }
253+
254+ hostname := parsed.Hostname()
255+ if hostname == "" || hostname == "localhost" {
256+ return ""
257+ }
258+
259+ // Skip internal URLs
260+ if hostname == "127.0.0.1" || hostname == "::1" {
261+ return ""
262+ }
263+
264+ return hostname
265+}
266+
267+// containsScheme checks if URL contains a scheme
268+func containsScheme(rawURL string) bool {
269+ for i := 0; i < len(rawURL) && i < 10; i++ {
270+ if rawURL[i] == ':' {
271+ return true
272+ }
273+ if rawURL[i] == '/' || rawURL[i] == '?' || rawURL[i] == '#' {
274+ return false
275+ }
276+ }
277+ return false
278+}
279+
280+// Stats returns prefetch statistics
281+func (pm *PrefetchManager) Stats() map[string]interface{} {
282+ pm.mu.RLock()
283+ defer pm.mu.RUnlock()
284+
285+ return map[string]interface{}{
286+ "enabled": pm.isEnabled(),
287+ "prefetch_count": pm.prefetchCount,
288+ "cached_domains": len(pm.recentPrefetches),
289+ }
290+}
291+
292+// SetEnabled enables or disables prefetching
293+func (pm *PrefetchManager) SetEnabled(enabled bool) {
294+ var val int32
295+ if enabled {
296+ val = 1
297+ }
298+ atomic.StoreInt32(&pm.enabled, val)
299+}
300+
301+// ClearCache clears the prefetch throttle cache
302+func (pm *PrefetchManager) ClearCache() {
303+ pm.mu.Lock()
304+ defer pm.mu.Unlock()
305+ pm.recentPrefetches = make(map[string]time.Time)
306+}
@@ -0,0 +1,306 @@
1+// Package browser provides the main browser application components.
2+package browser
3+
4+import (
5+ "context"
6+ "fmt"
7+ "net/url"
8+ "sync"
9+ "sync/atomic"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/db"
13+ "github.com/bnema/dumber/internal/logging"
14+ "github.com/bnema/dumber/pkg/webkit"
15+)
16+
17+// PrefetchManager handles DNS prefetching and resource preloading.
18+// Improves page load times by resolving DNS before navigation.
19+type PrefetchManager struct {
20+ mu sync.RWMutex
21+
22+ // Configuration
23+ enabled int32 // atomic: 1 = enabled, 0 = disabled
24+ maxPrefetches int // Maximum concurrent prefetches
25+ throttleDelay time.Duration // Minimum delay between prefetches for same domain
26+ maxCacheSize int // Maximum entries in recentPrefetches before cleanup
27+
28+ // State tracking
29+ recentPrefetches map[string]time.Time // domain -> last prefetch time
30+ prefetchCount int64
31+
32+ // Database access for history-based prefetching
33+ queries db.DatabaseQuerier
34+}
35+
36+// PrefetchConfig contains configuration for the prefetch manager
37+type PrefetchConfig struct {
38+ Enabled bool
39+ MaxPrefetches int
40+ ThrottleDelay time.Duration
41+}
42+
43+// DefaultPrefetchConfig returns sensible default configuration
44+func DefaultPrefetchConfig() PrefetchConfig {
45+ return PrefetchConfig{
46+ Enabled: true,
47+ MaxPrefetches: 10,
48+ ThrottleDelay: 100 * time.Millisecond,
49+ }
50+}
51+
52+// NewPrefetchManager creates a new prefetch manager
53+func NewPrefetchManager(cfg PrefetchConfig, queries db.DatabaseQuerier) *PrefetchManager {
54+ var enabled int32
55+ if cfg.Enabled {
56+ enabled = 1
57+ }
58+ return &PrefetchManager{
59+ enabled: enabled,
60+ maxPrefetches: cfg.MaxPrefetches,
61+ throttleDelay: cfg.ThrottleDelay,
62+ maxCacheSize: 500, // Limit cache to prevent unbounded growth
63+ recentPrefetches: make(map[string]time.Time),
64+ queries: queries,
65+ }
66+}
67+
68+// isEnabled returns true if prefetching is enabled (atomic read)
69+func (pm *PrefetchManager) isEnabled() bool {
70+ return atomic.LoadInt32(&pm.enabled) == 1
71+}
72+
73+// cleanupOldEntries removes old entries from the cache to prevent unbounded growth
74+func (pm *PrefetchManager) cleanupOldEntries() {
75+ // Only cleanup if we exceed max size
76+ if len(pm.recentPrefetches) <= pm.maxCacheSize {
77+ return
78+ }
79+
80+ cutoff := time.Now().Add(-10 * pm.throttleDelay)
81+ for hostname, lastTime := range pm.recentPrefetches {
82+ if lastTime.Before(cutoff) {
83+ delete(pm.recentPrefetches, hostname)
84+ }
85+ }
86+}
87+
88+// PrefetchDNS prefetches DNS for the given URL.
89+// Throttles requests to avoid excessive DNS queries.
90+func (pm *PrefetchManager) PrefetchDNS(rawURL string) {
91+ if !pm.isEnabled() || rawURL == "" {
92+ return
93+ }
94+
95+ // Extract hostname from URL
96+ hostname := pm.extractHostname(rawURL)
97+ if hostname == "" {
98+ return
99+ }
100+
101+ // Check throttle
102+ pm.mu.Lock()
103+ if lastPrefetch, exists := pm.recentPrefetches[hostname]; exists {
104+ if time.Since(lastPrefetch) < pm.throttleDelay {
105+ pm.mu.Unlock()
106+ return
107+ }
108+ }
109+ pm.recentPrefetches[hostname] = time.Now()
110+ pm.prefetchCount++
111+ pm.cleanupOldEntries() // Prevent unbounded growth
112+ pm.mu.Unlock()
113+
114+ // Perform DNS prefetch
115+ webkit.PrefetchDNS(hostname)
116+ logging.Debug(fmt.Sprintf("[prefetch] DNS prefetch for %s", hostname))
117+}
118+
119+// PrefetchDNSBatch prefetches DNS for multiple URLs
120+func (pm *PrefetchManager) PrefetchDNSBatch(urls []string) {
121+ if !pm.isEnabled() {
122+ return
123+ }
124+
125+ seen := make(map[string]bool)
126+ count := 0
127+
128+ for _, rawURL := range urls {
129+ if count >= pm.maxPrefetches {
130+ break
131+ }
132+
133+ hostname := pm.extractHostname(rawURL)
134+ if hostname == "" || seen[hostname] {
135+ continue
136+ }
137+ seen[hostname] = true
138+
139+ pm.PrefetchDNS(rawURL)
140+ count++
141+ }
142+
143+ if count > 0 {
144+ logging.Debug(fmt.Sprintf("[prefetch] Batch prefetched DNS for %d domains", count))
145+ }
146+}
147+
148+// PrefetchFromHistory prefetches DNS for frequently visited domains.
149+// Should be called after startup to warm DNS cache.
150+func (pm *PrefetchManager) PrefetchFromHistory(ctx context.Context) {
151+ if !pm.isEnabled() || pm.queries == nil {
152+ return
153+ }
154+
155+ // Get most visited URLs from history
156+ entries, err := pm.queries.GetMostVisited(ctx, int64(pm.maxPrefetches*2))
157+ if err != nil {
158+ logging.Warn(fmt.Sprintf("[prefetch] Failed to get history for prefetch: %v", err))
159+ return
160+ }
161+
162+ // Extract unique hostnames
163+ seen := make(map[string]bool)
164+ hostnames := make([]string, 0, pm.maxPrefetches)
165+
166+ for _, entry := range entries {
167+ hostname := pm.extractHostname(entry.Url)
168+ if hostname == "" || seen[hostname] {
169+ continue
170+ }
171+ seen[hostname] = true
172+ hostnames = append(hostnames, hostname)
173+
174+ if len(hostnames) >= pm.maxPrefetches {
175+ break
176+ }
177+ }
178+
179+ // Prefetch in background
180+ if len(hostnames) > 0 {
181+ go func() {
182+ for _, hostname := range hostnames {
183+ webkit.PrefetchDNS(hostname)
184+ pm.mu.Lock()
185+ pm.recentPrefetches[hostname] = time.Now()
186+ pm.prefetchCount++
187+ pm.mu.Unlock()
188+ }
189+ logging.Info(fmt.Sprintf("[prefetch] Prefetched DNS for %d domains from history", len(hostnames)))
190+ }()
191+ }
192+}
193+
194+// PrefetchCommonDomains prefetches DNS for commonly used CDNs and services.
195+// Call this at startup for faster loading of common resources.
196+func (pm *PrefetchManager) PrefetchCommonDomains() {
197+ if !pm.isEnabled() {
198+ return
199+ }
200+
201+ commonDomains := []string{
202+ // CDNs
203+ "cdnjs.cloudflare.com",
204+ "cdn.jsdelivr.net",
205+ "unpkg.com",
206+ "code.jquery.com",
207+ // Fonts
208+ "fonts.googleapis.com",
209+ "fonts.gstatic.com",
210+ // Common services
211+ "www.google.com",
212+ "www.googleapis.com",
213+ "api.github.com",
214+ "raw.githubusercontent.com",
215+ }
216+
217+ go func() {
218+ for _, domain := range commonDomains {
219+ webkit.PrefetchDNS(domain)
220+ pm.mu.Lock()
221+ pm.recentPrefetches[domain] = time.Now()
222+ pm.prefetchCount++
223+ pm.mu.Unlock()
224+ }
225+ logging.Info(fmt.Sprintf("[prefetch] Prefetched DNS for %d common domains", len(commonDomains)))
226+ }()
227+}
228+
229+// OnLinkHover should be called when user hovers over a link.
230+// Prefetches DNS for the link target.
231+func (pm *PrefetchManager) OnLinkHover(linkURL string) {
232+ pm.PrefetchDNS(linkURL)
233+}
234+
235+// extractHostname extracts the hostname from a URL string
236+func (pm *PrefetchManager) extractHostname(rawURL string) string {
237+ if rawURL == "" {
238+ return ""
239+ }
240+
241+ // Handle URLs without scheme
242+ if rawURL[0] != 'h' && rawURL[0] != 'H' {
243+ // Check if it looks like a hostname
244+ if !containsScheme(rawURL) {
245+ rawURL = "https://" + rawURL
246+ }
247+ }
248+
249+ parsed, err := url.Parse(rawURL)
250+ if err != nil {
251+ return ""
252+ }
253+
254+ hostname := parsed.Hostname()
255+ if hostname == "" || hostname == "localhost" {
256+ return ""
257+ }
258+
259+ // Skip internal URLs
260+ if hostname == "127.0.0.1" || hostname == "::1" {
261+ return ""
262+ }
263+
264+ return hostname
265+}
266+
267+// containsScheme checks if URL contains a scheme
268+func containsScheme(rawURL string) bool {
269+ for i := 0; i < len(rawURL) && i < 10; i++ {
270+ if rawURL[i] == ':' {
271+ return true
272+ }
273+ if rawURL[i] == '/' || rawURL[i] == '?' || rawURL[i] == '#' {
274+ return false
275+ }
276+ }
277+ return false
278+}
279+
280+// Stats returns prefetch statistics
281+func (pm *PrefetchManager) Stats() map[string]interface{} {
282+ pm.mu.RLock()
283+ defer pm.mu.RUnlock()
284+
285+ return map[string]interface{}{
286+ "enabled": pm.isEnabled(),
287+ "prefetch_count": pm.prefetchCount,
288+ "cached_domains": len(pm.recentPrefetches),
289+ }
290+}
291+
292+// SetEnabled enables or disables prefetching
293+func (pm *PrefetchManager) SetEnabled(enabled bool) {
294+ var val int32
295+ if enabled {
296+ val = 1
297+ }
298+ atomic.StoreInt32(&pm.enabled, val)
299+}
300+
301+// ClearCache clears the prefetch throttle cache
302+func (pm *PrefetchManager) ClearCache() {
303+ pm.mu.Lock()
304+ defer pm.mu.Unlock()
305+ pm.recentPrefetches = make(map[string]time.Time)
306+}
A internal/app/browser/webview_pool.go +346 -0
@@ -0,0 +1,346 @@
1+// Package browser provides the main browser application components.
2+package browser
3+
4+import (
5+ "fmt"
6+ "sync"
7+ "sync/atomic"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/pkg/webkit"
12+)
13+
14+// PoolState represents the current state of the WebView pool
15+type PoolState int32
16+
17+const (
18+ PoolStateIdle PoolState = iota
19+ PoolStateWarming
20+ PoolStateReady
21+ PoolStateStopped
22+)
23+
24+// WebViewPool manages pre-created WebViews for instant tab/pane creation.
25+// Uses a "warm pool" strategy: pre-creates fresh WebViews rather than recycling,
26+// since WebKitGTK doesn't support resetting WebView state.
27+type WebViewPool struct {
28+ mu sync.RWMutex
29+
30+ // Pool of ready-to-use WebViews
31+ available []*webkit.WebView
32+
33+ // Configuration
34+ targetSize int // Target number of WebViews to maintain
35+ maxSize int // Maximum pool size
36+ warmupDelay time.Duration // Delay before initial warmup
37+
38+ // State tracking
39+ state int32 // atomic: PoolState
40+ created int64 // atomic: total WebViews created
41+ acquired int64 // atomic: total WebViews acquired from pool
42+ misses int64 // atomic: times pool was empty when requested
43+ configGetter func() *webkit.Config
44+
45+ // Shutdown
46+ stopCh chan struct{}
47+ wg sync.WaitGroup
48+ stopOnce sync.Once // Ensures Stop() is idempotent
49+}
50+
51+// PoolStats contains statistics about pool usage
52+type PoolStats struct {
53+ Available int
54+ TargetSize int
55+ MaxSize int
56+ Created int64
57+ Acquired int64
58+ Misses int64
59+ HitRate float64
60+ State PoolState
61+}
62+
63+// NewWebViewPool creates a new WebView pool with the given configuration.
64+// configGetter is called each time a new WebView needs to be created.
65+func NewWebViewPool(targetSize, maxSize int, configGetter func() *webkit.Config) *WebViewPool {
66+ if targetSize < 1 {
67+ targetSize = 1
68+ }
69+ if maxSize < targetSize {
70+ maxSize = targetSize
71+ }
72+
73+ return &WebViewPool{
74+ available: make([]*webkit.WebView, 0, maxSize),
75+ targetSize: targetSize,
76+ maxSize: maxSize,
77+ warmupDelay: 100 * time.Millisecond, // Wait for UI to render first
78+ configGetter: configGetter,
79+ stopCh: make(chan struct{}),
80+ }
81+}
82+
83+// SetWarmupDelay sets the delay before initial pool warmup.
84+// Must be called before Start().
85+func (p *WebViewPool) SetWarmupDelay(delay time.Duration) {
86+ p.warmupDelay = delay
87+}
88+
89+// Start begins the pool warmup process in the background.
90+// Should be called after the main window is visible.
91+func (p *WebViewPool) Start() {
92+ if !atomic.CompareAndSwapInt32(&p.state, int32(PoolStateIdle), int32(PoolStateWarming)) {
93+ logging.Warn("[pool] Pool already started or stopped")
94+ return
95+ }
96+
97+ p.wg.Add(1)
98+ go p.warmupLoop()
99+
100+ logging.Info(fmt.Sprintf("[pool] WebView pool started (target=%d, max=%d, delay=%v)",
101+ p.targetSize, p.maxSize, p.warmupDelay))
102+}
103+
104+// warmupLoop runs in background to maintain pool size
105+func (p *WebViewPool) warmupLoop() {
106+ defer p.wg.Done()
107+
108+ // Initial delay to let UI render first
109+ select {
110+ case <-time.After(p.warmupDelay):
111+ case <-p.stopCh:
112+ return
113+ }
114+
115+ // Initial warmup
116+ p.warmup()
117+ atomic.StoreInt32(&p.state, int32(PoolStateReady))
118+
119+ // Periodic check to maintain pool size (every 500ms)
120+ ticker := time.NewTicker(500 * time.Millisecond)
121+ defer ticker.Stop()
122+
123+ for {
124+ select {
125+ case <-ticker.C:
126+ p.warmup()
127+ case <-p.stopCh:
128+ return
129+ }
130+ }
131+}
132+
133+// warmup creates WebViews to reach target size
134+func (p *WebViewPool) warmup() {
135+ p.mu.RLock()
136+ currentSize := len(p.available)
137+ p.mu.RUnlock()
138+
139+ needed := p.targetSize - currentSize
140+ if needed <= 0 {
141+ return
142+ }
143+
144+ logging.Debug(fmt.Sprintf("[pool] Warming up %d WebViews (current=%d, target=%d)",
145+ needed, currentSize, p.targetSize))
146+
147+ for i := 0; i < needed; i++ {
148+ select {
149+ case <-p.stopCh:
150+ return
151+ default:
152+ }
153+
154+ // Create WebView on GTK main thread
155+ var wv *webkit.WebView
156+ var err error
157+
158+ done := make(chan struct{})
159+ webkit.RunOnMainThread(func() {
160+ defer close(done)
161+ cfg := p.configGetter()
162+ if cfg == nil {
163+ logging.Error("[pool] Config getter returned nil")
164+ return
165+ }
166+ // Pool WebViews don't create their own window
167+ cfg.CreateWindow = false
168+ wv, err = webkit.NewWebView(cfg)
169+ })
170+ <-done
171+
172+ if err != nil {
173+ logging.Error(fmt.Sprintf("[pool] Failed to create WebView: %v", err))
174+ continue
175+ }
176+ if wv == nil {
177+ logging.Error("[pool] Created WebView is nil")
178+ continue
179+ }
180+
181+ p.mu.Lock()
182+ if len(p.available) < p.maxSize {
183+ p.available = append(p.available, wv)
184+ atomic.AddInt64(&p.created, 1)
185+ logging.Debug(fmt.Sprintf("[pool] Added WebView ID %d to pool (size=%d)",
186+ wv.ID(), len(p.available)))
187+ } else {
188+ // Pool full, destroy the extra WebView
189+ p.mu.Unlock()
190+ webkit.RunOnMainThread(func() {
191+ wv.Destroy()
192+ })
193+ logging.Debug("[pool] Pool at max capacity, discarding extra WebView")
194+ continue
195+ }
196+ p.mu.Unlock()
197+ }
198+}
199+
200+// Acquire gets a WebView from the pool, or creates one if pool is empty.
201+// The returned WebView is ready to use but needs to be added to a container.
202+// Returns nil and error if creation fails.
203+func (p *WebViewPool) Acquire() (*webkit.WebView, error) {
204+ state := PoolState(atomic.LoadInt32(&p.state))
205+ if state == PoolStateStopped {
206+ return nil, fmt.Errorf("pool is stopped")
207+ }
208+
209+ p.mu.Lock()
210+ if len(p.available) > 0 {
211+ // Take from pool (LIFO for better cache locality)
212+ wv := p.available[len(p.available)-1]
213+ p.available = p.available[:len(p.available)-1]
214+ remaining := len(p.available) // Capture before unlock to avoid race
215+ p.mu.Unlock()
216+
217+ atomic.AddInt64(&p.acquired, 1)
218+ logging.Debug(fmt.Sprintf("[pool] Acquired WebView ID %d from pool (remaining=%d)",
219+ wv.ID(), remaining))
220+
221+ return wv, nil
222+ }
223+ p.mu.Unlock()
224+
225+ // Pool empty, create on-demand (fallback)
226+ atomic.AddInt64(&p.misses, 1)
227+ logging.Debug("[pool] Pool empty, creating WebView on-demand")
228+
229+ var wv *webkit.WebView
230+ var err error
231+
232+ done := make(chan struct{})
233+ webkit.RunOnMainThread(func() {
234+ defer close(done)
235+ cfg := p.configGetter()
236+ if cfg == nil {
237+ err = fmt.Errorf("config getter returned nil")
238+ return
239+ }
240+ cfg.CreateWindow = false
241+ wv, err = webkit.NewWebView(cfg)
242+ })
243+ <-done
244+
245+ if err != nil {
246+ return nil, fmt.Errorf("failed to create WebView: %w", err)
247+ }
248+
249+ atomic.AddInt64(&p.created, 1)
250+ return wv, nil
251+}
252+
253+// TryAcquire attempts to get a WebView from the pool without blocking.
254+// Returns nil if pool is empty (does not create on-demand).
255+func (p *WebViewPool) TryAcquire() *webkit.WebView {
256+ state := PoolState(atomic.LoadInt32(&p.state))
257+ if state == PoolStateStopped {
258+ return nil
259+ }
260+
261+ p.mu.Lock()
262+ defer p.mu.Unlock()
263+
264+ if len(p.available) > 0 {
265+ wv := p.available[len(p.available)-1]
266+ p.available = p.available[:len(p.available)-1]
267+ atomic.AddInt64(&p.acquired, 1)
268+ logging.Debug(fmt.Sprintf("[pool] TryAcquire: got WebView ID %d (remaining=%d)",
269+ wv.ID(), len(p.available)))
270+ return wv
271+ }
272+
273+ atomic.AddInt64(&p.misses, 1)
274+ return nil
275+}
276+
277+// Stats returns current pool statistics
278+func (p *WebViewPool) Stats() PoolStats {
279+ p.mu.RLock()
280+ available := len(p.available)
281+ p.mu.RUnlock()
282+
283+ acquired := atomic.LoadInt64(&p.acquired)
284+ misses := atomic.LoadInt64(&p.misses)
285+
286+ var hitRate float64
287+ total := acquired + misses
288+ if total > 0 {
289+ hitRate = float64(acquired) / float64(total)
290+ }
291+
292+ return PoolStats{
293+ Available: available,
294+ TargetSize: p.targetSize,
295+ MaxSize: p.maxSize,
296+ Created: atomic.LoadInt64(&p.created),
297+ Acquired: acquired,
298+ Misses: misses,
299+ HitRate: hitRate,
300+ State: PoolState(atomic.LoadInt32(&p.state)),
301+ }
302+}
303+
304+// Stop shuts down the pool and destroys all pooled WebViews.
305+// Safe to call multiple times.
306+func (p *WebViewPool) Stop() {
307+ p.stopOnce.Do(func() {
308+ // Transition to stopped state from any running state
309+ for {
310+ oldState := atomic.LoadInt32(&p.state)
311+ if oldState == int32(PoolStateStopped) || oldState == int32(PoolStateIdle) {
312+ return
313+ }
314+ if atomic.CompareAndSwapInt32(&p.state, oldState, int32(PoolStateStopped)) {
315+ break
316+ }
317+ }
318+
319+ close(p.stopCh)
320+ p.wg.Wait()
321+
322+ // Destroy all pooled WebViews
323+ p.mu.Lock()
324+ views := p.available
325+ p.available = nil
326+ p.mu.Unlock()
327+
328+ if len(views) > 0 {
329+ logging.Info(fmt.Sprintf("[pool] Destroying %d pooled WebViews", len(views)))
330+ for _, wv := range views {
331+ if wv != nil && !wv.IsDestroyed() {
332+ wv.Destroy()
333+ }
334+ }
335+ }
336+
337+ stats := p.Stats()
338+ logging.Info(fmt.Sprintf("[pool] Pool stopped (created=%d, acquired=%d, misses=%d, hitRate=%.2f%%)",
339+ stats.Created, stats.Acquired, stats.Misses, stats.HitRate*100))
340+ })
341+}
342+
343+// IsReady returns true if the pool has completed initial warmup
344+func (p *WebViewPool) IsReady() bool {
345+ return PoolState(atomic.LoadInt32(&p.state)) == PoolStateReady
346+}
@@ -0,0 +1,346 @@
1+// Package browser provides the main browser application components.
2+package browser
3+
4+import (
5+ "fmt"
6+ "sync"
7+ "sync/atomic"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/pkg/webkit"
12+)
13+
14+// PoolState represents the current state of the WebView pool
15+type PoolState int32
16+
17+const (
18+ PoolStateIdle PoolState = iota
19+ PoolStateWarming
20+ PoolStateReady
21+ PoolStateStopped
22+)
23+
24+// WebViewPool manages pre-created WebViews for instant tab/pane creation.
25+// Uses a "warm pool" strategy: pre-creates fresh WebViews rather than recycling,
26+// since WebKitGTK doesn't support resetting WebView state.
27+type WebViewPool struct {
28+ mu sync.RWMutex
29+
30+ // Pool of ready-to-use WebViews
31+ available []*webkit.WebView
32+
33+ // Configuration
34+ targetSize int // Target number of WebViews to maintain
35+ maxSize int // Maximum pool size
36+ warmupDelay time.Duration // Delay before initial warmup
37+
38+ // State tracking
39+ state int32 // atomic: PoolState
40+ created int64 // atomic: total WebViews created
41+ acquired int64 // atomic: total WebViews acquired from pool
42+ misses int64 // atomic: times pool was empty when requested
43+ configGetter func() *webkit.Config
44+
45+ // Shutdown
46+ stopCh chan struct{}
47+ wg sync.WaitGroup
48+ stopOnce sync.Once // Ensures Stop() is idempotent
49+}
50+
51+// PoolStats contains statistics about pool usage
52+type PoolStats struct {
53+ Available int
54+ TargetSize int
55+ MaxSize int
56+ Created int64
57+ Acquired int64
58+ Misses int64
59+ HitRate float64
60+ State PoolState
61+}
62+
63+// NewWebViewPool creates a new WebView pool with the given configuration.
64+// configGetter is called each time a new WebView needs to be created.
65+func NewWebViewPool(targetSize, maxSize int, configGetter func() *webkit.Config) *WebViewPool {
66+ if targetSize < 1 {
67+ targetSize = 1
68+ }
69+ if maxSize < targetSize {
70+ maxSize = targetSize
71+ }
72+
73+ return &WebViewPool{
74+ available: make([]*webkit.WebView, 0, maxSize),
75+ targetSize: targetSize,
76+ maxSize: maxSize,
77+ warmupDelay: 100 * time.Millisecond, // Wait for UI to render first
78+ configGetter: configGetter,
79+ stopCh: make(chan struct{}),
80+ }
81+}
82+
83+// SetWarmupDelay sets the delay before initial pool warmup.
84+// Must be called before Start().
85+func (p *WebViewPool) SetWarmupDelay(delay time.Duration) {
86+ p.warmupDelay = delay
87+}
88+
89+// Start begins the pool warmup process in the background.
90+// Should be called after the main window is visible.
91+func (p *WebViewPool) Start() {
92+ if !atomic.CompareAndSwapInt32(&p.state, int32(PoolStateIdle), int32(PoolStateWarming)) {
93+ logging.Warn("[pool] Pool already started or stopped")
94+ return
95+ }
96+
97+ p.wg.Add(1)
98+ go p.warmupLoop()
99+
100+ logging.Info(fmt.Sprintf("[pool] WebView pool started (target=%d, max=%d, delay=%v)",
101+ p.targetSize, p.maxSize, p.warmupDelay))
102+}
103+
104+// warmupLoop runs in background to maintain pool size
105+func (p *WebViewPool) warmupLoop() {
106+ defer p.wg.Done()
107+
108+ // Initial delay to let UI render first
109+ select {
110+ case <-time.After(p.warmupDelay):
111+ case <-p.stopCh:
112+ return
113+ }
114+
115+ // Initial warmup
116+ p.warmup()
117+ atomic.StoreInt32(&p.state, int32(PoolStateReady))
118+
119+ // Periodic check to maintain pool size (every 500ms)
120+ ticker := time.NewTicker(500 * time.Millisecond)
121+ defer ticker.Stop()
122+
123+ for {
124+ select {
125+ case <-ticker.C:
126+ p.warmup()
127+ case <-p.stopCh:
128+ return
129+ }
130+ }
131+}
132+
133+// warmup creates WebViews to reach target size
134+func (p *WebViewPool) warmup() {
135+ p.mu.RLock()
136+ currentSize := len(p.available)
137+ p.mu.RUnlock()
138+
139+ needed := p.targetSize - currentSize
140+ if needed <= 0 {
141+ return
142+ }
143+
144+ logging.Debug(fmt.Sprintf("[pool] Warming up %d WebViews (current=%d, target=%d)",
145+ needed, currentSize, p.targetSize))
146+
147+ for i := 0; i < needed; i++ {
148+ select {
149+ case <-p.stopCh:
150+ return
151+ default:
152+ }
153+
154+ // Create WebView on GTK main thread
155+ var wv *webkit.WebView
156+ var err error
157+
158+ done := make(chan struct{})
159+ webkit.RunOnMainThread(func() {
160+ defer close(done)
161+ cfg := p.configGetter()
162+ if cfg == nil {
163+ logging.Error("[pool] Config getter returned nil")
164+ return
165+ }
166+ // Pool WebViews don't create their own window
167+ cfg.CreateWindow = false
168+ wv, err = webkit.NewWebView(cfg)
169+ })
170+ <-done
171+
172+ if err != nil {
173+ logging.Error(fmt.Sprintf("[pool] Failed to create WebView: %v", err))
174+ continue
175+ }
176+ if wv == nil {
177+ logging.Error("[pool] Created WebView is nil")
178+ continue
179+ }
180+
181+ p.mu.Lock()
182+ if len(p.available) < p.maxSize {
183+ p.available = append(p.available, wv)
184+ atomic.AddInt64(&p.created, 1)
185+ logging.Debug(fmt.Sprintf("[pool] Added WebView ID %d to pool (size=%d)",
186+ wv.ID(), len(p.available)))
187+ } else {
188+ // Pool full, destroy the extra WebView
189+ p.mu.Unlock()
190+ webkit.RunOnMainThread(func() {
191+ wv.Destroy()
192+ })
193+ logging.Debug("[pool] Pool at max capacity, discarding extra WebView")
194+ continue
195+ }
196+ p.mu.Unlock()
197+ }
198+}
199+
200+// Acquire gets a WebView from the pool, or creates one if pool is empty.
201+// The returned WebView is ready to use but needs to be added to a container.
202+// Returns nil and error if creation fails.
203+func (p *WebViewPool) Acquire() (*webkit.WebView, error) {
204+ state := PoolState(atomic.LoadInt32(&p.state))
205+ if state == PoolStateStopped {
206+ return nil, fmt.Errorf("pool is stopped")
207+ }
208+
209+ p.mu.Lock()
210+ if len(p.available) > 0 {
211+ // Take from pool (LIFO for better cache locality)
212+ wv := p.available[len(p.available)-1]
213+ p.available = p.available[:len(p.available)-1]
214+ remaining := len(p.available) // Capture before unlock to avoid race
215+ p.mu.Unlock()
216+
217+ atomic.AddInt64(&p.acquired, 1)
218+ logging.Debug(fmt.Sprintf("[pool] Acquired WebView ID %d from pool (remaining=%d)",
219+ wv.ID(), remaining))
220+
221+ return wv, nil
222+ }
223+ p.mu.Unlock()
224+
225+ // Pool empty, create on-demand (fallback)
226+ atomic.AddInt64(&p.misses, 1)
227+ logging.Debug("[pool] Pool empty, creating WebView on-demand")
228+
229+ var wv *webkit.WebView
230+ var err error
231+
232+ done := make(chan struct{})
233+ webkit.RunOnMainThread(func() {
234+ defer close(done)
235+ cfg := p.configGetter()
236+ if cfg == nil {
237+ err = fmt.Errorf("config getter returned nil")
238+ return
239+ }
240+ cfg.CreateWindow = false
241+ wv, err = webkit.NewWebView(cfg)
242+ })
243+ <-done
244+
245+ if err != nil {
246+ return nil, fmt.Errorf("failed to create WebView: %w", err)
247+ }
248+
249+ atomic.AddInt64(&p.created, 1)
250+ return wv, nil
251+}
252+
253+// TryAcquire attempts to get a WebView from the pool without blocking.
254+// Returns nil if pool is empty (does not create on-demand).
255+func (p *WebViewPool) TryAcquire() *webkit.WebView {
256+ state := PoolState(atomic.LoadInt32(&p.state))
257+ if state == PoolStateStopped {
258+ return nil
259+ }
260+
261+ p.mu.Lock()
262+ defer p.mu.Unlock()
263+
264+ if len(p.available) > 0 {
265+ wv := p.available[len(p.available)-1]
266+ p.available = p.available[:len(p.available)-1]
267+ atomic.AddInt64(&p.acquired, 1)
268+ logging.Debug(fmt.Sprintf("[pool] TryAcquire: got WebView ID %d (remaining=%d)",
269+ wv.ID(), len(p.available)))
270+ return wv
271+ }
272+
273+ atomic.AddInt64(&p.misses, 1)
274+ return nil
275+}
276+
277+// Stats returns current pool statistics
278+func (p *WebViewPool) Stats() PoolStats {
279+ p.mu.RLock()
280+ available := len(p.available)
281+ p.mu.RUnlock()
282+
283+ acquired := atomic.LoadInt64(&p.acquired)
284+ misses := atomic.LoadInt64(&p.misses)
285+
286+ var hitRate float64
287+ total := acquired + misses
288+ if total > 0 {
289+ hitRate = float64(acquired) / float64(total)
290+ }
291+
292+ return PoolStats{
293+ Available: available,
294+ TargetSize: p.targetSize,
295+ MaxSize: p.maxSize,
296+ Created: atomic.LoadInt64(&p.created),
297+ Acquired: acquired,
298+ Misses: misses,
299+ HitRate: hitRate,
300+ State: PoolState(atomic.LoadInt32(&p.state)),
301+ }
302+}
303+
304+// Stop shuts down the pool and destroys all pooled WebViews.
305+// Safe to call multiple times.
306+func (p *WebViewPool) Stop() {
307+ p.stopOnce.Do(func() {
308+ // Transition to stopped state from any running state
309+ for {
310+ oldState := atomic.LoadInt32(&p.state)
311+ if oldState == int32(PoolStateStopped) || oldState == int32(PoolStateIdle) {
312+ return
313+ }
314+ if atomic.CompareAndSwapInt32(&p.state, oldState, int32(PoolStateStopped)) {
315+ break
316+ }
317+ }
318+
319+ close(p.stopCh)
320+ p.wg.Wait()
321+
322+ // Destroy all pooled WebViews
323+ p.mu.Lock()
324+ views := p.available
325+ p.available = nil
326+ p.mu.Unlock()
327+
328+ if len(views) > 0 {
329+ logging.Info(fmt.Sprintf("[pool] Destroying %d pooled WebViews", len(views)))
330+ for _, wv := range views {
331+ if wv != nil && !wv.IsDestroyed() {
332+ wv.Destroy()
333+ }
334+ }
335+ }
336+
337+ stats := p.Stats()
338+ logging.Info(fmt.Sprintf("[pool] Pool stopped (created=%d, acquired=%d, misses=%d, hitRate=%.2f%%)",
339+ stats.Created, stats.Acquired, stats.Misses, stats.HitRate*100))
340+ })
341+}
342+
343+// IsReady returns true if the pool has completed initial warmup
344+func (p *WebViewPool) IsReady() bool {
345+ return PoolState(atomic.LoadInt32(&p.state)) == PoolStateReady
346+}
M pkg/webkit/mainloop.go +7 -10
@@ -44,21 +44,18 @@ func QuitMainLoop() {
4444 }
4545 }
4646
47-// IsMainThread returns true if called from the GTK main thread.
47+// IsMainThread returns true if GTK has been initialized.
48+// Note: This doesn't truly detect the main thread, but is used as a guard
49+// for whether GTK operations are safe. All GTK operations should use
50+// RunOnMainThread or IdleAdd to be thread-safe.
4851 func IsMainThread() bool {
49- // In gotk4, we're always on the main thread since we lock it
5052 return isInitialized
5153 }
5254
53-// RunOnMainThread executes a function on the GTK main thread.
54-// If already on the main thread, executes immediately.
55-// Otherwise, schedules the function via glib.IdleAdd.
55+// RunOnMainThread schedules a function to run on the GTK main thread.
56+// Always uses glib.IdleAdd to ensure thread safety.
57+// The function will execute during the next main loop iteration.
5658 func RunOnMainThread(fn func()) {
57- if IsMainThread() {
58- fn()
59- return
60- }
61-
6259 glib.IdleAdd(func() bool {
6360 fn()
6461 return false // Remove the idle handler after execution
@@ -44,21 +44,18 @@ func QuitMainLoop() {
44 }44 }
45 }45 }
46 46
47-// IsMainThread returns true if called from the GTK main thread.47+// IsMainThread returns true if GTK has been initialized.
48+// Note: This doesn't truly detect the main thread, but is used as a guard
49+// for whether GTK operations are safe. All GTK operations should use
50+// RunOnMainThread or IdleAdd to be thread-safe.
48 func IsMainThread() bool {51 func IsMainThread() bool {
49- // In gotk4, we're always on the main thread since we lock it
50 return isInitialized52 return isInitialized
51 }53 }
52 54
53-// RunOnMainThread executes a function on the GTK main thread.55+// RunOnMainThread schedules a function to run on the GTK main thread.
54-// If already on the main thread, executes immediately.56+// Always uses glib.IdleAdd to ensure thread safety.
55-// Otherwise, schedules the function via glib.IdleAdd.57+// The function will execute during the next main loop iteration.
56 func RunOnMainThread(fn func()) {58 func RunOnMainThread(fn func()) {
57- if IsMainThread() {
58- fn()
59- return
60- }
61-
62 glib.IdleAdd(func() bool {59 glib.IdleAdd(func() bool {
63 fn()60 fn()
64 return false // Remove the idle handler after execution61 return false // Remove the idle handler after execution

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