v0.8.0 0fa6785

favicon.go RawHistoryFolder

1package cache
2
3import (
4	"crypto/md5"
5	"fmt"
6	"image"
7	"image/color"
8	_ "image/gif"  // Register GIF decoder
9	_ "image/jpeg" // Register JPEG decoder
10	"image/png"
11	"io"
12	"log"
13	"net/http"
14	"os"
15	"path/filepath"
16	"time"
17
18	"github.com/bnema/dumber/internal/config"
19	"github.com/sergeymakinen/go-ico"
20)
21
22// FaviconCache manages favicon caching to local files
23type FaviconCache struct {
24	cacheDir string
25}
26
27// NewFaviconCache creates a new favicon cache
28func NewFaviconCache() (*FaviconCache, error) {
29	dataDir, err := config.GetDataDir()
30	if err != nil {
31		return nil, fmt.Errorf("failed to get data directory: %w", err)
32	}
33
34	cacheDir := filepath.Join(dataDir, "favicons")
35	if err := os.MkdirAll(cacheDir, 0755); err != nil {
36		return nil, fmt.Errorf("failed to create favicon cache directory: %w", err)
37	}
38
39	return &FaviconCache{
40		cacheDir: cacheDir,
41	}, nil
42}
43
44// GetCachedPath returns the local file path for a favicon URL if cached, empty string otherwise
45func (fc *FaviconCache) GetCachedPath(faviconURL string) string {
46	if faviconURL == "" {
47		return ""
48	}
49
50	filename := fc.getFilename(faviconURL)
51	cachedPath := filepath.Join(fc.cacheDir, filename)
52
53	// Check if file exists and is not too old (7 days)
54	if info, err := os.Stat(cachedPath); err == nil {
55		if time.Since(info.ModTime()) < 7*24*time.Hour {
56			return cachedPath
57		}
58	}
59
60	return ""
61}
62
63// CacheAsync downloads and caches a favicon asynchronously
64func (fc *FaviconCache) CacheAsync(faviconURL string) {
65	if faviconURL == "" {
66		return
67	}
68
69	go func() {
70		if err := fc.downloadFavicon(faviconURL); err != nil {
71			// Silently ignore errors in background download
72		}
73	}()
74}
75
76// getFilename generates a safe filename for a favicon URL - always PNG for fuzzel compatibility
77func (fc *FaviconCache) getFilename(faviconURL string) string {
78	hash := fmt.Sprintf("%x", md5.Sum([]byte(faviconURL)))
79	return hash + ".png"
80}
81
82// downloadFavicon downloads a favicon and saves it to cache
83func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
84	filename := fc.getFilename(faviconURL)
85	cachedPath := filepath.Join(fc.cacheDir, filename)
86
87	// Don't download if already exists and is recent
88	if info, err := os.Stat(cachedPath); err == nil {
89		if time.Since(info.ModTime()) < 7*24*time.Hour {
90			return nil
91		}
92	}
93
94	// Create HTTP client with timeout and redirect handling
95	client := &http.Client{
96		Timeout: 10 * time.Second,
97		CheckRedirect: func(req *http.Request, via []*http.Request) error {
98			if len(via) >= 5 {
99				return fmt.Errorf("too many redirects")
100			}
101			return nil
102		},
103	}
104
105	resp, err := client.Get(faviconURL)
106	if err != nil {
107		return fmt.Errorf("failed to download favicon: %w", err)
108	}
109	defer resp.Body.Close()
110
111	// Log redirects for debugging
112	if resp.Request.URL.String() != faviconURL {
113		log.Printf("[favicon] Redirected from %s to %s", faviconURL, resp.Request.URL.String())
114	}
115
116	if resp.StatusCode != http.StatusOK {
117		return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode)
118	}
119
120	// Limit response body size
121	limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max
122
123	// Download to temporary file first
124	tempDownloadFile := cachedPath + ".download"
125	downloadFile, err := os.Create(tempDownloadFile)
126	if err != nil {
127		return fmt.Errorf("failed to create download temp file: %w", err)
128	}
129	defer downloadFile.Close()
130	defer os.Remove(tempDownloadFile)
131
132	// Save raw favicon data
133	if _, err := io.Copy(downloadFile, limitedBody); err != nil {
134		return fmt.Errorf("failed to save favicon data: %w", err)
135	}
136	downloadFile.Close()
137
138	// Detect if it's an ICO file and convert with ffmpeg if needed
139	if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil {
140		return fmt.Errorf("failed to convert favicon: %w", err)
141	}
142
143	return nil
144}
145
146// convertToPNG converts a favicon file to PNG format
147func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) error {
148	// Try standard Go image decoder first for all formats (PNG, JPEG, GIF)
149	if img, err := fc.tryStandardImageDecode(inputPath); err == nil {
150		log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL)
151
152		// Apply dark theme improvements
153		img = invertIfTooDark(img)
154
155		// Save as PNG
156		return fc.savePNG(img, outputPath)
157	}
158
159	// Standard decode failed, try go-ico decoder for ICO files
160	log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL)
161	return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)
162}
163
164// tryStandardImageDecode attempts to decode image with Go standard library
165func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, error) {
166	file, err := os.Open(filePath)
167	if err != nil {
168		return nil, err
169	}
170	defer file.Close()
171
172	img, _, err := image.Decode(file)
173	return img, err
174}
175
176// savePNG saves an image as PNG
177func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
178	tempFile := outputPath + ".tmp"
179	file, err := os.Create(tempFile)
180	if err != nil {
181		return fmt.Errorf("failed to create temp file: %w", err)
182	}
183	defer file.Close()
184
185	if err := png.Encode(file, img); err != nil {
186		os.Remove(tempFile)
187		return fmt.Errorf("failed to encode PNG: %w", err)
188	}
189
190	file.Close()
191
192	// Atomic move
193	if err := os.Rename(tempFile, outputPath); err != nil {
194		os.Remove(tempFile)
195		return fmt.Errorf("failed to move PNG to cache: %w", err)
196	}
197
198	return nil
199}
200
201// convertICOWithGoICO converts ICO files to PNG using go-ico library
202func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL string) error {
203	// Open the ICO file
204	file, err := os.Open(inputPath)
205	if err != nil {
206		return fmt.Errorf("failed to open ICO file: %w", err)
207	}
208	defer file.Close()
209
210	// Decode ICO file
211	img, err := ico.Decode(file)
212	if err != nil {
213		return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err)
214	}
215
216	log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL)
217
218	// Apply dark theme improvements
219	img = invertIfTooDark(img)
220
221	// Save as PNG
222	return fc.savePNG(img, outputPath)
223}
224
225// CleanOld removes cached favicons older than 30 days
226func (fc *FaviconCache) CleanOld() error {
227	entries, err := os.ReadDir(fc.cacheDir)
228	if err != nil {
229		return err
230	}
231
232	cutoff := time.Now().Add(-30 * 24 * time.Hour)
233
234	for _, entry := range entries {
235		if entry.IsDir() {
236			continue
237		}
238
239		info, err := entry.Info()
240		if err != nil {
241			continue
242		}
243
244		if info.ModTime().Before(cutoff) {
245			os.Remove(filepath.Join(fc.cacheDir, entry.Name()))
246		}
247	}
248
249	return nil
250}
251
252// invertIfTooDark inverts icons that are too dark for dark theme visibility
253// Only inverts truly monochrome dark icons, preserves colorful icons
254func invertIfTooDark(img image.Image) image.Image {
255	bounds := img.Bounds()
256
257	// Sample pixels to calculate statistics
258	var totalR, totalG, totalB uint32
259	var pixelCount uint32
260	var colorVariance uint32
261	var transparentPixels uint32
262
263	// Sample every 4th pixel for performance
264	for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 {
265		for x := bounds.Min.X; x < bounds.Max.X; x += 4 {
266			r, g, b, a := img.At(x, y).RGBA()
267
268			if a == 0 {
269				transparentPixels++
270				continue
271			}
272
273			if a > 0 { // Only count non-transparent pixels
274				totalR += r
275				totalG += g
276				totalB += b
277				pixelCount++
278
279				// Calculate color variance to detect colorful vs monochrome icons
280				// High variance = colorful (don't invert), low variance = monochrome
281				rNorm := r >> 8 // Convert to 0-255 range
282				gNorm := g >> 8
283				bNorm := b >> 8
284
285				// Simple color variance: difference between max and min color channels
286				maxColor := rNorm
287				if gNorm > maxColor {
288					maxColor = gNorm
289				}
290				if bNorm > maxColor {
291					maxColor = bNorm
292				}
293
294				minColor := rNorm
295				if gNorm < minColor {
296					minColor = gNorm
297				}
298				if bNorm < minColor {
299					minColor = bNorm
300				}
301
302				colorVariance += maxColor - minColor
303			}
304		}
305	}
306
307	if pixelCount == 0 {
308		return img // All transparent
309	}
310
311	// Calculate average brightness (0-65535 range)
312	avgR := totalR / pixelCount
313	avgG := totalG / pixelCount
314	avgB := totalB / pixelCount
315
316	// Calculate perceived brightness using standard luminance formula
317	brightness := (299*avgR + 587*avgG + 114*avgB) / 1000
318
319	// Calculate average color variance (0-255 range)
320	avgColorVariance := colorVariance / pixelCount
321
322	// Calculate transparency ratio
323	totalPixels := pixelCount + transparentPixels
324	transparencyRatio := float64(transparentPixels) / float64(totalPixels)
325
326	// Only invert if:
327	// 1. Icon is very dark (brightness < 20000, low brightness)
328	// 2. AND icon is mostly monochrome (low color variance < 20)
329	// 3. AND icon is not mostly transparent (< 80% transparent)
330	// This makes dark monochrome icons visible on dark themes while preserving colorful icons
331
332	shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8
333
334	log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t",
335		brightness, avgColorVariance, transparencyRatio, shouldInvert)
336
337	if shouldInvert {
338		log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility")
339		return invertImage(img)
340	}
341
342	return img
343}
344
345// invertImage inverts the colors of an image with edge smoothing
346func invertImage(img image.Image) image.Image {
347	bounds := img.Bounds()
348	inverted := image.NewRGBA(bounds)
349
350	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
351		for x := bounds.Min.X; x < bounds.Max.X; x++ {
352			r, g, b, a := img.At(x, y).RGBA()
353
354			// Skip fully transparent pixels to avoid noise
355			if a == 0 {
356				inverted.Set(x, y, color.RGBA{A: 0})
357				continue
358			}
359
360			// Convert to 8-bit for easier processing
361			r8 := uint8(r >> 8)
362			g8 := uint8(g >> 8)
363			b8 := uint8(b >> 8)
364			a8 := uint8(a >> 8)
365
366			// Invert RGB but preserve alpha
367			newR := 255 - r8
368			newG := 255 - g8
369			newB := 255 - b8
370
371			// For semi-transparent pixels, blend the inversion more gently
372			// This reduces edge noise by making transitions smoother
373			if a8 < 255 {
374				// Reduce inversion intensity for semi-transparent pixels
375				blendFactor := float64(a8) / 255.0
376				newR = uint8(float64(r8) + (float64(newR-r8) * blendFactor))
377				newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor))
378				newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor))
379			}
380
381			inverted.Set(x, y, color.RGBA{
382				R: newR,
383				G: newG,
384				B: newB,
385				A: a8,
386			})
387		}
388	}
389
390	return inverted
391}

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