v0.23.0 d05d132

cache.go RawHistoryFolder

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

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