detached d6a17f6

fix(dmenu): display URLs and add PNG favicon support for rofi/fuzzel

Fixes two dmenu selection issues:
- Display URLs (scheme-stripped) instead of titles, ensuring rofi/fuzzel
  selection directly returns the usable URL
- Add PNG favicon format support (PNG/SVG only) by introducing
  SanitizeDomainForPNG and PNG path/write methods in favicon service

Simplified parseSelection logic since display now contains the URL directly.
commit
d6a17f6240e91f1cfc911421e5f8d451571b0499
author
bnema · · 2025-12-18 21:42 UTC
parent
3011e5d
signature
unsigned
6 files · +130 -46
M internal/cli/cmd/dmenu.go +27 -41
@@ -77,8 +77,9 @@ func getDmenuConfig() config.DmenuConfig {
7777 return cfg
7878 }
7979
80-// getFaviconPath returns the path to a cached favicon for the given URL.
81-// Returns empty string if the favicon doesn't exist in cache.
80+// getFaviconPath returns the path to a cached PNG favicon for the given URL.
81+// Returns empty string if the PNG favicon doesn't exist in cache.
82+// PNG format is required by rofi/fuzzel launchers.
8283 func getFaviconPath(rawURL string) string {
8384 cacheDir, err := config.GetFaviconCacheDir()
8485 if err != nil {
@@ -88,7 +89,8 @@ func getFaviconPath(rawURL string) string {
8889 if domain == "" {
8990 return ""
9091 }
91- filename := domainurl.SanitizeDomainForFilename(domain)
92+ // Use PNG format for fuzzel/rofi compatibility
93+ filename := domainurl.SanitizeDomainForPNG(domain)
9294 path := filepath.Join(cacheDir, filename)
9395 if _, err := os.Stat(path); err == nil {
9496 return path
@@ -108,34 +110,27 @@ func runDmenuPipe() error {
108110 }
109111
110112 // Output in rofi/fuzzel compatible format
113+ // Display URL (stripped of scheme) so selection directly returns usable URL
111114 for _, entry := range entries {
112- title := entry.Title
113- if title == "" {
114- title = entry.URL
115- }
116-
117- // Truncate long titles
118- if len(title) > 80 {
119- title = title[:77] + "..."
115+ // Strip scheme from URL for cleaner display
116+ display := entry.URL
117+ display = strings.TrimPrefix(display, "https://")
118+ display = strings.TrimPrefix(display, "http://")
119+
120+ // Truncate long URLs
121+ const maxDisplayLen = 100
122+ if len(display) > maxDisplayLen {
123+ display = display[:maxDisplayLen-3] + "..."
120124 }
121125
122126 // Check if we have a favicon
123127 faviconPath := getFaviconPath(entry.URL)
124128
125- // Build display text
126- var displayText string
127- if faviconPath != "" {
128- displayText = title
129- } else {
130- displayText = cfg.HistoryPrefix + " " + title
131- }
132-
133- // Format: "DisplayText\0icon\x1f/path\x1finfo\x1fURL\n"
134- // Using info field to store URL for selection
129+ // Format: "URL\0icon\x1f/path\n" (simple, selection returns URL directly)
135130 if faviconPath != "" {
136- fmt.Printf("%s\x00icon\x1f%s\x1finfo\x1f%s\n", displayText, faviconPath, entry.URL)
131+ fmt.Printf("%s\x00icon\x1f%s\n", display, faviconPath)
137132 } else {
138- fmt.Printf("%s\x00info\x1f%s\n", displayText, entry.URL)
133+ fmt.Println(display)
139134 }
140135 }
141136
@@ -164,32 +159,23 @@ func runDmenuSelect() error {
164159 }
165160
166161 // parseSelection extracts the URL from a dmenu selection.
162+// Selection is the URL (stripped of scheme) as displayed by runDmenuPipe.
167163 func parseSelection(selection string) string {
168164 selection = strings.TrimSpace(selection)
169165
170- // Extract info field if present (format: "text\0...info\x1fURL")
171- if infoIndex := strings.Index(selection, "info\x1f"); infoIndex > 0 {
172- info := selection[infoIndex+5:] // Skip "info\x1f"
173- // Info might have more fields after, but URL should be first
174- if endIndex := strings.Index(info, "\x1f"); endIndex > 0 {
175- return strings.TrimSpace(info[:endIndex])
176- }
177- return strings.TrimSpace(info)
178- }
179-
180- // Strip metadata if present (format: "text\0...")
166+ // Strip metadata if present (format: "url\0icon\x1f...")
181167 if nullIndex := strings.Index(selection, "\x00"); nullIndex > 0 {
182168 selection = selection[:nullIndex]
183169 }
184170
185- // Legacy: strip emoji prefix if present
186- selection = strings.TrimPrefix(selection, "🕒 ")
187- selection = strings.TrimPrefix(selection, "🔍 ")
188- selection = strings.TrimPrefix(selection, "🌐 ")
171+ selection = strings.TrimSpace(selection)
172+ if selection == "" {
173+ return ""
174+ }
189175
190- // If it looks like a URL, return it
191- if strings.Contains(selection, "://") || strings.Contains(selection, ".") {
192- return selection
176+ // Add https:// scheme if not present (display was stripped of scheme)
177+ if !strings.Contains(selection, "://") {
178+ selection = "https://" + selection
193179 }
194180
195181 return selection
@@ -77,8 +77,9 @@ func getDmenuConfig() config.DmenuConfig {
77 return cfg77 return cfg
78 }78 }
79 79
80-// getFaviconPath returns the path to a cached favicon for the given URL.80+// getFaviconPath returns the path to a cached PNG favicon for the given URL.
81-// Returns empty string if the favicon doesn't exist in cache.81+// Returns empty string if the PNG favicon doesn't exist in cache.
82+// PNG format is required by rofi/fuzzel launchers.
82 func getFaviconPath(rawURL string) string {83 func getFaviconPath(rawURL string) string {
83 cacheDir, err := config.GetFaviconCacheDir()84 cacheDir, err := config.GetFaviconCacheDir()
84 if err != nil {85 if err != nil {
@@ -88,7 +89,8 @@ func getFaviconPath(rawURL string) string {
88 if domain == "" {89 if domain == "" {
89 return ""90 return ""
90 }91 }
91- filename := domainurl.SanitizeDomainForFilename(domain)92+ // Use PNG format for fuzzel/rofi compatibility
93+ filename := domainurl.SanitizeDomainForPNG(domain)
92 path := filepath.Join(cacheDir, filename)94 path := filepath.Join(cacheDir, filename)
93 if _, err := os.Stat(path); err == nil {95 if _, err := os.Stat(path); err == nil {
94 return path96 return path
@@ -108,34 +110,27 @@ func runDmenuPipe() error {
108 }110 }
109 111
110 // Output in rofi/fuzzel compatible format112 // Output in rofi/fuzzel compatible format
113+ // Display URL (stripped of scheme) so selection directly returns usable URL
111 for _, entry := range entries {114 for _, entry := range entries {
112- title := entry.Title115+ // Strip scheme from URL for cleaner display
113- if title == "" {116+ display := entry.URL
114- title = entry.URL117+ display = strings.TrimPrefix(display, "https://")
115- }118+ display = strings.TrimPrefix(display, "http://")
116-119+
117- // Truncate long titles120+ // Truncate long URLs
118- if len(title) > 80 {121+ const maxDisplayLen = 100
119- title = title[:77] + "..."122+ if len(display) > maxDisplayLen {
123+ display = display[:maxDisplayLen-3] + "..."
120 }124 }
121 125
122 // Check if we have a favicon126 // Check if we have a favicon
123 faviconPath := getFaviconPath(entry.URL)127 faviconPath := getFaviconPath(entry.URL)
124 128
125- // Build display text129+ // Format: "URL\0icon\x1f/path\n" (simple, selection returns URL directly)
126- var displayText string
127- if faviconPath != "" {
128- displayText = title
129- } else {
130- displayText = cfg.HistoryPrefix + " " + title
131- }
132-
133- // Format: "DisplayText\0icon\x1f/path\x1finfo\x1fURL\n"
134- // Using info field to store URL for selection
135 if faviconPath != "" {130 if faviconPath != "" {
136- fmt.Printf("%s\x00icon\x1f%s\x1finfo\x1f%s\n", displayText, faviconPath, entry.URL)131+ fmt.Printf("%s\x00icon\x1f%s\n", display, faviconPath)
137 } else {132 } else {
138- fmt.Printf("%s\x00info\x1f%s\n", displayText, entry.URL)133+ fmt.Println(display)
139 }134 }
140 }135 }
141 136
@@ -164,32 +159,23 @@ func runDmenuSelect() error {
164 }159 }
165 160
166 // parseSelection extracts the URL from a dmenu selection.161 // parseSelection extracts the URL from a dmenu selection.
162+// Selection is the URL (stripped of scheme) as displayed by runDmenuPipe.
167 func parseSelection(selection string) string {163 func parseSelection(selection string) string {
168 selection = strings.TrimSpace(selection)164 selection = strings.TrimSpace(selection)
169 165
170- // Extract info field if present (format: "text\0...info\x1fURL")166+ // Strip metadata if present (format: "url\0icon\x1f...")
171- if infoIndex := strings.Index(selection, "info\x1f"); infoIndex > 0 {
172- info := selection[infoIndex+5:] // Skip "info\x1f"
173- // Info might have more fields after, but URL should be first
174- if endIndex := strings.Index(info, "\x1f"); endIndex > 0 {
175- return strings.TrimSpace(info[:endIndex])
176- }
177- return strings.TrimSpace(info)
178- }
179-
180- // Strip metadata if present (format: "text\0...")
181 if nullIndex := strings.Index(selection, "\x00"); nullIndex > 0 {167 if nullIndex := strings.Index(selection, "\x00"); nullIndex > 0 {
182 selection = selection[:nullIndex]168 selection = selection[:nullIndex]
183 }169 }
184 170
185- // Legacy: strip emoji prefix if present171+ selection = strings.TrimSpace(selection)
186- selection = strings.TrimPrefix(selection, "🕒 ")172+ if selection == "" {
187- selection = strings.TrimPrefix(selection, "🔍 ")173+ return ""
188- selection = strings.TrimPrefix(selection, "🌐 ")174+ }
189 175
190- // If it looks like a URL, return it176+ // Add https:// scheme if not present (display was stripped of scheme)
191- if strings.Contains(selection, "://") || strings.Contains(selection, ".") {177+ if !strings.Contains(selection, "://") {
192- return selection178+ selection = "https://" + selection
193 }179 }
194 180
195 return selection181 return selection
M internal/domain/service/favicon.go +12 -2
@@ -17,10 +17,20 @@ type FaviconService interface {
1717 // Store saves favicon bytes for a domain to cache.
1818 Store(domain string, data []byte) error
1919
20- // DiskPath returns the filesystem path where a domain's favicon is cached.
21- // Used by CLI tools (rofi/fuzzel) that need file paths for icon display.
20+ // DiskPath returns the filesystem path where a domain's favicon is cached (ICO).
2221 DiskPath(domain string) string
2322
23+ // DiskPathPNG returns the filesystem path for PNG favicon.
24+ // PNG format is required by CLI tools like rofi/fuzzel.
25+ DiskPathPNG(domain string) string
26+
27+ // HasPNGOnDisk checks if a PNG favicon exists on disk for the domain.
28+ HasPNGOnDisk(domain string) bool
29+
30+ // WritePNG writes raw PNG data to disk for a domain.
31+ // Used by UI layer to export WebKit textures for CLI tools.
32+ WritePNG(domain string, pngData []byte)
33+
2434 // Close shuts down background workers and releases resources.
2535 Close()
2636 }
@@ -17,10 +17,20 @@ type FaviconService interface {
17 // Store saves favicon bytes for a domain to cache.17 // Store saves favicon bytes for a domain to cache.
18 Store(domain string, data []byte) error18 Store(domain string, data []byte) error
19 19
20- // DiskPath returns the filesystem path where a domain's favicon is cached.20+ // DiskPath returns the filesystem path where a domain's favicon is cached (ICO).
21- // Used by CLI tools (rofi/fuzzel) that need file paths for icon display.
22 DiskPath(domain string) string21 DiskPath(domain string) string
23 22
23+ // DiskPathPNG returns the filesystem path for PNG favicon.
24+ // PNG format is required by CLI tools like rofi/fuzzel.
25+ DiskPathPNG(domain string) string
26+
27+ // HasPNGOnDisk checks if a PNG favicon exists on disk for the domain.
28+ HasPNGOnDisk(domain string) bool
29+
30+ // WritePNG writes raw PNG data to disk for a domain.
31+ // Used by UI layer to export WebKit textures for CLI tools.
32+ WritePNG(domain string, pngData []byte)
33+
24 // Close shuts down background workers and releases resources.34 // Close shuts down background workers and releases resources.
25 Close()35 Close()
26 }36 }
M internal/domain/url/normalize.go +12 -1
@@ -62,6 +62,17 @@ func ExtractDomain(rawURL string) string {
6262 // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
6363 // Replaces unsafe filesystem characters with underscores.
6464 func SanitizeDomainForFilename(domain string) string {
65+ return sanitizeDomain(domain) + ".ico"
66+}
67+
68+// SanitizeDomainForPNG converts a domain to a safe filename with .png extension.
69+// Used for favicon export for tools like rofi/fuzzel that require PNG format.
70+func SanitizeDomainForPNG(domain string) string {
71+ return sanitizeDomain(domain) + ".png"
72+}
73+
74+// sanitizeDomain replaces unsafe filesystem characters with underscores.
75+func sanitizeDomain(domain string) string {
6576 replacer := strings.NewReplacer(
6677 ":", "_",
6778 "/", "_",
@@ -73,5 +84,5 @@ func SanitizeDomainForFilename(domain string) string {
7384 ">", "_",
7485 "|", "_",
7586 )
76- return replacer.Replace(domain) + ".ico"
87+ return replacer.Replace(domain)
7788 }
@@ -62,6 +62,17 @@ func ExtractDomain(rawURL string) string {
62 // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.62 // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
63 // Replaces unsafe filesystem characters with underscores.63 // Replaces unsafe filesystem characters with underscores.
64 func SanitizeDomainForFilename(domain string) string {64 func SanitizeDomainForFilename(domain string) string {
65+ return sanitizeDomain(domain) + ".ico"
66+}
67+
68+// SanitizeDomainForPNG converts a domain to a safe filename with .png extension.
69+// Used for favicon export for tools like rofi/fuzzel that require PNG format.
70+func SanitizeDomainForPNG(domain string) string {
71+ return sanitizeDomain(domain) + ".png"
72+}
73+
74+// sanitizeDomain replaces unsafe filesystem characters with underscores.
75+func sanitizeDomain(domain string) string {
65 replacer := strings.NewReplacer(76 replacer := strings.NewReplacer(
66 ":", "_",77 ":", "_",
67 "/", "_",78 "/", "_",
@@ -73,5 +84,5 @@ func SanitizeDomainForFilename(domain string) string {
73 ">", "_",84 ">", "_",
74 "|", "_",85 "|", "_",
75 )86 )
76- return replacer.Replace(domain) + ".ico"87+ return replacer.Replace(domain)
77 }88 }
M internal/infrastructure/favicon/cache.go +49 -1
@@ -93,7 +93,7 @@ func (c *Cache) Set(domain string, data []byte) {
9393 c.queueDiskWrite(domain, data)
9494 }
9595
96-// DiskPath returns the filesystem path for a domain's cached favicon.
96+// DiskPath returns the filesystem path for a domain's cached favicon (ICO format).
9797 // Returns empty string if disk caching is disabled or domain is empty.
9898 func (c *Cache) DiskPath(domain string) string {
9999 if c.diskDir == "" || domain == "" {
@@ -103,6 +103,54 @@ func (c *Cache) DiskPath(domain string) string {
103103 return filepath.Join(c.diskDir, filename)
104104 }
105105
106+// DiskPathPNG returns the filesystem path for a domain's PNG favicon.
107+// PNG format is required by external tools like rofi/fuzzel.
108+// Returns empty string if disk caching is disabled or domain is empty.
109+func (c *Cache) DiskPathPNG(domain string) string {
110+ if c.diskDir == "" || domain == "" {
111+ return ""
112+ }
113+ filename := domainurl.SanitizeDomainForPNG(domain)
114+ return filepath.Join(c.diskDir, filename)
115+}
116+
117+// HasPNGOnDisk checks if a PNG favicon exists on disk for the given domain.
118+func (c *Cache) HasPNGOnDisk(domain string) bool {
119+ path := c.DiskPathPNG(domain)
120+ if path == "" {
121+ return false
122+ }
123+ _, err := os.Stat(path)
124+ return err == nil
125+}
126+
127+// WritePNG writes raw PNG data to disk for a domain.
128+// Used by UI layer to export WebKit textures for CLI tools.
129+func (c *Cache) WritePNG(domain string, pngData []byte) {
130+ if c.diskDir == "" || len(pngData) == 0 || domain == "" {
131+ return
132+ }
133+
134+ // Ensure directory exists
135+ if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
136+ return
137+ }
138+
139+ filename := domainurl.SanitizeDomainForPNG(domain)
140+ finalPath := filepath.Join(c.diskDir, filename)
141+ tempPath := finalPath + ".tmp"
142+
143+ // Write to temp file
144+ if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil {
145+ return
146+ }
147+
148+ // Atomic rename
149+ if err := os.Rename(tempPath, finalPath); err != nil {
150+ _ = os.Remove(tempPath)
151+ }
152+}
153+
106154 // HasOnDisk checks if a favicon exists on disk for the given domain.
107155 func (c *Cache) HasOnDisk(domain string) bool {
108156 path := c.DiskPath(domain)
@@ -93,7 +93,7 @@ func (c *Cache) Set(domain string, data []byte) {
93 c.queueDiskWrite(domain, data)93 c.queueDiskWrite(domain, data)
94 }94 }
95 95
96-// DiskPath returns the filesystem path for a domain's cached favicon.96+// DiskPath returns the filesystem path for a domain's cached favicon (ICO format).
97 // Returns empty string if disk caching is disabled or domain is empty.97 // Returns empty string if disk caching is disabled or domain is empty.
98 func (c *Cache) DiskPath(domain string) string {98 func (c *Cache) DiskPath(domain string) string {
99 if c.diskDir == "" || domain == "" {99 if c.diskDir == "" || domain == "" {
@@ -103,6 +103,54 @@ func (c *Cache) DiskPath(domain string) string {
103 return filepath.Join(c.diskDir, filename)103 return filepath.Join(c.diskDir, filename)
104 }104 }
105 105
106+// DiskPathPNG returns the filesystem path for a domain's PNG favicon.
107+// PNG format is required by external tools like rofi/fuzzel.
108+// Returns empty string if disk caching is disabled or domain is empty.
109+func (c *Cache) DiskPathPNG(domain string) string {
110+ if c.diskDir == "" || domain == "" {
111+ return ""
112+ }
113+ filename := domainurl.SanitizeDomainForPNG(domain)
114+ return filepath.Join(c.diskDir, filename)
115+}
116+
117+// HasPNGOnDisk checks if a PNG favicon exists on disk for the given domain.
118+func (c *Cache) HasPNGOnDisk(domain string) bool {
119+ path := c.DiskPathPNG(domain)
120+ if path == "" {
121+ return false
122+ }
123+ _, err := os.Stat(path)
124+ return err == nil
125+}
126+
127+// WritePNG writes raw PNG data to disk for a domain.
128+// Used by UI layer to export WebKit textures for CLI tools.
129+func (c *Cache) WritePNG(domain string, pngData []byte) {
130+ if c.diskDir == "" || len(pngData) == 0 || domain == "" {
131+ return
132+ }
133+
134+ // Ensure directory exists
135+ if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
136+ return
137+ }
138+
139+ filename := domainurl.SanitizeDomainForPNG(domain)
140+ finalPath := filepath.Join(c.diskDir, filename)
141+ tempPath := finalPath + ".tmp"
142+
143+ // Write to temp file
144+ if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil {
145+ return
146+ }
147+
148+ // Atomic rename
149+ if err := os.Rename(tempPath, finalPath); err != nil {
150+ _ = os.Remove(tempPath)
151+ }
152+}
153+
106 // HasOnDisk checks if a favicon exists on disk for the given domain.154 // HasOnDisk checks if a favicon exists on disk for the given domain.
107 func (c *Cache) HasOnDisk(domain string) bool {155 func (c *Cache) HasOnDisk(domain string) bool {
108 path := c.DiskPath(domain)156 path := c.DiskPath(domain)
M internal/infrastructure/favicon/service.go +16 -1
@@ -59,11 +59,26 @@ func (s *Service) Store(domain string, data []byte) error {
5959 return nil
6060 }
6161
62-// DiskPath returns the filesystem path where a domain's favicon is cached.
62+// DiskPath returns the filesystem path where a domain's favicon is cached (ICO).
6363 func (s *Service) DiskPath(domain string) string {
6464 return s.cache.DiskPath(domain)
6565 }
6666
67+// DiskPathPNG returns the filesystem path for PNG favicon.
68+func (s *Service) DiskPathPNG(domain string) string {
69+ return s.cache.DiskPathPNG(domain)
70+}
71+
72+// HasPNGOnDisk checks if a PNG favicon exists on disk for the domain.
73+func (s *Service) HasPNGOnDisk(domain string) bool {
74+ return s.cache.HasPNGOnDisk(domain)
75+}
76+
77+// WritePNG writes raw PNG data to disk for a domain.
78+func (s *Service) WritePNG(domain string, pngData []byte) {
79+ s.cache.WritePNG(domain, pngData)
80+}
81+
6782 // Close shuts down background workers and releases resources.
6883 func (s *Service) Close() {
6984 s.cache.Close()
@@ -59,11 +59,26 @@ func (s *Service) Store(domain string, data []byte) error {
59 return nil59 return nil
60 }60 }
61 61
62-// DiskPath returns the filesystem path where a domain's favicon is cached.62+// DiskPath returns the filesystem path where a domain's favicon is cached (ICO).
63 func (s *Service) DiskPath(domain string) string {63 func (s *Service) DiskPath(domain string) string {
64 return s.cache.DiskPath(domain)64 return s.cache.DiskPath(domain)
65 }65 }
66 66
67+// DiskPathPNG returns the filesystem path for PNG favicon.
68+func (s *Service) DiskPathPNG(domain string) string {
69+ return s.cache.DiskPathPNG(domain)
70+}
71+
72+// HasPNGOnDisk checks if a PNG favicon exists on disk for the domain.
73+func (s *Service) HasPNGOnDisk(domain string) bool {
74+ return s.cache.HasPNGOnDisk(domain)
75+}
76+
77+// WritePNG writes raw PNG data to disk for a domain.
78+func (s *Service) WritePNG(domain string, pngData []byte) {
79+ s.cache.WritePNG(domain, pngData)
80+}
81+
67 // Close shuts down background workers and releases resources.82 // Close shuts down background workers and releases resources.
68 func (s *Service) Close() {83 func (s *Service) Close() {
69 s.cache.Close()84 s.cache.Close()
M internal/ui/adapter/favicon.go +14 -0
@@ -151,6 +151,13 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
151151 // Store in texture cache
152152 a.setTexture(domain, texture)
153153
154+ // Export as PNG for CLI tools (rofi/fuzzel) - do this on main thread
155+ // since GTK texture operations need to happen there
156+ pngPath := a.service.DiskPathPNG(domain)
157+ if pngPath != "" && !a.service.HasPNGOnDisk(domain) {
158+ texture.SaveToPng(pngPath)
159+ }
160+
154161 // Ensure disk cache is populated (async, in background)
155162 go a.service.EnsureDiskCache(ctx, domain)
156163 }
@@ -168,6 +175,13 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
168175 currentDomain := domainurl.ExtractDomain(currentURL)
169176 if originDomain != "" && originDomain != currentDomain {
170177 a.setTexture(originDomain, texture)
178+
179+ // Export PNG for origin domain too
180+ pngPath := a.service.DiskPathPNG(originDomain)
181+ if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) {
182+ texture.SaveToPng(pngPath)
183+ }
184+
171185 go a.service.EnsureDiskCache(ctx, originDomain)
172186 }
173187 }
@@ -151,6 +151,13 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
151 // Store in texture cache151 // Store in texture cache
152 a.setTexture(domain, texture)152 a.setTexture(domain, texture)
153 153
154+ // Export as PNG for CLI tools (rofi/fuzzel) - do this on main thread
155+ // since GTK texture operations need to happen there
156+ pngPath := a.service.DiskPathPNG(domain)
157+ if pngPath != "" && !a.service.HasPNGOnDisk(domain) {
158+ texture.SaveToPng(pngPath)
159+ }
160+
154 // Ensure disk cache is populated (async, in background)161 // Ensure disk cache is populated (async, in background)
155 go a.service.EnsureDiskCache(ctx, domain)162 go a.service.EnsureDiskCache(ctx, domain)
156 }163 }
@@ -168,6 +175,13 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
168 currentDomain := domainurl.ExtractDomain(currentURL)175 currentDomain := domainurl.ExtractDomain(currentURL)
169 if originDomain != "" && originDomain != currentDomain {176 if originDomain != "" && originDomain != currentDomain {
170 a.setTexture(originDomain, texture)177 a.setTexture(originDomain, texture)
178+
179+ // Export PNG for origin domain too
180+ pngPath := a.service.DiskPathPNG(originDomain)
181+ if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) {
182+ texture.SaveToPng(pngPath)
183+ }
184+
171 go a.service.EnsureDiskCache(ctx, originDomain)185 go a.service.EnsureDiskCache(ctx, originDomain)
172 }186 }
173 }187 }

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