main 5041353

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

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