v0.23.0 d05d132

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	log := logging.FromContext(ctx)
170
171	// Store in texture cache
172	a.setTexture(domain, texture)
173
174	// Export as PNG for CLI tools (rofi/fuzzel) - do this on main thread
175	// since GTK texture operations need to happen there
176	pngPath := a.service.DiskPathPNG(domain)
177	if pngPath != "" && !a.service.HasPNGOnDisk(domain) {
178		if ok := texture.SaveToPng(pngPath); !ok {
179			log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
180		} else {
181			log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
182
183			// Create normalized sized copy for dmenu/fuzzel (async)
184			// The original PNG is now on disk, so this should succeed
185			go func() {
186				if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
187					log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG after save")
188				} else {
189					log.Debug().Str("domain", domain).Msg("created sized PNG after save")
190				}
191			}()
192		}
193	}
194
195	// Ensure disk cache is populated (async, in background)
196	go a.service.EnsureDiskCache(ctx, domain)
197}
198
199// StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
200// Used when redirects occur (e.g., example.com → www.example.com).
201func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
202	ctx context.Context, currentURL, originURL string, texture *gdk.Texture,
203) {
204	a.StoreFromWebKit(ctx, currentURL, texture)
205
206	// Also store under original URL domain if different
207	if originURL != "" && originURL != currentURL {
208		log := logging.FromContext(ctx)
209		originDomain := domainurl.ExtractDomain(originURL)
210		currentDomain := domainurl.ExtractDomain(currentURL)
211		if originDomain != "" && originDomain != currentDomain {
212			a.setTexture(originDomain, texture)
213
214			// Export PNG for origin domain too
215			pngPath := a.service.DiskPathPNG(originDomain)
216			if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) {
217				if ok := texture.SaveToPng(pngPath); !ok {
218					log.Warn().Str("domain", originDomain).Str("path", pngPath).Msg("failed to save origin favicon PNG")
219				} else {
220					log.Debug().Str("domain", originDomain).Str("path", pngPath).Msg("saved origin favicon PNG")
221
222					// Create normalized sized copy for dmenu/fuzzel (async)
223					go func() {
224						if err := a.service.EnsureSizedPNG(ctx, originDomain, favicon.NormalizedIconSize); err != nil {
225							log.Warn().Err(err).Str("domain", originDomain).Msg("failed to create sized PNG for origin")
226						} else {
227							log.Debug().Str("domain", originDomain).Msg("created sized PNG for origin")
228						}
229					}()
230				}
231			}
232
233			go a.service.EnsureDiskCache(ctx, originDomain)
234		}
235	}
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