1package cli
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "net/url"
8 "os"
9 "sort"
10 "strings"
11
12 "github.com/bnema/dumber/internal/cache"
13 "github.com/spf13/cobra"
14)
15
16const (
17 historyType = "history"
18)
19
20// NewDmenuCmd creates the dmenu command for launcher integration
21func NewDmenuCmd() *cobra.Command {
22 cmd := &cobra.Command{
23 Use: "dmenu",
24 Short: "Fast fuzzy history browser for launcher integration",
25 Long: `Fast dmenu mode showing cached browsing history with fuzzy search.
26Uses binary tree cache for sub-millisecond performance.
27
28Usage with rofi:
29 dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
30
31Usage with fuzzel:
32 dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`,
33 RunE: func(cmd *cobra.Command, _ []string) error {
34 selectFlag := cmd.Flag("select").Changed
35
36 cli, err := NewCLI()
37 if err != nil {
38 return fmt.Errorf("failed to initialize CLI: %w", err)
39 }
40 defer func() {
41 if closeErr := cli.Close(); closeErr != nil {
42 fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
43 }
44 }()
45
46 if selectFlag {
47 return handleSelection(cli)
48 }
49 return generateOptions(cli)
50 },
51 }
52
53 cmd.Flags().Bool("select", false, "Process selection from launcher (reads from stdin)")
54 return cmd
55}
56
57// generateOptions outputs all available options for the launcher using fuzzy cache
58func generateOptions(cli *CLI) error {
59 ctx := context.Background()
60
61 // Initialize cache manager
62 cacheConfig := cache.DefaultCacheConfig()
63 cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries
64 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
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
73 // Get top entries from cache (this is blazingly fast!)
74 result, err := cacheManager.GetTopEntries(ctx)
75 if err != nil {
76 // Fallback to old method if cache fails
77 return generateOptionsFallback(cli)
78 }
79
80 // Fast dmenu mode: only shows cached history entries sorted by relevance
81
82 // Generate options - only show history entries from fast cache
83 options := make([]DmenuOption, 0, len(result.Matches))
84
85 // Add history entries from cache (already sorted by relevance!)
86 for _, match := range result.Matches {
87 entry := match.Entry
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
96 }
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
129 options = append(options, DmenuOption{
130 Display: display,
131 Value: entry.URL,
132 Type: historyType,
133 Description: entry.URL,
134 FaviconURL: faviconURL,
135 })
136 }
137
138 // No sorting needed - history entries are already sorted by relevance from cache
139
140 // Output options to stdout with icon specifications
141 for _, option := range options {
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 }
148 }
149
150 // Trigger background cache refresh for next time if needed
151 defer cacheManager.OnApplicationExit(ctx)
152
153 return nil
154}
155
156// generateOptionsFallback is the legacy method used as a fallback if fast cache fails
157func generateOptionsFallback(cli *CLI) error {
158 ctx := context.Background()
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
167 const maxHistoryEntries = 50
168 // Get history entries
169 history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
170 if err != nil {
171 return fmt.Errorf("failed to get history: %w", err)
172 }
173
174 // Get shortcuts
175 shortcuts, err := cli.Queries.GetShortcuts(ctx)
176 if err != nil {
177 return fmt.Errorf("failed to get shortcuts: %w", err)
178 }
179
180 // Generate options
181 options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
182
183 // Add direct URL input option
184 options = append(options, DmenuOption{
185 Display: "π Enter URL or search query...",
186 Value: "",
187 Type: "input",
188 Description: "Type any URL or search term",
189 })
190
191 // Add shortcuts with examples
192 for _, shortcut := range shortcuts {
193 desc := shortcut.Description.String
194 if !shortcut.Description.Valid {
195 desc = "Custom shortcut"
196 }
197
198 options = append(options, DmenuOption{
199 Display: fmt.Sprintf("π %s: (%s)", shortcut.Shortcut, desc),
200 Value: shortcut.Shortcut + ":",
201 Type: "shortcut",
202 Description: desc,
203 })
204 }
205
206 // Add history entries
207 for _, entry := range history {
208 title := entry.Url
209 if entry.Title.Valid && entry.Title.String != "" {
210 title = entry.Title.String
211 }
212
213 const maxDisplayLength = 80
214 // Truncate long titles/URLs for display
215 display := truncateString(title, maxDisplayLength)
216
217 options = append(options, DmenuOption{
218 Display: fmt.Sprintf("π %s", display),
219 Value: entry.Url,
220 Type: historyType,
221 Description: entry.Url,
222 })
223 }
224
225 // Sort options: input first, then shortcuts, then history by visit count/recency
226 sort.Slice(options, func(i, j int) bool {
227 if options[i].Type != options[j].Type {
228 // Order: input, shortcut, history
229 typeOrder := map[string]int{"input": 0, "shortcut": 1, historyType: 2} //nolint:mnd
230 return typeOrder[options[i].Type] < typeOrder[options[j].Type]
231 }
232
233 if options[i].Type == historyType && options[j].Type == historyType {
234 // History already sorted by recency from database
235 return i < j
236 }
237
238 return options[i].Display < options[j].Display
239 })
240
241 // Output options to stdout with icon specifications
242 for _, option := range options {
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 }
249 }
250
251 return nil
252}
253
254// handleSelection processes the user's selection from the launcher
255func handleSelection(cli *CLI) error {
256 scanner := bufio.NewScanner(os.Stdin)
257 if !scanner.Scan() {
258 return fmt.Errorf("no input received")
259 }
260
261 selection := strings.TrimSpace(scanner.Text())
262 if selection == "" {
263 return fmt.Errorf("empty selection")
264 }
265
266 // Parse the selection to get the actual URL from history entry
267 input := parseSelection(selection)
268
269 if input == "" {
270 return fmt.Errorf("empty input")
271 }
272
273 // Browse the selected/entered URL
274 err := browse(cli, input)
275
276 // After successful browse, invalidate cache so next dmenu shows updated order
277 if err == nil {
278 ctx := context.Background()
279 cacheConfig := cache.DefaultCacheConfig()
280 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
281 cacheManager.InvalidateAndRefresh(ctx)
282 }
283
284 return err
285}
286
287// DmenuOption represents a selectable option in dmenu mode
288type DmenuOption struct {
289 Display string
290 Value string
291 Type string // "input", "shortcut", "history"
292 Description string
293 FaviconURL string // URL to favicon for this entry
294}
295
296// parseSelection extracts the actual URL from a dmenu selection
297func parseSelection(selection string) string {
298 selection = strings.TrimSpace(selection)
299
300 // Strip icon protocol if present (format: "text\0icon\x1ficonname")
301 if iconIndex := strings.Index(selection, "\x00icon\x1f"); iconIndex > 0 {
302 selection = selection[:iconIndex]
303 }
304
305 // 1. Handle new pipe-separated format: "Title | domain.com | full-url"
306 if strings.Contains(selection, " | ") {
307 parts := strings.Split(selection, " | ")
308 if len(parts) >= 3 {
309 // The URL is the last part (third field)
310 return strings.TrimSpace(parts[len(parts)-1])
311 } else if len(parts) == 2 {
312 // Might be "title | url" format, check if second part looks like URL
313 lastPart := strings.TrimSpace(parts[1])
314 if strings.Contains(lastPart, "://") || strings.Contains(lastPart, ".") {
315 return lastPart
316 }
317 }
318 }
319
320 // Legacy format handling for backward compatibility
321 if strings.HasPrefix(selection, "π ") {
322 // Input option selected (shouldn't happen with new format but just in case)
323 return ""
324 }
325
326 if strings.HasPrefix(selection, "π ") {
327 // Shortcut selected - extract the shortcut prefix
328 rest := strings.TrimPrefix(selection, "π ")
329 if idx := strings.Index(rest, ":"); idx > 0 {
330 return rest[:idx+1] // Return "g:" format
331 }
332 }
333
334 if strings.HasPrefix(selection, "π ") {
335 // History entry selected - remove emoji prefix
336 selection = strings.TrimPrefix(selection, "π ")
337 }
338
339 // If no pipes and looks like a URL, return as-is
340 if strings.Contains(selection, "://") {
341 return selection
342 }
343
344 // Fallback: treat as search query
345 return selection
346}
347
348// getIconName determines the appropriate icon name for a dmenu option
349func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string {
350 if option.Type != historyType {
351 return ""
352 }
353
354 // Only use cached favicons - no system theme fallbacks for consistent sizing
355 if option.FaviconURL != "" && faviconCache != nil {
356 cachedPath := faviconCache.GetCachedPath(option.FaviconURL)
357 if cachedPath != "" {
358 return cachedPath
359 }
360 // Start async download for next time
361 faviconCache.CacheAsync(option.FaviconURL)
362 }
363
364 // No favicon available - return empty string to show no icon
365 return ""
366}
367
368// truncateString truncates a string to the specified length with ellipsis
369func truncateString(s string, maxLen int) string {
370 if len(s) <= maxLen {
371 return s
372 }
373
374 const minEllipsisLength = 3
375 if maxLen <= minEllipsisLength {
376 return s[:maxLen]
377 }
378
379 return s[:maxLen-minEllipsisLength] + "..."
380}