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