v0.29.0 21b3150

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

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