v0.28.0 3cb3770

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

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