1package cmd
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "os/exec"
9 "strings"
10
11 tea "github.com/charmbracelet/bubbletea"
12 "github.com/spf13/cobra"
13
14 "github.com/bnema/dumber/internal/cli/model"
15 domainurl "github.com/bnema/dumber/internal/domain/url"
16 "github.com/bnema/dumber/internal/infrastructure/config"
17 "github.com/bnema/dumber/internal/logging"
18)
19
20var (
21 dmenuInteractive bool
22 dmenuSelect bool
23 dmenuMax int
24)
25
26var dmenuCmd = &cobra.Command{
27 Use: "dmenu",
28 Short: "Launcher integration for rofi/fuzzel",
29 Long: `Output history for use with rofi, fuzzel, or other launchers.
30
31Default mode outputs entries for piping:
32 dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
33
34Interactive mode provides a built-in TUI fuzzy finder:
35 dumber dmenu --interactive`,
36 RunE: runDmenu,
37}
38
39func init() {
40 rootCmd.AddCommand(dmenuCmd)
41
42 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
43 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")
44 dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)")
45}
46
47func runDmenu(_ *cobra.Command, _ []string) error {
48 app := GetApp()
49 if app == nil {
50 return fmt.Errorf("app not initialized")
51 }
52
53 // Selection mode: read URL from stdin and open it
54 if dmenuSelect {
55 return runDmenuSelect()
56 }
57
58 // Interactive TUI mode
59 if dmenuInteractive {
60 return runDmenuInteractive()
61 }
62
63 // Pipe mode: output entries for rofi/fuzzel
64 return runDmenuPipe()
65}
66
67// getDmenuConfig returns the dmenu configuration, with CLI flag overrides.
68func getDmenuConfig() config.DmenuConfig {
69 app := GetApp()
70 cfg := app.Config.Dmenu
71
72 // Override max if explicitly set via CLI flag
73 if dmenuMax > 0 {
74 cfg.MaxHistoryItems = dmenuMax
75 }
76
77 return cfg
78}
79
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// Prefers sized (32x32) PNG for consistent display in fuzzel/rofi.
83// Will create the sized PNG on-demand if only the original exists.
84func getFaviconPath(rawURL string) string {
85 app := GetApp()
86 if app == nil || app.FaviconService == nil {
87 return ""
88 }
89
90 domain := domainurl.ExtractDomain(rawURL)
91 if domain == "" {
92 return ""
93 }
94
95 ctx := app.Ctx()
96 log := logging.FromContext(ctx)
97 const normalizedSize = 32
98
99 // Check if sized PNG already exists
100 if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {
101 return app.FaviconService.DiskPathPNGSized(domain, normalizedSize)
102 }
103
104 // If original PNG exists but sized doesn't, create it on-demand
105 if app.FaviconService.HasPNGOnDisk(domain) {
106 log.Debug().Str("domain", domain).Msg("creating sized PNG on-demand from dmenu")
107 if err := app.FaviconService.EnsureSizedPNG(ctx, domain, normalizedSize); err != nil {
108 log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")
109 return ""
110 }
111 log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
112 return app.FaviconService.DiskPathPNGSized(domain, normalizedSize)
113 }
114
115 // No PNG at all for this domain
116 log.Debug().Str("domain", domain).Msg("no favicon PNG found for domain")
117 return ""
118}
119
120// runDmenuPipe outputs history entries for launcher consumption.
121func runDmenuPipe() error {
122 app := GetApp()
123 ctx := context.Background()
124 cfg := getDmenuConfig()
125
126 entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0)
127 if err != nil {
128 return fmt.Errorf("get history: %w", err)
129 }
130
131 // Output in rofi/fuzzel compatible format
132 // Display URL (stripped of scheme) so selection directly returns usable URL
133 for _, entry := range entries {
134 // Strip scheme from URL for cleaner display
135 display := entry.URL
136 display = strings.TrimPrefix(display, "https://")
137 display = strings.TrimPrefix(display, "http://")
138
139 // Truncate long URLs
140 const maxDisplayLen = 100
141 if len(display) > maxDisplayLen {
142 display = display[:maxDisplayLen-3] + "..."
143 }
144
145 // Check if we have a favicon
146 faviconPath := getFaviconPath(entry.URL)
147
148 // Format: "URL\0icon\x1f/path\n" (simple, selection returns URL directly)
149 // Note: Icon size is controlled by rofi theme (element-icon { size: X; })
150 if faviconPath != "" {
151 fmt.Printf("%s\x00icon\x1f%s\n", display, faviconPath)
152 } else {
153 fmt.Println(display)
154 }
155 }
156
157 return nil
158}
159
160// runDmenuSelect processes a selection from stdin.
161func runDmenuSelect() error {
162 reader := bufio.NewReader(os.Stdin)
163 line, err := reader.ReadString('\n')
164 if err != nil {
165 return fmt.Errorf("read selection: %w", err)
166 }
167
168 line = strings.TrimSpace(line)
169 if line == "" {
170 return nil // No selection
171 }
172
173 url := parseSelection(line)
174 if url != "" {
175 return openInBrowser(url)
176 }
177
178 return nil
179}
180
181// parseSelection extracts the URL from a dmenu selection.
182// Selection is the URL (stripped of scheme) as displayed by runDmenuPipe,
183// or a bang shortcut like "!g query" for search.
184func parseSelection(selection string) string {
185 selection = strings.TrimSpace(selection)
186
187 // Strip metadata if present (format: "url\0icon\x1f...")
188 if nullIndex := strings.Index(selection, "\x00"); nullIndex > 0 {
189 selection = selection[:nullIndex]
190 }
191
192 selection = strings.TrimSpace(selection)
193 if selection == "" {
194 return ""
195 }
196
197 // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries
198 app := GetApp()
199 if app != nil {
200 return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine)
201 }
202
203 // Fallback: add https:// scheme if no app context
204 if !strings.Contains(selection, "://") {
205 selection = "https://" + selection
206 }
207
208 return selection
209}
210
211// runDmenuInteractive runs the interactive TUI dmenu.
212func runDmenuInteractive() error {
213 app := GetApp()
214
215 m := model.NewDmenuModel(app.Theme, app.SearchHistoryUC)
216
217 p := tea.NewProgram(m, tea.WithAltScreen())
218 finalModel, err := p.Run()
219 if err != nil {
220 return err
221 }
222
223 // If user selected an entry, open it
224 dm, ok := finalModel.(model.DmenuModel)
225 if ok && dm.SelectedURL() != "" {
226 return openInBrowser(dm.SelectedURL())
227 }
228
229 return nil
230}
231
232// openInBrowser opens a URL in dumber browser.
233func openInBrowser(url string) error {
234 // Use the current executable to open in dumber
235 executable, err := os.Executable()
236 if err != nil {
237 // Fallback to searching PATH
238 executable = "dumber"
239 }
240 cmd := newCommand(executable, "browse", url)
241 return cmd.Start()
242}
243
244// newCommand creates a command (abstracted for testing).
245var newCommand = func(name string, args ...string) interface{ Start() error } {
246 return &commandWrapper{name: name, args: args}
247}
248
249type commandWrapper struct {
250 name string
251 args []string
252}
253
254func (c *commandWrapper) Start() error {
255 cmd := exec.Command(c.name, c.args...)
256 return cmd.Start()
257}