detached 9f6285b

feat(favicon): implement comprehensive favicon caching system for dmenu

- Add complete ICO file support using go-ico library for Windows icon conversion
- Implement smart brightness detection and color inversion for dark theme visibility
- Normalize all cached favicons to consistent 32x32 PNG format
- Add anti-aliasing preservation during color inversion to prevent edge noise
- Support asynchronous favicon downloading with 7-day freshness and 30-day cleanup
- Integrate fuzzel icon protocol (\0icon\x1f<path>) for native icon display
- Extract favicon URLs from webpage metadata during navigation
- Remove inconsistent system icon fallbacks for uniform sizing
- Store favicon cache in XDG-compliant data directories
commit
9f6285b1b9d9c79be54778b64fcdfebb5a5a0024
author
bamen · · 2025-09-12 04:22 UTC
parent
93132c7
signature
unsigned
22 files · +1058 -234
M CHANGELOG.md +11 -0
@@ -27,6 +27,17 @@ All notable changes to this project will be documented in this file.
2727 - Improved box-sizing model to prevent layout issues
2828 - Added conditional separator display (hidden when no search results)
2929 - Omnibox now scales proportionally with webpage content across all zoom levels
30+- Dmenu history favicon display:
31+ - Complete favicon caching system for fuzzel/dmenu integration with real website icons
32+ - ICO file support using go-ico library for perfect Windows icon conversion to PNG
33+ - Smart brightness detection and color inversion for dark theme visibility
34+ - All cached favicons normalized to consistent 32x32 PNG format for uniform display
35+ - Anti-aliasing preservation during color inversion to prevent edge noise and pixelation
36+ - Asynchronous favicon downloading and caching with 7-day freshness and 30-day cleanup
37+ - Fuzzel icon protocol integration (`\0icon\x1f<path>`) for native icon display
38+ - Favicon URLs extracted from webpage metadata and stored in database during navigation
39+ - Fallback system removed to ensure consistent sizing - shows real favicons or no icon
40+ - XDG Base Directory compliance for favicon cache storage
3041
3142 ## [0.4.0] - 2025-09-10
3243
@@ -27,6 +27,17 @@ All notable changes to this project will be documented in this file.
27 - Improved box-sizing model to prevent layout issues27 - Improved box-sizing model to prevent layout issues
28 - Added conditional separator display (hidden when no search results)28 - Added conditional separator display (hidden when no search results)
29 - Omnibox now scales proportionally with webpage content across all zoom levels29 - Omnibox now scales proportionally with webpage content across all zoom levels
30+- Dmenu history favicon display:
31+ - Complete favicon caching system for fuzzel/dmenu integration with real website icons
32+ - ICO file support using go-ico library for perfect Windows icon conversion to PNG
33+ - Smart brightness detection and color inversion for dark theme visibility
34+ - All cached favicons normalized to consistent 32x32 PNG format for uniform display
35+ - Anti-aliasing preservation during color inversion to prevent edge noise and pixelation
36+ - Asynchronous favicon downloading and caching with 7-day freshness and 30-day cleanup
37+ - Fuzzel icon protocol integration (`\0icon\x1f<path>`) for native icon display
38+ - Favicon URLs extracted from webpage metadata and stored in database during navigation
39+ - Fallback system removed to ensure consistent sizing - shows real favicons or no icon
40+ - XDG Base Directory compliance for favicon cache storage
30 41
31 ## [0.4.0] - 2025-09-1042 ## [0.4.0] - 2025-09-10
32 43
M go.mod +2 -0
@@ -18,6 +18,8 @@ require (
1818 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
1919 github.com/rogpeppe/go-internal v1.12.0 // indirect
2020 github.com/sagikazarmark/locafero v0.11.0 // indirect
21+ github.com/sergeymakinen/go-bmp v1.0.0 // indirect
22+ github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
2123 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
2224 github.com/spf13/afero v1.15.0 // indirect
2325 github.com/spf13/cast v1.10.0 // indirect
@@ -18,6 +18,8 @@ require (
18 github.com/pelletier/go-toml/v2 v2.2.4 // indirect18 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
19 github.com/rogpeppe/go-internal v1.12.0 // indirect19 github.com/rogpeppe/go-internal v1.12.0 // indirect
20 github.com/sagikazarmark/locafero v0.11.0 // indirect20 github.com/sagikazarmark/locafero v0.11.0 // indirect
21+ github.com/sergeymakinen/go-bmp v1.0.0 // indirect
22+ github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
21 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect23 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
22 github.com/spf13/afero v1.15.0 // indirect24 github.com/spf13/afero v1.15.0 // indirect
23 github.com/spf13/cast v1.10.0 // indirect25 github.com/spf13/cast v1.10.0 // indirect
M go.sum +6 -0 generated file: collapsed
A internal/cache/favicon.go +387 -0
@@ -0,0 +1,387 @@
1+package cache
2+
3+import (
4+ "crypto/md5"
5+ "fmt"
6+ "image"
7+ "image/color"
8+ "image/png"
9+ _ "image/jpeg" // Register JPEG decoder
10+ _ "image/gif" // Register GIF decoder
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
24+type FaviconCache struct {
25+ cacheDir string
26+}
27+
28+// NewFaviconCache creates a new favicon cache
29+func 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
46+func (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
65+func (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
78+func (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
84+func (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
137+func (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
161+func (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
173+func (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
198+func (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
222+func (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
250+func 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
342+func 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+}⏎̸
@@ -0,0 +1,387 @@
1+package cache
2+
3+import (
4+ "crypto/md5"
5+ "fmt"
6+ "image"
7+ "image/color"
8+ "image/png"
9+ _ "image/jpeg" // Register JPEG decoder
10+ _ "image/gif" // Register GIF decoder
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
24+type FaviconCache struct {
25+ cacheDir string
26+}
27+
28+// NewFaviconCache creates a new favicon cache
29+func 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
46+func (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
65+func (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
78+func (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
84+func (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
137+func (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
161+func (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
173+func (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
198+func (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
222+func (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
250+func 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
342+func 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+}⏎̸
M internal/cache/fuzzy_cache.go +12 -6
@@ -171,9 +171,15 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache,
171171 lastVisited = entry.LastVisited.Time
172172 }
173173
174+ faviconURL := ""
175+ if entry.FaviconUrl.Valid {
176+ faviconURL = entry.FaviconUrl.String
177+ }
178+
174179 newCache.entries[i] = NewCompactEntry(
175180 entry.Url,
176181 title,
182+ faviconURL,
177183 visitCount,
178184 lastVisited,
179185 )
@@ -193,7 +199,7 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache,
193199 // Save to filesystem in background
194200 go cm.saveToFilesystemAsync()
195201
196- fmt.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history))
202+ log.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history))
197203 return cm.cache, nil
198204 }
199205
@@ -216,7 +222,7 @@ func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) {
216222 // Build new cache
217223 _, err := cm.buildCacheFromDB(ctx)
218224 if err != nil {
219- fmt.Printf("Warning: failed to refresh cache: %v\n", err)
225+ log.Printf("Warning: failed to refresh cache: %v\n", err)
220226 }
221227 }()
222228 }
@@ -233,7 +239,7 @@ func (cm *CacheManager) OnApplicationExit(ctx context.Context) {
233239
234240 _, err := cm.buildCacheFromDB(ctx)
235241 if err != nil {
236- fmt.Printf("Warning: failed to refresh cache on exit: %v\n", err)
242+ log.Printf("Warning: failed to refresh cache on exit: %v\n", err)
237243 }
238244 }()
239245 }
@@ -244,14 +250,14 @@ func (cm *CacheManager) saveToFilesystemAsync() {
244250 // Create cache directory if it doesn't exist
245251 cacheDir := filepath.Dir(cm.config.CacheFile)
246252 if err := os.MkdirAll(cacheDir, dirPerm); err != nil {
247- fmt.Printf("Warning: failed to create cache directory: %v\n", err)
253+ log.Printf("Warning: failed to create cache directory: %v\n", err)
248254 return
249255 }
250256
251257 // Save to temporary file first, then atomic rename
252258 tempFile := cm.config.CacheFile + ".tmp"
253259 if err := cm.cache.SaveToBinary(tempFile); err != nil {
254- fmt.Printf("Warning: failed to save cache: %v\n", err)
260+ log.Printf("Warning: failed to save cache: %v\n", err)
255261 if err := os.Remove(tempFile); err != nil {
256262 log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err)
257263 }
@@ -260,7 +266,7 @@ func (cm *CacheManager) saveToFilesystemAsync() {
260266
261267 // Atomic rename
262268 if err := os.Rename(tempFile, cm.config.CacheFile); err != nil {
263- fmt.Printf("Warning: failed to rename cache file: %v\n", err)
269+ log.Printf("Warning: failed to rename cache file: %v\n", err)
264270 if err := os.Remove(tempFile); err != nil {
265271 log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err)
266272 }
@@ -171,9 +171,15 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache,
171 lastVisited = entry.LastVisited.Time171 lastVisited = entry.LastVisited.Time
172 }172 }
173 173
174+ faviconURL := ""
175+ if entry.FaviconUrl.Valid {
176+ faviconURL = entry.FaviconUrl.String
177+ }
178+
174 newCache.entries[i] = NewCompactEntry(179 newCache.entries[i] = NewCompactEntry(
175 entry.Url,180 entry.Url,
176 title,181 title,
182+ faviconURL,
177 visitCount,183 visitCount,
178 lastVisited,184 lastVisited,
179 )185 )
@@ -193,7 +199,7 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache,
193 // Save to filesystem in background199 // Save to filesystem in background
194 go cm.saveToFilesystemAsync()200 go cm.saveToFilesystemAsync()
195 201
196- fmt.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history))202+ log.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history))
197 return cm.cache, nil203 return cm.cache, nil
198 }204 }
199 205
@@ -216,7 +222,7 @@ func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) {
216 // Build new cache222 // Build new cache
217 _, err := cm.buildCacheFromDB(ctx)223 _, err := cm.buildCacheFromDB(ctx)
218 if err != nil {224 if err != nil {
219- fmt.Printf("Warning: failed to refresh cache: %v\n", err)225+ log.Printf("Warning: failed to refresh cache: %v\n", err)
220 }226 }
221 }()227 }()
222 }228 }
@@ -233,7 +239,7 @@ func (cm *CacheManager) OnApplicationExit(ctx context.Context) {
233 239
234 _, err := cm.buildCacheFromDB(ctx)240 _, err := cm.buildCacheFromDB(ctx)
235 if err != nil {241 if err != nil {
236- fmt.Printf("Warning: failed to refresh cache on exit: %v\n", err)242+ log.Printf("Warning: failed to refresh cache on exit: %v\n", err)
237 }243 }
238 }()244 }()
239 }245 }
@@ -244,14 +250,14 @@ func (cm *CacheManager) saveToFilesystemAsync() {
244 // Create cache directory if it doesn't exist250 // Create cache directory if it doesn't exist
245 cacheDir := filepath.Dir(cm.config.CacheFile)251 cacheDir := filepath.Dir(cm.config.CacheFile)
246 if err := os.MkdirAll(cacheDir, dirPerm); err != nil {252 if err := os.MkdirAll(cacheDir, dirPerm); err != nil {
247- fmt.Printf("Warning: failed to create cache directory: %v\n", err)253+ log.Printf("Warning: failed to create cache directory: %v\n", err)
248 return254 return
249 }255 }
250 256
251 // Save to temporary file first, then atomic rename257 // Save to temporary file first, then atomic rename
252 tempFile := cm.config.CacheFile + ".tmp"258 tempFile := cm.config.CacheFile + ".tmp"
253 if err := cm.cache.SaveToBinary(tempFile); err != nil {259 if err := cm.cache.SaveToBinary(tempFile); err != nil {
254- fmt.Printf("Warning: failed to save cache: %v\n", err)260+ log.Printf("Warning: failed to save cache: %v\n", err)
255 if err := os.Remove(tempFile); err != nil {261 if err := os.Remove(tempFile); err != nil {
256 log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err)262 log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err)
257 }263 }
@@ -260,7 +266,7 @@ func (cm *CacheManager) saveToFilesystemAsync() {
260 266
261 // Atomic rename267 // Atomic rename
262 if err := os.Rename(tempFile, cm.config.CacheFile); err != nil {268 if err := os.Rename(tempFile, cm.config.CacheFile); err != nil {
263- fmt.Printf("Warning: failed to rename cache file: %v\n", err)269+ log.Printf("Warning: failed to rename cache file: %v\n", err)
264 if err := os.Remove(tempFile); err != nil {270 if err := os.Remove(tempFile); err != nil {
265 log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err)271 log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err)
266 }272 }
M internal/cache/types.go +5 -3
@@ -32,6 +32,7 @@ type DmenuFuzzyCache struct {
3232 type CompactEntry struct {
3333 URL string // Original URL
3434 Title string // Page title
35+ FaviconURL string // URL to favicon for this entry
3536 VisitCount uint16 // Max 65535 visits (sufficient for most use cases)
3637 LastVisit uint32 // Days since Unix epoch (saves 4 bytes vs time.Time)
3738 Score uint16 // Pre-computed base score (0-65535)
@@ -153,10 +154,11 @@ func TimeFromDays(days uint32) time.Time {
153154 }
154155
155156 // NewCompactEntry creates a CompactEntry from database values.
156-func NewCompactEntry(url, title string, visitCount int64, lastVisit time.Time) CompactEntry {
157+func NewCompactEntry(url, title, faviconURL string, visitCount int64, lastVisit time.Time) CompactEntry {
157158 entry := CompactEntry{
158- URL: url,
159- Title: title,
159+ URL: url,
160+ Title: title,
161+ FaviconURL: faviconURL,
160162 }
161163
162164 // Clamp visit count to uint16 max
@@ -32,6 +32,7 @@ type DmenuFuzzyCache struct {
32 type CompactEntry struct {32 type CompactEntry struct {
33 URL string // Original URL33 URL string // Original URL
34 Title string // Page title34 Title string // Page title
35+ FaviconURL string // URL to favicon for this entry
35 VisitCount uint16 // Max 65535 visits (sufficient for most use cases)36 VisitCount uint16 // Max 65535 visits (sufficient for most use cases)
36 LastVisit uint32 // Days since Unix epoch (saves 4 bytes vs time.Time)37 LastVisit uint32 // Days since Unix epoch (saves 4 bytes vs time.Time)
37 Score uint16 // Pre-computed base score (0-65535)38 Score uint16 // Pre-computed base score (0-65535)
@@ -153,10 +154,11 @@ func TimeFromDays(days uint32) time.Time {
153 }154 }
154 155
155 // NewCompactEntry creates a CompactEntry from database values.156 // NewCompactEntry creates a CompactEntry from database values.
156-func NewCompactEntry(url, title string, visitCount int64, lastVisit time.Time) CompactEntry {157+func NewCompactEntry(url, title, faviconURL string, visitCount int64, lastVisit time.Time) CompactEntry {
157 entry := CompactEntry{158 entry := CompactEntry{
158- URL: url,159+ URL: url,
159- Title: title,160+ Title: title,
161+ FaviconURL: faviconURL,
160 }162 }
161 163
162 // Clamp visit count to uint16 max164 // Clamp visit count to uint16 max
M internal/cli/browse.go +39 -76
@@ -7,9 +7,9 @@ import (
77 "net/url"
88 "os"
99 "os/exec"
10- "regexp"
1110 "strings"
1211
12+ "github.com/bnema/dumber/internal/cache"
1313 "github.com/bnema/dumber/internal/config"
1414
1515 "github.com/spf13/cobra"
@@ -67,95 +67,27 @@ Direct URLs are also supported:
6767 func browse(cli *CLI, input string) error {
6868 ctx := context.Background()
6969
70- // Parse the input to determine URL or shortcut
71- finalURL, err := parseInput(ctx, cli, input)
70+ // Parse the input using parser service
71+ result, err := cli.ParserService.ParseInput(ctx, input)
7272 if err != nil {
7373 return fmt.Errorf("failed to parse input: %w", err)
7474 }
7575
76+ finalURL := result.URL
77+
7678 // Record in history
7779 if err := recordVisit(ctx, cli, finalURL, ""); err != nil {
7880 // Don't fail the browse operation if history recording fails
7981 fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err)
8082 }
8183
84+ // Update favicon asynchronously (non-blocking)
85+ go updateFavicon(ctx, cli, finalURL)
86+
8287 // Open URL using configuration
8388 return openURLWithConfig(finalURL, cli.Config)
8489 }
8590
86-// parseInput processes user input and returns the final URL to browse
87-func parseInput(ctx context.Context, cli *CLI, input string) (string, error) {
88- // Check if it's a shortcut (format: "prefix:query")
89- if strings.Contains(input, ":") && !strings.Contains(input, "://") {
90- parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only
91- if len(parts) == 2 { //nolint:mnd // expect prefix and query parts
92- shortcut := strings.TrimSpace(parts[0])
93- query := strings.TrimSpace(parts[1])
94-
95- if query == "" {
96- return "", fmt.Errorf("empty query for shortcut '%s'", shortcut)
97- }
98-
99- // First check configuration-based shortcuts
100- if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists {
101- return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil
102- }
103-
104- // Fallback to database shortcuts for backward compatibility
105- shortcuts, err := cli.Queries.GetShortcuts(ctx)
106- if err != nil {
107- return "", fmt.Errorf("failed to get shortcuts: %w", err)
108- }
109-
110- for _, s := range shortcuts {
111- if s.Shortcut == shortcut {
112- return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil
113- }
114- }
115-
116- return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
117- }
118- }
119-
120- // Check if it's already a valid URL
121- if isValidURL(input) {
122- return input, nil
123- }
124-
125- // Try to make it a URL by adding https://
126- if !strings.Contains(input, "://") {
127- candidate := "https://" + input
128- if isValidURL(candidate) {
129- return candidate, nil
130- }
131- }
132-
133- // If all else fails, search Google for it
134- return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil
135-}
136-
137-// isValidURL checks if a string is a valid URL
138-func isValidURL(str string) bool {
139- u, err := url.Parse(str)
140- if err != nil {
141- return false
142- }
143-
144- // Must have a scheme and host
145- if u.Scheme == "" || u.Host == "" {
146- return false
147- }
148-
149- // Must be http or https
150- if u.Scheme != "http" && u.Scheme != "https" {
151- return false
152- }
153-
154- // Basic domain validation
155- domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` +
156- `(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
157- return domainRegex.MatchString(u.Host)
158-}
15991
16092 // recordVisit adds or updates a URL visit in the history
16193 func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
@@ -205,3 +137,34 @@ func openURLWithConfig(url string, cfg *config.Config) error {
205137 return nil
206138 }
207139
140+// updateFavicon fetches and stores favicon URL for a given page URL
141+func updateFavicon(ctx context.Context, cli *CLI, pageURL string) {
142+ parsedURL, err := url.Parse(pageURL)
143+ if err != nil {
144+ return // Silently fail for invalid URLs
145+ }
146+
147+ // Skip favicon update for localhost, file://, or special schemes
148+ if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
149+ return
150+ }
151+ if strings.Contains(parsedURL.Host, "localhost") || strings.Contains(parsedURL.Host, "127.0.0.1") {
152+ return
153+ }
154+
155+ // Standard favicon location
156+ faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
157+
158+ // Update in database using the new sqlc-generated method
159+ faviconNullString := sql.NullString{String: faviconURL, Valid: true}
160+ if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
161+ // Silently fail - favicon is not critical
162+ return
163+ }
164+
165+ // Also cache the favicon for dmenu use
166+ if faviconCache, err := cache.NewFaviconCache(); err == nil {
167+ faviconCache.CacheAsync(faviconURL)
168+ }
169+}
170+
@@ -7,9 +7,9 @@ import (
7 "net/url"7 "net/url"
8 "os"8 "os"
9 "os/exec"9 "os/exec"
10- "regexp"
11 "strings"10 "strings"
12 11
12+ "github.com/bnema/dumber/internal/cache"
13 "github.com/bnema/dumber/internal/config"13 "github.com/bnema/dumber/internal/config"
14 14
15 "github.com/spf13/cobra"15 "github.com/spf13/cobra"
@@ -67,95 +67,27 @@ Direct URLs are also supported:
67 func browse(cli *CLI, input string) error {67 func browse(cli *CLI, input string) error {
68 ctx := context.Background()68 ctx := context.Background()
69 69
70- // Parse the input to determine URL or shortcut70+ // Parse the input using parser service
71- finalURL, err := parseInput(ctx, cli, input)71+ result, err := cli.ParserService.ParseInput(ctx, input)
72 if err != nil {72 if err != nil {
73 return fmt.Errorf("failed to parse input: %w", err)73 return fmt.Errorf("failed to parse input: %w", err)
74 }74 }
75 75
76+ finalURL := result.URL
77+
76 // Record in history78 // Record in history
77 if err := recordVisit(ctx, cli, finalURL, ""); err != nil {79 if err := recordVisit(ctx, cli, finalURL, ""); err != nil {
78 // Don't fail the browse operation if history recording fails80 // Don't fail the browse operation if history recording fails
79 fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err)81 fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err)
80 }82 }
81 83
84+ // Update favicon asynchronously (non-blocking)
85+ go updateFavicon(ctx, cli, finalURL)
86+
82 // Open URL using configuration87 // Open URL using configuration
83 return openURLWithConfig(finalURL, cli.Config)88 return openURLWithConfig(finalURL, cli.Config)
84 }89 }
85 90
86-// parseInput processes user input and returns the final URL to browse
87-func parseInput(ctx context.Context, cli *CLI, input string) (string, error) {
88- // Check if it's a shortcut (format: "prefix:query")
89- if strings.Contains(input, ":") && !strings.Contains(input, "://") {
90- parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only
91- if len(parts) == 2 { //nolint:mnd // expect prefix and query parts
92- shortcut := strings.TrimSpace(parts[0])
93- query := strings.TrimSpace(parts[1])
94-
95- if query == "" {
96- return "", fmt.Errorf("empty query for shortcut '%s'", shortcut)
97- }
98-
99- // First check configuration-based shortcuts
100- if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists {
101- return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil
102- }
103-
104- // Fallback to database shortcuts for backward compatibility
105- shortcuts, err := cli.Queries.GetShortcuts(ctx)
106- if err != nil {
107- return "", fmt.Errorf("failed to get shortcuts: %w", err)
108- }
109-
110- for _, s := range shortcuts {
111- if s.Shortcut == shortcut {
112- return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil
113- }
114- }
115-
116- return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
117- }
118- }
119-
120- // Check if it's already a valid URL
121- if isValidURL(input) {
122- return input, nil
123- }
124-
125- // Try to make it a URL by adding https://
126- if !strings.Contains(input, "://") {
127- candidate := "https://" + input
128- if isValidURL(candidate) {
129- return candidate, nil
130- }
131- }
132-
133- // If all else fails, search Google for it
134- return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil
135-}
136-
137-// isValidURL checks if a string is a valid URL
138-func isValidURL(str string) bool {
139- u, err := url.Parse(str)
140- if err != nil {
141- return false
142- }
143-
144- // Must have a scheme and host
145- if u.Scheme == "" || u.Host == "" {
146- return false
147- }
148-
149- // Must be http or https
150- if u.Scheme != "http" && u.Scheme != "https" {
151- return false
152- }
153-
154- // Basic domain validation
155- domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` +
156- `(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
157- return domainRegex.MatchString(u.Host)
158-}
159 91
160 // recordVisit adds or updates a URL visit in the history92 // recordVisit adds or updates a URL visit in the history
161 func recordVisit(ctx context.Context, cli *CLI, url, title string) error {93 func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
@@ -205,3 +137,34 @@ func openURLWithConfig(url string, cfg *config.Config) error {
205 return nil137 return nil
206 }138 }
207 139
140+// updateFavicon fetches and stores favicon URL for a given page URL
141+func updateFavicon(ctx context.Context, cli *CLI, pageURL string) {
142+ parsedURL, err := url.Parse(pageURL)
143+ if err != nil {
144+ return // Silently fail for invalid URLs
145+ }
146+
147+ // Skip favicon update for localhost, file://, or special schemes
148+ if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
149+ return
150+ }
151+ if strings.Contains(parsedURL.Host, "localhost") || strings.Contains(parsedURL.Host, "127.0.0.1") {
152+ return
153+ }
154+
155+ // Standard favicon location
156+ faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
157+
158+ // Update in database using the new sqlc-generated method
159+ faviconNullString := sql.NullString{String: faviconURL, Valid: true}
160+ if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
161+ // Silently fail - favicon is not critical
162+ return
163+ }
164+
165+ // Also cache the favicon for dmenu use
166+ if faviconCache, err := cache.NewFaviconCache(); err == nil {
167+ faviconCache.CacheAsync(faviconURL)
168+ }
169+}
170+
M internal/cli/dmenu.go +147 -83
@@ -4,6 +4,7 @@ import (
44 "bufio"
55 "context"
66 "fmt"
7+ "net/url"
78 "os"
89 "sort"
910 "strings"
@@ -20,15 +21,15 @@ const (
2021 func NewDmenuCmd() *cobra.Command {
2122 cmd := &cobra.Command{
2223 Use: "dmenu",
23- Short: "Run in dmenu mode for launcher integration",
24- Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers.
25-This mode reads from stdin and outputs selectable options to stdout.
24+ Short: "Fast fuzzy history browser for launcher integration",
25+ Long: `Fast dmenu mode showing cached browsing history with fuzzy search.
26+Uses binary tree cache for sub-millisecond performance.
2627
2728 Usage with rofi:
2829 dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
2930
30-Usage with dmenu:
31- dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`,
31+Usage with fuzzel:
32+ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`,
3233 RunE: func(cmd *cobra.Command, _ []string) error {
3334 selectFlag := cmd.Flag("select").Changed
3435
@@ -62,6 +63,13 @@ func generateOptions(cli *CLI) error {
6263 cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries
6364 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
6465
66+ // Initialize favicon cache
67+ faviconCache, err := cache.NewFaviconCache()
68+ if err != nil {
69+ // Continue without favicon cache if it fails
70+ faviconCache = nil
71+ }
72+
6573 // Get top entries from cache (this is blazingly fast!)
6674 result, err := cacheManager.GetTopEntries(ctx)
6775 if err != nil {
@@ -69,77 +77,74 @@ func generateOptions(cli *CLI) error {
6977 return generateOptionsFallback(cli)
7078 }
7179
72- // Get shortcuts for display
73- shortcuts, err := cli.Queries.GetShortcuts(ctx)
74- if err != nil {
75- return fmt.Errorf("failed to get shortcuts: %w", err)
76- }
77-
78- // Generate options
79- options := make([]DmenuOption, 0, len(result.Matches)+len(shortcuts)+1)
80-
81- // Add direct URL input option
82- options = append(options, DmenuOption{
83- Display: "🌐 Enter URL or search query...",
84- Value: "",
85- Type: "input",
86- Description: "Type any URL or search term",
87- })
88-
89- // Add shortcuts with examples
90- for _, shortcut := range shortcuts {
91- desc := shortcut.Description.String
92- if !shortcut.Description.Valid {
93- desc = "Custom shortcut"
94- }
80+ // Fast dmenu mode: only shows cached history entries sorted by relevance
9581
96- options = append(options, DmenuOption{
97- Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc),
98- Value: shortcut.Shortcut + ":",
99- Type: "shortcut",
100- Description: desc,
101- })
102- }
82+ // Generate options - only show history entries from fast cache
83+ options := make([]DmenuOption, 0, len(result.Matches))
10384
10485 // Add history entries from cache (already sorted by relevance!)
10586 for _, match := range result.Matches {
10687 entry := match.Entry
107- title := entry.URL
108- if entry.Title != "" {
109- title = entry.Title
88+
89+ // Parse URL for domain extraction
90+ parsedURL, err := url.Parse(entry.URL)
91+ var domain string
92+ if err != nil || parsedURL.Host == "" {
93+ domain = "local"
94+ } else {
95+ domain = parsedURL.Host
11096 }
111-
112- const maxDisplayLength = 80
113- // Truncate long titles/URLs for display
114- display := truncateString(title, maxDisplayLength)
115-
97+
98+ // Determine title
99+ title := entry.Title
100+ if title == "" || title == entry.URL {
101+ // Use meaningful fallback for untitled pages
102+ if parsedURL != nil && parsedURL.Path != "" && parsedURL.Path != "/" {
103+ title = fmt.Sprintf("[%s%s]", domain, parsedURL.Path)
104+ } else {
105+ title = fmt.Sprintf("[%s]", domain)
106+ }
107+ }
108+
109+ // Check if it's a Google search result and format accordingly
110+ isGoogleSearch := domain == "www.google.com" && strings.Contains(entry.URL, "/search?q=")
111+ if isGoogleSearch {
112+ if parsedURL != nil {
113+ if q := parsedURL.Query().Get("q"); q != "" {
114+ title = fmt.Sprintf("Google: \"%s\"", q)
115+ }
116+ }
117+ }
118+
119+ // Format: "Title | domain.com | full-url"
120+ // Using pipe separator for rofi/dmenu compatibility
121+ display := fmt.Sprintf("%s | %s | %s",
122+ truncateString(title, 50),
123+ domain,
124+ truncateString(entry.URL, 70))
125+
126+ // Get favicon URL from cached entry
127+ faviconURL := match.Entry.FaviconURL
128+
116129 options = append(options, DmenuOption{
117- Display: fmt.Sprintf("🕒 %s", display),
130+ Display: display,
118131 Value: entry.URL,
119132 Type: historyType,
120133 Description: entry.URL,
134+ FaviconURL: faviconURL,
121135 })
122136 }
123137
124- // Sort options: input first, then shortcuts, then history (already sorted by cache)
125- sort.Slice(options, func(i, j int) bool {
126- if options[i].Type != options[j].Type {
127- // Order: input, shortcut, history
128- typeOrder := map[string]int{"input": 0, "shortcut": 1, historyType: 2} //nolint:mnd
129- return typeOrder[options[i].Type] < typeOrder[options[j].Type]
130- }
131-
132- if options[i].Type == historyType && options[j].Type == historyType {
133- // History already sorted by cache relevance score
134- return i < j
135- }
136-
137- return options[i].Display < options[j].Display
138- })
138+ // No sorting needed - history entries are already sorted by relevance from cache
139139
140- // Output options to stdout
140+ // Output options to stdout with icon specifications
141141 for _, option := range options {
142- fmt.Println(option.Display)
142+ iconName := getIconName(option, faviconCache)
143+ if iconName != "" {
144+ fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
145+ } else {
146+ fmt.Println(option.Display)
147+ }
143148 }
144149
145150 // Trigger background cache refresh for next time if needed
@@ -148,10 +153,17 @@ func generateOptions(cli *CLI) error {
148153 return nil
149154 }
150155
151-// generateOptionsFallback is the old method used as a fallback if cache fails
156+// generateOptionsFallback is the legacy method used as a fallback if fast cache fails
152157 func generateOptionsFallback(cli *CLI) error {
153158 ctx := context.Background()
154159
160+ // Initialize favicon cache
161+ faviconCache, err := cache.NewFaviconCache()
162+ if err != nil {
163+ // Continue without favicon cache if it fails
164+ faviconCache = nil
165+ }
166+
155167 const maxHistoryEntries = 50
156168 // Get history entries
157169 history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
@@ -226,9 +238,14 @@ func generateOptionsFallback(cli *CLI) error {
226238 return options[i].Display < options[j].Display
227239 })
228240
229- // Output options to stdout
241+ // Output options to stdout with icon specifications
230242 for _, option := range options {
231- fmt.Println(option.Display)
243+ iconName := getIconName(option, faviconCache)
244+ if iconName != "" {
245+ fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
246+ } else {
247+ fmt.Println(option.Display)
248+ }
232249 }
233250
234251 return nil
@@ -246,42 +263,65 @@ func handleSelection(cli *CLI) error {
246263 return fmt.Errorf("empty selection")
247264 }
248265
249- // Parse the selection to get the actual input
250- input := parseSelection(selection)
251266
252- // If it's just the input prompt, read another line for the actual input
253- if input == "" || input == "Enter URL or search query..." {
254- fmt.Print("Enter URL or search query: ")
255- if !scanner.Scan() {
256- return fmt.Errorf("no input received")
257- }
258- input = strings.TrimSpace(scanner.Text())
259- }
267+ // Parse the selection to get the actual URL from history entry
268+ input := parseSelection(selection)
260269
261270 if input == "" {
262271 return fmt.Errorf("empty input")
263272 }
264273
265274 // Browse the selected/entered URL
266- return browse(cli, input)
275+ err := browse(cli, input)
276+
277+ // After successful browse, invalidate cache so next dmenu shows updated order
278+ if err == nil {
279+ ctx := context.Background()
280+ cacheConfig := cache.DefaultCacheConfig()
281+ cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
282+ cacheManager.InvalidateAndRefresh(ctx)
283+ }
284+
285+ return err
267286 }
268287
288+
269289 // DmenuOption represents a selectable option in dmenu mode
270290 type DmenuOption struct {
271291 Display string
272292 Value string
273293 Type string // "input", "shortcut", "history"
274294 Description string
295+ FaviconURL string // URL to favicon for this entry
275296 }
276297
277-// parseSelection extracts the actual URL/query from a dmenu selection
298+// parseSelection extracts the actual URL from a dmenu selection
278299 func parseSelection(selection string) string {
279- // Remove emoji prefixes and clean up the selection
280300 selection = strings.TrimSpace(selection)
281301
282- // Handle different option types
302+ // Strip icon protocol if present (format: "text\0icon\x1ficonname")
303+ if iconIndex := strings.Index(selection, "\x00icon\x1f"); iconIndex > 0 {
304+ selection = selection[:iconIndex]
305+ }
306+
307+ // 1. Handle new pipe-separated format: "Title | domain.com | full-url"
308+ if strings.Contains(selection, " | ") {
309+ parts := strings.Split(selection, " | ")
310+ if len(parts) >= 3 {
311+ // The URL is the last part (third field)
312+ return strings.TrimSpace(parts[len(parts)-1])
313+ } else if len(parts) == 2 {
314+ // Might be "title | url" format, check if second part looks like URL
315+ lastPart := strings.TrimSpace(parts[1])
316+ if strings.Contains(lastPart, "://") || strings.Contains(lastPart, ".") {
317+ return lastPart
318+ }
319+ }
320+ }
321+
322+ // Legacy format handling for backward compatibility
283323 if strings.HasPrefix(selection, "🌐 ") {
284- // Input option selected
324+ // Input option selected (shouldn't happen with new format but just in case)
285325 return ""
286326 }
287327
@@ -294,15 +334,39 @@ func parseSelection(selection string) string {
294334 }
295335
296336 if strings.HasPrefix(selection, "🕒 ") {
297- // History entry selected - this is the display title, need to extract URL
298- // For now, we'll assume the display is the URL or close enough
299- return strings.TrimPrefix(selection, "🕒 ")
337+ // History entry selected - remove emoji prefix
338+ selection = strings.TrimPrefix(selection, "🕒 ")
300339 }
301340
302- // Fallback: return the selection as-is
341+ // If no pipes and looks like a URL, return as-is
342+ if strings.Contains(selection, "://") {
343+ return selection
344+ }
345+
346+ // Fallback: treat as search query
303347 return selection
304348 }
305349
350+// getIconName determines the appropriate icon name for a dmenu option
351+func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string {
352+ if option.Type != historyType {
353+ return ""
354+ }
355+
356+ // Only use cached favicons - no system theme fallbacks for consistent sizing
357+ if option.FaviconURL != "" && faviconCache != nil {
358+ cachedPath := faviconCache.GetCachedPath(option.FaviconURL)
359+ if cachedPath != "" {
360+ return cachedPath
361+ }
362+ // Start async download for next time
363+ faviconCache.CacheAsync(option.FaviconURL)
364+ }
365+
366+ // No favicon available - return empty string to show no icon
367+ return ""
368+}
369+
306370 // truncateString truncates a string to the specified length with ellipsis
307371 func truncateString(s string, maxLen int) string {
308372 if len(s) <= maxLen {
@@ -4,6 +4,7 @@ import (
4 "bufio"4 "bufio"
5 "context"5 "context"
6 "fmt"6 "fmt"
7+ "net/url"
7 "os"8 "os"
8 "sort"9 "sort"
9 "strings"10 "strings"
@@ -20,15 +21,15 @@ const (
20 func NewDmenuCmd() *cobra.Command {21 func NewDmenuCmd() *cobra.Command {
21 cmd := &cobra.Command{22 cmd := &cobra.Command{
22 Use: "dmenu",23 Use: "dmenu",
23- Short: "Run in dmenu mode for launcher integration",24+ Short: "Fast fuzzy history browser for launcher integration",
24- Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers.25+ Long: `Fast dmenu mode showing cached browsing history with fuzzy search.
25-This mode reads from stdin and outputs selectable options to stdout.26+Uses binary tree cache for sub-millisecond performance.
26 27
27 Usage with rofi:28 Usage with rofi:
28 dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select29 dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
29 30
30-Usage with dmenu:31+Usage with fuzzel:
31- dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`,32+ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`,
32 RunE: func(cmd *cobra.Command, _ []string) error {33 RunE: func(cmd *cobra.Command, _ []string) error {
33 selectFlag := cmd.Flag("select").Changed34 selectFlag := cmd.Flag("select").Changed
34 35
@@ -62,6 +63,13 @@ func generateOptions(cli *CLI) error {
62 cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries63 cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries
63 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)64 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
64 65
66+ // Initialize favicon cache
67+ faviconCache, err := cache.NewFaviconCache()
68+ if err != nil {
69+ // Continue without favicon cache if it fails
70+ faviconCache = nil
71+ }
72+
65 // Get top entries from cache (this is blazingly fast!)73 // Get top entries from cache (this is blazingly fast!)
66 result, err := cacheManager.GetTopEntries(ctx)74 result, err := cacheManager.GetTopEntries(ctx)
67 if err != nil {75 if err != nil {
@@ -69,77 +77,74 @@ func generateOptions(cli *CLI) error {
69 return generateOptionsFallback(cli)77 return generateOptionsFallback(cli)
70 }78 }
71 79
72- // Get shortcuts for display80+ // Fast dmenu mode: only shows cached history entries sorted by relevance
73- shortcuts, err := cli.Queries.GetShortcuts(ctx)
74- if err != nil {
75- return fmt.Errorf("failed to get shortcuts: %w", err)
76- }
77-
78- // Generate options
79- options := make([]DmenuOption, 0, len(result.Matches)+len(shortcuts)+1)
80-
81- // Add direct URL input option
82- options = append(options, DmenuOption{
83- Display: "🌐 Enter URL or search query...",
84- Value: "",
85- Type: "input",
86- Description: "Type any URL or search term",
87- })
88-
89- // Add shortcuts with examples
90- for _, shortcut := range shortcuts {
91- desc := shortcut.Description.String
92- if !shortcut.Description.Valid {
93- desc = "Custom shortcut"
94- }
95 81
96- options = append(options, DmenuOption{82+ // Generate options - only show history entries from fast cache
97- Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc),83+ options := make([]DmenuOption, 0, len(result.Matches))
98- Value: shortcut.Shortcut + ":",
99- Type: "shortcut",
100- Description: desc,
101- })
102- }
103 84
104 // Add history entries from cache (already sorted by relevance!)85 // Add history entries from cache (already sorted by relevance!)
105 for _, match := range result.Matches {86 for _, match := range result.Matches {
106 entry := match.Entry87 entry := match.Entry
107- title := entry.URL88+
108- if entry.Title != "" {89+ // Parse URL for domain extraction
109- title = entry.Title90+ parsedURL, err := url.Parse(entry.URL)
91+ var domain string
92+ if err != nil || parsedURL.Host == "" {
93+ domain = "local"
94+ } else {
95+ domain = parsedURL.Host
110 }96 }
111-97+
112- const maxDisplayLength = 8098+ // Determine title
113- // Truncate long titles/URLs for display99+ title := entry.Title
114- display := truncateString(title, maxDisplayLength)100+ if title == "" || title == entry.URL {
115-101+ // Use meaningful fallback for untitled pages
102+ if parsedURL != nil && parsedURL.Path != "" && parsedURL.Path != "/" {
103+ title = fmt.Sprintf("[%s%s]", domain, parsedURL.Path)
104+ } else {
105+ title = fmt.Sprintf("[%s]", domain)
106+ }
107+ }
108+
109+ // Check if it's a Google search result and format accordingly
110+ isGoogleSearch := domain == "www.google.com" && strings.Contains(entry.URL, "/search?q=")
111+ if isGoogleSearch {
112+ if parsedURL != nil {
113+ if q := parsedURL.Query().Get("q"); q != "" {
114+ title = fmt.Sprintf("Google: \"%s\"", q)
115+ }
116+ }
117+ }
118+
119+ // Format: "Title | domain.com | full-url"
120+ // Using pipe separator for rofi/dmenu compatibility
121+ display := fmt.Sprintf("%s | %s | %s",
122+ truncateString(title, 50),
123+ domain,
124+ truncateString(entry.URL, 70))
125+
126+ // Get favicon URL from cached entry
127+ faviconURL := match.Entry.FaviconURL
128+
116 options = append(options, DmenuOption{129 options = append(options, DmenuOption{
117- Display: fmt.Sprintf("🕒 %s", display),130+ Display: display,
118 Value: entry.URL,131 Value: entry.URL,
119 Type: historyType,132 Type: historyType,
120 Description: entry.URL,133 Description: entry.URL,
134+ FaviconURL: faviconURL,
121 })135 })
122 }136 }
123 137
124- // Sort options: input first, then shortcuts, then history (already sorted by cache)138+ // No sorting needed - history entries are already sorted by relevance from cache
125- sort.Slice(options, func(i, j int) bool {
126- if options[i].Type != options[j].Type {
127- // Order: input, shortcut, history
128- typeOrder := map[string]int{"input": 0, "shortcut": 1, historyType: 2} //nolint:mnd
129- return typeOrder[options[i].Type] < typeOrder[options[j].Type]
130- }
131-
132- if options[i].Type == historyType && options[j].Type == historyType {
133- // History already sorted by cache relevance score
134- return i < j
135- }
136-
137- return options[i].Display < options[j].Display
138- })
139 139
140- // Output options to stdout140+ // Output options to stdout with icon specifications
141 for _, option := range options {141 for _, option := range options {
142- fmt.Println(option.Display)142+ iconName := getIconName(option, faviconCache)
143+ if iconName != "" {
144+ fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
145+ } else {
146+ fmt.Println(option.Display)
147+ }
143 }148 }
144 149
145 // Trigger background cache refresh for next time if needed150 // Trigger background cache refresh for next time if needed
@@ -148,10 +153,17 @@ func generateOptions(cli *CLI) error {
148 return nil153 return nil
149 }154 }
150 155
151-// generateOptionsFallback is the old method used as a fallback if cache fails156+// generateOptionsFallback is the legacy method used as a fallback if fast cache fails
152 func generateOptionsFallback(cli *CLI) error {157 func generateOptionsFallback(cli *CLI) error {
153 ctx := context.Background()158 ctx := context.Background()
154 159
160+ // Initialize favicon cache
161+ faviconCache, err := cache.NewFaviconCache()
162+ if err != nil {
163+ // Continue without favicon cache if it fails
164+ faviconCache = nil
165+ }
166+
155 const maxHistoryEntries = 50167 const maxHistoryEntries = 50
156 // Get history entries168 // Get history entries
157 history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)169 history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
@@ -226,9 +238,14 @@ func generateOptionsFallback(cli *CLI) error {
226 return options[i].Display < options[j].Display238 return options[i].Display < options[j].Display
227 })239 })
228 240
229- // Output options to stdout241+ // Output options to stdout with icon specifications
230 for _, option := range options {242 for _, option := range options {
231- fmt.Println(option.Display)243+ iconName := getIconName(option, faviconCache)
244+ if iconName != "" {
245+ fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
246+ } else {
247+ fmt.Println(option.Display)
248+ }
232 }249 }
233 250
234 return nil251 return nil
@@ -246,42 +263,65 @@ func handleSelection(cli *CLI) error {
246 return fmt.Errorf("empty selection")263 return fmt.Errorf("empty selection")
247 }264 }
248 265
249- // Parse the selection to get the actual input
250- input := parseSelection(selection)
251 266
252- // If it's just the input prompt, read another line for the actual input267+ // Parse the selection to get the actual URL from history entry
253- if input == "" || input == "Enter URL or search query..." {268+ input := parseSelection(selection)
254- fmt.Print("Enter URL or search query: ")
255- if !scanner.Scan() {
256- return fmt.Errorf("no input received")
257- }
258- input = strings.TrimSpace(scanner.Text())
259- }
260 269
261 if input == "" {270 if input == "" {
262 return fmt.Errorf("empty input")271 return fmt.Errorf("empty input")
263 }272 }
264 273
265 // Browse the selected/entered URL274 // Browse the selected/entered URL
266- return browse(cli, input)275+ err := browse(cli, input)
276+
277+ // After successful browse, invalidate cache so next dmenu shows updated order
278+ if err == nil {
279+ ctx := context.Background()
280+ cacheConfig := cache.DefaultCacheConfig()
281+ cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
282+ cacheManager.InvalidateAndRefresh(ctx)
283+ }
284+
285+ return err
267 }286 }
268 287
288+
269 // DmenuOption represents a selectable option in dmenu mode289 // DmenuOption represents a selectable option in dmenu mode
270 type DmenuOption struct {290 type DmenuOption struct {
271 Display string291 Display string
272 Value string292 Value string
273 Type string // "input", "shortcut", "history"293 Type string // "input", "shortcut", "history"
274 Description string294 Description string
295+ FaviconURL string // URL to favicon for this entry
275 }296 }
276 297
277-// parseSelection extracts the actual URL/query from a dmenu selection298+// parseSelection extracts the actual URL from a dmenu selection
278 func parseSelection(selection string) string {299 func parseSelection(selection string) string {
279- // Remove emoji prefixes and clean up the selection
280 selection = strings.TrimSpace(selection)300 selection = strings.TrimSpace(selection)
281 301
282- // Handle different option types302+ // Strip icon protocol if present (format: "text\0icon\x1ficonname")
303+ if iconIndex := strings.Index(selection, "\x00icon\x1f"); iconIndex > 0 {
304+ selection = selection[:iconIndex]
305+ }
306+
307+ // 1. Handle new pipe-separated format: "Title | domain.com | full-url"
308+ if strings.Contains(selection, " | ") {
309+ parts := strings.Split(selection, " | ")
310+ if len(parts) >= 3 {
311+ // The URL is the last part (third field)
312+ return strings.TrimSpace(parts[len(parts)-1])
313+ } else if len(parts) == 2 {
314+ // Might be "title | url" format, check if second part looks like URL
315+ lastPart := strings.TrimSpace(parts[1])
316+ if strings.Contains(lastPart, "://") || strings.Contains(lastPart, ".") {
317+ return lastPart
318+ }
319+ }
320+ }
321+
322+ // Legacy format handling for backward compatibility
283 if strings.HasPrefix(selection, "🌐 ") {323 if strings.HasPrefix(selection, "🌐 ") {
284- // Input option selected324+ // Input option selected (shouldn't happen with new format but just in case)
285 return ""325 return ""
286 }326 }
287 327
@@ -294,15 +334,39 @@ func parseSelection(selection string) string {
294 }334 }
295 335
296 if strings.HasPrefix(selection, "🕒 ") {336 if strings.HasPrefix(selection, "🕒 ") {
297- // History entry selected - this is the display title, need to extract URL337+ // History entry selected - remove emoji prefix
298- // For now, we'll assume the display is the URL or close enough338+ selection = strings.TrimPrefix(selection, "🕒 ")
299- return strings.TrimPrefix(selection, "🕒 ")
300 }339 }
301 340
302- // Fallback: return the selection as-is341+ // If no pipes and looks like a URL, return as-is
342+ if strings.Contains(selection, "://") {
343+ return selection
344+ }
345+
346+ // Fallback: treat as search query
303 return selection347 return selection
304 }348 }
305 349
350+// getIconName determines the appropriate icon name for a dmenu option
351+func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string {
352+ if option.Type != historyType {
353+ return ""
354+ }
355+
356+ // Only use cached favicons - no system theme fallbacks for consistent sizing
357+ if option.FaviconURL != "" && faviconCache != nil {
358+ cachedPath := faviconCache.GetCachedPath(option.FaviconURL)
359+ if cachedPath != "" {
360+ return cachedPath
361+ }
362+ // Start async download for next time
363+ faviconCache.CacheAsync(option.FaviconURL)
364+ }
365+
366+ // No favicon available - return empty string to show no icon
367+ return ""
368+}
369+
306 // truncateString truncates a string to the specified length with ellipsis370 // truncateString truncates a string to the specified length with ellipsis
307 func truncateString(s string, maxLen int) string {371 func truncateString(s string, maxLen int) string {
308 if len(s) <= maxLen {372 if len(s) <= maxLen {
M internal/cli/root.go +26 -35
@@ -8,8 +8,11 @@ import (
88 "os"
99 "path/filepath"
1010
11+ "github.com/bnema/dumber/internal/cache"
1112 "github.com/bnema/dumber/internal/config"
1213 "github.com/bnema/dumber/internal/db"
14+ "github.com/bnema/dumber/internal/migrations"
15+ "github.com/bnema/dumber/services"
1316
1417 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql
1518 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite for cross-platform compatibility
@@ -23,9 +26,10 @@ const (
2326
2427 // CLI holds the database connection, queries, and configuration for the CLI commands
2528 type CLI struct {
26- DB *sql.DB
27- Queries *db.Queries
28- Config *config.Config
29+ DB *sql.DB
30+ Queries *db.Queries
31+ Config *config.Config
32+ ParserService *services.ParserService
2933 }
3034
3135 // NewCLI creates a new CLI instance with database connection and configuration
@@ -71,10 +75,21 @@ func NewCLI() (*CLI, error) {
7175 return nil, fmt.Errorf("failed to initialize database: %w", err)
7276 }
7377
78+ queries := db.New(database)
79+ parserService := services.NewParserService(cfg, queries)
80+
81+ // Clean old cached favicons on startup (async, non-blocking)
82+ go func() {
83+ if faviconCache, err := cache.NewFaviconCache(); err == nil {
84+ faviconCache.CleanOld()
85+ }
86+ }()
87+
7488 return &CLI{
75- DB: database,
76- Queries: db.New(database),
77- Config: cfg,
89+ DB: database,
90+ Queries: queries,
91+ Config: cfg,
92+ ParserService: parserService,
7893 }, nil
7994 }
8095
@@ -177,38 +192,14 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
177192 }
178193
179194
180-// initializeDatabase creates the database schema if it doesn't exist
195+// initializeDatabase runs embedded migrations and ensures database is up to date
181196 func initializeDatabase(db *sql.DB, cfg *config.Config) error {
182- schema := `
183- -- History tracking for visited URLs
184- CREATE TABLE IF NOT EXISTS history (
185- id INTEGER PRIMARY KEY AUTOINCREMENT,
186- url TEXT NOT NULL UNIQUE,
187- title TEXT,
188- visit_count INTEGER DEFAULT 1,
189- last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
190- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
191- );
192-
193- CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
194- CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
195-
196- -- URL shortcuts configuration
197- CREATE TABLE IF NOT EXISTS shortcuts (
198- id INTEGER PRIMARY KEY AUTOINCREMENT,
199- shortcut TEXT NOT NULL UNIQUE,
200- url_template TEXT NOT NULL,
201- description TEXT,
202- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
203- );
204- `
205-
206- // Execute schema creation
207- if _, err := db.Exec(schema); err != nil {
208- return err
197+ // Run embedded migrations - this will create all tables and apply any new migrations
198+ if err := migrations.RunEmbeddedMigrations(db); err != nil {
199+ return fmt.Errorf("failed to run database migrations: %w", err)
209200 }
210201
211- // Insert configured shortcuts
202+ // Insert configured shortcuts (these are additive, won't override existing)
212203 for shortcut, shortcutCfg := range cfg.SearchShortcuts {
213204 _, err := db.Exec(
214205 "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
@@ -8,8 +8,11 @@ import (
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10 10
11+ "github.com/bnema/dumber/internal/cache"
11 "github.com/bnema/dumber/internal/config"12 "github.com/bnema/dumber/internal/config"
12 "github.com/bnema/dumber/internal/db"13 "github.com/bnema/dumber/internal/db"
14+ "github.com/bnema/dumber/internal/migrations"
15+ "github.com/bnema/dumber/services"
13 16
14 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql17 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql
15 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite for cross-platform compatibility18 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite for cross-platform compatibility
@@ -23,9 +26,10 @@ const (
23 26
24 // CLI holds the database connection, queries, and configuration for the CLI commands27 // CLI holds the database connection, queries, and configuration for the CLI commands
25 type CLI struct {28 type CLI struct {
26- DB *sql.DB29+ DB *sql.DB
27- Queries *db.Queries30+ Queries *db.Queries
28- Config *config.Config31+ Config *config.Config
32+ ParserService *services.ParserService
29 }33 }
30 34
31 // NewCLI creates a new CLI instance with database connection and configuration35 // NewCLI creates a new CLI instance with database connection and configuration
@@ -71,10 +75,21 @@ func NewCLI() (*CLI, error) {
71 return nil, fmt.Errorf("failed to initialize database: %w", err)75 return nil, fmt.Errorf("failed to initialize database: %w", err)
72 }76 }
73 77
78+ queries := db.New(database)
79+ parserService := services.NewParserService(cfg, queries)
80+
81+ // Clean old cached favicons on startup (async, non-blocking)
82+ go func() {
83+ if faviconCache, err := cache.NewFaviconCache(); err == nil {
84+ faviconCache.CleanOld()
85+ }
86+ }()
87+
74 return &CLI{88 return &CLI{
75- DB: database,89+ DB: database,
76- Queries: db.New(database),90+ Queries: queries,
77- Config: cfg,91+ Config: cfg,
92+ ParserService: parserService,
78 }, nil93 }, nil
79 }94 }
80 95
@@ -177,38 +192,14 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
177 }192 }
178 193
179 194
180-// initializeDatabase creates the database schema if it doesn't exist195+// initializeDatabase runs embedded migrations and ensures database is up to date
181 func initializeDatabase(db *sql.DB, cfg *config.Config) error {196 func initializeDatabase(db *sql.DB, cfg *config.Config) error {
182- schema := `197+ // Run embedded migrations - this will create all tables and apply any new migrations
183- -- History tracking for visited URLs198+ if err := migrations.RunEmbeddedMigrations(db); err != nil {
184- CREATE TABLE IF NOT EXISTS history (199+ return fmt.Errorf("failed to run database migrations: %w", err)
185- id INTEGER PRIMARY KEY AUTOINCREMENT,
186- url TEXT NOT NULL UNIQUE,
187- title TEXT,
188- visit_count INTEGER DEFAULT 1,
189- last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
190- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
191- );
192-
193- CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
194- CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
195-
196- -- URL shortcuts configuration
197- CREATE TABLE IF NOT EXISTS shortcuts (
198- id INTEGER PRIMARY KEY AUTOINCREMENT,
199- shortcut TEXT NOT NULL UNIQUE,
200- url_template TEXT NOT NULL,
201- description TEXT,
202- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
203- );
204- `
205-
206- // Execute schema creation
207- if _, err := db.Exec(schema); err != nil {
208- return err
209 }200 }
210 201
211- // Insert configured shortcuts202+ // Insert configured shortcuts (these are additive, won't override existing)
212 for shortcut, shortcutCfg := range cfg.SearchShortcuts {203 for shortcut, shortcutCfg := range cfg.SearchShortcuts {
213 _, err := db.Exec(204 _, err := db.Exec(
214 "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",205 "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
M internal/db/history.sql.go +15 -2
@@ -26,7 +26,7 @@ func (q *Queries) AddOrUpdateHistory(ctx context.Context, url string, title sql.
2626 }
2727
2828 const GetHistory = `-- name: GetHistory :many
29-SELECT id, url, title, visit_count, last_visited, created_at
29+SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
3030 FROM history
3131 ORDER BY last_visited DESC
3232 LIMIT ?
@@ -48,6 +48,7 @@ func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error
4848 &i.VisitCount,
4949 &i.LastVisited,
5050 &i.CreatedAt,
51+ &i.FaviconUrl,
5152 ); err != nil {
5253 return nil, err
5354 }
@@ -98,7 +99,7 @@ func (q *Queries) GetShortcuts(ctx context.Context) ([]Shortcut, error) {
9899 }
99100
100101 const SearchHistory = `-- name: SearchHistory :many
101-SELECT id, url, title, visit_count, last_visited, created_at
102+SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
102103 FROM history
103104 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
104105 ORDER BY visit_count DESC, last_visited DESC
@@ -121,6 +122,7 @@ func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, col
121122 &i.VisitCount,
122123 &i.LastVisited,
123124 &i.CreatedAt,
125+ &i.FaviconUrl,
124126 ); err != nil {
125127 return nil, err
126128 }
@@ -134,3 +136,14 @@ func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, col
134136 }
135137 return items, nil
136138 }
139+
140+const UpdateHistoryFavicon = `-- name: UpdateHistoryFavicon :exec
141+UPDATE history
142+SET favicon_url = ?
143+WHERE url = ?
144+`
145+
146+func (q *Queries) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error {
147+ _, err := q.db.ExecContext(ctx, UpdateHistoryFavicon, faviconUrl, url)
148+ return err
149+}
@@ -26,7 +26,7 @@ func (q *Queries) AddOrUpdateHistory(ctx context.Context, url string, title sql.
26 }26 }
27 27
28 const GetHistory = `-- name: GetHistory :many28 const GetHistory = `-- name: GetHistory :many
29-SELECT id, url, title, visit_count, last_visited, created_at29+SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
30 FROM history30 FROM history
31 ORDER BY last_visited DESC31 ORDER BY last_visited DESC
32 LIMIT ?32 LIMIT ?
@@ -48,6 +48,7 @@ func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error
48 &i.VisitCount,48 &i.VisitCount,
49 &i.LastVisited,49 &i.LastVisited,
50 &i.CreatedAt,50 &i.CreatedAt,
51+ &i.FaviconUrl,
51 ); err != nil {52 ); err != nil {
52 return nil, err53 return nil, err
53 }54 }
@@ -98,7 +99,7 @@ func (q *Queries) GetShortcuts(ctx context.Context) ([]Shortcut, error) {
98 }99 }
99 100
100 const SearchHistory = `-- name: SearchHistory :many101 const SearchHistory = `-- name: SearchHistory :many
101-SELECT id, url, title, visit_count, last_visited, created_at102+SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
102 FROM history 103 FROM history
103 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'104 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
104 ORDER BY visit_count DESC, last_visited DESC105 ORDER BY visit_count DESC, last_visited DESC
@@ -121,6 +122,7 @@ func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, col
121 &i.VisitCount,122 &i.VisitCount,
122 &i.LastVisited,123 &i.LastVisited,
123 &i.CreatedAt,124 &i.CreatedAt,
125+ &i.FaviconUrl,
124 ); err != nil {126 ); err != nil {
125 return nil, err127 return nil, err
126 }128 }
@@ -134,3 +136,14 @@ func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, col
134 }136 }
135 return items, nil137 return items, nil
136 }138 }
139+
140+const UpdateHistoryFavicon = `-- name: UpdateHistoryFavicon :exec
141+UPDATE history
142+SET favicon_url = ?
143+WHERE url = ?
144+`
145+
146+func (q *Queries) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error {
147+ _, err := q.db.ExecContext(ctx, UpdateHistoryFavicon, faviconUrl, url)
148+ return err
149+}
M internal/db/interfaces.go +1 -0
@@ -19,6 +19,7 @@ type HistoryQuerier interface {
1919 GetHistory(ctx context.Context, limit int64) ([]History, error)
2020 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
2121 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
22+ UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error
2223 }
2324
2425 // ShortcutQuerier defines the interface for shortcut-related database operations
@@ -19,6 +19,7 @@ type HistoryQuerier interface {
19 GetHistory(ctx context.Context, limit int64) ([]History, error)19 GetHistory(ctx context.Context, limit int64) ([]History, error)
20 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)20 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
21 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error21 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
22+ UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error
22 }23 }
23 24
24 // ShortcutQuerier defines the interface for shortcut-related database operations25 // ShortcutQuerier defines the interface for shortcut-related database operations
M internal/db/models.go +1 -0
@@ -15,6 +15,7 @@ type History struct {
1515 VisitCount sql.NullInt64 `json:"visit_count"`
1616 LastVisited sql.NullTime `json:"last_visited"`
1717 CreatedAt sql.NullTime `json:"created_at"`
18+ FaviconUrl sql.NullString `json:"favicon_url"`
1819 }
1920
2021 type Shortcut struct {
@@ -15,6 +15,7 @@ type History struct {
15 VisitCount sql.NullInt64 `json:"visit_count"`15 VisitCount sql.NullInt64 `json:"visit_count"`
16 LastVisited sql.NullTime `json:"last_visited"`16 LastVisited sql.NullTime `json:"last_visited"`
17 CreatedAt sql.NullTime `json:"created_at"`17 CreatedAt sql.NullTime `json:"created_at"`
18+ FaviconUrl sql.NullString `json:"favicon_url"`
18 }19 }
19 20
20 type Shortcut struct {21 type Shortcut struct {
M internal/db/querier.go +1 -0
@@ -26,6 +26,7 @@ type Querier interface {
2626 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
2727 // Set or update zoom level for a domain with validation
2828 SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error
29+ UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error
2930 }
3031
3132 var _ Querier = (*Queries)(nil)
@@ -26,6 +26,7 @@ type Querier interface {
26 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)26 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
27 // Set or update zoom level for a domain with validation27 // Set or update zoom level for a domain with validation
28 SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error28 SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error
29+ UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error
29 }30 }
30 31
31 var _ Querier = (*Queries)(nil)32 var _ Querier = (*Queries)(nil)
M internal/db/queries/history.sql +7 -2
@@ -1,5 +1,5 @@
11 -- name: GetHistory :many
2-SELECT id, url, title, visit_count, last_visited, created_at
2+SELECT *
33 FROM history
44 ORDER BY last_visited DESC
55 LIMIT ?;
@@ -14,12 +14,17 @@ DO UPDATE SET
1414 title = EXCLUDED.title;
1515
1616 -- name: SearchHistory :many
17-SELECT id, url, title, visit_count, last_visited, created_at
17+SELECT *
1818 FROM history
1919 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
2020 ORDER BY visit_count DESC, last_visited DESC
2121 LIMIT ?;
2222
23+-- name: UpdateHistoryFavicon :exec
24+UPDATE history
25+SET favicon_url = ?
26+WHERE url = ?;
27+
2328 -- name: GetShortcuts :many
2429 SELECT id, shortcut, url_template, description, created_at
2530 FROM shortcuts
@@ -1,5 +1,5 @@
1 -- name: GetHistory :many1 -- name: GetHistory :many
2-SELECT id, url, title, visit_count, last_visited, created_at2+SELECT *
3 FROM history3 FROM history
4 ORDER BY last_visited DESC4 ORDER BY last_visited DESC
5 LIMIT ?;5 LIMIT ?;
@@ -14,12 +14,17 @@ DO UPDATE SET
14 title = EXCLUDED.title;14 title = EXCLUDED.title;
15 15
16 -- name: SearchHistory :many16 -- name: SearchHistory :many
17-SELECT id, url, title, visit_count, last_visited, created_at17+SELECT *
18 FROM history 18 FROM history
19 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'19 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
20 ORDER BY visit_count DESC, last_visited DESC20 ORDER BY visit_count DESC, last_visited DESC
21 LIMIT ?;21 LIMIT ?;
22 22
23+-- name: UpdateHistoryFavicon :exec
24+UPDATE history
25+SET favicon_url = ?
26+WHERE url = ?;
27+
23 -- name: GetShortcuts :many28 -- name: GetShortcuts :many
24 SELECT id, shortcut, url_template, description, created_at29 SELECT id, shortcut, url_template, description, created_at
25 FROM shortcuts30 FROM shortcuts
A internal/migrations/001_initial.sql +29 -0
@@ -0,0 +1,29 @@
1+-- Initial database schema for dumber
2+-- History tracking for visited URLs
3+
4+CREATE TABLE IF NOT EXISTS history (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ url TEXT NOT NULL UNIQUE,
7+ title TEXT,
8+ visit_count INTEGER DEFAULT 1,
9+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
10+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
11+);
12+
13+CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
14+CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
15+
16+-- URL shortcuts configuration
17+CREATE TABLE IF NOT EXISTS shortcuts (
18+ id INTEGER PRIMARY KEY AUTOINCREMENT,
19+ shortcut TEXT NOT NULL UNIQUE,
20+ url_template TEXT NOT NULL,
21+ description TEXT,
22+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
23+);
24+
25+INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES
26+ ('g', 'https://www.google.com/search?q=%s', 'Google search'),
27+ ('gh', 'https://github.com/search?q=%s', 'GitHub search'),
28+ ('yt', 'https://www.youtube.com/results?search_query=%s', 'YouTube search'),
29+ ('w', 'https://en.wikipedia.org/wiki/%s', 'Wikipedia search');⏎̸
@@ -0,0 +1,29 @@
1+-- Initial database schema for dumber
2+-- History tracking for visited URLs
3+
4+CREATE TABLE IF NOT EXISTS history (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ url TEXT NOT NULL UNIQUE,
7+ title TEXT,
8+ visit_count INTEGER DEFAULT 1,
9+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
10+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
11+);
12+
13+CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
14+CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
15+
16+-- URL shortcuts configuration
17+CREATE TABLE IF NOT EXISTS shortcuts (
18+ id INTEGER PRIMARY KEY AUTOINCREMENT,
19+ shortcut TEXT NOT NULL UNIQUE,
20+ url_template TEXT NOT NULL,
21+ description TEXT,
22+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
23+);
24+
25+INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES
26+ ('g', 'https://www.google.com/search?q=%s', 'Google search'),
27+ ('gh', 'https://github.com/search?q=%s', 'GitHub search'),
28+ ('yt', 'https://www.youtube.com/results?search_query=%s', 'YouTube search'),
29+ ('w', 'https://en.wikipedia.org/wiki/%s', 'Wikipedia search');⏎̸
A internal/migrations/002_zoom_persistence.sql +11 -0
@@ -0,0 +1,11 @@
1+-- Add zoom persistence table
2+-- Store zoom levels per URL for restoration between sessions
3+
4+CREATE TABLE IF NOT EXISTS zoom_settings (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ url TEXT NOT NULL UNIQUE,
7+ zoom_level REAL NOT NULL DEFAULT 1.0,
8+ last_updated DATETIME DEFAULT CURRENT_TIMESTAMP
9+);
10+
11+CREATE INDEX idx_zoom_settings_url ON zoom_settings(url);⏎̸
@@ -0,0 +1,11 @@
1+-- Add zoom persistence table
2+-- Store zoom levels per URL for restoration between sessions
3+
4+CREATE TABLE IF NOT EXISTS zoom_settings (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ url TEXT NOT NULL UNIQUE,
7+ zoom_level REAL NOT NULL DEFAULT 1.0,
8+ last_updated DATETIME DEFAULT CURRENT_TIMESTAMP
9+);
10+
11+CREATE INDEX idx_zoom_settings_url ON zoom_settings(url);⏎̸
A internal/migrations/003_add_zoom_levels.sql +15 -0
@@ -0,0 +1,15 @@
1+-- Add zoom_levels table to replace zoom_settings with spec requirements
2+-- Store zoom levels per domain (not full URL) with Firefox zoom constraints
3+
4+-- Drop existing zoom_settings table
5+DROP TABLE IF EXISTS zoom_settings;
6+
7+-- Create new zoom_levels table following spec requirements
8+CREATE TABLE IF NOT EXISTS zoom_levels (
9+ domain TEXT PRIMARY KEY,
10+ zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0),
11+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
12+);
13+
14+-- Create index for performance on updated_at for cleanup operations
15+CREATE INDEX IF NOT EXISTS idx_zoom_levels_updated_at ON zoom_levels(updated_at);⏎̸
@@ -0,0 +1,15 @@
1+-- Add zoom_levels table to replace zoom_settings with spec requirements
2+-- Store zoom levels per domain (not full URL) with Firefox zoom constraints
3+
4+-- Drop existing zoom_settings table
5+DROP TABLE IF EXISTS zoom_settings;
6+
7+-- Create new zoom_levels table following spec requirements
8+CREATE TABLE IF NOT EXISTS zoom_levels (
9+ domain TEXT PRIMARY KEY,
10+ zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0),
11+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
12+);
13+
14+-- Create index for performance on updated_at for cleanup operations
15+CREATE INDEX IF NOT EXISTS idx_zoom_levels_updated_at ON zoom_levels(updated_at);⏎̸
A internal/migrations/004_add_favicon_url.sql +5 -0
@@ -0,0 +1,5 @@
1+-- Add favicon_url column to history table
2+-- Store favicon URLs for better UI display in dmenu
3+
4+ALTER TABLE history ADD COLUMN favicon_url TEXT DEFAULT NULL;
5+CREATE INDEX IF NOT EXISTS idx_history_favicon ON history(favicon_url);⏎̸
@@ -0,0 +1,5 @@
1+-- Add favicon_url column to history table
2+-- Store favicon URLs for better UI display in dmenu
3+
4+ALTER TABLE history ADD COLUMN favicon_url TEXT DEFAULT NULL;
5+CREATE INDEX IF NOT EXISTS idx_history_favicon ON history(favicon_url);⏎̸
A internal/migrations/migrations.go +190 -0
@@ -0,0 +1,190 @@
1+package migrations
2+
3+import (
4+ "database/sql"
5+ "embed"
6+ "fmt"
7+ "log"
8+ "sort"
9+ "strconv"
10+ "strings"
11+)
12+
13+//go:embed *.sql
14+var migrationFiles embed.FS
15+
16+// Migration represents a single database migration
17+type Migration struct {
18+ Version int
19+ Name string
20+ SQL string
21+}
22+
23+// GetMigrations returns all embedded migrations sorted by version
24+func GetMigrations() ([]Migration, error) {
25+ entries, err := migrationFiles.ReadDir(".")
26+ if err != nil {
27+ return nil, fmt.Errorf("failed to read embedded migrations directory: %w", err)
28+ }
29+
30+ var migrations []Migration
31+ for _, entry := range entries {
32+ if !strings.HasSuffix(entry.Name(), ".sql") {
33+ continue
34+ }
35+
36+ // Parse version from filename (e.g., "001_initial.sql" -> version 1)
37+ parts := strings.SplitN(entry.Name(), "_", 2)
38+ if len(parts) != 2 {
39+ log.Printf("Warning: skipping migration file with invalid name format: %s", entry.Name())
40+ continue
41+ }
42+
43+ versionStr := parts[0]
44+ version, err := strconv.Atoi(versionStr)
45+ if err != nil {
46+ log.Printf("Warning: skipping migration file with invalid version: %s", entry.Name())
47+ continue
48+ }
49+
50+ // Extract name without extension
51+ name := strings.TrimSuffix(parts[1], ".sql")
52+
53+ // Read SQL content
54+ content, err := migrationFiles.ReadFile(entry.Name())
55+ if err != nil {
56+ return nil, fmt.Errorf("failed to read migration file %s: %w", entry.Name(), err)
57+ }
58+
59+ migrations = append(migrations, Migration{
60+ Version: version,
61+ Name: name,
62+ SQL: string(content),
63+ })
64+ }
65+
66+ // Sort by version
67+ sort.Slice(migrations, func(i, j int) bool {
68+ return migrations[i].Version < migrations[j].Version
69+ })
70+
71+ return migrations, nil
72+}
73+
74+// RunEmbeddedMigrations applies all pending embedded migrations to the database
75+func RunEmbeddedMigrations(db *sql.DB) error {
76+ // Create migrations tracking table if it doesn't exist
77+ if err := createMigrationsTable(db); err != nil {
78+ return fmt.Errorf("failed to create migrations table: %w", err)
79+ }
80+
81+ migrations, err := GetMigrations()
82+ if err != nil {
83+ return fmt.Errorf("failed to load migrations: %w", err)
84+ }
85+
86+ for _, migration := range migrations {
87+ if err := applyMigration(db, migration); err != nil {
88+ return fmt.Errorf("failed to apply migration %d (%s): %w", migration.Version, migration.Name, err)
89+ }
90+ }
91+
92+ return nil
93+}
94+
95+// createMigrationsTable creates the table for tracking applied migrations
96+func createMigrationsTable(db *sql.DB) error {
97+ schema := `
98+ CREATE TABLE IF NOT EXISTS schema_migrations (
99+ version INTEGER PRIMARY KEY,
100+ name TEXT NOT NULL,
101+ applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
102+ );`
103+
104+ _, err := db.Exec(schema)
105+ return err
106+}
107+
108+// applyMigration applies a single migration if it hasn't been applied yet
109+func applyMigration(db *sql.DB, migration Migration) error {
110+ // Check if migration has already been applied
111+ var count int
112+ err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", migration.Version).Scan(&count)
113+ if err != nil {
114+ return fmt.Errorf("failed to check migration status: %w", err)
115+ }
116+
117+ if count > 0 {
118+ // Migration already applied, skip
119+ return nil
120+ }
121+
122+ log.Printf("Applying migration %03d: %s", migration.Version, migration.Name)
123+
124+ // Use transaction for safety
125+ tx, err := db.Begin()
126+ if err != nil {
127+ return fmt.Errorf("failed to begin transaction: %w", err)
128+ }
129+ defer func() {
130+ if err != nil {
131+ tx.Rollback()
132+ }
133+ }()
134+
135+ // Execute migration SQL
136+ if _, err := tx.Exec(migration.SQL); err != nil {
137+ return fmt.Errorf("failed to execute migration SQL: %w", err)
138+ }
139+
140+ // Record migration as applied
141+ if _, err := tx.Exec(
142+ "INSERT INTO schema_migrations (version, name) VALUES (?, ?)",
143+ migration.Version, migration.Name,
144+ ); err != nil {
145+ return fmt.Errorf("failed to record migration: %w", err)
146+ }
147+
148+ if err := tx.Commit(); err != nil {
149+ return fmt.Errorf("failed to commit migration transaction: %w", err)
150+ }
151+
152+ log.Printf("Successfully applied migration %03d: %s", migration.Version, migration.Name)
153+ return nil
154+}
155+
156+// GetAppliedMigrations returns the list of applied migration versions
157+func GetAppliedMigrations(db *sql.DB) ([]int, error) {
158+ // First check if migrations table exists
159+ var count int
160+ err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'").Scan(&count)
161+ if err != nil {
162+ return nil, fmt.Errorf("failed to check if migrations table exists: %w", err)
163+ }
164+
165+ if count == 0 {
166+ // Migrations table doesn't exist, no migrations applied
167+ return []int{}, nil
168+ }
169+
170+ rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version")
171+ if err != nil {
172+ return nil, fmt.Errorf("failed to query applied migrations: %w", err)
173+ }
174+ defer rows.Close()
175+
176+ var versions []int
177+ for rows.Next() {
178+ var version int
179+ if err := rows.Scan(&version); err != nil {
180+ return nil, fmt.Errorf("failed to scan migration version: %w", err)
181+ }
182+ versions = append(versions, version)
183+ }
184+
185+ if err := rows.Err(); err != nil {
186+ return nil, fmt.Errorf("error iterating over migration rows: %w", err)
187+ }
188+
189+ return versions, nil
190+}⏎̸
@@ -0,0 +1,190 @@
1+package migrations
2+
3+import (
4+ "database/sql"
5+ "embed"
6+ "fmt"
7+ "log"
8+ "sort"
9+ "strconv"
10+ "strings"
11+)
12+
13+//go:embed *.sql
14+var migrationFiles embed.FS
15+
16+// Migration represents a single database migration
17+type Migration struct {
18+ Version int
19+ Name string
20+ SQL string
21+}
22+
23+// GetMigrations returns all embedded migrations sorted by version
24+func GetMigrations() ([]Migration, error) {
25+ entries, err := migrationFiles.ReadDir(".")
26+ if err != nil {
27+ return nil, fmt.Errorf("failed to read embedded migrations directory: %w", err)
28+ }
29+
30+ var migrations []Migration
31+ for _, entry := range entries {
32+ if !strings.HasSuffix(entry.Name(), ".sql") {
33+ continue
34+ }
35+
36+ // Parse version from filename (e.g., "001_initial.sql" -> version 1)
37+ parts := strings.SplitN(entry.Name(), "_", 2)
38+ if len(parts) != 2 {
39+ log.Printf("Warning: skipping migration file with invalid name format: %s", entry.Name())
40+ continue
41+ }
42+
43+ versionStr := parts[0]
44+ version, err := strconv.Atoi(versionStr)
45+ if err != nil {
46+ log.Printf("Warning: skipping migration file with invalid version: %s", entry.Name())
47+ continue
48+ }
49+
50+ // Extract name without extension
51+ name := strings.TrimSuffix(parts[1], ".sql")
52+
53+ // Read SQL content
54+ content, err := migrationFiles.ReadFile(entry.Name())
55+ if err != nil {
56+ return nil, fmt.Errorf("failed to read migration file %s: %w", entry.Name(), err)
57+ }
58+
59+ migrations = append(migrations, Migration{
60+ Version: version,
61+ Name: name,
62+ SQL: string(content),
63+ })
64+ }
65+
66+ // Sort by version
67+ sort.Slice(migrations, func(i, j int) bool {
68+ return migrations[i].Version < migrations[j].Version
69+ })
70+
71+ return migrations, nil
72+}
73+
74+// RunEmbeddedMigrations applies all pending embedded migrations to the database
75+func RunEmbeddedMigrations(db *sql.DB) error {
76+ // Create migrations tracking table if it doesn't exist
77+ if err := createMigrationsTable(db); err != nil {
78+ return fmt.Errorf("failed to create migrations table: %w", err)
79+ }
80+
81+ migrations, err := GetMigrations()
82+ if err != nil {
83+ return fmt.Errorf("failed to load migrations: %w", err)
84+ }
85+
86+ for _, migration := range migrations {
87+ if err := applyMigration(db, migration); err != nil {
88+ return fmt.Errorf("failed to apply migration %d (%s): %w", migration.Version, migration.Name, err)
89+ }
90+ }
91+
92+ return nil
93+}
94+
95+// createMigrationsTable creates the table for tracking applied migrations
96+func createMigrationsTable(db *sql.DB) error {
97+ schema := `
98+ CREATE TABLE IF NOT EXISTS schema_migrations (
99+ version INTEGER PRIMARY KEY,
100+ name TEXT NOT NULL,
101+ applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
102+ );`
103+
104+ _, err := db.Exec(schema)
105+ return err
106+}
107+
108+// applyMigration applies a single migration if it hasn't been applied yet
109+func applyMigration(db *sql.DB, migration Migration) error {
110+ // Check if migration has already been applied
111+ var count int
112+ err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", migration.Version).Scan(&count)
113+ if err != nil {
114+ return fmt.Errorf("failed to check migration status: %w", err)
115+ }
116+
117+ if count > 0 {
118+ // Migration already applied, skip
119+ return nil
120+ }
121+
122+ log.Printf("Applying migration %03d: %s", migration.Version, migration.Name)
123+
124+ // Use transaction for safety
125+ tx, err := db.Begin()
126+ if err != nil {
127+ return fmt.Errorf("failed to begin transaction: %w", err)
128+ }
129+ defer func() {
130+ if err != nil {
131+ tx.Rollback()
132+ }
133+ }()
134+
135+ // Execute migration SQL
136+ if _, err := tx.Exec(migration.SQL); err != nil {
137+ return fmt.Errorf("failed to execute migration SQL: %w", err)
138+ }
139+
140+ // Record migration as applied
141+ if _, err := tx.Exec(
142+ "INSERT INTO schema_migrations (version, name) VALUES (?, ?)",
143+ migration.Version, migration.Name,
144+ ); err != nil {
145+ return fmt.Errorf("failed to record migration: %w", err)
146+ }
147+
148+ if err := tx.Commit(); err != nil {
149+ return fmt.Errorf("failed to commit migration transaction: %w", err)
150+ }
151+
152+ log.Printf("Successfully applied migration %03d: %s", migration.Version, migration.Name)
153+ return nil
154+}
155+
156+// GetAppliedMigrations returns the list of applied migration versions
157+func GetAppliedMigrations(db *sql.DB) ([]int, error) {
158+ // First check if migrations table exists
159+ var count int
160+ err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'").Scan(&count)
161+ if err != nil {
162+ return nil, fmt.Errorf("failed to check if migrations table exists: %w", err)
163+ }
164+
165+ if count == 0 {
166+ // Migrations table doesn't exist, no migrations applied
167+ return []int{}, nil
168+ }
169+
170+ rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version")
171+ if err != nil {
172+ return nil, fmt.Errorf("failed to query applied migrations: %w", err)
173+ }
174+ defer rows.Close()
175+
176+ var versions []int
177+ for rows.Next() {
178+ var version int
179+ if err := rows.Scan(&version); err != nil {
180+ return nil, fmt.Errorf("failed to scan migration version: %w", err)
181+ }
182+ versions = append(versions, version)
183+ }
184+
185+ if err := rows.Err(); err != nil {
186+ return nil, fmt.Errorf("error iterating over migration rows: %w", err)
187+ }
188+
189+ return versions, nil
190+}⏎̸
M internal/parser/parser.go +108 -27
@@ -3,6 +3,7 @@ package parser
33 import (
44 "fmt"
55 neturl "net/url"
6+ "regexp"
67 "strings"
78 "time"
89
@@ -10,59 +11,139 @@ import (
1011 )
1112
1213 // ParseInput parses user input and returns a ParseResult with URL and metadata.
13-//
14-// Learning Behavior: When users search with shortcuts like "g: viper cli" and visit
15-// results, those URLs are recorded in history. Future searches for "viper cli" will
16-// find the GitHub page in history via fuzzy matching, creating intelligent learning.
14+// Now uses the proven CLI parsing logic for consistent behavior.
1715 func (p *Parser) ParseInput(input string) (*ParseResult, error) {
1816 startTime := time.Now()
1917
20- // Sanitize input
21- validator := NewURLValidator()
22- cleanInput := validator.SanitizeInput(input)
23-
24- if cleanInput == "" {
18+ if input == "" {
2519 return &ParseResult{
2620 Type: InputTypeFallbackSearch,
27- URL: p.buildSearchURL("", cleanInput),
21+ URL: "https://www.google.com/search?q=",
2822 Query: input,
2923 Confidence: 0.0,
3024 ProcessingTime: time.Since(startTime),
3125 }, nil
3226 }
3327
34- // Determine input type
35- inputType := validator.GetURLType(cleanInput)
36-
37- switch inputType {
38- case InputTypeDirectURL:
39- return p.parseDirectURL(cleanInput, input, startTime)
28+ // Use the working CLI parsing logic
29+ finalURL, err := p.parseInputUsingCLILogic(input)
30+ if err != nil {
31+ // If parsing fails, fall back to search
32+ finalURL = fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input))
33+ }
4034
41- case InputTypeSearchShortcut:
42- return p.parseSearchShortcut(cleanInput, input, startTime)
35+ // Determine result type based on the URL
36+ var resultType InputType = InputTypeFallbackSearch
37+ confidence := 0.1
4338
44- case InputTypeHistorySearch:
45- return p.parseHistorySearch(cleanInput, input, startTime)
39+ if strings.Contains(finalURL, "google.com/search") {
40+ resultType = InputTypeFallbackSearch
41+ confidence = 0.1
42+ } else if strings.HasPrefix(finalURL, "http://") || strings.HasPrefix(finalURL, "https://") {
43+ resultType = InputTypeDirectURL
44+ confidence = 1.0
45+ }
4646
47- default:
48- return p.parseFallbackSearch(cleanInput, input, startTime)
47+ // Check if it was a shortcut
48+ if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") {
49+ shortcut := strings.TrimSpace(input[:idx])
50+ if _, exists := p.config.SearchShortcuts[shortcut]; exists {
51+ resultType = InputTypeSearchShortcut
52+ confidence = 0.95
53+ }
4954 }
55+
56+ return &ParseResult{
57+ Type: resultType,
58+ URL: finalURL,
59+ Query: input,
60+ Confidence: confidence,
61+ ProcessingTime: time.Since(startTime),
62+ }, nil
5063 }
5164
52-// parseDirectURL handles direct URL inputs.
65+// parseDirectURL handles direct URL inputs using CLI logic.
5366 func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
54- validator := NewURLValidator()
55- normalizedURL := validator.NormalizeURL(cleanInput)
67+ // Use the working CLI parsing logic instead
68+ finalURL, err := p.parseInputUsingCLILogic(cleanInput)
69+ if err != nil {
70+ return nil, err
71+ }
5672
5773 return &ParseResult{
5874 Type: InputTypeDirectURL,
59- URL: normalizedURL,
75+ URL: finalURL,
6076 Query: originalInput,
61- Confidence: 1.0, // High confidence for direct URLs
77+ Confidence: 1.0,
6278 ProcessingTime: time.Since(startTime),
6379 }, nil
6480 }
6581
82+// parseInputUsingCLILogic implements the working CLI parsing logic
83+func (p *Parser) parseInputUsingCLILogic(input string) (string, error) {
84+ input = strings.TrimSpace(input)
85+
86+ // 1. Check if it's a shortcut (format: "prefix:query")
87+ if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") {
88+ shortcut := strings.TrimSpace(input[:idx])
89+ query := strings.TrimSpace(input[idx+1:])
90+
91+ if query != "" {
92+ // First check configuration-based shortcuts
93+ if shortcutCfg, exists := p.config.SearchShortcuts[shortcut]; exists {
94+ return fmt.Sprintf(shortcutCfg.URL, neturl.QueryEscape(query)), nil
95+ }
96+
97+ return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
98+ }
99+ }
100+
101+ // 2. Already a full URL with protocol
102+ if regexp.MustCompile(`^https?://`).MatchString(input) {
103+ return input, nil
104+ }
105+
106+ // 3. File protocol
107+ if strings.HasPrefix(input, "file://") {
108+ return input, nil
109+ }
110+
111+ // 4. Localhost/development URLs
112+ if regexp.MustCompile(`^(localhost|127\.0\.0\.1|0\.0\.0\.0|::1)(:\d+)?(/.*)?$`).MatchString(input) {
113+ return "http://" + input, nil
114+ }
115+
116+ // 5. Local network IPs
117+ if regexp.MustCompile(`^(192\.168|10\.|172\.(1[6-9]|2[0-9]|3[01]))\.\d+\.\d+(:\d+)?(/.*)?$`).MatchString(input) {
118+ return "http://" + input, nil
119+ }
120+
121+ // 6. Domain with TLD (must have dot and valid TLD)
122+ // More comprehensive TLD pattern for better URL detection
123+ domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(/.*)?$`)
124+ if domainPattern.MatchString(input) {
125+ // Additional check for common TLDs to reduce false positives
126+ commonTLDs := regexp.MustCompile(`\.(com|org|net|edu|gov|io|co|uk|de|fr|jp|cn|in|br|au|ca|ru|nl|it|es|se|no|fi|dk|pl|ch|at|be|cz|gr|il|mx|nz|sg|kr|tw|hk|my|th|vn|id|ph|za|eg|ng|ke|dev|app|xyz|tech|site|online|store|blog|info|biz|name|pro)(/.*)?$`)
127+ if commonTLDs.MatchString(input) {
128+ return "https://" + input, nil
129+ }
130+
131+ // If it has a dot but uncommon TLD, still try as URL
132+ return "https://" + input, nil
133+ }
134+
135+ // 7. Possible local hostname (single word, might be on local network)
136+ singleHostPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`)
137+ if singleHostPattern.MatchString(input) && len(input) <= 63 {
138+ // Could be local hostname, try as URL but don't validate
139+ return "http://" + input, nil
140+ }
141+
142+ // 8. Everything else is a search query
143+ // This includes: multi-word phrases, questions, special characters, etc.
144+ return fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input)), nil
145+}
146+
66147 // parseSearchShortcut handles search shortcut inputs.
67148 func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
68149 validator := NewURLValidator()
@@ -3,6 +3,7 @@ package parser
3 import (3 import (
4 "fmt"4 "fmt"
5 neturl "net/url"5 neturl "net/url"
6+ "regexp"
6 "strings"7 "strings"
7 "time"8 "time"
8 9
@@ -10,59 +11,139 @@ import (
10 )11 )
11 12
12 // ParseInput parses user input and returns a ParseResult with URL and metadata.13 // ParseInput parses user input and returns a ParseResult with URL and metadata.
13-//14+// Now uses the proven CLI parsing logic for consistent behavior.
14-// Learning Behavior: When users search with shortcuts like "g: viper cli" and visit
15-// results, those URLs are recorded in history. Future searches for "viper cli" will
16-// find the GitHub page in history via fuzzy matching, creating intelligent learning.
17 func (p *Parser) ParseInput(input string) (*ParseResult, error) {15 func (p *Parser) ParseInput(input string) (*ParseResult, error) {
18 startTime := time.Now()16 startTime := time.Now()
19 17
20- // Sanitize input18+ if input == "" {
21- validator := NewURLValidator()
22- cleanInput := validator.SanitizeInput(input)
23-
24- if cleanInput == "" {
25 return &ParseResult{19 return &ParseResult{
26 Type: InputTypeFallbackSearch,20 Type: InputTypeFallbackSearch,
27- URL: p.buildSearchURL("", cleanInput),21+ URL: "https://www.google.com/search?q=",
28 Query: input,22 Query: input,
29 Confidence: 0.0,23 Confidence: 0.0,
30 ProcessingTime: time.Since(startTime),24 ProcessingTime: time.Since(startTime),
31 }, nil25 }, nil
32 }26 }
33 27
34- // Determine input type28+ // Use the working CLI parsing logic
35- inputType := validator.GetURLType(cleanInput)29+ finalURL, err := p.parseInputUsingCLILogic(input)
36-30+ if err != nil {
37- switch inputType {31+ // If parsing fails, fall back to search
38- case InputTypeDirectURL:32+ finalURL = fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input))
39- return p.parseDirectURL(cleanInput, input, startTime)33+ }
40 34
41- case InputTypeSearchShortcut:35+ // Determine result type based on the URL
42- return p.parseSearchShortcut(cleanInput, input, startTime)36+ var resultType InputType = InputTypeFallbackSearch
37+ confidence := 0.1
43 38
44- case InputTypeHistorySearch:39+ if strings.Contains(finalURL, "google.com/search") {
45- return p.parseHistorySearch(cleanInput, input, startTime)40+ resultType = InputTypeFallbackSearch
41+ confidence = 0.1
42+ } else if strings.HasPrefix(finalURL, "http://") || strings.HasPrefix(finalURL, "https://") {
43+ resultType = InputTypeDirectURL
44+ confidence = 1.0
45+ }
46 46
47- default:47+ // Check if it was a shortcut
48- return p.parseFallbackSearch(cleanInput, input, startTime)48+ if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") {
49+ shortcut := strings.TrimSpace(input[:idx])
50+ if _, exists := p.config.SearchShortcuts[shortcut]; exists {
51+ resultType = InputTypeSearchShortcut
52+ confidence = 0.95
53+ }
49 }54 }
55+
56+ return &ParseResult{
57+ Type: resultType,
58+ URL: finalURL,
59+ Query: input,
60+ Confidence: confidence,
61+ ProcessingTime: time.Since(startTime),
62+ }, nil
50 }63 }
51 64
52-// parseDirectURL handles direct URL inputs.65+// parseDirectURL handles direct URL inputs using CLI logic.
53 func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {66 func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
54- validator := NewURLValidator()67+ // Use the working CLI parsing logic instead
55- normalizedURL := validator.NormalizeURL(cleanInput)68+ finalURL, err := p.parseInputUsingCLILogic(cleanInput)
69+ if err != nil {
70+ return nil, err
71+ }
56 72
57 return &ParseResult{73 return &ParseResult{
58 Type: InputTypeDirectURL,74 Type: InputTypeDirectURL,
59- URL: normalizedURL,75+ URL: finalURL,
60 Query: originalInput,76 Query: originalInput,
61- Confidence: 1.0, // High confidence for direct URLs77+ Confidence: 1.0,
62 ProcessingTime: time.Since(startTime),78 ProcessingTime: time.Since(startTime),
63 }, nil79 }, nil
64 }80 }
65 81
82+// parseInputUsingCLILogic implements the working CLI parsing logic
83+func (p *Parser) parseInputUsingCLILogic(input string) (string, error) {
84+ input = strings.TrimSpace(input)
85+
86+ // 1. Check if it's a shortcut (format: "prefix:query")
87+ if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") {
88+ shortcut := strings.TrimSpace(input[:idx])
89+ query := strings.TrimSpace(input[idx+1:])
90+
91+ if query != "" {
92+ // First check configuration-based shortcuts
93+ if shortcutCfg, exists := p.config.SearchShortcuts[shortcut]; exists {
94+ return fmt.Sprintf(shortcutCfg.URL, neturl.QueryEscape(query)), nil
95+ }
96+
97+ return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
98+ }
99+ }
100+
101+ // 2. Already a full URL with protocol
102+ if regexp.MustCompile(`^https?://`).MatchString(input) {
103+ return input, nil
104+ }
105+
106+ // 3. File protocol
107+ if strings.HasPrefix(input, "file://") {
108+ return input, nil
109+ }
110+
111+ // 4. Localhost/development URLs
112+ if regexp.MustCompile(`^(localhost|127\.0\.0\.1|0\.0\.0\.0|::1)(:\d+)?(/.*)?$`).MatchString(input) {
113+ return "http://" + input, nil
114+ }
115+
116+ // 5. Local network IPs
117+ if regexp.MustCompile(`^(192\.168|10\.|172\.(1[6-9]|2[0-9]|3[01]))\.\d+\.\d+(:\d+)?(/.*)?$`).MatchString(input) {
118+ return "http://" + input, nil
119+ }
120+
121+ // 6. Domain with TLD (must have dot and valid TLD)
122+ // More comprehensive TLD pattern for better URL detection
123+ domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(/.*)?$`)
124+ if domainPattern.MatchString(input) {
125+ // Additional check for common TLDs to reduce false positives
126+ commonTLDs := regexp.MustCompile(`\.(com|org|net|edu|gov|io|co|uk|de|fr|jp|cn|in|br|au|ca|ru|nl|it|es|se|no|fi|dk|pl|ch|at|be|cz|gr|il|mx|nz|sg|kr|tw|hk|my|th|vn|id|ph|za|eg|ng|ke|dev|app|xyz|tech|site|online|store|blog|info|biz|name|pro)(/.*)?$`)
127+ if commonTLDs.MatchString(input) {
128+ return "https://" + input, nil
129+ }
130+
131+ // If it has a dot but uncommon TLD, still try as URL
132+ return "https://" + input, nil
133+ }
134+
135+ // 7. Possible local hostname (single word, might be on local network)
136+ singleHostPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`)
137+ if singleHostPattern.MatchString(input) && len(input) <= 63 {
138+ // Could be local hostname, try as URL but don't validate
139+ return "http://" + input, nil
140+ }
141+
142+ // 8. Everything else is a search query
143+ // This includes: multi-word phrases, questions, special characters, etc.
144+ return fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input)), nil
145+}
146+
66 // parseSearchShortcut handles search shortcut inputs.147 // parseSearchShortcut handles search shortcut inputs.
67 func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {148 func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
68 validator := NewURLValidator()149 validator := NewURLValidator()
A migrations/004_add_favicon_url.sql +5 -0
@@ -0,0 +1,5 @@
1+-- Add favicon_url column to history table
2+-- Store favicon URLs for better UI display in dmenu
3+
4+ALTER TABLE history ADD COLUMN favicon_url TEXT DEFAULT NULL;
5+CREATE INDEX IF NOT EXISTS idx_history_favicon ON history(favicon_url);⏎̸
@@ -0,0 +1,5 @@
1+-- Add favicon_url column to history table
2+-- Store favicon URLs for better UI display in dmenu
3+
4+ALTER TABLE history ADD COLUMN favicon_url TEXT DEFAULT NULL;
5+CREATE INDEX IF NOT EXISTS idx_history_favicon ON history(favicon_url);⏎̸
M services/browser_service.go +35 -0
@@ -11,6 +11,7 @@ import (
1111 neturl "net/url"
1212 "time"
1313
14+ "github.com/bnema/dumber/internal/cache"
1415 "github.com/bnema/dumber/internal/config"
1516 "github.com/bnema/dumber/internal/db"
1617 "github.com/bnema/dumber/pkg/webkit"
@@ -140,6 +141,9 @@ func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationR
140141 fmt.Printf("Failed to record history: %v\n", err)
141142 }
142143
144+ // Update favicon asynchronously after navigation
145+ go s.updateFavicon(ctx, url)
146+
143147 return &NavigationResult{
144148 URL: url,
145149 Success: true,
@@ -486,3 +490,34 @@ func zoomKeyFromURL(raw string) string {
486490
487491 // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
488492 func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
493+
494+// updateFavicon fetches and stores favicon URL for a given page URL
495+func (s *BrowserService) updateFavicon(ctx context.Context, pageURL string) {
496+ parsedURL, err := neturl.Parse(pageURL)
497+ if err != nil {
498+ return // Silently fail for invalid URLs
499+ }
500+
501+ // Skip favicon update for localhost, file://, or special schemes
502+ if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
503+ return
504+ }
505+ if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
506+ return
507+ }
508+
509+ // Standard favicon location
510+ faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
511+
512+ // Update in database
513+ faviconNullString := sql.NullString{String: faviconURL, Valid: true}
514+ if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
515+ // Silently fail - favicon is not critical
516+ return
517+ }
518+
519+ // Also cache the favicon for dmenu use
520+ if faviconCache, err := cache.NewFaviconCache(); err == nil {
521+ faviconCache.CacheAsync(faviconURL)
522+ }
523+}
@@ -11,6 +11,7 @@ import (
11 neturl "net/url"11 neturl "net/url"
12 "time"12 "time"
13 13
14+ "github.com/bnema/dumber/internal/cache"
14 "github.com/bnema/dumber/internal/config"15 "github.com/bnema/dumber/internal/config"
15 "github.com/bnema/dumber/internal/db"16 "github.com/bnema/dumber/internal/db"
16 "github.com/bnema/dumber/pkg/webkit"17 "github.com/bnema/dumber/pkg/webkit"
@@ -140,6 +141,9 @@ func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationR
140 fmt.Printf("Failed to record history: %v\n", err)141 fmt.Printf("Failed to record history: %v\n", err)
141 }142 }
142 143
144+ // Update favicon asynchronously after navigation
145+ go s.updateFavicon(ctx, url)
146+
143 return &NavigationResult{147 return &NavigationResult{
144 URL: url,148 URL: url,
145 Success: true,149 Success: true,
@@ -486,3 +490,34 @@ func zoomKeyFromURL(raw string) string {
486 490
487 // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.491 // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
488 func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }492 func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
493+
494+// updateFavicon fetches and stores favicon URL for a given page URL
495+func (s *BrowserService) updateFavicon(ctx context.Context, pageURL string) {
496+ parsedURL, err := neturl.Parse(pageURL)
497+ if err != nil {
498+ return // Silently fail for invalid URLs
499+ }
500+
501+ // Skip favicon update for localhost, file://, or special schemes
502+ if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
503+ return
504+ }
505+ if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
506+ return
507+ }
508+
509+ // Standard favicon location
510+ faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
511+
512+ // Update in database
513+ faviconNullString := sql.NullString{String: faviconURL, Valid: true}
514+ if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
515+ // Silently fail - favicon is not critical
516+ return
517+ }
518+
519+ // Also cache the favicon for dmenu use
520+ if faviconCache, err := cache.NewFaviconCache(); err == nil {
521+ faviconCache.CacheAsync(faviconURL)
522+ }
523+}

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