v0.20.0 c741a39

dmenu.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close