v0.27.1 c78ce83

favicon.go RawHistoryFolder

1// Package adapter provides UI-layer adapters that bridge domain services to GTK.
2package adapter
3
4import (
5	"context"
6	"sync"
7
8	domainurl "github.com/bnema/dumber/internal/domain/url"
9	"github.com/bnema/dumber/internal/infrastructure/favicon"
10	"github.com/bnema/dumber/internal/infrastructure/webkit"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/jwijenbergh/puregotk/v4/gdk"
13	"github.com/jwijenbergh/puregotk/v4/gio"
14	"github.com/jwijenbergh/puregotk/v4/glib"
15	"github.com/rs/zerolog"
16)
17
18// FaviconAdapter bridges the domain FaviconService to GTK by providing
19// gdk.Texture conversion and WebKit FaviconDatabase integration.
20type FaviconAdapter struct {
21	service      *favicon.Service
22	faviconDB    *webkit.FaviconDatabase
23	textureCache map[string]*gdk.Texture
24	mu           sync.RWMutex
25	warnMu       sync.Mutex
26	warnCounts   map[string]int
27}
28
29type warningLogFunc func(log *zerolog.Logger, err error)
30
31// NewFaviconAdapter creates a new FaviconAdapter.
32// The faviconDB can be nil if WebKit favicon database is not available.
33func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter {
34	return &FaviconAdapter{
35		service:      service,
36		faviconDB:    faviconDB,
37		textureCache: make(map[string]*gdk.Texture),
38		warnCounts:   make(map[string]int),
39	}
40}
41
42// GetTexture returns a cached texture for the domain, or nil if not cached.
43func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
44	if domain == "" {
45		return nil
46	}
47
48	a.mu.RLock()
49	texture := a.textureCache[domain]
50	a.mu.RUnlock()
51	return texture
52}
53
54// GetTextureByURL returns a cached texture by extracting domain from URL.
55// For internal dumb:// URLs, returns the app logo texture.
56func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
57	// Handle internal dumb:// scheme URLs
58	if favicon.IsInternalURL(pageURL) {
59		return a.GetTexture(favicon.InternalDomain)
60	}
61	domain := domainurl.ExtractDomain(pageURL)
62	return a.GetTexture(domain)
63}
64
65// GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
66// The callback is invoked on the GTK main thread with the texture (or nil).
67// For internal dumb:// URLs, returns the app logo texture.
68func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
69	if callback == nil {
70		return
71	}
72
73	log := logging.FromContext(ctx)
74
75	// Handle internal dumb:// scheme URLs - use app logo
76	if favicon.IsInternalURL(pageURL) {
77		texture := a.getOrCreateLogoTexture()
78		callback(texture)
79		return
80	}
81
82	domain := domainurl.ExtractDomain(pageURL)
83
84	// Check texture cache first
85	if texture := a.GetTexture(domain); texture != nil {
86		// Ensure sized PNG exists for CLI tools (async, idempotent)
87		go a.ensureSizedPNG(ctx, domain)
88		callback(texture)
89		return
90	}
91
92	// Check service cache (memory + disk) and convert to texture
93	if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
94		texture := bytesToTexture(data)
95		if texture != nil {
96			a.setTexture(domain, texture)
97			// Ensure sized PNG exists for CLI tools (async, idempotent)
98			go a.ensureSizedPNG(ctx, domain)
99			callback(texture)
100			return
101		}
102	}
103
104	// Helper to invoke callback on main thread
105	invokeCallback := func(texture *gdk.Texture) {
106		cb := glib.SourceFunc(func(uintptr) bool {
107			callback(texture)
108			return false
109		})
110		glib.IdleAdd(&cb, 0)
111	}
112
113	// Helper to fetch via service and invoke callback
114	fetchViaService := func() {
115		go func() {
116			data, err := a.service.Get(ctx, domain)
117			if err != nil || len(data) == 0 {
118				invokeCallback(nil)
119				return
120			}
121
122			texture := bytesToTexture(data)
123			if texture != nil {
124				a.setTexture(domain, texture)
125			}
126			invokeCallback(texture)
127		}()
128	}
129
130	// Try WebKit FaviconDatabase if available
131	if a.faviconDB != nil {
132		asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
133			if resultPtr == 0 {
134				log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
135				fetchViaService()
136				return
137			}
138
139			result := &gio.AsyncResultBase{Ptr: resultPtr}
140			texture, err := a.faviconDB.GetFaviconFinish(result)
141			if err != nil || texture == nil {
142				log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
143				fetchViaService()
144				return
145			}
146
147			// Found in WebKit database
148			a.setTexture(domain, texture)
149			invokeCallback(texture)
150
151			// Ensure disk cache is populated (async)
152			go a.service.EnsureDiskCache(ctx, domain)
153		})
154
155		a.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
156		return
157	}
158
159	// No WebKit DB, fetch via service directly
160	fetchViaService()
161}
162
163// StoreFromWebKit stores a favicon texture received from WebKit signals.
164// Also ensures the favicon is persisted to disk cache via the service.
165func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
166	if texture == nil {
167		return
168	}
169
170	domain := domainurl.ExtractDomain(pageURL)
171	if domain == "" {
172		return
173	}
174
175	// Store in texture cache
176	a.setTexture(domain, texture)
177
178	// Export as PNG and ensure disk cache
179	a.saveFaviconToDisk(ctx, domain, texture)
180}
181
182// StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
183// Used when redirects occur (e.g., example.com → www.example.com).
184func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
185	ctx context.Context, currentURL, originURL string, texture *gdk.Texture,
186) {
187	a.StoreFromWebKit(ctx, currentURL, texture)
188
189	// Also store under original URL domain if different
190	if originURL != "" && originURL != currentURL {
191		originDomain := domainurl.ExtractDomain(originURL)
192		currentDomain := domainurl.ExtractDomain(currentURL)
193		if originDomain != "" && originDomain != currentDomain {
194			a.setTexture(originDomain, texture)
195			a.saveFaviconToDisk(ctx, originDomain, texture)
196		}
197	}
198}
199
200// saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.
201// Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
202func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
203	log := logging.FromContext(ctx)
204	cacheDirWarnKey := "cache-dir"
205	savePNGWarnKey := "save-png:" + domain
206	sizedPNGWarnKey := "sized-png:" + domain
207
208	// Check if PNG save is needed before scheduling idle callback
209	pngPath := a.service.DiskPathPNG(domain)
210	needsPNGSave := pngPath != "" && !a.service.HasPNGOnDisk(domain)
211
212	if needsPNGSave {
213		// Ensure cache directory exists (cheap check, do it now)
214		if err := a.service.EnsureCacheDir(); err != nil {
215			a.logWarningDedup(ctx, cacheDirWarnKey, err, func(log *zerolog.Logger, warnErr error) {
216				log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create favicon cache dir")
217			})
218			needsPNGSave = false
219		} else {
220			a.clearWarningDedup(cacheDirWarnKey)
221		}
222	}
223
224	if needsPNGSave {
225		// Schedule PNG save for when GTK main loop is idle (non-blocking)
226		// texture.SaveToPng must run on main thread but we defer it to avoid blocking
227		cb := glib.SourceFunc(func(_ uintptr) bool {
228			if ok := texture.SaveToPng(pngPath); !ok {
229				a.logWarningDedup(ctx, savePNGWarnKey, nil, func(log *zerolog.Logger, _ error) {
230					log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
231				})
232			} else {
233				a.clearWarningDedup(savePNGWarnKey)
234				log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
235
236				// Create normalized sized copy for dmenu/fuzzel (async)
237				go func() {
238					if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
239						a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) {
240							log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
241						})
242					} else {
243						a.clearWarningDedup(sizedPNGWarnKey)
244						log.Debug().Str("domain", domain).Msg("created sized PNG")
245					}
246				}()
247			}
248			return false // don't repeat
249		})
250		glib.IdleAdd(&cb, 0)
251	}
252
253	// Ensure disk cache is populated (async, in background)
254	go a.service.EnsureDiskCache(ctx, domain)
255}
256
257// PreloadFromCache attempts to load a favicon from cache without fetching.
258// Returns the texture if found in memory or disk cache, nil otherwise.
259func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
260	domain := domainurl.ExtractDomain(pageURL)
261	if domain == "" {
262		return nil
263	}
264
265	// Check texture cache
266	if texture := a.GetTexture(domain); texture != nil {
267		return texture
268	}
269
270	// Check service cache (memory + disk)
271	if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
272		texture := bytesToTexture(data)
273		if texture != nil {
274			a.setTexture(domain, texture)
275			return texture
276		}
277	}
278
279	return nil
280}
281
282// Service returns the underlying favicon service.
283// Used by CLI components that need disk paths.
284func (a *FaviconAdapter) Service() *favicon.Service {
285	return a.service
286}
287
288// Close shuts down the adapter and underlying service.
289func (a *FaviconAdapter) Close() {
290	a.service.Close()
291}
292
293// Clear removes all entries from the texture cache.
294func (a *FaviconAdapter) Clear() {
295	a.mu.Lock()
296	a.textureCache = make(map[string]*gdk.Texture)
297	a.mu.Unlock()
298}
299
300// Size returns the number of entries in the texture cache.
301func (a *FaviconAdapter) Size() int {
302	a.mu.RLock()
303	size := len(a.textureCache)
304	a.mu.RUnlock()
305	return size
306}
307
308// setTexture stores a texture in the cache.
309func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
310	if domain == "" || texture == nil {
311		return
312	}
313	a.mu.Lock()
314	a.textureCache[domain] = texture
315	a.mu.Unlock()
316}
317
318// ensureSizedPNG ensures the sized PNG exists for CLI tools.
319// This is idempotent - it only creates the file if it doesn't exist.
320func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
321	if domain == "" {
322		return
323	}
324
325	log := logging.FromContext(ctx)
326
327	hasPNG := a.service.HasPNGOnDisk(domain)
328	hasSized := a.service.HasPNGSizedOnDisk(domain, favicon.NormalizedIconSize)
329
330	log.Debug().
331		Str("domain", domain).
332		Bool("has_png", hasPNG).
333		Bool("has_sized", hasSized).
334		Msg("checking sized PNG status")
335
336	// Only create if original PNG exists but sized version doesn't
337	if hasPNG && !hasSized {
338		log.Debug().Str("domain", domain).Msg("creating sized PNG")
339		if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
340			a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) {
341				log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
342			})
343		} else {
344			a.clearWarningDedup("sized-png:" + domain)
345			log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
346		}
347	}
348}
349
350func (a *FaviconAdapter) shouldLogWarningDedup(key string) (bool, int) {
351	a.warnMu.Lock()
352	defer a.warnMu.Unlock()
353
354	count := a.warnCounts[key] + 1
355	a.warnCounts[key] = count
356
357	return count == 1, count - 1
358}
359
360func (a *FaviconAdapter) clearWarningDedup(key string) {
361	a.warnMu.Lock()
362	delete(a.warnCounts, key)
363	a.warnMu.Unlock()
364}
365
366func (a *FaviconAdapter) logWarningDedup(
367	ctx context.Context,
368	key string,
369	err error,
370	warnFn warningLogFunc,
371) {
372	log := logging.FromContext(ctx)
373	logWarning, suppressed := a.shouldLogWarningDedup(key)
374	if logWarning {
375		warnFn(log, err)
376		return
377	}
378
379	if suppressed == 1 {
380		event := log.Debug()
381		if err != nil {
382			event = event.Err(err)
383		}
384		event.Str("dedupe_key", key).Msg("suppressing repeated favicon warning")
385	}
386}
387
388// getOrCreateLogoTexture returns the app logo texture, creating it if needed.
389// The texture is cached under the InternalDomain key.
390func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
391	// Check cache first
392	if texture := a.GetTexture(favicon.InternalDomain); texture != nil {
393		return texture
394	}
395
396	// Create texture from logo bytes
397	logoBytes := favicon.GetLogoBytes()
398	texture := bytesToTexture(logoBytes)
399	if texture != nil {
400		a.setTexture(favicon.InternalDomain, texture)
401	}
402	return texture
403}
404
405// bytesToTexture converts raw favicon bytes to a GDK texture.
406func bytesToTexture(data []byte) *gdk.Texture {
407	if len(data) == 0 {
408		return nil
409	}
410
411	bytes := glib.NewBytes(data, uint(len(data)))
412	if bytes == nil {
413		return nil
414	}
415
416	texture, err := gdk.NewTextureFromBytes(bytes)
417	if err != nil {
418		return nil
419	}
420
421	return texture
422}

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