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