v0.9.0 636c44c

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 func() {
110		if err := resp.Body.Close(); err != nil {
111			log.Printf("[favicon] failed to close response body: %v", err)
112		}
113	}()
114
115	// Log redirects for debugging
116	if resp.Request.URL.String() != faviconURL {
117		log.Printf("[favicon] Redirected from %s to %s", faviconURL, resp.Request.URL.String())
118	}
119
120	if resp.StatusCode != http.StatusOK {
121		return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode)
122	}
123
124	// Limit response body size
125	limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max
126
127	// Download to temporary file first
128	tempDownloadFile := cachedPath + ".download"
129	downloadFile, err := os.Create(tempDownloadFile)
130	if err != nil {
131		return fmt.Errorf("failed to create download temp file: %w", err)
132	}
133	defer func() {
134		if err := downloadFile.Close(); err != nil {
135			log.Printf("[favicon] failed to close download file: %v", err)
136		}
137	}()
138	defer func() {
139		if err := os.Remove(tempDownloadFile); err != nil {
140			log.Printf("[favicon] failed to remove temp download file: %v", err)
141		}
142	}()
143
144	// Save raw favicon data
145	if _, err := io.Copy(downloadFile, limitedBody); err != nil {
146		return fmt.Errorf("failed to save favicon data: %w", err)
147	}
148	if err := downloadFile.Close(); err != nil {
149		log.Printf("[favicon] failed to close download file: %v", err)
150	}
151
152	// Detect if it's an ICO file and convert with ffmpeg if needed
153	if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil {
154		return fmt.Errorf("failed to convert favicon: %w", err)
155	}
156
157	return nil
158}
159
160// convertToPNG converts a favicon file to PNG format
161func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) error {
162	// Try standard Go image decoder first for all formats (PNG, JPEG, GIF)
163	if img, err := fc.tryStandardImageDecode(inputPath); err == nil {
164		log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL)
165
166		// Apply dark theme improvements
167		img = invertIfTooDark(img)
168
169		// Save as PNG
170		return fc.savePNG(img, outputPath)
171	}
172
173	// Standard decode failed, try go-ico decoder for ICO files
174	log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL)
175	return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)
176}
177
178// tryStandardImageDecode attempts to decode image with Go standard library
179func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, error) {
180	file, err := os.Open(filePath)
181	if err != nil {
182		return nil, err
183	}
184	defer func() {
185		if err := file.Close(); err != nil {
186			log.Printf("[favicon] failed to close file: %v", err)
187		}
188	}()
189
190	img, _, err := image.Decode(file)
191	return img, err
192}
193
194// savePNG saves an image as PNG
195func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
196	tempFile := outputPath + ".tmp"
197	file, err := os.Create(tempFile)
198	if err != nil {
199		return fmt.Errorf("failed to create temp file: %w", err)
200	}
201	defer func() {
202		if err := file.Close(); err != nil {
203			log.Printf("[favicon] failed to close file: %v", err)
204		}
205	}()
206
207	if err := png.Encode(file, img); err != nil {
208		if removeErr := os.Remove(tempFile); removeErr != nil {
209			log.Printf("[favicon] failed to remove temp file: %v", removeErr)
210		}
211		return fmt.Errorf("failed to encode PNG: %w", err)
212	}
213
214	file.Close()
215
216	// Atomic move
217	if err := os.Rename(tempFile, outputPath); err != nil {
218		os.Remove(tempFile)
219		return fmt.Errorf("failed to move PNG to cache: %w", err)
220	}
221
222	return nil
223}
224
225// convertICOWithGoICO converts ICO files to PNG using go-ico library
226func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL string) error {
227	// Open the ICO file
228	file, err := os.Open(inputPath)
229	if err != nil {
230		return fmt.Errorf("failed to open ICO file: %w", err)
231	}
232	defer file.Close()
233
234	// Decode ICO file
235	img, err := ico.Decode(file)
236	if err != nil {
237		return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err)
238	}
239
240	log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL)
241
242	// Apply dark theme improvements
243	img = invertIfTooDark(img)
244
245	// Save as PNG
246	return fc.savePNG(img, outputPath)
247}
248
249// CleanOld removes cached favicons older than 30 days
250func (fc *FaviconCache) CleanOld() error {
251	entries, err := os.ReadDir(fc.cacheDir)
252	if err != nil {
253		return err
254	}
255
256	cutoff := time.Now().Add(-30 * 24 * time.Hour)
257
258	for _, entry := range entries {
259		if entry.IsDir() {
260			continue
261		}
262
263		info, err := entry.Info()
264		if err != nil {
265			continue
266		}
267
268		if info.ModTime().Before(cutoff) {
269			os.Remove(filepath.Join(fc.cacheDir, entry.Name()))
270		}
271	}
272
273	return nil
274}
275
276// invertIfTooDark inverts icons that are too dark for dark theme visibility
277// Only inverts truly monochrome dark icons, preserves colorful icons
278func invertIfTooDark(img image.Image) image.Image {
279	bounds := img.Bounds()
280
281	// Sample pixels to calculate statistics
282	var totalR, totalG, totalB uint32
283	var pixelCount uint32
284	var colorVariance uint32
285	var transparentPixels uint32
286
287	// Sample every 4th pixel for performance
288	for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 {
289		for x := bounds.Min.X; x < bounds.Max.X; x += 4 {
290			r, g, b, a := img.At(x, y).RGBA()
291
292			if a == 0 {
293				transparentPixels++
294				continue
295			}
296
297			if a > 0 { // Only count non-transparent pixels
298				totalR += r
299				totalG += g
300				totalB += b
301				pixelCount++
302
303				// Calculate color variance to detect colorful vs monochrome icons
304				// High variance = colorful (don't invert), low variance = monochrome
305				rNorm := r >> 8 // Convert to 0-255 range
306				gNorm := g >> 8
307				bNorm := b >> 8
308
309				// Simple color variance: difference between max and min color channels
310				maxColor := rNorm
311				if gNorm > maxColor {
312					maxColor = gNorm
313				}
314				if bNorm > maxColor {
315					maxColor = bNorm
316				}
317
318				minColor := rNorm
319				if gNorm < minColor {
320					minColor = gNorm
321				}
322				if bNorm < minColor {
323					minColor = bNorm
324				}
325
326				colorVariance += maxColor - minColor
327			}
328		}
329	}
330
331	if pixelCount == 0 {
332		return img // All transparent
333	}
334
335	// Calculate average brightness (0-65535 range)
336	avgR := totalR / pixelCount
337	avgG := totalG / pixelCount
338	avgB := totalB / pixelCount
339
340	// Calculate perceived brightness using standard luminance formula
341	brightness := (299*avgR + 587*avgG + 114*avgB) / 1000
342
343	// Calculate average color variance (0-255 range)
344	avgColorVariance := colorVariance / pixelCount
345
346	// Calculate transparency ratio
347	totalPixels := pixelCount + transparentPixels
348	transparencyRatio := float64(transparentPixels) / float64(totalPixels)
349
350	// Only invert if:
351	// 1. Icon is very dark (brightness < 20000, low brightness)
352	// 2. AND icon is mostly monochrome (low color variance < 20)
353	// 3. AND icon is not mostly transparent (< 80% transparent)
354	// This makes dark monochrome icons visible on dark themes while preserving colorful icons
355
356	shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8
357
358	log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t",
359		brightness, avgColorVariance, transparencyRatio, shouldInvert)
360
361	if shouldInvert {
362		log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility")
363		return invertImage(img)
364	}
365
366	return img
367}
368
369// invertImage inverts the colors of an image with edge smoothing
370func invertImage(img image.Image) image.Image {
371	bounds := img.Bounds()
372	inverted := image.NewRGBA(bounds)
373
374	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
375		for x := bounds.Min.X; x < bounds.Max.X; x++ {
376			r, g, b, a := img.At(x, y).RGBA()
377
378			// Skip fully transparent pixels to avoid noise
379			if a == 0 {
380				inverted.Set(x, y, color.RGBA{A: 0})
381				continue
382			}
383
384			// Convert to 8-bit for easier processing
385			r8 := uint8(r >> 8)
386			g8 := uint8(g >> 8)
387			b8 := uint8(b >> 8)
388			a8 := uint8(a >> 8)
389
390			// Invert RGB but preserve alpha
391			newR := 255 - r8
392			newG := 255 - g8
393			newB := 255 - b8
394
395			// For semi-transparent pixels, blend the inversion more gently
396			// This reduces edge noise by making transitions smoother
397			if a8 < 255 {
398				// Reduce inversion intensity for semi-transparent pixels
399				blendFactor := float64(a8) / 255.0
400				newR = uint8(float64(r8) + (float64(newR-r8) * blendFactor))
401				newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor))
402				newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor))
403			}
404
405			inverted.Set(x, y, color.RGBA{
406				R: newR,
407				G: newG,
408				B: newB,
409				A: a8,
410			})
411		}
412	}
413
414	return inverted
415}

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