v0.14.1 0da30ba

dmenu.go RawHistoryFolder

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

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