v0.27.2 5960dd8

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/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}

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