v0.2.0 759cc92

dmenu.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"fmt"
7	"os"
8	"sort"
9	"strings"
10
11	"github.com/spf13/cobra"
12)
13
14// NewDmenuCmd creates the dmenu command for launcher integration
15func NewDmenuCmd() *cobra.Command {
16	cmd := &cobra.Command{
17		Use:   "dmenu",
18		Short: "Run in dmenu mode for launcher integration",
19		Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers.
20This mode reads from stdin and outputs selectable options to stdout.
21
22Usage with rofi:
23  dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
24
25Usage with dmenu:
26  dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`,
27		RunE: func(cmd *cobra.Command, _ []string) error {
28			selectFlag := cmd.Flag("select").Changed
29
30			cli, err := NewCLI()
31			if err != nil {
32				return fmt.Errorf("failed to initialize CLI: %w", err)
33			}
34			defer func() {
35				if closeErr := cli.Close(); closeErr != nil {
36					fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
37				}
38			}()
39
40			if selectFlag {
41				return handleSelection(cli)
42			}
43			return generateOptions(cli)
44		},
45	}
46
47	cmd.Flags().Bool("select", false, "Process selection from launcher (reads from stdin)")
48	return cmd
49}
50
51// generateOptions outputs all available options for the launcher
52func generateOptions(cli *CLI) error {
53	ctx := context.Background()
54
55	const maxHistoryEntries = 50
56	// Get history entries
57	history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
58	if err != nil {
59		return fmt.Errorf("failed to get history: %w", err)
60	}
61
62	// Get shortcuts
63	shortcuts, err := cli.Queries.GetShortcuts(ctx)
64	if err != nil {
65		return fmt.Errorf("failed to get shortcuts: %w", err)
66	}
67
68	// Generate options
69	options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
70
71	// Add direct URL input option
72	options = append(options, DmenuOption{
73		Display:     "🌐 Enter URL or search query...",
74		Value:       "",
75		Type:        "input",
76		Description: "Type any URL or search term",
77	})
78
79	// Add shortcuts with examples
80	for _, shortcut := range shortcuts {
81		desc := shortcut.Description.String
82		if !shortcut.Description.Valid {
83			desc = "Custom shortcut"
84		}
85
86		options = append(options, DmenuOption{
87			Display:     fmt.Sprintf("πŸ” %s: (%s)", shortcut.Shortcut, desc),
88			Value:       shortcut.Shortcut + ":",
89			Type:        "shortcut",
90			Description: desc,
91		})
92	}
93
94	// Add history entries
95	for _, entry := range history {
96		title := entry.Url
97		if entry.Title.Valid && entry.Title.String != "" {
98			title = entry.Title.String
99		}
100
101		const maxDisplayLength = 80
102		// Truncate long titles/URLs for display
103		display := truncateString(title, maxDisplayLength)
104
105		options = append(options, DmenuOption{
106			Display:     fmt.Sprintf("πŸ•’ %s", display),
107			Value:       entry.Url,
108			Type:        "history",
109			Description: entry.Url,
110		})
111	}
112
113	// Sort options: input first, then shortcuts, then history by visit count/recency
114	sort.Slice(options, func(i, j int) bool {
115		if options[i].Type != options[j].Type {
116			// Order: input, shortcut, history
117			typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd
118			return typeOrder[options[i].Type] < typeOrder[options[j].Type]
119		}
120
121		if options[i].Type == "history" && options[j].Type == "history" {
122			// History already sorted by recency from database
123			return i < j
124		}
125
126		return options[i].Display < options[j].Display
127	})
128
129	// Output options to stdout
130	for _, option := range options {
131		fmt.Println(option.Display)
132	}
133
134	return nil
135}
136
137// handleSelection processes the user's selection from the launcher
138func handleSelection(cli *CLI) error {
139	scanner := bufio.NewScanner(os.Stdin)
140	if !scanner.Scan() {
141		return fmt.Errorf("no input received")
142	}
143
144	selection := strings.TrimSpace(scanner.Text())
145	if selection == "" {
146		return fmt.Errorf("empty selection")
147	}
148
149	// Parse the selection to get the actual input
150	input := parseSelection(selection)
151
152	// If it's just the input prompt, read another line for the actual input
153	if input == "" || input == "Enter URL or search query..." {
154		fmt.Print("Enter URL or search query: ")
155		if !scanner.Scan() {
156			return fmt.Errorf("no input received")
157		}
158		input = strings.TrimSpace(scanner.Text())
159	}
160
161	if input == "" {
162		return fmt.Errorf("empty input")
163	}
164
165	// Browse the selected/entered URL
166	return browse(cli, input)
167}
168
169// DmenuOption represents a selectable option in dmenu mode
170type DmenuOption struct {
171	Display     string
172	Value       string
173	Type        string // "input", "shortcut", "history"
174	Description string
175}
176
177// parseSelection extracts the actual URL/query from a dmenu selection
178func parseSelection(selection string) string {
179	// Remove emoji prefixes and clean up the selection
180	selection = strings.TrimSpace(selection)
181
182	// Handle different option types
183	if strings.HasPrefix(selection, "🌐 ") {
184		// Input option selected
185		return ""
186	}
187
188	if strings.HasPrefix(selection, "πŸ” ") {
189		// Shortcut selected - extract the shortcut prefix
190		rest := strings.TrimPrefix(selection, "πŸ” ")
191		if idx := strings.Index(rest, ":"); idx > 0 {
192			return rest[:idx+1] // Return "g:" format
193		}
194	}
195
196	if strings.HasPrefix(selection, "πŸ•’ ") {
197		// History entry selected - this is the display title, need to extract URL
198		// For now, we'll assume the display is the URL or close enough
199		return strings.TrimPrefix(selection, "πŸ•’ ")
200	}
201
202	// Fallback: return the selection as-is
203	return selection
204}
205
206// truncateString truncates a string to the specified length with ellipsis
207func truncateString(s string, maxLen int) string {
208	if len(s) <= maxLen {
209		return s
210	}
211
212	const minEllipsisLength = 3
213	if maxLen <= minEllipsisLength {
214		return s[:maxLen]
215	}
216
217	return s[:maxLen-minEllipsisLength] + "..."
218}

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