v0.22.0 a85da87

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

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