v0.4.0 6203a22

dmenu.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"fmt"
7	"os"
8	"sort"
9	"strings"
10
11	"github.com/bnema/dumber/internal/cache"
12	"github.com/spf13/cobra"
13)
14
15// NewDmenuCmd creates the dmenu command for launcher integration
16func NewDmenuCmd() *cobra.Command {
17	cmd := &cobra.Command{
18		Use:   "dmenu",
19		Short: "Run in dmenu mode for launcher integration",
20		Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers.
21This mode reads from stdin and outputs selectable options to stdout.
22
23Usage with rofi:
24  dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
25
26Usage with dmenu:
27  dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`,
28		RunE: func(cmd *cobra.Command, _ []string) error {
29			selectFlag := cmd.Flag("select").Changed
30
31			cli, err := NewCLI()
32			if err != nil {
33				return fmt.Errorf("failed to initialize CLI: %w", err)
34			}
35			defer func() {
36				if closeErr := cli.Close(); closeErr != nil {
37					fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
38				}
39			}()
40
41			if selectFlag {
42				return handleSelection(cli)
43			}
44			return generateOptions(cli)
45		},
46	}
47
48	cmd.Flags().Bool("select", false, "Process selection from launcher (reads from stdin)")
49	return cmd
50}
51
52// generateOptions outputs all available options for the launcher using fuzzy cache
53func generateOptions(cli *CLI) error {
54	ctx := context.Background()
55
56	// Initialize cache manager
57	cacheConfig := cache.DefaultCacheConfig()
58	cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries
59	cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
60
61	// Get top entries from cache (this is blazingly fast!)
62	result, err := cacheManager.GetTopEntries(ctx)
63	if err != nil {
64		// Fallback to old method if cache fails
65		return generateOptionsFallback(cli)
66	}
67
68	// Get shortcuts for display
69	shortcuts, err := cli.Queries.GetShortcuts(ctx)
70	if err != nil {
71		return fmt.Errorf("failed to get shortcuts: %w", err)
72	}
73
74	// Generate options
75	options := make([]DmenuOption, 0, len(result.Matches)+len(shortcuts)+1)
76
77	// Add direct URL input option
78	options = append(options, DmenuOption{
79		Display:     "🌐 Enter URL or search query...",
80		Value:       "",
81		Type:        "input",
82		Description: "Type any URL or search term",
83	})
84
85	// Add shortcuts with examples
86	for _, shortcut := range shortcuts {
87		desc := shortcut.Description.String
88		if !shortcut.Description.Valid {
89			desc = "Custom shortcut"
90		}
91
92		options = append(options, DmenuOption{
93			Display:     fmt.Sprintf("πŸ” %s: (%s)", shortcut.Shortcut, desc),
94			Value:       shortcut.Shortcut + ":",
95			Type:        "shortcut",
96			Description: desc,
97		})
98	}
99
100	// Add history entries from cache (already sorted by relevance!)
101	for _, match := range result.Matches {
102		entry := match.Entry
103		title := entry.URL
104		if entry.Title != "" {
105			title = entry.Title
106		}
107
108		const maxDisplayLength = 80
109		// Truncate long titles/URLs for display
110		display := truncateString(title, maxDisplayLength)
111
112		options = append(options, DmenuOption{
113			Display:     fmt.Sprintf("πŸ•’ %s", display),
114			Value:       entry.URL,
115			Type:        "history",
116			Description: entry.URL,
117		})
118	}
119
120	// Sort options: input first, then shortcuts, then history (already sorted by cache)
121	sort.Slice(options, func(i, j int) bool {
122		if options[i].Type != options[j].Type {
123			// Order: input, shortcut, history
124			typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd
125			return typeOrder[options[i].Type] < typeOrder[options[j].Type]
126		}
127
128		if options[i].Type == "history" && options[j].Type == "history" {
129			// History already sorted by cache relevance score
130			return i < j
131		}
132
133		return options[i].Display < options[j].Display
134	})
135
136	// Output options to stdout
137	for _, option := range options {
138		fmt.Println(option.Display)
139	}
140
141	// Trigger background cache refresh for next time if needed
142	defer cacheManager.OnApplicationExit(ctx)
143
144	return nil
145}
146
147// generateOptionsFallback is the old method used as a fallback if cache fails
148func generateOptionsFallback(cli *CLI) error {
149	ctx := context.Background()
150
151	const maxHistoryEntries = 50
152	// Get history entries
153	history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
154	if err != nil {
155		return fmt.Errorf("failed to get history: %w", err)
156	}
157
158	// Get shortcuts
159	shortcuts, err := cli.Queries.GetShortcuts(ctx)
160	if err != nil {
161		return fmt.Errorf("failed to get shortcuts: %w", err)
162	}
163
164	// Generate options
165	options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
166
167	// Add direct URL input option
168	options = append(options, DmenuOption{
169		Display:     "🌐 Enter URL or search query...",
170		Value:       "",
171		Type:        "input",
172		Description: "Type any URL or search term",
173	})
174
175	// Add shortcuts with examples
176	for _, shortcut := range shortcuts {
177		desc := shortcut.Description.String
178		if !shortcut.Description.Valid {
179			desc = "Custom shortcut"
180		}
181
182		options = append(options, DmenuOption{
183			Display:     fmt.Sprintf("πŸ” %s: (%s)", shortcut.Shortcut, desc),
184			Value:       shortcut.Shortcut + ":",
185			Type:        "shortcut",
186			Description: desc,
187		})
188	}
189
190	// Add history entries
191	for _, entry := range history {
192		title := entry.Url
193		if entry.Title.Valid && entry.Title.String != "" {
194			title = entry.Title.String
195		}
196
197		const maxDisplayLength = 80
198		// Truncate long titles/URLs for display
199		display := truncateString(title, maxDisplayLength)
200
201		options = append(options, DmenuOption{
202			Display:     fmt.Sprintf("πŸ•’ %s", display),
203			Value:       entry.Url,
204			Type:        "history",
205			Description: entry.Url,
206		})
207	}
208
209	// Sort options: input first, then shortcuts, then history by visit count/recency
210	sort.Slice(options, func(i, j int) bool {
211		if options[i].Type != options[j].Type {
212			// Order: input, shortcut, history
213			typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd
214			return typeOrder[options[i].Type] < typeOrder[options[j].Type]
215		}
216
217		if options[i].Type == "history" && options[j].Type == "history" {
218			// History already sorted by recency from database
219			return i < j
220		}
221
222		return options[i].Display < options[j].Display
223	})
224
225	// Output options to stdout
226	for _, option := range options {
227		fmt.Println(option.Display)
228	}
229
230	return nil
231}
232
233// handleSelection processes the user's selection from the launcher
234func handleSelection(cli *CLI) error {
235	scanner := bufio.NewScanner(os.Stdin)
236	if !scanner.Scan() {
237		return fmt.Errorf("no input received")
238	}
239
240	selection := strings.TrimSpace(scanner.Text())
241	if selection == "" {
242		return fmt.Errorf("empty selection")
243	}
244
245	// Parse the selection to get the actual input
246	input := parseSelection(selection)
247
248	// If it's just the input prompt, read another line for the actual input
249	if input == "" || input == "Enter URL or search query..." {
250		fmt.Print("Enter URL or search query: ")
251		if !scanner.Scan() {
252			return fmt.Errorf("no input received")
253		}
254		input = strings.TrimSpace(scanner.Text())
255	}
256
257	if input == "" {
258		return fmt.Errorf("empty input")
259	}
260
261	// Browse the selected/entered URL
262	return browse(cli, input)
263}
264
265// DmenuOption represents a selectable option in dmenu mode
266type DmenuOption struct {
267	Display     string
268	Value       string
269	Type        string // "input", "shortcut", "history"
270	Description string
271}
272
273// parseSelection extracts the actual URL/query from a dmenu selection
274func parseSelection(selection string) string {
275	// Remove emoji prefixes and clean up the selection
276	selection = strings.TrimSpace(selection)
277
278	// Handle different option types
279	if strings.HasPrefix(selection, "🌐 ") {
280		// Input option selected
281		return ""
282	}
283
284	if strings.HasPrefix(selection, "πŸ” ") {
285		// Shortcut selected - extract the shortcut prefix
286		rest := strings.TrimPrefix(selection, "πŸ” ")
287		if idx := strings.Index(rest, ":"); idx > 0 {
288			return rest[:idx+1] // Return "g:" format
289		}
290	}
291
292	if strings.HasPrefix(selection, "πŸ•’ ") {
293		// History entry selected - this is the display title, need to extract URL
294		// For now, we'll assume the display is the URL or close enough
295		return strings.TrimPrefix(selection, "πŸ•’ ")
296	}
297
298	// Fallback: return the selection as-is
299	return selection
300}
301
302// truncateString truncates a string to the specified length with ellipsis
303func truncateString(s string, maxLen int) string {
304	if len(s) <= maxLen {
305		return s
306	}
307
308	const minEllipsisLength = 3
309	if maxLen <= minEllipsisLength {
310		return s[:maxLen]
311	}
312
313	return s[:maxLen-minEllipsisLength] + "..."
314}

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