v0.20.0 c741a39

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)
16
17// FaviconAdapter bridges the domain FaviconService to GTK by providing
18// gdk.Texture conversion and WebKit FaviconDatabase integration.
19type FaviconAdapter struct {
20	service      *favicon.Service
21	faviconDB    *webkit.FaviconDatabase
22	textureCache map[string]*gdk.Texture
23	mu           sync.RWMutex
24}
25
26// NewFaviconAdapter creates a new FaviconAdapter.
27// The faviconDB can be nil if WebKit favicon database is not available.
28func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter {
29	return &FaviconAdapter{
30		service:      service,
31		faviconDB:    faviconDB,
32		textureCache: make(map[string]*gdk.Texture),
33	}
34}
35
36// GetTexture returns a cached texture for the domain, or nil if not cached.
37func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
38	if domain == "" {
39		return nil
40	}
41
42	a.mu.RLock()
43	texture := a.textureCache[domain]
44	a.mu.RUnlock()
45	return texture
46}
47
48// GetTextureByURL returns a cached texture by extracting domain from URL.
49func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
50	domain := domainurl.ExtractDomain(pageURL)
51	return a.GetTexture(domain)
52}
53
54// GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
55// The callback is invoked on the GTK main thread with the texture (or nil).
56func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
57	if callback == nil {
58		return
59	}
60
61	log := logging.FromContext(ctx)
62	domain := domainurl.ExtractDomain(pageURL)
63
64	// Check texture cache first
65	if texture := a.GetTexture(domain); texture != nil {
66		// Ensure sized PNG exists for CLI tools (async, idempotent)
67		go a.ensureSizedPNG(ctx, domain)
68		callback(texture)
69		return
70	}
71
72	// Check service cache (memory + disk) and convert to texture
73	if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
74		texture := bytesToTexture(data)
75		if texture != nil {
76			a.setTexture(domain, texture)
77			// Ensure sized PNG exists for CLI tools (async, idempotent)
78			go a.ensureSizedPNG(ctx, domain)
79			callback(texture)
80			return
81		}
82	}
83
84	// Helper to invoke callback on main thread
85	invokeCallback := func(texture *gdk.Texture) {
86		cb := glib.SourceFunc(func(uintptr) bool {
87			callback(texture)
88			return false
89		})
90		glib.IdleAdd(&cb, 0)
91	}
92
93	// Helper to fetch via service and invoke callback
94	fetchViaService := func() {
95		go func() {
96			data, err := a.service.Get(ctx, domain)
97			if err != nil || len(data) == 0 {
98				invokeCallback(nil)
99				return
100			}
101
102			texture := bytesToTexture(data)
103			if texture != nil {
104				a.setTexture(domain, texture)
105			}
106			invokeCallback(texture)
107		}()
108	}
109
110	// Try WebKit FaviconDatabase if available
111	if a.faviconDB != nil {
112		asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
113			if resultPtr == 0 {
114				log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
115				fetchViaService()
116				return
117			}
118
119			result := &gio.AsyncResultBase{Ptr: resultPtr}
120			texture, err := a.faviconDB.GetFaviconFinish(result)
121			if err != nil || texture == nil {
122				log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
123				fetchViaService()
124				return
125			}
126
127			// Found in WebKit database
128			a.setTexture(domain, texture)
129			invokeCallback(texture)
130
131			// Ensure disk cache is populated (async)
132			go a.service.EnsureDiskCache(ctx, domain)
133		})
134
135		a.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
136		return
137	}
138
139	// No WebKit DB, fetch via service directly
140	fetchViaService()
141}
142
143// StoreFromWebKit stores a favicon texture received from WebKit signals.
144// Also ensures the favicon is persisted to disk cache via the service.
145func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
146	if texture == nil {
147		return
148	}
149
150	domain := domainurl.ExtractDomain(pageURL)
151	if domain == "" {
152		return
153	}
154
155	log := logging.FromContext(ctx)
156
157	// Store in texture cache
158	a.setTexture(domain, texture)
159
160	// Export as PNG for CLI tools (rofi/fuzzel) - do this on main thread
161	// since GTK texture operations need to happen there
162	pngPath := a.service.DiskPathPNG(domain)
163	if pngPath != "" && !a.service.HasPNGOnDisk(domain) {
164		if ok := texture.SaveToPng(pngPath); !ok {
165			log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
166		} else {
167			log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
168
169			// Create normalized sized copy for dmenu/fuzzel (async)
170			// The original PNG is now on disk, so this should succeed
171			go func() {
172				if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
173					log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG after save")
174				} else {
175					log.Debug().Str("domain", domain).Msg("created sized PNG after save")
176				}
177			}()
178		}
179	}
180
181	// Ensure disk cache is populated (async, in background)
182	go a.service.EnsureDiskCache(ctx, domain)
183}
184
185// StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
186// Used when redirects occur (e.g., example.com → www.example.com).
187func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
188	ctx context.Context, currentURL, originURL string, texture *gdk.Texture,
189) {
190	a.StoreFromWebKit(ctx, currentURL, texture)
191
192	// Also store under original URL domain if different
193	if originURL != "" && originURL != currentURL {
194		log := logging.FromContext(ctx)
195		originDomain := domainurl.ExtractDomain(originURL)
196		currentDomain := domainurl.ExtractDomain(currentURL)
197		if originDomain != "" && originDomain != currentDomain {
198			a.setTexture(originDomain, texture)
199
200			// Export PNG for origin domain too
201			pngPath := a.service.DiskPathPNG(originDomain)
202			if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) {
203				if ok := texture.SaveToPng(pngPath); !ok {
204					log.Warn().Str("domain", originDomain).Str("path", pngPath).Msg("failed to save origin favicon PNG")
205				} else {
206					log.Debug().Str("domain", originDomain).Str("path", pngPath).Msg("saved origin favicon PNG")
207
208					// Create normalized sized copy for dmenu/fuzzel (async)
209					go func() {
210						if err := a.service.EnsureSizedPNG(ctx, originDomain, favicon.NormalizedIconSize); err != nil {
211							log.Warn().Err(err).Str("domain", originDomain).Msg("failed to create sized PNG for origin")
212						} else {
213							log.Debug().Str("domain", originDomain).Msg("created sized PNG for origin")
214						}
215					}()
216				}
217			}
218
219			go a.service.EnsureDiskCache(ctx, originDomain)
220		}
221	}
222}
223
224// PreloadFromCache attempts to load a favicon from cache without fetching.
225// Returns the texture if found in memory or disk cache, nil otherwise.
226func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
227	domain := domainurl.ExtractDomain(pageURL)
228	if domain == "" {
229		return nil
230	}
231
232	// Check texture cache
233	if texture := a.GetTexture(domain); texture != nil {
234		return texture
235	}
236
237	// Check service cache (memory + disk)
238	if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
239		texture := bytesToTexture(data)
240		if texture != nil {
241			a.setTexture(domain, texture)
242			return texture
243		}
244	}
245
246	return nil
247}
248
249// Service returns the underlying favicon service.
250// Used by CLI components that need disk paths.
251func (a *FaviconAdapter) Service() *favicon.Service {
252	return a.service
253}
254
255// Close shuts down the adapter and underlying service.
256func (a *FaviconAdapter) Close() {
257	a.service.Close()
258}
259
260// Clear removes all entries from the texture cache.
261func (a *FaviconAdapter) Clear() {
262	a.mu.Lock()
263	a.textureCache = make(map[string]*gdk.Texture)
264	a.mu.Unlock()
265}
266
267// Size returns the number of entries in the texture cache.
268func (a *FaviconAdapter) Size() int {
269	a.mu.RLock()
270	size := len(a.textureCache)
271	a.mu.RUnlock()
272	return size
273}
274
275// setTexture stores a texture in the cache.
276func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
277	if domain == "" || texture == nil {
278		return
279	}
280	a.mu.Lock()
281	a.textureCache[domain] = texture
282	a.mu.Unlock()
283}
284
285// ensureSizedPNG ensures the sized PNG exists for CLI tools.
286// This is idempotent - it only creates the file if it doesn't exist.
287func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
288	if domain == "" {
289		return
290	}
291
292	log := logging.FromContext(ctx)
293
294	hasPNG := a.service.HasPNGOnDisk(domain)
295	hasSized := a.service.HasPNGSizedOnDisk(domain, favicon.NormalizedIconSize)
296
297	log.Debug().
298		Str("domain", domain).
299		Bool("has_png", hasPNG).
300		Bool("has_sized", hasSized).
301		Msg("checking sized PNG status")
302
303	// Only create if original PNG exists but sized version doesn't
304	if hasPNG && !hasSized {
305		log.Debug().Str("domain", domain).Msg("creating sized PNG")
306		if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
307			log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")
308		} else {
309			log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
310		}
311	}
312}
313
314// bytesToTexture converts raw favicon bytes to a GDK texture.
315func bytesToTexture(data []byte) *gdk.Texture {
316	if len(data) == 0 {
317		return nil
318	}
319
320	bytes := glib.NewBytes(data, uint(len(data)))
321	if bytes == nil {
322		return nil
323	}
324
325	texture, err := gdk.NewTextureFromBytes(bytes)
326	if err != nil {
327		return nil
328	}
329
330	return texture
331}

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