v0.6.0 5c50dd3

dmenu.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"fmt"
7	"net/url"
8	"os"
9	"sort"
10	"strings"
11
12	"github.com/bnema/dumber/internal/cache"
13	"github.com/spf13/cobra"
14)
15
16const (
17	historyType = "history"
18)
19
20// NewDmenuCmd creates the dmenu command for launcher integration
21func NewDmenuCmd() *cobra.Command {
22	cmd := &cobra.Command{
23		Use:   "dmenu",
24		Short: "Fast fuzzy history browser for launcher integration",
25		Long: `Fast dmenu mode showing cached browsing history with fuzzy search.
26Uses binary tree cache for sub-millisecond performance.
27
28Usage with rofi:
29  dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
30
31Usage with fuzzel:
32  dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`,
33		RunE: func(cmd *cobra.Command, _ []string) error {
34			selectFlag := cmd.Flag("select").Changed
35
36			cli, err := NewCLI()
37			if err != nil {
38				return fmt.Errorf("failed to initialize CLI: %w", err)
39			}
40			defer func() {
41				if closeErr := cli.Close(); closeErr != nil {
42					fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
43				}
44			}()
45
46			if selectFlag {
47				return handleSelection(cli)
48			}
49			return generateOptions(cli)
50		},
51	}
52
53	cmd.Flags().Bool("select", false, "Process selection from launcher (reads from stdin)")
54	return cmd
55}
56
57// generateOptions outputs all available options for the launcher using fuzzy cache
58func generateOptions(cli *CLI) error {
59	ctx := context.Background()
60
61	// Initialize cache manager
62	cacheConfig := cache.DefaultCacheConfig()
63	cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries
64	cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
65
66	// Initialize favicon cache
67	faviconCache, err := cache.NewFaviconCache()
68	if err != nil {
69		// Continue without favicon cache if it fails
70		faviconCache = nil
71	}
72
73	// Get top entries from cache (this is blazingly fast!)
74	result, err := cacheManager.GetTopEntries(ctx)
75	if err != nil {
76		// Fallback to old method if cache fails
77		return generateOptionsFallback(cli)
78	}
79
80	// Fast dmenu mode: only shows cached history entries sorted by relevance
81
82	// Generate options - only show history entries from fast cache
83	options := make([]DmenuOption, 0, len(result.Matches))
84
85	// Add history entries from cache (already sorted by relevance!)
86	for _, match := range result.Matches {
87		entry := match.Entry
88
89		// Parse URL for domain extraction
90		parsedURL, err := url.Parse(entry.URL)
91		var domain string
92		if err != nil || parsedURL.Host == "" {
93			domain = "local"
94		} else {
95			domain = parsedURL.Host
96		}
97
98		// Determine title
99		title := entry.Title
100		if title == "" || title == entry.URL {
101			// Use meaningful fallback for untitled pages
102			if parsedURL != nil && parsedURL.Path != "" && parsedURL.Path != "/" {
103				title = fmt.Sprintf("[%s%s]", domain, parsedURL.Path)
104			} else {
105				title = fmt.Sprintf("[%s]", domain)
106			}
107		}
108
109		// Check if it's a Google search result and format accordingly
110		isGoogleSearch := domain == "www.google.com" && strings.Contains(entry.URL, "/search?q=")
111		if isGoogleSearch {
112			if parsedURL != nil {
113				if q := parsedURL.Query().Get("q"); q != "" {
114					title = fmt.Sprintf("Google: \"%s\"", q)
115				}
116			}
117		}
118
119		// Format: "Title | domain.com | full-url"
120		// Using pipe separator for rofi/dmenu compatibility
121		display := fmt.Sprintf("%s | %s | %s",
122			truncateString(title, 50),
123			domain,
124			truncateString(entry.URL, 70))
125
126		// Get favicon URL from cached entry
127		faviconURL := match.Entry.FaviconURL
128
129		options = append(options, DmenuOption{
130			Display:     display,
131			Value:       entry.URL,
132			Type:        historyType,
133			Description: entry.URL,
134			FaviconURL:  faviconURL,
135		})
136	}
137
138	// No sorting needed - history entries are already sorted by relevance from cache
139
140	// Output options to stdout with icon specifications
141	for _, option := range options {
142		iconName := getIconName(option, faviconCache)
143		if iconName != "" {
144			fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
145		} else {
146			fmt.Println(option.Display)
147		}
148	}
149
150	// Trigger background cache refresh for next time if needed
151	defer cacheManager.OnApplicationExit(ctx)
152
153	return nil
154}
155
156// generateOptionsFallback is the legacy method used as a fallback if fast cache fails
157func generateOptionsFallback(cli *CLI) error {
158	ctx := context.Background()
159
160	// Initialize favicon cache
161	faviconCache, err := cache.NewFaviconCache()
162	if err != nil {
163		// Continue without favicon cache if it fails
164		faviconCache = nil
165	}
166
167	const maxHistoryEntries = 50
168	// Get history entries
169	history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
170	if err != nil {
171		return fmt.Errorf("failed to get history: %w", err)
172	}
173
174	// Get shortcuts
175	shortcuts, err := cli.Queries.GetShortcuts(ctx)
176	if err != nil {
177		return fmt.Errorf("failed to get shortcuts: %w", err)
178	}
179
180	// Generate options
181	options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
182
183	// Add direct URL input option
184	options = append(options, DmenuOption{
185		Display:     "🌐 Enter URL or search query...",
186		Value:       "",
187		Type:        "input",
188		Description: "Type any URL or search term",
189	})
190
191	// Add shortcuts with examples
192	for _, shortcut := range shortcuts {
193		desc := shortcut.Description.String
194		if !shortcut.Description.Valid {
195			desc = "Custom shortcut"
196		}
197
198		options = append(options, DmenuOption{
199			Display:     fmt.Sprintf("πŸ” %s: (%s)", shortcut.Shortcut, desc),
200			Value:       shortcut.Shortcut + ":",
201			Type:        "shortcut",
202			Description: desc,
203		})
204	}
205
206	// Add history entries
207	for _, entry := range history {
208		title := entry.Url
209		if entry.Title.Valid && entry.Title.String != "" {
210			title = entry.Title.String
211		}
212
213		const maxDisplayLength = 80
214		// Truncate long titles/URLs for display
215		display := truncateString(title, maxDisplayLength)
216
217		options = append(options, DmenuOption{
218			Display:     fmt.Sprintf("πŸ•’ %s", display),
219			Value:       entry.Url,
220			Type:        historyType,
221			Description: entry.Url,
222		})
223	}
224
225	// Sort options: input first, then shortcuts, then history by visit count/recency
226	sort.Slice(options, func(i, j int) bool {
227		if options[i].Type != options[j].Type {
228			// Order: input, shortcut, history
229			typeOrder := map[string]int{"input": 0, "shortcut": 1, historyType: 2} //nolint:mnd
230			return typeOrder[options[i].Type] < typeOrder[options[j].Type]
231		}
232
233		if options[i].Type == historyType && options[j].Type == historyType {
234			// History already sorted by recency from database
235			return i < j
236		}
237
238		return options[i].Display < options[j].Display
239	})
240
241	// Output options to stdout with icon specifications
242	for _, option := range options {
243		iconName := getIconName(option, faviconCache)
244		if iconName != "" {
245			fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
246		} else {
247			fmt.Println(option.Display)
248		}
249	}
250
251	return nil
252}
253
254// handleSelection processes the user's selection from the launcher
255func handleSelection(cli *CLI) error {
256	scanner := bufio.NewScanner(os.Stdin)
257	if !scanner.Scan() {
258		return fmt.Errorf("no input received")
259	}
260
261	selection := strings.TrimSpace(scanner.Text())
262	if selection == "" {
263		return fmt.Errorf("empty selection")
264	}
265
266	// Parse the selection to get the actual URL from history entry
267	input := parseSelection(selection)
268
269	if input == "" {
270		return fmt.Errorf("empty input")
271	}
272
273	// Browse the selected/entered URL
274	err := browse(cli, input)
275
276	// After successful browse, invalidate cache so next dmenu shows updated order
277	if err == nil {
278		ctx := context.Background()
279		cacheConfig := cache.DefaultCacheConfig()
280		cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
281		cacheManager.InvalidateAndRefresh(ctx)
282	}
283
284	return err
285}
286
287// DmenuOption represents a selectable option in dmenu mode
288type DmenuOption struct {
289	Display     string
290	Value       string
291	Type        string // "input", "shortcut", "history"
292	Description string
293	FaviconURL  string // URL to favicon for this entry
294}
295
296// parseSelection extracts the actual URL from a dmenu selection
297func parseSelection(selection string) string {
298	selection = strings.TrimSpace(selection)
299
300	// Strip icon protocol if present (format: "text\0icon\x1ficonname")
301	if iconIndex := strings.Index(selection, "\x00icon\x1f"); iconIndex > 0 {
302		selection = selection[:iconIndex]
303	}
304
305	// 1. Handle new pipe-separated format: "Title | domain.com | full-url"
306	if strings.Contains(selection, " | ") {
307		parts := strings.Split(selection, " | ")
308		if len(parts) >= 3 {
309			// The URL is the last part (third field)
310			return strings.TrimSpace(parts[len(parts)-1])
311		} else if len(parts) == 2 {
312			// Might be "title | url" format, check if second part looks like URL
313			lastPart := strings.TrimSpace(parts[1])
314			if strings.Contains(lastPart, "://") || strings.Contains(lastPart, ".") {
315				return lastPart
316			}
317		}
318	}
319
320	// Legacy format handling for backward compatibility
321	if strings.HasPrefix(selection, "🌐 ") {
322		// Input option selected (shouldn't happen with new format but just in case)
323		return ""
324	}
325
326	if strings.HasPrefix(selection, "πŸ” ") {
327		// Shortcut selected - extract the shortcut prefix
328		rest := strings.TrimPrefix(selection, "πŸ” ")
329		if idx := strings.Index(rest, ":"); idx > 0 {
330			return rest[:idx+1] // Return "g:" format
331		}
332	}
333
334	if strings.HasPrefix(selection, "πŸ•’ ") {
335		// History entry selected - remove emoji prefix
336		selection = strings.TrimPrefix(selection, "πŸ•’ ")
337	}
338
339	// If no pipes and looks like a URL, return as-is
340	if strings.Contains(selection, "://") {
341		return selection
342	}
343
344	// Fallback: treat as search query
345	return selection
346}
347
348// getIconName determines the appropriate icon name for a dmenu option
349func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string {
350	if option.Type != historyType {
351		return ""
352	}
353
354	// Only use cached favicons - no system theme fallbacks for consistent sizing
355	if option.FaviconURL != "" && faviconCache != nil {
356		cachedPath := faviconCache.GetCachedPath(option.FaviconURL)
357		if cachedPath != "" {
358			return cachedPath
359		}
360		// Start async download for next time
361		faviconCache.CacheAsync(option.FaviconURL)
362	}
363
364	// No favicon available - return empty string to show no icon
365	return ""
366}
367
368// truncateString truncates a string to the specified length with ellipsis
369func truncateString(s string, maxLen int) string {
370	if len(s) <= maxLen {
371		return s
372	}
373
374	const minEllipsisLength = 3
375	if maxLen <= minEllipsisLength {
376		return s[:maxLen]
377	}
378
379	return s[:maxLen-minEllipsisLength] + "..."
380}

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