v0.26.0 ae3a77a

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

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