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