v0.30.0 eb3068f

cache.go RawHistoryFolder

1package favicon
2
3import (
4	"context"
5	"os"
6	"path/filepath"
7	"sync"
8
9	domainurl "github.com/bnema/dumber/internal/domain/url"
10	"github.com/bnema/dumber/internal/logging"
11)
12
13const (
14	// diskWriteBufferSize defines the capacity of the favicon write channel.
15	diskWriteBufferSize = 100
16	// File permissions for favicon cache.
17	diskCacheDirPerm  = 0750
18	diskCacheFilePerm = 0600
19)
20
21// diskWrite represents a favicon to be written to disk asynchronously.
22type diskWrite struct {
23	domain string
24	data   []byte
25}
26
27// Cache provides multi-tier favicon caching (memory + disk).
28type Cache struct {
29	memCache  map[string][]byte
30	diskDir   string
31	writeChan chan diskWrite
32	closeOnce sync.Once
33	closed    bool
34	mu        sync.RWMutex
35}
36
37// NewCache creates a new favicon cache.
38// If diskDir is empty, only in-memory caching is used.
39func NewCache(diskDir string) *Cache {
40	c := &Cache{
41		memCache:  make(map[string][]byte),
42		diskDir:   diskDir,
43		writeChan: make(chan diskWrite, diskWriteBufferSize),
44	}
45
46	// Start background writer goroutine if disk caching is enabled
47	if diskDir != "" {
48		go c.diskWriter()
49	}
50
51	return c
52}
53
54// Get retrieves favicon bytes for a domain.
55// Checks memory cache first, then disk cache.
56// Returns the bytes and true if found, nil and false otherwise.
57func (c *Cache) Get(ctx context.Context, domain string) ([]byte, bool) {
58	if domain == "" {
59		return nil, false
60	}
61	log := logging.FromContext(ctx)
62
63	// Check memory cache first
64	c.mu.RLock()
65	data, ok := c.memCache[domain]
66	c.mu.RUnlock()
67	if ok {
68		log.Debug().
69			Str("domain", domain).
70			Int("bytes", len(data)).
71			Msg("favicon: Cache.Get memory hit")
72		return data, true
73	}
74
75	// Check disk cache
76	data = c.loadFromDisk(domain)
77	if data != nil {
78		// Populate memory cache
79		c.mu.Lock()
80		c.memCache[domain] = data
81		c.mu.Unlock()
82		log.Debug().
83			Str("domain", domain).
84			Int("bytes", len(data)).
85			Msg("favicon: Cache.Get disk hit")
86		return data, true
87	}
88
89	log.Debug().Str("domain", domain).Msg("favicon: Cache.Get miss")
90	return nil, false
91}
92
93// Set stores favicon bytes for a domain.
94// Writes to memory cache immediately and queues async disk write.
95func (c *Cache) Set(ctx context.Context, domain string, data []byte) {
96	if domain == "" || len(data) == 0 {
97		return
98	}
99	logging.FromContext(ctx).Debug().
100		Str("domain", domain).
101		Int("bytes", len(data)).
102		Msg("favicon: Cache.Set")
103
104	// Write to memory cache unless the cache is already shutting down.
105	c.mu.Lock()
106	if c.closed {
107		c.mu.Unlock()
108		return
109	}
110	c.memCache[domain] = data
111	c.mu.Unlock()
112
113	// Queue async disk write
114	c.queueDiskWrite(domain, data)
115}
116
117// DiskPath returns the filesystem path for a domain's cached favicon (ICO format).
118// Returns empty string if disk caching is disabled or domain is empty.
119func (c *Cache) DiskPath(domain string) string {
120	if c.diskDir == "" || domain == "" {
121		return ""
122	}
123	filename := domainurl.SanitizeDomainForFilename(domain)
124	return filepath.Join(c.diskDir, filename)
125}
126
127// DiskPathPNG returns the filesystem path for a domain's PNG favicon.
128// PNG format is required by external tools like rofi/fuzzel.
129// Returns empty string if disk caching is disabled or domain is empty.
130func (c *Cache) DiskPathPNG(domain string) string {
131	if c.diskDir == "" || domain == "" {
132		return ""
133	}
134	filename := domainurl.SanitizeDomainForPNG(domain)
135	return filepath.Join(c.diskDir, filename)
136}
137
138// HasPNGOnDisk checks if a PNG favicon exists on disk for the given domain.
139func (c *Cache) HasPNGOnDisk(domain string) bool {
140	path := c.DiskPathPNG(domain)
141	if path == "" {
142		return false
143	}
144	_, err := os.Stat(path)
145	return err == nil
146}
147
148// DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
149// Used for normalized favicon export (e.g., 32x32) for CLI tools like rofi/fuzzel.
150// Returns empty string if disk caching is disabled or domain is empty.
151func (c *Cache) DiskPathPNGSized(domain string, size int) string {
152	if c.diskDir == "" || domain == "" {
153		return ""
154	}
155	filename := domainurl.SanitizeDomainForPNGSized(domain, size)
156	return filepath.Join(c.diskDir, filename)
157}
158
159// HasPNGSizedOnDisk checks if a sized PNG favicon exists on disk for the given domain.
160func (c *Cache) HasPNGSizedOnDisk(domain string, size int) bool {
161	path := c.DiskPathPNGSized(domain, size)
162	if path == "" {
163		return false
164	}
165	_, err := os.Stat(path)
166	return err == nil
167}
168
169// WritePNG writes raw PNG data to disk for a domain.
170// Used by UI layer to export WebKit textures for CLI tools.
171func (c *Cache) WritePNG(domain string, pngData []byte) {
172	if c.diskDir == "" || len(pngData) == 0 || domain == "" {
173		return
174	}
175
176	// Ensure directory exists
177	if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
178		return
179	}
180
181	filename := domainurl.SanitizeDomainForPNG(domain)
182	finalPath := filepath.Join(c.diskDir, filename)
183	tempPath := finalPath + ".tmp"
184
185	// Write to temp file
186	if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil {
187		return
188	}
189
190	// Atomic rename
191	if err := os.Rename(tempPath, finalPath); err != nil {
192		_ = os.Remove(tempPath)
193	}
194}
195
196// WritePNGSized writes raw PNG data to disk for a domain at a specific size.
197// Used for internal favicons that are pre-sized (e.g., embedded app logo).
198func (c *Cache) WritePNGSized(domain string, pngData []byte, size int) {
199	if c.diskDir == "" || len(pngData) == 0 || domain == "" {
200		return
201	}
202
203	// Ensure directory exists
204	if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
205		return
206	}
207
208	filename := domainurl.SanitizeDomainForPNGSized(domain, size)
209	finalPath := filepath.Join(c.diskDir, filename)
210	tempPath := finalPath + ".tmp"
211
212	// Write to temp file
213	if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil {
214		return
215	}
216
217	// Atomic rename
218	if err := os.Rename(tempPath, finalPath); err != nil {
219		_ = os.Remove(tempPath)
220	}
221}
222
223// HasOnDisk checks if a favicon exists on disk for the given domain.
224func (c *Cache) HasOnDisk(domain string) bool {
225	path := c.DiskPath(domain)
226	if path == "" {
227		return false
228	}
229	_, err := os.Stat(path)
230	return err == nil
231}
232
233// Close shuts down the background writer goroutine.
234func (c *Cache) Close() {
235	c.closeOnce.Do(func() {
236		c.mu.Lock()
237		defer c.mu.Unlock()
238
239		c.closed = true
240		if c.writeChan != nil {
241			close(c.writeChan)
242		}
243	})
244}
245
246// EnsureDir ensures the cache directory exists on disk.
247// Call this before using DiskPathPNG with external save functions.
248func (c *Cache) EnsureDir() error {
249	if c.diskDir == "" {
250		return nil
251	}
252	return os.MkdirAll(c.diskDir, diskCacheDirPerm)
253}
254
255// Clear removes all entries from the in-memory cache.
256func (c *Cache) Clear() {
257	c.mu.Lock()
258	c.memCache = make(map[string][]byte)
259	c.mu.Unlock()
260}
261
262// Size returns the number of entries in the in-memory cache.
263func (c *Cache) Size() int {
264	c.mu.RLock()
265	size := len(c.memCache)
266	c.mu.RUnlock()
267	return size
268}
269
270// loadFromDisk attempts to load favicon bytes from disk cache.
271func (c *Cache) loadFromDisk(domain string) []byte {
272	path := c.DiskPath(domain)
273	if path == "" {
274		return nil
275	}
276
277	data, err := os.ReadFile(path)
278	if err != nil {
279		return nil
280	}
281
282	if len(data) == 0 {
283		return nil
284	}
285
286	return data
287}
288
289// writeToDisk atomically writes favicon data to disk.
290func (c *Cache) writeToDisk(domain string, data []byte) {
291	if c.diskDir == "" || len(data) == 0 {
292		return
293	}
294
295	// Ensure directory exists
296	if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
297		return
298	}
299
300	filename := domainurl.SanitizeDomainForFilename(domain)
301	finalPath := filepath.Join(c.diskDir, filename)
302	tempPath := finalPath + ".tmp"
303
304	// Write to temp file
305	if err := os.WriteFile(tempPath, data, diskCacheFilePerm); err != nil {
306		return
307	}
308
309	// Atomic rename
310	if err := os.Rename(tempPath, finalPath); err != nil {
311		_ = os.Remove(tempPath)
312	}
313}
314
315// queueDiskWrite sends favicon data to be written asynchronously.
316func (c *Cache) queueDiskWrite(domain string, data []byte) {
317	if c.diskDir == "" {
318		return
319	}
320
321	c.mu.RLock()
322	defer c.mu.RUnlock()
323	if c.closed {
324		return
325	}
326
327	select {
328	case c.writeChan <- diskWrite{domain: domain, data: data}:
329		// queued successfully
330	default:
331		// channel full, skip write
332	}
333}
334
335// diskWriter processes async write requests.
336func (c *Cache) diskWriter() {
337	for write := range c.writeChan {
338		c.writeToDisk(write.domain, write.data)
339	}
340}

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