detached f8b9839

perf: optimize navigation with async history, zoom caching, and progress throttling (fixes #17)

commit
f8b9839ffa2abc85c199d59ca3ae8460b0b4d256
author
brice · · 2025-12-23 18:39 UTC
committer
brice · · 2025-12-24 16:11 UTC
parent
b9e91d6
signature
unsigned
13 files · +543 -19
M CHANGELOG.md +7 -0
@@ -10,12 +10,19 @@ All notable changes to this project will be documented in this file.
1010 - **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference.
1111 - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.
1212 - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.).
13+- **Navigation performance**: Significantly faster page navigation through multiple optimizations:
14+ - Async history recording via background worker (SQLite writes no longer block GTK main thread)
15+ - LRU cache for per-domain zoom levels (repeat visits skip database queries)
16+ - Async favicon preload (cache lookup doesn't block navigation start)
17+ - Progress callback throttling at ~60fps (reduces UI callback overhead)
18+ - Increased WebView pool prewarm from 2 to 4 for faster initial tab creation
1319
1420 ### Changed
1521 - **GSK renderer default**: Changed default from `vulkan` to `auto` to let GTK choose the best renderer and avoid potential DMA-BUF synchronization conflicts with WebKit.
1622 - **Removed invalid GStreamer config**: Removed `video_buffer_size_mb` and `queue_buffer_time_sec` config options as these were not valid GStreamer environment variables (they are element properties that cannot be set via env vars).
1723
1824 ### Added
25+- **Performance config section**: New `performance` config section with `zoom_cache_size` (default: 256) and `webview_pool_prewarm_count` (default: 4) for tuning. Not exposed in UI but can be set in config file.
1926 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.
2027 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.
2128 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.
@@ -10,12 +10,19 @@ All notable changes to this project will be documented in this file.
10 - **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference.10 - **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference.
11 - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.11 - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.
12 - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.).12 - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.).
13+- **Navigation performance**: Significantly faster page navigation through multiple optimizations:
14+ - Async history recording via background worker (SQLite writes no longer block GTK main thread)
15+ - LRU cache for per-domain zoom levels (repeat visits skip database queries)
16+ - Async favicon preload (cache lookup doesn't block navigation start)
17+ - Progress callback throttling at ~60fps (reduces UI callback overhead)
18+ - Increased WebView pool prewarm from 2 to 4 for faster initial tab creation
13 19
14 ### Changed20 ### Changed
15 - **GSK renderer default**: Changed default from `vulkan` to `auto` to let GTK choose the best renderer and avoid potential DMA-BUF synchronization conflicts with WebKit.21 - **GSK renderer default**: Changed default from `vulkan` to `auto` to let GTK choose the best renderer and avoid potential DMA-BUF synchronization conflicts with WebKit.
16 - **Removed invalid GStreamer config**: Removed `video_buffer_size_mb` and `queue_buffer_time_sec` config options as these were not valid GStreamer environment variables (they are element properties that cannot be set via env vars).22 - **Removed invalid GStreamer config**: Removed `video_buffer_size_mb` and `queue_buffer_time_sec` config options as these were not valid GStreamer environment variables (they are element properties that cannot be set via env vars).
17 23
18 ### Added24 ### Added
25+- **Performance config section**: New `performance` config section with `zoom_cache_size` (default: 256) and `webview_pool_prewarm_count` (default: 4) for tuning. Not exposed in UI but can be set in config file.
19 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.26 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.
20 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.27 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.
21 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.28 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.
M cmd/dumber/main.go +9 -3
@@ -15,7 +15,9 @@ import (
1515 "github.com/bnema/dumber/internal/bootstrap"
1616 "github.com/bnema/dumber/internal/cli/cmd"
1717 "github.com/bnema/dumber/internal/domain/build"
18+ "github.com/bnema/dumber/internal/domain/entity"
1819 "github.com/bnema/dumber/internal/domain/repository"
20+ "github.com/bnema/dumber/internal/infrastructure/cache"
1921 "github.com/bnema/dumber/internal/infrastructure/clipboard"
2022 "github.com/bnema/dumber/internal/infrastructure/config"
2123 "github.com/bnema/dumber/internal/infrastructure/deps"
@@ -109,7 +111,7 @@ func runGUI() int {
109111 themeManager := theme.NewManager(ctx, cfg)
110112 stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)
111113 repos := createRepositories(db)
112- useCases := createUseCases(repos, cfg.DefaultWebpageZoom)
114+ useCases := createUseCases(repos, cfg)
113115 idleInhibitor := idle.NewPortalInhibitor(ctx)
114116 defer func() {
115117 if idleInhibitor != nil {
@@ -254,7 +256,7 @@ type useCases struct {
254256 favicon *favicon.Service
255257 }
256258
257-func createUseCases(repos *repositories, defaultZoom float64) *useCases {
259+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
258260 const idAlphabetSize = 26
259261 idCounter := uint64(0)
260262 idGenerator := func() string {
@@ -264,13 +266,17 @@ func createUseCases(repos *repositories, defaultZoom float64) *useCases {
264266
265267 clipboardAdapter := clipboard.New()
266268 faviconCacheDir, _ := config.GetFaviconCacheDir()
269+ defaultZoom := cfg.DefaultWebpageZoom
270+
271+ // Create LRU cache for zoom levels to avoid database queries on repeat visits
272+ zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
267273
268274 return &useCases{
269275 tabs: usecase.NewManageTabsUseCase(idGenerator),
270276 panes: usecase.NewManagePanesUseCase(idGenerator),
271277 history: usecase.NewSearchHistoryUseCase(repos.history),
272278 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
273- zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom),
279+ zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
274280 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
275281 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
276282 clipboard: clipboardAdapter,
@@ -15,7 +15,9 @@ import (
15 "github.com/bnema/dumber/internal/bootstrap"15 "github.com/bnema/dumber/internal/bootstrap"
16 "github.com/bnema/dumber/internal/cli/cmd"16 "github.com/bnema/dumber/internal/cli/cmd"
17 "github.com/bnema/dumber/internal/domain/build"17 "github.com/bnema/dumber/internal/domain/build"
18+ "github.com/bnema/dumber/internal/domain/entity"
18 "github.com/bnema/dumber/internal/domain/repository"19 "github.com/bnema/dumber/internal/domain/repository"
20+ "github.com/bnema/dumber/internal/infrastructure/cache"
19 "github.com/bnema/dumber/internal/infrastructure/clipboard"21 "github.com/bnema/dumber/internal/infrastructure/clipboard"
20 "github.com/bnema/dumber/internal/infrastructure/config"22 "github.com/bnema/dumber/internal/infrastructure/config"
21 "github.com/bnema/dumber/internal/infrastructure/deps"23 "github.com/bnema/dumber/internal/infrastructure/deps"
@@ -109,7 +111,7 @@ func runGUI() int {
109 themeManager := theme.NewManager(ctx, cfg)111 themeManager := theme.NewManager(ctx, cfg)
110 stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)112 stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)
111 repos := createRepositories(db)113 repos := createRepositories(db)
112- useCases := createUseCases(repos, cfg.DefaultWebpageZoom)114+ useCases := createUseCases(repos, cfg)
113 idleInhibitor := idle.NewPortalInhibitor(ctx)115 idleInhibitor := idle.NewPortalInhibitor(ctx)
114 defer func() {116 defer func() {
115 if idleInhibitor != nil {117 if idleInhibitor != nil {
@@ -254,7 +256,7 @@ type useCases struct {
254 favicon *favicon.Service256 favicon *favicon.Service
255 }257 }
256 258
257-func createUseCases(repos *repositories, defaultZoom float64) *useCases {259+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
258 const idAlphabetSize = 26260 const idAlphabetSize = 26
259 idCounter := uint64(0)261 idCounter := uint64(0)
260 idGenerator := func() string {262 idGenerator := func() string {
@@ -264,13 +266,17 @@ func createUseCases(repos *repositories, defaultZoom float64) *useCases {
264 266
265 clipboardAdapter := clipboard.New()267 clipboardAdapter := clipboard.New()
266 faviconCacheDir, _ := config.GetFaviconCacheDir()268 faviconCacheDir, _ := config.GetFaviconCacheDir()
269+ defaultZoom := cfg.DefaultWebpageZoom
270+
271+ // Create LRU cache for zoom levels to avoid database queries on repeat visits
272+ zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
267 273
268 return &useCases{274 return &useCases{
269 tabs: usecase.NewManageTabsUseCase(idGenerator),275 tabs: usecase.NewManageTabsUseCase(idGenerator),
270 panes: usecase.NewManagePanesUseCase(idGenerator),276 panes: usecase.NewManagePanesUseCase(idGenerator),
271 history: usecase.NewSearchHistoryUseCase(repos.history),277 history: usecase.NewSearchHistoryUseCase(repos.history),
272 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),278 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
273- zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom),279+ zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
274 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),280 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
275 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),281 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
276 clipboard: clipboardAdapter,282 clipboard: clipboardAdapter,
A internal/application/port/cache.go +19 -0
@@ -0,0 +1,19 @@
1+package port
2+
3+// Cache is a generic cache interface for storing key-value pairs.
4+// Implementations should be thread-safe.
5+type Cache[K comparable, V any] interface {
6+ // Get retrieves a value by key. Returns the value and true if found,
7+ // or the zero value and false if not found.
8+ Get(key K) (V, bool)
9+
10+ // Set stores a value for the given key. If the cache is at capacity,
11+ // the least recently used entry may be evicted.
12+ Set(key K, value V)
13+
14+ // Remove deletes a key from the cache.
15+ Remove(key K)
16+
17+ // Len returns the number of items currently in the cache.
18+ Len() int
19+}
@@ -0,0 +1,19 @@
1+package port
2+
3+// Cache is a generic cache interface for storing key-value pairs.
4+// Implementations should be thread-safe.
5+type Cache[K comparable, V any] interface {
6+ // Get retrieves a value by key. Returns the value and true if found,
7+ // or the zero value and false if not found.
8+ Get(key K) (V, bool)
9+
10+ // Set stores a value for the given key. If the cache is at capacity,
11+ // the least recently used entry may be evicted.
12+ Set(key K, value V)
13+
14+ // Remove deletes a key from the cache.
15+ Remove(key K)
16+
17+ // Len returns the number of items currently in the cache.
18+ Len() int
19+}
M internal/application/usecase/manage_zoom.go +45 -2
@@ -13,20 +13,28 @@ import (
1313 )
1414
1515 // ManageZoomUseCase handles per-domain zoom level operations.
16+// It uses an LRU cache to avoid database queries on every navigation.
1617 type ManageZoomUseCase struct {
1718 zoomRepo repository.ZoomRepository
1819 defaultZoom float64
20+ cache port.Cache[string, *entity.ZoomLevel]
1921 }
2022
2123 // NewManageZoomUseCase creates a new zoom management use case.
2224 // defaultZoom is the zoom level to use when resetting (typically from config).
23-func NewManageZoomUseCase(zoomRepo repository.ZoomRepository, defaultZoom float64) *ManageZoomUseCase {
25+// cache is an LRU cache for zoom levels to avoid database queries on repeat visits.
26+func NewManageZoomUseCase(
27+ zoomRepo repository.ZoomRepository,
28+ defaultZoom float64,
29+ cache port.Cache[string, *entity.ZoomLevel],
30+) *ManageZoomUseCase {
2431 if defaultZoom <= 0 {
2532 defaultZoom = entity.ZoomDefault
2633 }
2734 return &ManageZoomUseCase{
2835 zoomRepo: zoomRepo,
2936 defaultZoom: defaultZoom,
37+ cache: cache,
3038 }
3139 }
3240
@@ -37,10 +45,20 @@ func (uc *ManageZoomUseCase) DefaultZoom() float64 {
3745
3846 // GetZoom retrieves the zoom level for a domain.
3947 // Returns the configured default zoom level if none is set.
48+// Uses LRU cache to avoid database queries on repeat visits.
4049 func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
4150 log := logging.FromContext(ctx)
42- log.Debug().Str("domain", domain).Msg("getting zoom level")
4351
52+ // Check cache first (fast path - no I/O)
53+ if uc.cache != nil {
54+ if cached, ok := uc.cache.Get(domain); ok {
55+ log.Debug().Str("domain", domain).Float64("zoom", cached.ZoomFactor).Msg("zoom level from cache")
56+ return cached, nil
57+ }
58+ }
59+
60+ // Cache miss - fetch from database
61+ log.Debug().Str("domain", domain).Msg("zoom cache miss, querying database")
4462 zoom, err := uc.zoomRepo.Get(ctx, domain)
4563 if err != nil {
4664 return nil, fmt.Errorf("failed to get zoom level: %w", err)
@@ -51,6 +69,11 @@ func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entit
5169 log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")
5270 }
5371
72+ // Cache for future lookups
73+ if uc.cache != nil {
74+ uc.cache.Set(domain, zoom)
75+ }
76+
5477 return zoom, nil
5578 }
5679
@@ -64,6 +87,11 @@ func (uc *ManageZoomUseCase) SetZoom(ctx context.Context, domain string, factor
6487 return fmt.Errorf("failed to set zoom level: %w", err)
6588 }
6689
90+ // Update cache
91+ if uc.cache != nil {
92+ uc.cache.Set(domain, zoom)
93+ }
94+
6795 log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")
6896 return nil
6997 }
@@ -77,6 +105,11 @@ func (uc *ManageZoomUseCase) ResetZoom(ctx context.Context, domain string) error
77105 return fmt.Errorf("failed to reset zoom level: %w", err)
78106 }
79107
108+ // Invalidate cache
109+ if uc.cache != nil {
110+ uc.cache.Remove(domain)
111+ }
112+
80113 log.Info().Str("domain", domain).Msg("zoom level reset to default")
81114 return nil
82115 }
@@ -98,6 +131,11 @@ func (uc *ManageZoomUseCase) ZoomIn(ctx context.Context, domain string, current
98131 return nil, fmt.Errorf("failed to save zoom level: %w", err)
99132 }
100133
134+ // Update cache
135+ if uc.cache != nil {
136+ uc.cache.Set(domain, zoom)
137+ }
138+
101139 return zoom, nil
102140 }
103141
@@ -118,6 +156,11 @@ func (uc *ManageZoomUseCase) ZoomOut(ctx context.Context, domain string, current
118156 return nil, fmt.Errorf("failed to save zoom level: %w", err)
119157 }
120158
159+ // Update cache
160+ if uc.cache != nil {
161+ uc.cache.Set(domain, zoom)
162+ }
163+
121164 return zoom, nil
122165 }
123166
@@ -13,20 +13,28 @@ import (
13 )13 )
14 14
15 // ManageZoomUseCase handles per-domain zoom level operations.15 // ManageZoomUseCase handles per-domain zoom level operations.
16+// It uses an LRU cache to avoid database queries on every navigation.
16 type ManageZoomUseCase struct {17 type ManageZoomUseCase struct {
17 zoomRepo repository.ZoomRepository18 zoomRepo repository.ZoomRepository
18 defaultZoom float6419 defaultZoom float64
20+ cache port.Cache[string, *entity.ZoomLevel]
19 }21 }
20 22
21 // NewManageZoomUseCase creates a new zoom management use case.23 // NewManageZoomUseCase creates a new zoom management use case.
22 // defaultZoom is the zoom level to use when resetting (typically from config).24 // defaultZoom is the zoom level to use when resetting (typically from config).
23-func NewManageZoomUseCase(zoomRepo repository.ZoomRepository, defaultZoom float64) *ManageZoomUseCase {25+// cache is an LRU cache for zoom levels to avoid database queries on repeat visits.
26+func NewManageZoomUseCase(
27+ zoomRepo repository.ZoomRepository,
28+ defaultZoom float64,
29+ cache port.Cache[string, *entity.ZoomLevel],
30+) *ManageZoomUseCase {
24 if defaultZoom <= 0 {31 if defaultZoom <= 0 {
25 defaultZoom = entity.ZoomDefault32 defaultZoom = entity.ZoomDefault
26 }33 }
27 return &ManageZoomUseCase{34 return &ManageZoomUseCase{
28 zoomRepo: zoomRepo,35 zoomRepo: zoomRepo,
29 defaultZoom: defaultZoom,36 defaultZoom: defaultZoom,
37+ cache: cache,
30 }38 }
31 }39 }
32 40
@@ -37,10 +45,20 @@ func (uc *ManageZoomUseCase) DefaultZoom() float64 {
37 45
38 // GetZoom retrieves the zoom level for a domain.46 // GetZoom retrieves the zoom level for a domain.
39 // Returns the configured default zoom level if none is set.47 // Returns the configured default zoom level if none is set.
48+// Uses LRU cache to avoid database queries on repeat visits.
40 func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {49 func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
41 log := logging.FromContext(ctx)50 log := logging.FromContext(ctx)
42- log.Debug().Str("domain", domain).Msg("getting zoom level")
43 51
52+ // Check cache first (fast path - no I/O)
53+ if uc.cache != nil {
54+ if cached, ok := uc.cache.Get(domain); ok {
55+ log.Debug().Str("domain", domain).Float64("zoom", cached.ZoomFactor).Msg("zoom level from cache")
56+ return cached, nil
57+ }
58+ }
59+
60+ // Cache miss - fetch from database
61+ log.Debug().Str("domain", domain).Msg("zoom cache miss, querying database")
44 zoom, err := uc.zoomRepo.Get(ctx, domain)62 zoom, err := uc.zoomRepo.Get(ctx, domain)
45 if err != nil {63 if err != nil {
46 return nil, fmt.Errorf("failed to get zoom level: %w", err)64 return nil, fmt.Errorf("failed to get zoom level: %w", err)
@@ -51,6 +69,11 @@ func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entit
51 log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")69 log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")
52 }70 }
53 71
72+ // Cache for future lookups
73+ if uc.cache != nil {
74+ uc.cache.Set(domain, zoom)
75+ }
76+
54 return zoom, nil77 return zoom, nil
55 }78 }
56 79
@@ -64,6 +87,11 @@ func (uc *ManageZoomUseCase) SetZoom(ctx context.Context, domain string, factor
64 return fmt.Errorf("failed to set zoom level: %w", err)87 return fmt.Errorf("failed to set zoom level: %w", err)
65 }88 }
66 89
90+ // Update cache
91+ if uc.cache != nil {
92+ uc.cache.Set(domain, zoom)
93+ }
94+
67 log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")95 log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")
68 return nil96 return nil
69 }97 }
@@ -77,6 +105,11 @@ func (uc *ManageZoomUseCase) ResetZoom(ctx context.Context, domain string) error
77 return fmt.Errorf("failed to reset zoom level: %w", err)105 return fmt.Errorf("failed to reset zoom level: %w", err)
78 }106 }
79 107
108+ // Invalidate cache
109+ if uc.cache != nil {
110+ uc.cache.Remove(domain)
111+ }
112+
80 log.Info().Str("domain", domain).Msg("zoom level reset to default")113 log.Info().Str("domain", domain).Msg("zoom level reset to default")
81 return nil114 return nil
82 }115 }
@@ -98,6 +131,11 @@ func (uc *ManageZoomUseCase) ZoomIn(ctx context.Context, domain string, current
98 return nil, fmt.Errorf("failed to save zoom level: %w", err)131 return nil, fmt.Errorf("failed to save zoom level: %w", err)
99 }132 }
100 133
134+ // Update cache
135+ if uc.cache != nil {
136+ uc.cache.Set(domain, zoom)
137+ }
138+
101 return zoom, nil139 return zoom, nil
102 }140 }
103 141
@@ -118,6 +156,11 @@ func (uc *ManageZoomUseCase) ZoomOut(ctx context.Context, domain string, current
118 return nil, fmt.Errorf("failed to save zoom level: %w", err)156 return nil, fmt.Errorf("failed to save zoom level: %w", err)
119 }157 }
120 158
159+ // Update cache
160+ if uc.cache != nil {
161+ uc.cache.Set(domain, zoom)
162+ }
163+
121 return zoom, nil164 return zoom, nil
122 }165 }
123 166
M internal/application/usecase/navigate.go +92 -11
@@ -13,12 +13,30 @@ import (
1313 "github.com/bnema/dumber/internal/logging"
1414 )
1515
16+const (
17+ // historyQueueSize is the buffer size for the async history queue.
18+ // If the queue is full, new records are dropped with a warning.
19+ historyQueueSize = 100
20+)
21+
22+// historyRecord holds data for async history recording.
23+type historyRecord struct {
24+ url string
25+ timestamp time.Time
26+}
27+
1628 // NavigateUseCase handles URL navigation with history recording and zoom application.
1729 type NavigateUseCase struct {
1830 historyRepo repository.HistoryRepository
1931 zoomRepo repository.ZoomRepository
2032 defaultZoom float64
2133 recentVisits sync.Map // key: url, value: time.Time - for deduplication
34+
35+ // Async history recording
36+ historyQueue chan historyRecord
37+ done chan struct{}
38+ wg sync.WaitGroup
39+ ctx context.Context // Base context for background worker
2240 }
2341
2442 // NewNavigateUseCase creates a new navigation use case.
@@ -31,11 +49,28 @@ func NewNavigateUseCase(
3149 if defaultZoom <= 0 {
3250 defaultZoom = entity.ZoomDefault
3351 }
34- return &NavigateUseCase{
35- historyRepo: historyRepo,
36- zoomRepo: zoomRepo,
37- defaultZoom: defaultZoom,
52+
53+ uc := &NavigateUseCase{
54+ historyRepo: historyRepo,
55+ zoomRepo: zoomRepo,
56+ defaultZoom: defaultZoom,
57+ historyQueue: make(chan historyRecord, historyQueueSize),
58+ done: make(chan struct{}),
59+ ctx: context.Background(),
3860 }
61+
62+ // Start background history worker
63+ uc.wg.Add(1)
64+ go uc.historyWorker()
65+
66+ return uc
67+}
68+
69+// Close shuts down the background history worker and drains any pending records.
70+// This should be called when the application is shutting down.
71+func (uc *NavigateUseCase) Close() {
72+ close(uc.done)
73+ uc.wg.Wait()
3974 }
4075
4176 // NavigateInput contains parameters for navigation.
@@ -79,7 +114,8 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
79114 // This prevents inflation from redirects and rapid navigation.
80115 const historyDeduplicationWindow = 2 * time.Second
81116
82-// RecordHistory saves or updates the history entry.
117+// RecordHistory queues a history entry for async recording.
118+// This is non-blocking to avoid SQLite I/O on the GTK main thread.
83119 // Should be called on LoadCommitted when URI is guaranteed correct.
84120 func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
85121 log := logging.FromContext(ctx)
@@ -88,6 +124,7 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
88124 url = normalizeURLForHistory(url)
89125
90126 // Time-based deduplication: skip if same URL was recorded recently
127+ // This check is fast (sync.Map lookup) and stays on the main thread
91128 now := time.Now()
92129 if lastTime, ok := uc.recentVisits.Load(url); ok {
93130 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {
@@ -97,23 +134,67 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
97134 }
98135 uc.recentVisits.Store(url, now)
99136
137+ // Non-blocking send to async queue
138+ select {
139+ case uc.historyQueue <- historyRecord{url: url, timestamp: now}:
140+ log.Debug().Str("url", url).Msg("history record queued")
141+ default:
142+ // Queue full - log warning but don't block navigation
143+ log.Warn().Str("url", url).Msg("history queue full, dropping record")
144+ }
145+}
146+
147+// historyWorker is a background goroutine that processes history records.
148+// It drains the queue and persists records to the database without blocking the UI.
149+func (uc *NavigateUseCase) historyWorker() {
150+ defer uc.wg.Done()
151+
152+ log := logging.FromContext(uc.ctx).With().
153+ Str("component", "history-worker").
154+ Logger()
155+
156+ for {
157+ select {
158+ case record := <-uc.historyQueue:
159+ uc.persistHistory(uc.ctx, record)
160+ case <-uc.done:
161+ // Drain remaining records before shutdown
162+ log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
163+ for {
164+ select {
165+ case record := <-uc.historyQueue:
166+ uc.persistHistory(uc.ctx, record)
167+ default:
168+ log.Debug().Msg("history worker shutdown complete")
169+ return
170+ }
171+ }
172+ }
173+ }
174+}
175+
176+// persistHistory writes a history record to the database.
177+// Called from the background worker goroutine.
178+func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRecord) {
179+ log := logging.FromContext(ctx)
180+
100181 // Check if entry exists
101- existing, err := uc.historyRepo.FindByURL(ctx, url)
182+ existing, err := uc.historyRepo.FindByURL(ctx, record.url)
102183 if err != nil {
103- log.Warn().Err(err).Str("url", url).Msg("failed to check history")
184+ log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")
104185 return
105186 }
106187
107188 if existing != nil {
108189 // Increment visit count
109- if err := uc.historyRepo.IncrementVisitCount(ctx, url); err != nil {
110- log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
190+ if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
191+ log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
111192 }
112193 } else {
113194 // Create new entry
114- entry := entity.NewHistoryEntry(url, "")
195+ entry := entity.NewHistoryEntry(record.url, "")
115196 if err := uc.historyRepo.Save(ctx, entry); err != nil {
116- log.Warn().Err(err).Str("url", url).Msg("failed to save history")
197+ log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
117198 }
118199 }
119200 }
@@ -13,12 +13,30 @@ import (
13 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
14 )14 )
15 15
16+const (
17+ // historyQueueSize is the buffer size for the async history queue.
18+ // If the queue is full, new records are dropped with a warning.
19+ historyQueueSize = 100
20+)
21+
22+// historyRecord holds data for async history recording.
23+type historyRecord struct {
24+ url string
25+ timestamp time.Time
26+}
27+
16 // NavigateUseCase handles URL navigation with history recording and zoom application.28 // NavigateUseCase handles URL navigation with history recording and zoom application.
17 type NavigateUseCase struct {29 type NavigateUseCase struct {
18 historyRepo repository.HistoryRepository30 historyRepo repository.HistoryRepository
19 zoomRepo repository.ZoomRepository31 zoomRepo repository.ZoomRepository
20 defaultZoom float6432 defaultZoom float64
21 recentVisits sync.Map // key: url, value: time.Time - for deduplication33 recentVisits sync.Map // key: url, value: time.Time - for deduplication
34+
35+ // Async history recording
36+ historyQueue chan historyRecord
37+ done chan struct{}
38+ wg sync.WaitGroup
39+ ctx context.Context // Base context for background worker
22 }40 }
23 41
24 // NewNavigateUseCase creates a new navigation use case.42 // NewNavigateUseCase creates a new navigation use case.
@@ -31,11 +49,28 @@ func NewNavigateUseCase(
31 if defaultZoom <= 0 {49 if defaultZoom <= 0 {
32 defaultZoom = entity.ZoomDefault50 defaultZoom = entity.ZoomDefault
33 }51 }
34- return &NavigateUseCase{52+
35- historyRepo: historyRepo,53+ uc := &NavigateUseCase{
36- zoomRepo: zoomRepo,54+ historyRepo: historyRepo,
37- defaultZoom: defaultZoom,55+ zoomRepo: zoomRepo,
56+ defaultZoom: defaultZoom,
57+ historyQueue: make(chan historyRecord, historyQueueSize),
58+ done: make(chan struct{}),
59+ ctx: context.Background(),
38 }60 }
61+
62+ // Start background history worker
63+ uc.wg.Add(1)
64+ go uc.historyWorker()
65+
66+ return uc
67+}
68+
69+// Close shuts down the background history worker and drains any pending records.
70+// This should be called when the application is shutting down.
71+func (uc *NavigateUseCase) Close() {
72+ close(uc.done)
73+ uc.wg.Wait()
39 }74 }
40 75
41 // NavigateInput contains parameters for navigation.76 // NavigateInput contains parameters for navigation.
@@ -79,7 +114,8 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
79 // This prevents inflation from redirects and rapid navigation.114 // This prevents inflation from redirects and rapid navigation.
80 const historyDeduplicationWindow = 2 * time.Second115 const historyDeduplicationWindow = 2 * time.Second
81 116
82-// RecordHistory saves or updates the history entry.117+// RecordHistory queues a history entry for async recording.
118+// This is non-blocking to avoid SQLite I/O on the GTK main thread.
83 // Should be called on LoadCommitted when URI is guaranteed correct.119 // Should be called on LoadCommitted when URI is guaranteed correct.
84 func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {120 func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
85 log := logging.FromContext(ctx)121 log := logging.FromContext(ctx)
@@ -88,6 +124,7 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
88 url = normalizeURLForHistory(url)124 url = normalizeURLForHistory(url)
89 125
90 // Time-based deduplication: skip if same URL was recorded recently126 // Time-based deduplication: skip if same URL was recorded recently
127+ // This check is fast (sync.Map lookup) and stays on the main thread
91 now := time.Now()128 now := time.Now()
92 if lastTime, ok := uc.recentVisits.Load(url); ok {129 if lastTime, ok := uc.recentVisits.Load(url); ok {
93 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {130 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {
@@ -97,23 +134,67 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
97 }134 }
98 uc.recentVisits.Store(url, now)135 uc.recentVisits.Store(url, now)
99 136
137+ // Non-blocking send to async queue
138+ select {
139+ case uc.historyQueue <- historyRecord{url: url, timestamp: now}:
140+ log.Debug().Str("url", url).Msg("history record queued")
141+ default:
142+ // Queue full - log warning but don't block navigation
143+ log.Warn().Str("url", url).Msg("history queue full, dropping record")
144+ }
145+}
146+
147+// historyWorker is a background goroutine that processes history records.
148+// It drains the queue and persists records to the database without blocking the UI.
149+func (uc *NavigateUseCase) historyWorker() {
150+ defer uc.wg.Done()
151+
152+ log := logging.FromContext(uc.ctx).With().
153+ Str("component", "history-worker").
154+ Logger()
155+
156+ for {
157+ select {
158+ case record := <-uc.historyQueue:
159+ uc.persistHistory(uc.ctx, record)
160+ case <-uc.done:
161+ // Drain remaining records before shutdown
162+ log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
163+ for {
164+ select {
165+ case record := <-uc.historyQueue:
166+ uc.persistHistory(uc.ctx, record)
167+ default:
168+ log.Debug().Msg("history worker shutdown complete")
169+ return
170+ }
171+ }
172+ }
173+ }
174+}
175+
176+// persistHistory writes a history record to the database.
177+// Called from the background worker goroutine.
178+func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRecord) {
179+ log := logging.FromContext(ctx)
180+
100 // Check if entry exists181 // Check if entry exists
101- existing, err := uc.historyRepo.FindByURL(ctx, url)182+ existing, err := uc.historyRepo.FindByURL(ctx, record.url)
102 if err != nil {183 if err != nil {
103- log.Warn().Err(err).Str("url", url).Msg("failed to check history")184+ log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")
104 return185 return
105 }186 }
106 187
107 if existing != nil {188 if existing != nil {
108 // Increment visit count189 // Increment visit count
109- if err := uc.historyRepo.IncrementVisitCount(ctx, url); err != nil {190+ if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
110- log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")191+ log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
111 }192 }
112 } else {193 } else {
113 // Create new entry194 // Create new entry
114- entry := entity.NewHistoryEntry(url, "")195+ entry := entity.NewHistoryEntry(record.url, "")
115 if err := uc.historyRepo.Save(ctx, entry); err != nil {196 if err := uc.historyRepo.Save(ctx, entry); err != nil {
116- log.Warn().Err(err).Str("url", url).Msg("failed to save history")197+ log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
117 }198 }
118 }199 }
119 }200 }
M internal/bootstrap/webkit_stack.go +4 -0
@@ -103,6 +103,10 @@ func BuildWebKitStack(
103103
104104 messageRouter := webkit.NewMessageRouter(ctx)
105105 poolCfg := webkit.DefaultPoolConfig()
106+ // Override prewarm count from config if set
107+ if cfg.Performance.WebViewPoolPrewarmCount > 0 {
108+ poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount
109+ }
106110 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
107111
108112 if filterManager != nil {
@@ -103,6 +103,10 @@ func BuildWebKitStack(
103 103
104 messageRouter := webkit.NewMessageRouter(ctx)104 messageRouter := webkit.NewMessageRouter(ctx)
105 poolCfg := webkit.DefaultPoolConfig()105 poolCfg := webkit.DefaultPoolConfig()
106+ // Override prewarm count from config if set
107+ if cfg.Performance.WebViewPoolPrewarmCount > 0 {
108+ poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount
109+ }
106 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)110 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
107 111
108 if filterManager != nil {112 if filterManager != nil {
A internal/infrastructure/cache/lru.go +109 -0
@@ -0,0 +1,109 @@
1+// Package cache provides cache implementations for the application layer.
2+package cache
3+
4+import (
5+ "container/list"
6+ "sync"
7+)
8+
9+// LRU is a thread-safe LRU (Least Recently Used) cache with a fixed capacity.
10+// It implements port.Cache[K, V].
11+//
12+// When the cache reaches capacity, the least recently accessed entry is evicted
13+// to make room for new entries. Both Get and Set operations mark an entry as
14+// recently used.
15+type LRU[K comparable, V any] struct {
16+ capacity int
17+ mu sync.RWMutex
18+ items map[K]*list.Element
19+ order *list.List // Front = most recent, Back = least recent
20+}
21+
22+// entry holds a key-value pair in the LRU cache.
23+type entry[K comparable, V any] struct {
24+ key K
25+ value V
26+}
27+
28+// NewLRU creates a new LRU cache with the given capacity.
29+// Capacity must be positive; if zero or negative, a capacity of 1 is used.
30+func NewLRU[K comparable, V any](capacity int) *LRU[K, V] {
31+ if capacity <= 0 {
32+ capacity = 1
33+ }
34+ return &LRU[K, V]{
35+ capacity: capacity,
36+ items: make(map[K]*list.Element),
37+ order: list.New(),
38+ }
39+}
40+
41+// Get retrieves a value by key and marks it as recently used.
42+// Returns the value and true if found, or the zero value and false if not found.
43+func (c *LRU[K, V]) Get(key K) (V, bool) {
44+ c.mu.Lock()
45+ defer c.mu.Unlock()
46+
47+ if elem, ok := c.items[key]; ok {
48+ c.order.MoveToFront(elem)
49+ return elem.Value.(*entry[K, V]).value, true
50+ }
51+ var zero V
52+ return zero, false
53+}
54+
55+// Set adds or updates a value in the cache.
56+// If the key already exists, its value is updated and it's marked as recently used.
57+// If the cache is at capacity, the least recently used entry is evicted.
58+func (c *LRU[K, V]) Set(key K, value V) {
59+ c.mu.Lock()
60+ defer c.mu.Unlock()
61+
62+ // Update existing entry
63+ if elem, ok := c.items[key]; ok {
64+ c.order.MoveToFront(elem)
65+ elem.Value.(*entry[K, V]).value = value
66+ return
67+ }
68+
69+ // Evict LRU entry if at capacity
70+ if c.order.Len() >= c.capacity {
71+ oldest := c.order.Back()
72+ if oldest != nil {
73+ c.order.Remove(oldest)
74+ delete(c.items, oldest.Value.(*entry[K, V]).key)
75+ }
76+ }
77+
78+ // Add new entry at front (most recent)
79+ elem := c.order.PushFront(&entry[K, V]{key: key, value: value})
80+ c.items[key] = elem
81+}
82+
83+// Remove deletes a key from the cache.
84+// If the key doesn't exist, this is a no-op.
85+func (c *LRU[K, V]) Remove(key K) {
86+ c.mu.Lock()
87+ defer c.mu.Unlock()
88+
89+ if elem, ok := c.items[key]; ok {
90+ c.order.Remove(elem)
91+ delete(c.items, key)
92+ }
93+}
94+
95+// Len returns the number of items currently in the cache.
96+func (c *LRU[K, V]) Len() int {
97+ c.mu.RLock()
98+ defer c.mu.RUnlock()
99+ return c.order.Len()
100+}
101+
102+// Clear removes all items from the cache.
103+func (c *LRU[K, V]) Clear() {
104+ c.mu.Lock()
105+ defer c.mu.Unlock()
106+
107+ c.items = make(map[K]*list.Element)
108+ c.order.Init()
109+}
@@ -0,0 +1,109 @@
1+// Package cache provides cache implementations for the application layer.
2+package cache
3+
4+import (
5+ "container/list"
6+ "sync"
7+)
8+
9+// LRU is a thread-safe LRU (Least Recently Used) cache with a fixed capacity.
10+// It implements port.Cache[K, V].
11+//
12+// When the cache reaches capacity, the least recently accessed entry is evicted
13+// to make room for new entries. Both Get and Set operations mark an entry as
14+// recently used.
15+type LRU[K comparable, V any] struct {
16+ capacity int
17+ mu sync.RWMutex
18+ items map[K]*list.Element
19+ order *list.List // Front = most recent, Back = least recent
20+}
21+
22+// entry holds a key-value pair in the LRU cache.
23+type entry[K comparable, V any] struct {
24+ key K
25+ value V
26+}
27+
28+// NewLRU creates a new LRU cache with the given capacity.
29+// Capacity must be positive; if zero or negative, a capacity of 1 is used.
30+func NewLRU[K comparable, V any](capacity int) *LRU[K, V] {
31+ if capacity <= 0 {
32+ capacity = 1
33+ }
34+ return &LRU[K, V]{
35+ capacity: capacity,
36+ items: make(map[K]*list.Element),
37+ order: list.New(),
38+ }
39+}
40+
41+// Get retrieves a value by key and marks it as recently used.
42+// Returns the value and true if found, or the zero value and false if not found.
43+func (c *LRU[K, V]) Get(key K) (V, bool) {
44+ c.mu.Lock()
45+ defer c.mu.Unlock()
46+
47+ if elem, ok := c.items[key]; ok {
48+ c.order.MoveToFront(elem)
49+ return elem.Value.(*entry[K, V]).value, true
50+ }
51+ var zero V
52+ return zero, false
53+}
54+
55+// Set adds or updates a value in the cache.
56+// If the key already exists, its value is updated and it's marked as recently used.
57+// If the cache is at capacity, the least recently used entry is evicted.
58+func (c *LRU[K, V]) Set(key K, value V) {
59+ c.mu.Lock()
60+ defer c.mu.Unlock()
61+
62+ // Update existing entry
63+ if elem, ok := c.items[key]; ok {
64+ c.order.MoveToFront(elem)
65+ elem.Value.(*entry[K, V]).value = value
66+ return
67+ }
68+
69+ // Evict LRU entry if at capacity
70+ if c.order.Len() >= c.capacity {
71+ oldest := c.order.Back()
72+ if oldest != nil {
73+ c.order.Remove(oldest)
74+ delete(c.items, oldest.Value.(*entry[K, V]).key)
75+ }
76+ }
77+
78+ // Add new entry at front (most recent)
79+ elem := c.order.PushFront(&entry[K, V]{key: key, value: value})
80+ c.items[key] = elem
81+}
82+
83+// Remove deletes a key from the cache.
84+// If the key doesn't exist, this is a no-op.
85+func (c *LRU[K, V]) Remove(key K) {
86+ c.mu.Lock()
87+ defer c.mu.Unlock()
88+
89+ if elem, ok := c.items[key]; ok {
90+ c.order.Remove(elem)
91+ delete(c.items, key)
92+ }
93+}
94+
95+// Len returns the number of items currently in the cache.
96+func (c *LRU[K, V]) Len() int {
97+ c.mu.RLock()
98+ defer c.mu.RUnlock()
99+ return c.order.Len()
100+}
101+
102+// Clear removes all items from the cache.
103+func (c *LRU[K, V]) Clear() {
104+ c.mu.Lock()
105+ defer c.mu.Unlock()
106+
107+ c.items = make(map[K]*list.Element)
108+ c.order.Init()
109+}
A internal/infrastructure/cache/lru_test.go +212 -0
@@ -0,0 +1,212 @@
1+package cache
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestLRU_BasicOperations(t *testing.T) {
12+ cache := NewLRU[string, int](3)
13+
14+ // Test Set and Get
15+ cache.Set("a", 1)
16+ cache.Set("b", 2)
17+ cache.Set("c", 3)
18+
19+ val, ok := cache.Get("a")
20+ assert.True(t, ok)
21+ assert.Equal(t, 1, val)
22+
23+ val, ok = cache.Get("b")
24+ assert.True(t, ok)
25+ assert.Equal(t, 2, val)
26+
27+ // Test not found
28+ val, ok = cache.Get("notfound")
29+ assert.False(t, ok)
30+ assert.Equal(t, 0, val)
31+
32+ assert.Equal(t, 3, cache.Len())
33+}
34+
35+func TestLRU_Eviction(t *testing.T) {
36+ cache := NewLRU[string, int](2)
37+
38+ cache.Set("a", 1)
39+ cache.Set("b", 2)
40+ // Cache is now at capacity: [b, a] (b is most recent)
41+
42+ // Adding "c" should evict "a" (least recently used)
43+ cache.Set("c", 3)
44+
45+ _, ok := cache.Get("a")
46+ assert.False(t, ok, "a should have been evicted")
47+
48+ val, ok := cache.Get("b")
49+ assert.True(t, ok)
50+ assert.Equal(t, 2, val)
51+
52+ val, ok = cache.Get("c")
53+ assert.True(t, ok)
54+ assert.Equal(t, 3, val)
55+}
56+
57+func TestLRU_GetUpdatesRecency(t *testing.T) {
58+ cache := NewLRU[string, int](2)
59+
60+ cache.Set("a", 1)
61+ cache.Set("b", 2)
62+ // Order: [b, a]
63+
64+ // Access "a" to make it most recent
65+ cache.Get("a")
66+ // Order: [a, b]
67+
68+ // Adding "c" should now evict "b" (least recently used)
69+ cache.Set("c", 3)
70+
71+ val, ok := cache.Get("a")
72+ assert.True(t, ok, "a should still exist")
73+ assert.Equal(t, 1, val)
74+
75+ _, ok = cache.Get("b")
76+ assert.False(t, ok, "b should have been evicted")
77+}
78+
79+func TestLRU_UpdateExisting(t *testing.T) {
80+ cache := NewLRU[string, int](2)
81+
82+ cache.Set("a", 1)
83+ cache.Set("b", 2)
84+
85+ // Update "a" with new value
86+ cache.Set("a", 100)
87+
88+ val, ok := cache.Get("a")
89+ assert.True(t, ok)
90+ assert.Equal(t, 100, val)
91+
92+ // Cache should still have 2 items (no duplicate)
93+ assert.Equal(t, 2, cache.Len())
94+}
95+
96+func TestLRU_Remove(t *testing.T) {
97+ cache := NewLRU[string, int](3)
98+
99+ cache.Set("a", 1)
100+ cache.Set("b", 2)
101+ cache.Set("c", 3)
102+
103+ cache.Remove("b")
104+
105+ _, ok := cache.Get("b")
106+ assert.False(t, ok)
107+ assert.Equal(t, 2, cache.Len())
108+
109+ // Remove non-existent key should not panic
110+ cache.Remove("notfound")
111+ assert.Equal(t, 2, cache.Len())
112+}
113+
114+func TestLRU_Clear(t *testing.T) {
115+ cache := NewLRU[string, int](3)
116+
117+ cache.Set("a", 1)
118+ cache.Set("b", 2)
119+ cache.Set("c", 3)
120+
121+ cache.Clear()
122+
123+ assert.Equal(t, 0, cache.Len())
124+ _, ok := cache.Get("a")
125+ assert.False(t, ok)
126+}
127+
128+func TestLRU_ZeroCapacity(t *testing.T) {
129+ // Should default to capacity of 1
130+ cache := NewLRU[string, int](0)
131+
132+ cache.Set("a", 1)
133+ val, ok := cache.Get("a")
134+ assert.True(t, ok)
135+ assert.Equal(t, 1, val)
136+
137+ // Adding another should evict first
138+ cache.Set("b", 2)
139+ _, ok = cache.Get("a")
140+ assert.False(t, ok)
141+}
142+
143+func TestLRU_ConcurrentAccess(t *testing.T) {
144+ cache := NewLRU[int, int](100)
145+ var wg sync.WaitGroup
146+
147+ // Concurrent writes
148+ for i := 0; i < 100; i++ {
149+ wg.Add(1)
150+ go func(i int) {
151+ defer wg.Done()
152+ cache.Set(i, i*10)
153+ }(i)
154+ }
155+ wg.Wait()
156+
157+ // Concurrent reads
158+ for i := 0; i < 100; i++ {
159+ wg.Add(1)
160+ go func(i int) {
161+ defer wg.Done()
162+ cache.Get(i)
163+ }(i)
164+ }
165+ wg.Wait()
166+
167+ // Concurrent mixed operations
168+ for i := 0; i < 50; i++ {
169+ wg.Add(3)
170+ go func(i int) {
171+ defer wg.Done()
172+ cache.Set(i+100, i)
173+ }(i)
174+ go func(i int) {
175+ defer wg.Done()
176+ cache.Get(i)
177+ }(i)
178+ go func(i int) {
179+ defer wg.Done()
180+ cache.Remove(i + 50)
181+ }(i)
182+ }
183+ wg.Wait()
184+
185+ // Should not panic and cache should be in valid state
186+ require.LessOrEqual(t, cache.Len(), 100)
187+}
188+
189+func TestLRU_PointerValues(t *testing.T) {
190+ type Data struct {
191+ Name string
192+ Value float64
193+ }
194+
195+ cache := NewLRU[string, *Data](2)
196+
197+ cache.Set("key1", &Data{Name: "test1", Value: 1.5})
198+ cache.Set("key2", &Data{Name: "test2", Value: 2.5})
199+
200+ val, ok := cache.Get("key1")
201+ require.True(t, ok)
202+ assert.Equal(t, "test1", val.Name)
203+ assert.InDelta(t, 1.5, val.Value, 0.001)
204+
205+ // Update pointer value
206+ cache.Set("key1", &Data{Name: "updated", Value: 99.9})
207+
208+ val, ok = cache.Get("key1")
209+ require.True(t, ok)
210+ assert.Equal(t, "updated", val.Name)
211+ assert.InDelta(t, 99.9, val.Value, 0.001)
212+}
@@ -0,0 +1,212 @@
1+package cache
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestLRU_BasicOperations(t *testing.T) {
12+ cache := NewLRU[string, int](3)
13+
14+ // Test Set and Get
15+ cache.Set("a", 1)
16+ cache.Set("b", 2)
17+ cache.Set("c", 3)
18+
19+ val, ok := cache.Get("a")
20+ assert.True(t, ok)
21+ assert.Equal(t, 1, val)
22+
23+ val, ok = cache.Get("b")
24+ assert.True(t, ok)
25+ assert.Equal(t, 2, val)
26+
27+ // Test not found
28+ val, ok = cache.Get("notfound")
29+ assert.False(t, ok)
30+ assert.Equal(t, 0, val)
31+
32+ assert.Equal(t, 3, cache.Len())
33+}
34+
35+func TestLRU_Eviction(t *testing.T) {
36+ cache := NewLRU[string, int](2)
37+
38+ cache.Set("a", 1)
39+ cache.Set("b", 2)
40+ // Cache is now at capacity: [b, a] (b is most recent)
41+
42+ // Adding "c" should evict "a" (least recently used)
43+ cache.Set("c", 3)
44+
45+ _, ok := cache.Get("a")
46+ assert.False(t, ok, "a should have been evicted")
47+
48+ val, ok := cache.Get("b")
49+ assert.True(t, ok)
50+ assert.Equal(t, 2, val)
51+
52+ val, ok = cache.Get("c")
53+ assert.True(t, ok)
54+ assert.Equal(t, 3, val)
55+}
56+
57+func TestLRU_GetUpdatesRecency(t *testing.T) {
58+ cache := NewLRU[string, int](2)
59+
60+ cache.Set("a", 1)
61+ cache.Set("b", 2)
62+ // Order: [b, a]
63+
64+ // Access "a" to make it most recent
65+ cache.Get("a")
66+ // Order: [a, b]
67+
68+ // Adding "c" should now evict "b" (least recently used)
69+ cache.Set("c", 3)
70+
71+ val, ok := cache.Get("a")
72+ assert.True(t, ok, "a should still exist")
73+ assert.Equal(t, 1, val)
74+
75+ _, ok = cache.Get("b")
76+ assert.False(t, ok, "b should have been evicted")
77+}
78+
79+func TestLRU_UpdateExisting(t *testing.T) {
80+ cache := NewLRU[string, int](2)
81+
82+ cache.Set("a", 1)
83+ cache.Set("b", 2)
84+
85+ // Update "a" with new value
86+ cache.Set("a", 100)
87+
88+ val, ok := cache.Get("a")
89+ assert.True(t, ok)
90+ assert.Equal(t, 100, val)
91+
92+ // Cache should still have 2 items (no duplicate)
93+ assert.Equal(t, 2, cache.Len())
94+}
95+
96+func TestLRU_Remove(t *testing.T) {
97+ cache := NewLRU[string, int](3)
98+
99+ cache.Set("a", 1)
100+ cache.Set("b", 2)
101+ cache.Set("c", 3)
102+
103+ cache.Remove("b")
104+
105+ _, ok := cache.Get("b")
106+ assert.False(t, ok)
107+ assert.Equal(t, 2, cache.Len())
108+
109+ // Remove non-existent key should not panic
110+ cache.Remove("notfound")
111+ assert.Equal(t, 2, cache.Len())
112+}
113+
114+func TestLRU_Clear(t *testing.T) {
115+ cache := NewLRU[string, int](3)
116+
117+ cache.Set("a", 1)
118+ cache.Set("b", 2)
119+ cache.Set("c", 3)
120+
121+ cache.Clear()
122+
123+ assert.Equal(t, 0, cache.Len())
124+ _, ok := cache.Get("a")
125+ assert.False(t, ok)
126+}
127+
128+func TestLRU_ZeroCapacity(t *testing.T) {
129+ // Should default to capacity of 1
130+ cache := NewLRU[string, int](0)
131+
132+ cache.Set("a", 1)
133+ val, ok := cache.Get("a")
134+ assert.True(t, ok)
135+ assert.Equal(t, 1, val)
136+
137+ // Adding another should evict first
138+ cache.Set("b", 2)
139+ _, ok = cache.Get("a")
140+ assert.False(t, ok)
141+}
142+
143+func TestLRU_ConcurrentAccess(t *testing.T) {
144+ cache := NewLRU[int, int](100)
145+ var wg sync.WaitGroup
146+
147+ // Concurrent writes
148+ for i := 0; i < 100; i++ {
149+ wg.Add(1)
150+ go func(i int) {
151+ defer wg.Done()
152+ cache.Set(i, i*10)
153+ }(i)
154+ }
155+ wg.Wait()
156+
157+ // Concurrent reads
158+ for i := 0; i < 100; i++ {
159+ wg.Add(1)
160+ go func(i int) {
161+ defer wg.Done()
162+ cache.Get(i)
163+ }(i)
164+ }
165+ wg.Wait()
166+
167+ // Concurrent mixed operations
168+ for i := 0; i < 50; i++ {
169+ wg.Add(3)
170+ go func(i int) {
171+ defer wg.Done()
172+ cache.Set(i+100, i)
173+ }(i)
174+ go func(i int) {
175+ defer wg.Done()
176+ cache.Get(i)
177+ }(i)
178+ go func(i int) {
179+ defer wg.Done()
180+ cache.Remove(i + 50)
181+ }(i)
182+ }
183+ wg.Wait()
184+
185+ // Should not panic and cache should be in valid state
186+ require.LessOrEqual(t, cache.Len(), 100)
187+}
188+
189+func TestLRU_PointerValues(t *testing.T) {
190+ type Data struct {
191+ Name string
192+ Value float64
193+ }
194+
195+ cache := NewLRU[string, *Data](2)
196+
197+ cache.Set("key1", &Data{Name: "test1", Value: 1.5})
198+ cache.Set("key2", &Data{Name: "test2", Value: 2.5})
199+
200+ val, ok := cache.Get("key1")
201+ require.True(t, ok)
202+ assert.Equal(t, "test1", val.Name)
203+ assert.InDelta(t, 1.5, val.Value, 0.001)
204+
205+ // Update pointer value
206+ cache.Set("key1", &Data{Name: "updated", Value: 99.9})
207+
208+ val, ok = cache.Get("key1")
209+ require.True(t, ok)
210+ assert.Equal(t, "updated", val.Name)
211+ assert.InDelta(t, 99.9, val.Value, 0.001)
212+}
M internal/infrastructure/config/defaults.go +8 -0
@@ -42,6 +42,10 @@ const (
4242 // Other styling
4343 defaultTransitionDuration = 120
4444 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
45+
46+ // Performance defaults
47+ defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
48+ defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
4549 )
4650
4751 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -205,6 +209,10 @@ func DefaultConfig() *Config {
205209 Runtime: RuntimeConfig{
206210 Prefix: "",
207211 },
212+ Performance: PerformanceConfig{
213+ ZoomCacheSize: defaultZoomCacheSize,
214+ WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
215+ },
208216 }
209217 }
210218
@@ -42,6 +42,10 @@ const (
42 // Other styling42 // Other styling
43 defaultTransitionDuration = 12043 defaultTransitionDuration = 120
44 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)44 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
45+
46+ // Performance defaults
47+ defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
48+ defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
45 )49 )
46 50
47 // getDefaultLogDir returns the default log directory, falls back to empty string on error51 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -205,6 +209,10 @@ func DefaultConfig() *Config {
205 Runtime: RuntimeConfig{209 Runtime: RuntimeConfig{
206 Prefix: "",210 Prefix: "",
207 },211 },
212+ Performance: PerformanceConfig{
213+ ZoomCacheSize: defaultZoomCacheSize,
214+ WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
215+ },
208 }216 }
209 }217 }
210 218
M internal/infrastructure/config/schema.go +16 -0
@@ -27,6 +27,8 @@ type Config struct {
2727 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
2828 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
2929 Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"`
30+ // Performance holds internal performance tuning options (not exposed in UI).
31+ Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`
3032 }
3133
3234 // RenderingMode selects GPU vs CPU rendering.
@@ -172,6 +174,20 @@ type HistoryConfig struct {
172174 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`
173175 }
174176
177+// PerformanceConfig holds internal performance tuning options.
178+// These are not exposed in dumb://config UI but can be set in config file.
179+type PerformanceConfig struct {
180+ // ZoomCacheSize is the number of domain zoom levels to cache in memory.
181+ // Higher values reduce database queries but use more memory.
182+ // Default: 256 domains (~20KB memory)
183+ ZoomCacheSize int `mapstructure:"zoom_cache_size" yaml:"zoom_cache_size" toml:"zoom_cache_size"`
184+
185+ // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup.
186+ // Higher values speed up initial tab creation but use more memory.
187+ // Default: 4
188+ WebViewPoolPrewarmCount int `mapstructure:"webview_pool_prewarm_count" yaml:"webview_pool_prewarm_count" toml:"webview_pool_prewarm_count"` //nolint:lll // struct tags must stay on one line
189+}
190+
175191 // SearchShortcut represents a search shortcut configuration.
176192 type SearchShortcut struct {
177193 URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
@@ -27,6 +27,8 @@ type Config struct {
27 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`27 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
28 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).28 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
29 Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"`29 Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"`
30+ // Performance holds internal performance tuning options (not exposed in UI).
31+ Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`
30 }32 }
31 33
32 // RenderingMode selects GPU vs CPU rendering.34 // RenderingMode selects GPU vs CPU rendering.
@@ -172,6 +174,20 @@ type HistoryConfig struct {
172 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`174 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`
173 }175 }
174 176
177+// PerformanceConfig holds internal performance tuning options.
178+// These are not exposed in dumb://config UI but can be set in config file.
179+type PerformanceConfig struct {
180+ // ZoomCacheSize is the number of domain zoom levels to cache in memory.
181+ // Higher values reduce database queries but use more memory.
182+ // Default: 256 domains (~20KB memory)
183+ ZoomCacheSize int `mapstructure:"zoom_cache_size" yaml:"zoom_cache_size" toml:"zoom_cache_size"`
184+
185+ // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup.
186+ // Higher values speed up initial tab creation but use more memory.
187+ // Default: 4
188+ WebViewPoolPrewarmCount int `mapstructure:"webview_pool_prewarm_count" yaml:"webview_pool_prewarm_count" toml:"webview_pool_prewarm_count"` //nolint:lll // struct tags must stay on one line
189+}
190+
175 // SearchShortcut represents a search shortcut configuration.191 // SearchShortcut represents a search shortcut configuration.
176 type SearchShortcut struct {192 type SearchShortcut struct {
177 URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`193 URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
M internal/infrastructure/webkit/pool.go +1 -1
@@ -33,7 +33,7 @@ func DefaultPoolConfig() PoolConfig {
3333 return PoolConfig{
3434 MinSize: 2,
3535 MaxSize: 8,
36- PrewarmCount: 2,
36+ PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
3737 IdleTimeout: 5 * time.Minute,
3838 }
3939 }
@@ -33,7 +33,7 @@ func DefaultPoolConfig() PoolConfig {
33 return PoolConfig{33 return PoolConfig{
34 MinSize: 2,34 MinSize: 2,
35 MaxSize: 8,35 MaxSize: 8,
36- PrewarmCount: 2,36+ PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
37 IdleTimeout: 5 * time.Minute,37 IdleTimeout: 5 * time.Minute,
38 }38 }
39 }39 }
M internal/infrastructure/webkit/webview.go +19 -0
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "sync"
77 "sync/atomic"
8+ "time"
89
910 "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/logging"
@@ -94,6 +95,9 @@ type WebView struct {
9495 canGoFwd bool
9596 isLoading bool
9697
98+ // Progress throttling (~60fps)
99+ lastProgressUpdate atomic.Int64 // Unix nanoseconds
100+
97101 // Signal handler IDs for disconnection
98102 signalIDs []uint32
99103
@@ -451,9 +455,24 @@ func (wv *WebView) connectFaviconSignal() {
451455 wv.signalIDs = append(wv.signalIDs, sigID)
452456 }
453457
458+// progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
459+const progressThrottleInterval = 16 * time.Millisecond
460+
454461 func (wv *WebView) connectProgressSignal() {
455462 progressCb := func() {
456463 progress := wv.inner.GetEstimatedLoadProgress()
464+
465+ // Throttle progress updates to ~60fps (16ms interval)
466+ // Always allow 0.0 (start) and 1.0 (complete) through
467+ now := time.Now().UnixNano()
468+ last := wv.lastProgressUpdate.Load()
469+ if progress > 0.0 && progress < 1.0 {
470+ if now-last < int64(progressThrottleInterval) {
471+ return // Skip this update
472+ }
473+ }
474+ wv.lastProgressUpdate.Store(now)
475+
457476 wv.mu.Lock()
458477 wv.progress = progress
459478 wv.mu.Unlock()
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "sync"6 "sync"
7 "sync/atomic"7 "sync/atomic"
8+ "time"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
@@ -94,6 +95,9 @@ type WebView struct {
94 canGoFwd bool95 canGoFwd bool
95 isLoading bool96 isLoading bool
96 97
98+ // Progress throttling (~60fps)
99+ lastProgressUpdate atomic.Int64 // Unix nanoseconds
100+
97 // Signal handler IDs for disconnection101 // Signal handler IDs for disconnection
98 signalIDs []uint32102 signalIDs []uint32
99 103
@@ -451,9 +455,24 @@ func (wv *WebView) connectFaviconSignal() {
451 wv.signalIDs = append(wv.signalIDs, sigID)455 wv.signalIDs = append(wv.signalIDs, sigID)
452 }456 }
453 457
458+// progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
459+const progressThrottleInterval = 16 * time.Millisecond
460+
454 func (wv *WebView) connectProgressSignal() {461 func (wv *WebView) connectProgressSignal() {
455 progressCb := func() {462 progressCb := func() {
456 progress := wv.inner.GetEstimatedLoadProgress()463 progress := wv.inner.GetEstimatedLoadProgress()
464+
465+ // Throttle progress updates to ~60fps (16ms interval)
466+ // Always allow 0.0 (start) and 1.0 (complete) through
467+ now := time.Now().UnixNano()
468+ last := wv.lastProgressUpdate.Load()
469+ if progress > 0.0 && progress < 1.0 {
470+ if now-last < int64(progressThrottleInterval) {
471+ return // Skip this update
472+ }
473+ }
474+ wv.lastProgressUpdate.Store(now)
475+
457 wv.mu.Lock()476 wv.mu.Lock()
458 wv.progress = progress477 wv.progress = progress
459 wv.mu.Unlock()478 wv.mu.Unlock()
M internal/ui/coordinator/navigation.go +2 -2
@@ -63,8 +63,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
6363 paneID = string(pane.Pane.ID)
6464 // Track original URL for cross-domain redirect favicon caching
6565 c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)
66- // Pre-load cached favicon for instant display in stacked pane title bar
67- c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)
66+ // Pre-load cached favicon asynchronously (don't block navigation start)
67+ go c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)
6868 }
6969 }
7070
@@ -63,8 +63,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
63 paneID = string(pane.Pane.ID)63 paneID = string(pane.Pane.ID)
64 // Track original URL for cross-domain redirect favicon caching64 // Track original URL for cross-domain redirect favicon caching
65 c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)65 c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)
66- // Pre-load cached favicon for instant display in stacked pane title bar66+ // Pre-load cached favicon asynchronously (don't block navigation start)
67- c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)67+ go c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)
68 }68 }
69 }69 }
70 70

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