1package cli
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "sort"
9 "strings"
10
11 "github.com/bnema/dumber/internal/cache"
12 "github.com/spf13/cobra"
13)
14
15// NewDmenuCmd creates the dmenu command for launcher integration
16func NewDmenuCmd() *cobra.Command {
17 cmd := &cobra.Command{
18 Use: "dmenu",
19 Short: "Run in dmenu mode for launcher integration",
20 Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers.
21This mode reads from stdin and outputs selectable options to stdout.
22
23Usage with rofi:
24 dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
25
26Usage with dmenu:
27 dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`,
28 RunE: func(cmd *cobra.Command, _ []string) error {
29 selectFlag := cmd.Flag("select").Changed
30
31 cli, err := NewCLI()
32 if err != nil {
33 return fmt.Errorf("failed to initialize CLI: %w", err)
34 }
35 defer func() {
36 if closeErr := cli.Close(); closeErr != nil {
37 fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
38 }
39 }()
40
41 if selectFlag {
42 return handleSelection(cli)
43 }
44 return generateOptions(cli)
45 },
46 }
47
48 cmd.Flags().Bool("select", false, "Process selection from launcher (reads from stdin)")
49 return cmd
50}
51
52// generateOptions outputs all available options for the launcher using fuzzy cache
53func generateOptions(cli *CLI) error {
54 ctx := context.Background()
55
56 // Initialize cache manager
57 cacheConfig := cache.DefaultCacheConfig()
58 cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries
59 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
60
61 // Get top entries from cache (this is blazingly fast!)
62 result, err := cacheManager.GetTopEntries(ctx)
63 if err != nil {
64 // Fallback to old method if cache fails
65 return generateOptionsFallback(cli)
66 }
67
68 // Get shortcuts for display
69 shortcuts, err := cli.Queries.GetShortcuts(ctx)
70 if err != nil {
71 return fmt.Errorf("failed to get shortcuts: %w", err)
72 }
73
74 // Generate options
75 options := make([]DmenuOption, 0, len(result.Matches)+len(shortcuts)+1)
76
77 // Add direct URL input option
78 options = append(options, DmenuOption{
79 Display: "π Enter URL or search query...",
80 Value: "",
81 Type: "input",
82 Description: "Type any URL or search term",
83 })
84
85 // Add shortcuts with examples
86 for _, shortcut := range shortcuts {
87 desc := shortcut.Description.String
88 if !shortcut.Description.Valid {
89 desc = "Custom shortcut"
90 }
91
92 options = append(options, DmenuOption{
93 Display: fmt.Sprintf("π %s: (%s)", shortcut.Shortcut, desc),
94 Value: shortcut.Shortcut + ":",
95 Type: "shortcut",
96 Description: desc,
97 })
98 }
99
100 // Add history entries from cache (already sorted by relevance!)
101 for _, match := range result.Matches {
102 entry := match.Entry
103 title := entry.URL
104 if entry.Title != "" {
105 title = entry.Title
106 }
107
108 const maxDisplayLength = 80
109 // Truncate long titles/URLs for display
110 display := truncateString(title, maxDisplayLength)
111
112 options = append(options, DmenuOption{
113 Display: fmt.Sprintf("π %s", display),
114 Value: entry.URL,
115 Type: "history",
116 Description: entry.URL,
117 })
118 }
119
120 // Sort options: input first, then shortcuts, then history (already sorted by cache)
121 sort.Slice(options, func(i, j int) bool {
122 if options[i].Type != options[j].Type {
123 // Order: input, shortcut, history
124 typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd
125 return typeOrder[options[i].Type] < typeOrder[options[j].Type]
126 }
127
128 if options[i].Type == "history" && options[j].Type == "history" {
129 // History already sorted by cache relevance score
130 return i < j
131 }
132
133 return options[i].Display < options[j].Display
134 })
135
136 // Output options to stdout
137 for _, option := range options {
138 fmt.Println(option.Display)
139 }
140
141 // Trigger background cache refresh for next time if needed
142 defer cacheManager.OnApplicationExit(ctx)
143
144 return nil
145}
146
147// generateOptionsFallback is the old method used as a fallback if cache fails
148func generateOptionsFallback(cli *CLI) error {
149 ctx := context.Background()
150
151 const maxHistoryEntries = 50
152 // Get history entries
153 history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
154 if err != nil {
155 return fmt.Errorf("failed to get history: %w", err)
156 }
157
158 // Get shortcuts
159 shortcuts, err := cli.Queries.GetShortcuts(ctx)
160 if err != nil {
161 return fmt.Errorf("failed to get shortcuts: %w", err)
162 }
163
164 // Generate options
165 options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
166
167 // Add direct URL input option
168 options = append(options, DmenuOption{
169 Display: "π Enter URL or search query...",
170 Value: "",
171 Type: "input",
172 Description: "Type any URL or search term",
173 })
174
175 // Add shortcuts with examples
176 for _, shortcut := range shortcuts {
177 desc := shortcut.Description.String
178 if !shortcut.Description.Valid {
179 desc = "Custom shortcut"
180 }
181
182 options = append(options, DmenuOption{
183 Display: fmt.Sprintf("π %s: (%s)", shortcut.Shortcut, desc),
184 Value: shortcut.Shortcut + ":",
185 Type: "shortcut",
186 Description: desc,
187 })
188 }
189
190 // Add history entries
191 for _, entry := range history {
192 title := entry.Url
193 if entry.Title.Valid && entry.Title.String != "" {
194 title = entry.Title.String
195 }
196
197 const maxDisplayLength = 80
198 // Truncate long titles/URLs for display
199 display := truncateString(title, maxDisplayLength)
200
201 options = append(options, DmenuOption{
202 Display: fmt.Sprintf("π %s", display),
203 Value: entry.Url,
204 Type: "history",
205 Description: entry.Url,
206 })
207 }
208
209 // Sort options: input first, then shortcuts, then history by visit count/recency
210 sort.Slice(options, func(i, j int) bool {
211 if options[i].Type != options[j].Type {
212 // Order: input, shortcut, history
213 typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd
214 return typeOrder[options[i].Type] < typeOrder[options[j].Type]
215 }
216
217 if options[i].Type == "history" && options[j].Type == "history" {
218 // History already sorted by recency from database
219 return i < j
220 }
221
222 return options[i].Display < options[j].Display
223 })
224
225 // Output options to stdout
226 for _, option := range options {
227 fmt.Println(option.Display)
228 }
229
230 return nil
231}
232
233// handleSelection processes the user's selection from the launcher
234func handleSelection(cli *CLI) error {
235 scanner := bufio.NewScanner(os.Stdin)
236 if !scanner.Scan() {
237 return fmt.Errorf("no input received")
238 }
239
240 selection := strings.TrimSpace(scanner.Text())
241 if selection == "" {
242 return fmt.Errorf("empty selection")
243 }
244
245 // Parse the selection to get the actual input
246 input := parseSelection(selection)
247
248 // If it's just the input prompt, read another line for the actual input
249 if input == "" || input == "Enter URL or search query..." {
250 fmt.Print("Enter URL or search query: ")
251 if !scanner.Scan() {
252 return fmt.Errorf("no input received")
253 }
254 input = strings.TrimSpace(scanner.Text())
255 }
256
257 if input == "" {
258 return fmt.Errorf("empty input")
259 }
260
261 // Browse the selected/entered URL
262 return browse(cli, input)
263}
264
265// DmenuOption represents a selectable option in dmenu mode
266type DmenuOption struct {
267 Display string
268 Value string
269 Type string // "input", "shortcut", "history"
270 Description string
271}
272
273// parseSelection extracts the actual URL/query from a dmenu selection
274func parseSelection(selection string) string {
275 // Remove emoji prefixes and clean up the selection
276 selection = strings.TrimSpace(selection)
277
278 // Handle different option types
279 if strings.HasPrefix(selection, "π ") {
280 // Input option selected
281 return ""
282 }
283
284 if strings.HasPrefix(selection, "π ") {
285 // Shortcut selected - extract the shortcut prefix
286 rest := strings.TrimPrefix(selection, "π ")
287 if idx := strings.Index(rest, ":"); idx > 0 {
288 return rest[:idx+1] // Return "g:" format
289 }
290 }
291
292 if strings.HasPrefix(selection, "π ") {
293 // History entry selected - this is the display title, need to extract URL
294 // For now, we'll assume the display is the URL or close enough
295 return strings.TrimPrefix(selection, "π ")
296 }
297
298 // Fallback: return the selection as-is
299 return selection
300}
301
302// truncateString truncates a string to the specified length with ellipsis
303func truncateString(s string, maxLen int) string {
304 if len(s) <= maxLen {
305 return s
306 }
307
308 const minEllipsisLength = 3
309 if maxLen <= minEllipsisLength {
310 return s[:maxLen]
311 }
312
313 return s[:maxLen-minEllipsisLength] + "..."
314}