detached 975148c

chore: remove old favicon cache file

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

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