detached 458bd84

feat(cache): implement two-tier FaviconCache with async loading

Add new FaviconCache with in-memory cache keyed by normalized domain and WebKit FaviconDatabase as persistent backing store. Domain normalization strips www prefix for consistent lookups. GetOrFetch method provides async loading with URL variant matching (www/non-www, trailing slash) to handle WebKit's exact URL storage. Callback runs on GLib main loop for safe UI updates.
commit
458bd84188abc1f888156b4919630fdefd5bf76b
author
brice · · 2025-12-15 18:34 UTC
committer
brice · · 2025-12-15 20:01 UTC
parent
65aa3b0
signature
unsigned
1 file · +242 -0
A internal/ui/cache/favicon_cache.go +242 -0
@@ -0,0 +1,242 @@
1+// Package cache provides in-memory caching utilities for the UI layer.
2+package cache
3+
4+import (
5+ "context"
6+ "net/url"
7+ "strings"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/puregotk-webkit/webkit"
12+ "github.com/jwijenbergh/puregotk/v4/gdk"
13+ "github.com/jwijenbergh/puregotk/v4/gio"
14+ "github.com/jwijenbergh/puregotk/v4/glib"
15+)
16+
17+// FaviconCache provides a two-tier favicon caching system:
18+// 1. In-memory cache keyed by domain for fast lookups
19+// 2. WebKit FaviconDatabase as persistent backing store
20+type FaviconCache struct {
21+ mu sync.RWMutex
22+ cache map[string]*gdk.Texture // key: domain (e.g., "github.com")
23+ faviconDB *webkit.FaviconDatabase
24+}
25+
26+// NewFaviconCache creates a new FaviconCache with the given FaviconDatabase.
27+// The faviconDB can be nil, in which case only the in-memory cache is used.
28+func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache {
29+ return &FaviconCache{
30+ cache: make(map[string]*gdk.Texture),
31+ faviconDB: faviconDB,
32+ }
33+}
34+
35+// extractDomain extracts the normalized domain (host) from a URL string.
36+// Normalizes by stripping "www." prefix so youtube.com and www.youtube.com
37+// resolve to the same cache key.
38+func extractDomain(rawURL string) string {
39+ if rawURL == "" {
40+ return ""
41+ }
42+ parsed, err := url.Parse(rawURL)
43+ if err != nil {
44+ return ""
45+ }
46+ host := parsed.Host
47+ // Normalize: strip www. prefix
48+ host = strings.TrimPrefix(host, "www.")
49+ return host
50+}
51+
52+// Set stores a favicon texture for the given domain.
53+func (fc *FaviconCache) Set(domain string, texture *gdk.Texture) {
54+ if domain == "" || texture == nil {
55+ return
56+ }
57+ fc.mu.Lock()
58+ fc.cache[domain] = texture
59+ fc.mu.Unlock()
60+}
61+
62+// SetByURL stores a favicon texture for the domain extracted from the URL.
63+func (fc *FaviconCache) SetByURL(pageURL string, texture *gdk.Texture) {
64+ domain := extractDomain(pageURL)
65+ fc.Set(domain, texture)
66+}
67+
68+// Get retrieves a favicon texture by domain from the in-memory cache.
69+// Returns nil if not found.
70+func (fc *FaviconCache) Get(domain string) *gdk.Texture {
71+ if domain == "" {
72+ return nil
73+ }
74+ fc.mu.RLock()
75+ texture := fc.cache[domain]
76+ fc.mu.RUnlock()
77+ return texture
78+}
79+
80+// GetByURL retrieves a favicon texture by extracting the domain from the URL.
81+// Returns nil if not found.
82+func (fc *FaviconCache) GetByURL(pageURL string) *gdk.Texture {
83+ domain := extractDomain(pageURL)
84+ return fc.Get(domain)
85+}
86+
87+// getURLVariants returns URL variants to try for favicon lookup.
88+// WebKit stores favicons under exact URLs (e.g., https://www.youtube.com/)
89+// so we need to try multiple variants: with/without www, with/without trailing slash.
90+func getURLVariants(pageURL string) []string {
91+ parsed, err := url.Parse(pageURL)
92+ if err != nil {
93+ return nil
94+ }
95+
96+ variants := make([]string, 0, 4)
97+
98+ // Generate host variants (with and without www.)
99+ hosts := []string{parsed.Host}
100+ if strings.HasPrefix(parsed.Host, "www.") {
101+ hosts = append(hosts, strings.TrimPrefix(parsed.Host, "www."))
102+ } else {
103+ hosts = append(hosts, "www."+parsed.Host)
104+ }
105+
106+ // Generate path variants (with and without trailing slash)
107+ paths := []string{parsed.Path}
108+ if parsed.Path == "" || parsed.Path == "/" {
109+ paths = []string{"", "/"}
110+ } else if strings.HasSuffix(parsed.Path, "/") {
111+ paths = append(paths, strings.TrimSuffix(parsed.Path, "/"))
112+ } else {
113+ paths = append(paths, parsed.Path+"/")
114+ }
115+
116+ // Generate all combinations (skip the original URL)
117+ original := pageURL
118+ for _, host := range hosts {
119+ for _, path := range paths {
120+ p := *parsed
121+ p.Host = host
122+ p.Path = path
123+ variant := p.String()
124+ if variant != original {
125+ variants = append(variants, variant)
126+ }
127+ }
128+ }
129+
130+ return variants
131+}
132+
133+// GetOrFetch checks the in-memory cache first, then queries the FaviconDatabase
134+// asynchronously on cache miss. The callback is invoked with the texture (or nil).
135+// This is safe to call from any goroutine; the callback runs on the GLib main loop.
136+func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
137+ if callback == nil {
138+ return
139+ }
140+
141+ log := logging.FromContext(ctx)
142+ domain := extractDomain(pageURL)
143+
144+ // Check in-memory cache first
145+ if texture := fc.Get(domain); texture != nil {
146+ callback(texture)
147+ return
148+ }
149+
150+ // No FaviconDatabase, return nil
151+ if fc.faviconDB == nil {
152+ callback(nil)
153+ return
154+ }
155+
156+ // Helper to invoke callback on main thread
157+ invokeCallback := func(texture *gdk.Texture) {
158+ cb := glib.SourceFunc(func(uintptr) bool {
159+ callback(texture)
160+ return false
161+ })
162+ glib.IdleAdd(&cb, 0)
163+ }
164+
165+ // Get all URL variants to try (www/non-www, with/without trailing slash)
166+ variants := getURLVariants(pageURL)
167+
168+ // Recursive function to try variants one by one
169+ var tryVariant func(index int)
170+ tryVariant = func(index int) {
171+ if index >= len(variants) {
172+ // All variants exhausted
173+ invokeCallback(nil)
174+ return
175+ }
176+
177+ variantURL := variants[index]
178+ variantCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
179+ if resultPtr == 0 {
180+ tryVariant(index + 1)
181+ return
182+ }
183+
184+ result := &gio.AsyncResultBase{Ptr: resultPtr}
185+ texture, err := fc.faviconDB.GetFaviconFinish(result)
186+ if err != nil {
187+ log.Debug().Err(err).Str("url", variantURL).Msg("favicon variant fetch failed")
188+ tryVariant(index + 1)
189+ return
190+ }
191+
192+ if texture != nil && domain != "" {
193+ fc.Set(domain, texture)
194+ }
195+ invokeCallback(texture)
196+ })
197+
198+ fc.faviconDB.GetFavicon(variantURL, nil, &variantCb, 0)
199+ }
200+
201+ // Query FaviconDatabase with original URL first
202+ asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
203+ if resultPtr == 0 {
204+ log.Debug().Str("url", pageURL).Msg("favicon fetch: nil result, trying variants")
205+ tryVariant(0)
206+ return
207+ }
208+
209+ result := &gio.AsyncResultBase{Ptr: resultPtr}
210+ texture, err := fc.faviconDB.GetFaviconFinish(result)
211+ if err != nil {
212+ log.Debug().Err(err).Str("url", pageURL).Msg("favicon fetch failed, trying variants")
213+ tryVariant(0)
214+ return
215+ }
216+
217+ // Cache the result
218+ if texture != nil && domain != "" {
219+ fc.Set(domain, texture)
220+ }
221+
222+ invokeCallback(texture)
223+ })
224+
225+ // Start async fetch (nil cancellable, 0 userData)
226+ fc.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
227+}
228+
229+// Clear removes all entries from the in-memory cache.
230+func (fc *FaviconCache) Clear() {
231+ fc.mu.Lock()
232+ fc.cache = make(map[string]*gdk.Texture)
233+ fc.mu.Unlock()
234+}
235+
236+// Size returns the number of entries in the in-memory cache.
237+func (fc *FaviconCache) Size() int {
238+ fc.mu.RLock()
239+ size := len(fc.cache)
240+ fc.mu.RUnlock()
241+ return size
242+}
@@ -0,0 +1,242 @@
1+// Package cache provides in-memory caching utilities for the UI layer.
2+package cache
3+
4+import (
5+ "context"
6+ "net/url"
7+ "strings"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/puregotk-webkit/webkit"
12+ "github.com/jwijenbergh/puregotk/v4/gdk"
13+ "github.com/jwijenbergh/puregotk/v4/gio"
14+ "github.com/jwijenbergh/puregotk/v4/glib"
15+)
16+
17+// FaviconCache provides a two-tier favicon caching system:
18+// 1. In-memory cache keyed by domain for fast lookups
19+// 2. WebKit FaviconDatabase as persistent backing store
20+type FaviconCache struct {
21+ mu sync.RWMutex
22+ cache map[string]*gdk.Texture // key: domain (e.g., "github.com")
23+ faviconDB *webkit.FaviconDatabase
24+}
25+
26+// NewFaviconCache creates a new FaviconCache with the given FaviconDatabase.
27+// The faviconDB can be nil, in which case only the in-memory cache is used.
28+func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache {
29+ return &FaviconCache{
30+ cache: make(map[string]*gdk.Texture),
31+ faviconDB: faviconDB,
32+ }
33+}
34+
35+// extractDomain extracts the normalized domain (host) from a URL string.
36+// Normalizes by stripping "www." prefix so youtube.com and www.youtube.com
37+// resolve to the same cache key.
38+func extractDomain(rawURL string) string {
39+ if rawURL == "" {
40+ return ""
41+ }
42+ parsed, err := url.Parse(rawURL)
43+ if err != nil {
44+ return ""
45+ }
46+ host := parsed.Host
47+ // Normalize: strip www. prefix
48+ host = strings.TrimPrefix(host, "www.")
49+ return host
50+}
51+
52+// Set stores a favicon texture for the given domain.
53+func (fc *FaviconCache) Set(domain string, texture *gdk.Texture) {
54+ if domain == "" || texture == nil {
55+ return
56+ }
57+ fc.mu.Lock()
58+ fc.cache[domain] = texture
59+ fc.mu.Unlock()
60+}
61+
62+// SetByURL stores a favicon texture for the domain extracted from the URL.
63+func (fc *FaviconCache) SetByURL(pageURL string, texture *gdk.Texture) {
64+ domain := extractDomain(pageURL)
65+ fc.Set(domain, texture)
66+}
67+
68+// Get retrieves a favicon texture by domain from the in-memory cache.
69+// Returns nil if not found.
70+func (fc *FaviconCache) Get(domain string) *gdk.Texture {
71+ if domain == "" {
72+ return nil
73+ }
74+ fc.mu.RLock()
75+ texture := fc.cache[domain]
76+ fc.mu.RUnlock()
77+ return texture
78+}
79+
80+// GetByURL retrieves a favicon texture by extracting the domain from the URL.
81+// Returns nil if not found.
82+func (fc *FaviconCache) GetByURL(pageURL string) *gdk.Texture {
83+ domain := extractDomain(pageURL)
84+ return fc.Get(domain)
85+}
86+
87+// getURLVariants returns URL variants to try for favicon lookup.
88+// WebKit stores favicons under exact URLs (e.g., https://www.youtube.com/)
89+// so we need to try multiple variants: with/without www, with/without trailing slash.
90+func getURLVariants(pageURL string) []string {
91+ parsed, err := url.Parse(pageURL)
92+ if err != nil {
93+ return nil
94+ }
95+
96+ variants := make([]string, 0, 4)
97+
98+ // Generate host variants (with and without www.)
99+ hosts := []string{parsed.Host}
100+ if strings.HasPrefix(parsed.Host, "www.") {
101+ hosts = append(hosts, strings.TrimPrefix(parsed.Host, "www."))
102+ } else {
103+ hosts = append(hosts, "www."+parsed.Host)
104+ }
105+
106+ // Generate path variants (with and without trailing slash)
107+ paths := []string{parsed.Path}
108+ if parsed.Path == "" || parsed.Path == "/" {
109+ paths = []string{"", "/"}
110+ } else if strings.HasSuffix(parsed.Path, "/") {
111+ paths = append(paths, strings.TrimSuffix(parsed.Path, "/"))
112+ } else {
113+ paths = append(paths, parsed.Path+"/")
114+ }
115+
116+ // Generate all combinations (skip the original URL)
117+ original := pageURL
118+ for _, host := range hosts {
119+ for _, path := range paths {
120+ p := *parsed
121+ p.Host = host
122+ p.Path = path
123+ variant := p.String()
124+ if variant != original {
125+ variants = append(variants, variant)
126+ }
127+ }
128+ }
129+
130+ return variants
131+}
132+
133+// GetOrFetch checks the in-memory cache first, then queries the FaviconDatabase
134+// asynchronously on cache miss. The callback is invoked with the texture (or nil).
135+// This is safe to call from any goroutine; the callback runs on the GLib main loop.
136+func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
137+ if callback == nil {
138+ return
139+ }
140+
141+ log := logging.FromContext(ctx)
142+ domain := extractDomain(pageURL)
143+
144+ // Check in-memory cache first
145+ if texture := fc.Get(domain); texture != nil {
146+ callback(texture)
147+ return
148+ }
149+
150+ // No FaviconDatabase, return nil
151+ if fc.faviconDB == nil {
152+ callback(nil)
153+ return
154+ }
155+
156+ // Helper to invoke callback on main thread
157+ invokeCallback := func(texture *gdk.Texture) {
158+ cb := glib.SourceFunc(func(uintptr) bool {
159+ callback(texture)
160+ return false
161+ })
162+ glib.IdleAdd(&cb, 0)
163+ }
164+
165+ // Get all URL variants to try (www/non-www, with/without trailing slash)
166+ variants := getURLVariants(pageURL)
167+
168+ // Recursive function to try variants one by one
169+ var tryVariant func(index int)
170+ tryVariant = func(index int) {
171+ if index >= len(variants) {
172+ // All variants exhausted
173+ invokeCallback(nil)
174+ return
175+ }
176+
177+ variantURL := variants[index]
178+ variantCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
179+ if resultPtr == 0 {
180+ tryVariant(index + 1)
181+ return
182+ }
183+
184+ result := &gio.AsyncResultBase{Ptr: resultPtr}
185+ texture, err := fc.faviconDB.GetFaviconFinish(result)
186+ if err != nil {
187+ log.Debug().Err(err).Str("url", variantURL).Msg("favicon variant fetch failed")
188+ tryVariant(index + 1)
189+ return
190+ }
191+
192+ if texture != nil && domain != "" {
193+ fc.Set(domain, texture)
194+ }
195+ invokeCallback(texture)
196+ })
197+
198+ fc.faviconDB.GetFavicon(variantURL, nil, &variantCb, 0)
199+ }
200+
201+ // Query FaviconDatabase with original URL first
202+ asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
203+ if resultPtr == 0 {
204+ log.Debug().Str("url", pageURL).Msg("favicon fetch: nil result, trying variants")
205+ tryVariant(0)
206+ return
207+ }
208+
209+ result := &gio.AsyncResultBase{Ptr: resultPtr}
210+ texture, err := fc.faviconDB.GetFaviconFinish(result)
211+ if err != nil {
212+ log.Debug().Err(err).Str("url", pageURL).Msg("favicon fetch failed, trying variants")
213+ tryVariant(0)
214+ return
215+ }
216+
217+ // Cache the result
218+ if texture != nil && domain != "" {
219+ fc.Set(domain, texture)
220+ }
221+
222+ invokeCallback(texture)
223+ })
224+
225+ // Start async fetch (nil cancellable, 0 userData)
226+ fc.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
227+}
228+
229+// Clear removes all entries from the in-memory cache.
230+func (fc *FaviconCache) Clear() {
231+ fc.mu.Lock()
232+ fc.cache = make(map[string]*gdk.Texture)
233+ fc.mu.Unlock()
234+}
235+
236+// Size returns the number of entries in the in-memory cache.
237+func (fc *FaviconCache) Size() int {
238+ fc.mu.RLock()
239+ size := len(fc.cache)
240+ fc.mu.RUnlock()
241+ return size
242+}

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