v0.6.0 5c50dd3

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

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