1package cli
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/spf13/cobra"
13
14 "github.com/bnema/dumber/internal/cache"
15 "github.com/bnema/dumber/internal/config"
16 "github.com/bnema/dumber/internal/services"
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 // Strip scheme from URL (remove https:// or http://)
92 display := entry.URL
93 display = strings.TrimPrefix(display, "https://")
94 display = strings.TrimPrefix(display, "http://")
95 display = truncateString(display, 100)
96
97 // Get favicon URL from cached entry
98 faviconURL := match.Entry.FaviconURL
99
100 options = append(options, DmenuOption{
101 Display: display,
102 Value: entry.URL,
103 Type: historyType,
104 Description: entry.URL,
105 FaviconURL: faviconURL,
106 })
107 }
108
109 // No sorting needed - history entries are already sorted by relevance from cache
110
111 // Output options to stdout with icon specifications
112 for _, option := range options {
113 iconName := getIconName(option, dataDir)
114 if iconName != "" {
115 fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
116 } else {
117 fmt.Println(option.Display)
118 }
119 }
120
121 // Trigger background cache refresh for next time if needed
122 defer cacheManager.OnApplicationExit(ctx)
123
124 return nil
125}
126
127// generateOptionsFallback is the legacy method used as a fallback if fast cache fails
128func generateOptionsFallback(cli *CLI) error {
129 ctx := context.Background()
130
131 // Get data directory for favicon exports
132 dataDir, err := config.GetDataDir()
133 if err != nil {
134 dataDir = "" // Continue without favicons if data dir fails
135 }
136
137 const maxHistoryEntries = 50
138 // Get history entries
139 history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
140 if err != nil {
141 return fmt.Errorf("failed to get history: %w", err)
142 }
143
144 // Get shortcuts from configuration (not database)
145 shortcuts := cli.Config.SearchShortcuts
146
147 // Generate options
148 options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
149
150 // Add direct URL input option
151 options = append(options, DmenuOption{
152 Display: "🌐 Enter URL or search query...",
153 Value: "",
154 Type: "input",
155 Description: "Type any URL or search term",
156 })
157
158 // Add shortcuts with examples
159 for shortcutKey, shortcut := range shortcuts {
160 desc := shortcut.Description
161 if desc == "" {
162 desc = "Custom shortcut"
163 }
164
165 options = append(options, DmenuOption{
166 Display: fmt.Sprintf("🔍 %s: (%s)", shortcutKey, desc),
167 Value: shortcutKey + ":",
168 Type: "shortcut",
169 Description: desc,
170 })
171 }
172
173 // Add history entries
174 for _, entry := range history {
175 title := entry.Url
176 if entry.Title.Valid && entry.Title.String != "" {
177 title = entry.Title.String
178 }
179
180 const maxDisplayLength = 80
181 // Truncate long titles/URLs for display
182 display := truncateString(title, maxDisplayLength)
183
184 options = append(options, DmenuOption{
185 Display: fmt.Sprintf("🕒 %s", display),
186 Value: entry.Url,
187 Type: historyType,
188 Description: entry.Url,
189 })
190 }
191
192 // Sort options: input first, then shortcuts, then history by visit count/recency
193 sort.Slice(options, func(i, j int) bool {
194 if options[i].Type != options[j].Type {
195 // Order: input, shortcut, history
196 typeOrder := map[string]int{"input": 0, "shortcut": 1, historyType: 2} //nolint:mnd
197 return typeOrder[options[i].Type] < typeOrder[options[j].Type]
198 }
199
200 if options[i].Type == historyType && options[j].Type == historyType {
201 // History already sorted by recency from database
202 return i < j
203 }
204
205 return options[i].Display < options[j].Display
206 })
207
208 // Output options to stdout with icon specifications
209 for _, option := range options {
210 iconName := getIconName(option, dataDir)
211 if iconName != "" {
212 fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
213 } else {
214 fmt.Println(option.Display)
215 }
216 }
217
218 return nil
219}
220
221// handleSelection processes the user's selection from the launcher
222func handleSelection(cli *CLI) error {
223 scanner := bufio.NewScanner(os.Stdin)
224 if !scanner.Scan() {
225 return fmt.Errorf("no input received")
226 }
227
228 selection := strings.TrimSpace(scanner.Text())
229 if selection == "" {
230 return fmt.Errorf("empty selection")
231 }
232
233 // Parse the selection to get the actual URL from history entry
234 input := parseSelection(selection)
235
236 if input == "" {
237 return fmt.Errorf("empty input")
238 }
239
240 // Browse the selected/entered URL
241 err := browse(cli, input)
242
243 // After successful browse, invalidate cache so next dmenu shows updated order
244 if err == nil {
245 ctx := context.Background()
246 cacheConfig := cache.DefaultCacheConfig()
247 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
248 cacheManager.InvalidateAndRefresh(ctx)
249 }
250
251 return err
252}
253
254// DmenuOption represents a selectable option in dmenu mode
255type DmenuOption struct {
256 Display string
257 Value string
258 Type string // "input", "shortcut", "history"
259 Description string
260 FaviconURL string // URL to favicon for this entry
261}
262
263// parseSelection extracts the actual URL from a dmenu selection
264func parseSelection(selection string) string {
265 selection = strings.TrimSpace(selection)
266
267 // Strip icon protocol if present (format: "text\0icon\x1ficonname")
268 if iconIndex := strings.Index(selection, "\x00icon\x1f"); iconIndex > 0 {
269 selection = selection[:iconIndex]
270 }
271
272 // 1. Handle new pipe-separated format: "Title | domain.com | full-url"
273 if strings.Contains(selection, " | ") {
274 parts := strings.Split(selection, " | ")
275 if len(parts) >= 3 {
276 // The URL is the last part (third field)
277 return strings.TrimSpace(parts[len(parts)-1])
278 } else if len(parts) == 2 {
279 // Might be "title | url" format, check if second part looks like URL
280 lastPart := strings.TrimSpace(parts[1])
281 if strings.Contains(lastPart, "://") || strings.Contains(lastPart, ".") {
282 return lastPart
283 }
284 }
285 }
286
287 // Legacy format handling for backward compatibility
288 if strings.HasPrefix(selection, "🌐 ") {
289 // Input option selected (shouldn't happen with new format but just in case)
290 return ""
291 }
292
293 if strings.HasPrefix(selection, "🔍 ") {
294 // Shortcut selected - extract the shortcut prefix
295 rest := strings.TrimPrefix(selection, "🔍 ")
296 if idx := strings.Index(rest, ":"); idx > 0 {
297 return rest[:idx+1] // Return "g:" format
298 }
299 }
300
301 // History entry selected - remove emoji prefix if present
302 selection = strings.TrimPrefix(selection, "🕒 ")
303
304 // If no pipes and looks like a URL, return as-is
305 if strings.Contains(selection, "://") {
306 return selection
307 }
308
309 // Fallback: treat as search query
310 return selection
311}
312
313// getIconName determines the appropriate icon name for a dmenu option
314func getIconName(option DmenuOption, dataDir string) string {
315 if option.Type != historyType {
316 return ""
317 }
318
319 // Get exported favicon path from FaviconService
320 // FaviconService exports all favicons as 32x32 PNG files for CLI access
321 if option.Value != "" {
322 faviconPath := services.GetExportedFaviconPath(dataDir, option.Value)
323 // Check if file exists and is recent (within 7 days)
324 if info, err := os.Stat(faviconPath); err == nil {
325 if time.Since(info.ModTime()) < 7*24*time.Hour {
326 return faviconPath
327 }
328 }
329 }
330
331 // No favicon available - return empty string to show no icon
332 return ""
333}
334
335// truncateString truncates a string to the specified length with ellipsis
336func truncateString(s string, maxLen int) string {
337 if len(s) <= maxLen {
338 return s
339 }
340
341 const minEllipsisLength = 3
342 if maxLen <= minEllipsisLength {
343 return s[:maxLen]
344 }
345
346 return s[:maxLen-minEllipsisLength] + "..."
347}