v0.15.0 9006957

dmenu.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"fmt"
7	"os"
8	"sort"
9	"strings"
10	"time"
11
12	"github.com/spf13/cobra"
13
14	"github.com/bnema/dumber/internal/cache"
15	"github.com/bnema/dumber/internal/config"
16	"github.com/bnema/dumber/internal/services"
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		// Strip scheme from URL (remove https:// or http://)
92		display := entry.URL
93		display = strings.TrimPrefix(display, "https://")
94		display = strings.TrimPrefix(display, "http://")
95		display = truncateString(display, 100)
96
97		// Get favicon URL from cached entry
98		faviconURL := match.Entry.FaviconURL
99
100		options = append(options, DmenuOption{
101			Display:     display,
102			Value:       entry.URL,
103			Type:        historyType,
104			Description: entry.URL,
105			FaviconURL:  faviconURL,
106		})
107	}
108
109	// No sorting needed - history entries are already sorted by relevance from cache
110
111	// Output options to stdout with icon specifications
112	for _, option := range options {
113		iconName := getIconName(option, dataDir)
114		if iconName != "" {
115			fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
116		} else {
117			fmt.Println(option.Display)
118		}
119	}
120
121	// Trigger background cache refresh for next time if needed
122	defer cacheManager.OnApplicationExit(ctx)
123
124	return nil
125}
126
127// generateOptionsFallback is the legacy method used as a fallback if fast cache fails
128func generateOptionsFallback(cli *CLI) error {
129	ctx := context.Background()
130
131	// Get data directory for favicon exports
132	dataDir, err := config.GetDataDir()
133	if err != nil {
134		dataDir = "" // Continue without favicons if data dir fails
135	}
136
137	const maxHistoryEntries = 50
138	// Get history entries
139	history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
140	if err != nil {
141		return fmt.Errorf("failed to get history: %w", err)
142	}
143
144	// Get shortcuts from configuration (not database)
145	shortcuts := cli.Config.SearchShortcuts
146
147	// Generate options
148	options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
149
150	// Add direct URL input option
151	options = append(options, DmenuOption{
152		Display:     "🌐 Enter URL or search query...",
153		Value:       "",
154		Type:        "input",
155		Description: "Type any URL or search term",
156	})
157
158	// Add shortcuts with examples
159	for shortcutKey, shortcut := range shortcuts {
160		desc := shortcut.Description
161		if desc == "" {
162			desc = "Custom shortcut"
163		}
164
165		options = append(options, DmenuOption{
166			Display:     fmt.Sprintf("🔍 %s: (%s)", shortcutKey, desc),
167			Value:       shortcutKey + ":",
168			Type:        "shortcut",
169			Description: desc,
170		})
171	}
172
173	// Add history entries
174	for _, entry := range history {
175		title := entry.Url
176		if entry.Title.Valid && entry.Title.String != "" {
177			title = entry.Title.String
178		}
179
180		const maxDisplayLength = 80
181		// Truncate long titles/URLs for display
182		display := truncateString(title, maxDisplayLength)
183
184		options = append(options, DmenuOption{
185			Display:     fmt.Sprintf("🕒 %s", display),
186			Value:       entry.Url,
187			Type:        historyType,
188			Description: entry.Url,
189		})
190	}
191
192	// Sort options: input first, then shortcuts, then history by visit count/recency
193	sort.Slice(options, func(i, j int) bool {
194		if options[i].Type != options[j].Type {
195			// Order: input, shortcut, history
196			typeOrder := map[string]int{"input": 0, "shortcut": 1, historyType: 2} //nolint:mnd
197			return typeOrder[options[i].Type] < typeOrder[options[j].Type]
198		}
199
200		if options[i].Type == historyType && options[j].Type == historyType {
201			// History already sorted by recency from database
202			return i < j
203		}
204
205		return options[i].Display < options[j].Display
206	})
207
208	// Output options to stdout with icon specifications
209	for _, option := range options {
210		iconName := getIconName(option, dataDir)
211		if iconName != "" {
212			fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
213		} else {
214			fmt.Println(option.Display)
215		}
216	}
217
218	return nil
219}
220
221// handleSelection processes the user's selection from the launcher
222func handleSelection(cli *CLI) error {
223	scanner := bufio.NewScanner(os.Stdin)
224	if !scanner.Scan() {
225		return fmt.Errorf("no input received")
226	}
227
228	selection := strings.TrimSpace(scanner.Text())
229	if selection == "" {
230		return fmt.Errorf("empty selection")
231	}
232
233	// Parse the selection to get the actual URL from history entry
234	input := parseSelection(selection)
235
236	if input == "" {
237		return fmt.Errorf("empty input")
238	}
239
240	// Browse the selected/entered URL
241	err := browse(cli, input)
242
243	// After successful browse, invalidate cache so next dmenu shows updated order
244	if err == nil {
245		ctx := context.Background()
246		cacheConfig := cache.DefaultCacheConfig()
247		cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
248		cacheManager.InvalidateAndRefresh(ctx)
249	}
250
251	return err
252}
253
254// DmenuOption represents a selectable option in dmenu mode
255type DmenuOption struct {
256	Display     string
257	Value       string
258	Type        string // "input", "shortcut", "history"
259	Description string
260	FaviconURL  string // URL to favicon for this entry
261}
262
263// parseSelection extracts the actual URL from a dmenu selection
264func parseSelection(selection string) string {
265	selection = strings.TrimSpace(selection)
266
267	// Strip icon protocol if present (format: "text\0icon\x1ficonname")
268	if iconIndex := strings.Index(selection, "\x00icon\x1f"); iconIndex > 0 {
269		selection = selection[:iconIndex]
270	}
271
272	// 1. Handle new pipe-separated format: "Title | domain.com | full-url"
273	if strings.Contains(selection, " | ") {
274		parts := strings.Split(selection, " | ")
275		if len(parts) >= 3 {
276			// The URL is the last part (third field)
277			return strings.TrimSpace(parts[len(parts)-1])
278		} else if len(parts) == 2 {
279			// Might be "title | url" format, check if second part looks like URL
280			lastPart := strings.TrimSpace(parts[1])
281			if strings.Contains(lastPart, "://") || strings.Contains(lastPart, ".") {
282				return lastPart
283			}
284		}
285	}
286
287	// Legacy format handling for backward compatibility
288	if strings.HasPrefix(selection, "🌐 ") {
289		// Input option selected (shouldn't happen with new format but just in case)
290		return ""
291	}
292
293	if strings.HasPrefix(selection, "🔍 ") {
294		// Shortcut selected - extract the shortcut prefix
295		rest := strings.TrimPrefix(selection, "🔍 ")
296		if idx := strings.Index(rest, ":"); idx > 0 {
297			return rest[:idx+1] // Return "g:" format
298		}
299	}
300
301	// History entry selected - remove emoji prefix if present
302	selection = strings.TrimPrefix(selection, "🕒 ")
303
304	// If no pipes and looks like a URL, return as-is
305	if strings.Contains(selection, "://") {
306		return selection
307	}
308
309	// Fallback: treat as search query
310	return selection
311}
312
313// getIconName determines the appropriate icon name for a dmenu option
314func getIconName(option DmenuOption, dataDir string) string {
315	if option.Type != historyType {
316		return ""
317	}
318
319	// Get exported favicon path from FaviconService
320	// FaviconService exports all favicons as 32x32 PNG files for CLI access
321	if option.Value != "" {
322		faviconPath := services.GetExportedFaviconPath(dataDir, option.Value)
323		// Check if file exists and is recent (within 7 days)
324		if info, err := os.Stat(faviconPath); err == nil {
325			if time.Since(info.ModTime()) < 7*24*time.Hour {
326				return faviconPath
327			}
328		}
329	}
330
331	// No favicon available - return empty string to show no icon
332	return ""
333}
334
335// truncateString truncates a string to the specified length with ellipsis
336func truncateString(s string, maxLen int) string {
337	if len(s) <= maxLen {
338		return s
339	}
340
341	const minEllipsisLength = 3
342	if maxLen <= minEllipsisLength {
343		return s[:maxLen]
344	}
345
346	return s[:maxLen-minEllipsisLength] + "..."
347}

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