detached 645dfb3

fix(dmenu): use rofi/fuzzel info field for URL storage

commit
645dfb3a8bf1ac7c649d256310f531ed0eeccdd2
author
bnema · · 2025-12-18 20:04 UTC
parent
3a58730
signature
unsigned
1 file · +46 -25
M internal/cli/cmd/dmenu.go +46 -25
@@ -107,7 +107,7 @@ func runDmenuPipe() error {
107107 return fmt.Errorf("get history: %w", err)
108108 }
109109
110- // Output in rofi-compatible format
110+ // Output in rofi/fuzzel compatible format
111111 for _, entry := range entries {
112112 title := entry.Title
113113 if title == "" {
@@ -119,27 +119,23 @@ func runDmenuPipe() error {
119119 title = title[:77] + "..."
120120 }
121121
122- // Build output line based on config
123- var parts []string
124- parts = append(parts, cfg.HistoryPrefix+" "+title)
125-
126- if cfg.ShowVisitCount && entry.VisitCount > 0 {
127- parts[0] = fmt.Sprintf("%s (%d)", parts[0], entry.VisitCount)
128- }
122+ // Check if we have a favicon
123+ faviconPath := getFaviconPath(entry.URL)
129124
130- if cfg.ShowLastVisited && !entry.LastVisited.IsZero() {
131- dateStr := entry.LastVisited.Format(cfg.DateFormat)
132- parts[0] = fmt.Sprintf("%s [%s]", parts[0], dateStr)
125+ // Build display text
126+ var displayText string
127+ if faviconPath != "" {
128+ displayText = title
129+ } else {
130+ displayText = cfg.HistoryPrefix + " " + title
133131 }
134132
135- // Output with URL as the actual value (for selection)
136- // Using tab separator so rofi can use -d '\t'
137- // Format: EntryName\0icon\x1f/path/to/favicon\tURL (rofi/fuzzel compatible)
138- faviconPath := getFaviconPath(entry.URL)
133+ // Format: "DisplayText\0icon\x1f/path\x1finfo\x1fURL\n"
134+ // Using info field to store URL for selection
139135 if faviconPath != "" {
140- fmt.Printf("%s\x00icon\x1f%s\t%s\n", parts[0], faviconPath, entry.URL)
136+ fmt.Printf("%s\x00icon\x1f%s\x1finfo\x1f%s\n", displayText, faviconPath, entry.URL)
141137 } else {
142- fmt.Printf("%s\t%s\n", parts[0], entry.URL)
138+ fmt.Printf("%s\x00info\x1f%s\n", displayText, entry.URL)
143139 }
144140 }
145141
@@ -159,14 +155,7 @@ func runDmenuSelect() error {
159155 return nil // No selection
160156 }
161157
162- // If the line contains a tab, the URL is after it
163- // Otherwise, try to extract URL or use the whole line
164- url := line
165- if idx := strings.Index(line, "\t"); idx != -1 {
166- url = strings.TrimSpace(line[idx+1:])
167- }
168-
169- // Try to open the URL
158+ url := parseSelection(line)
170159 if url != "" {
171160 return openInBrowser(url)
172161 }
@@ -174,6 +163,38 @@ func runDmenuSelect() error {
174163 return nil
175164 }
176165
166+// parseSelection extracts the URL from a dmenu selection.
167+func parseSelection(selection string) string {
168+ selection = strings.TrimSpace(selection)
169+
170+ // Extract info field if present (format: "text\0...info\x1fURL")
171+ if infoIndex := strings.Index(selection, "info\x1f"); infoIndex > 0 {
172+ info := selection[infoIndex+5:] // Skip "info\x1f"
173+ // Info might have more fields after, but URL should be first
174+ if endIndex := strings.Index(info, "\x1f"); endIndex > 0 {
175+ return strings.TrimSpace(info[:endIndex])
176+ }
177+ return strings.TrimSpace(info)
178+ }
179+
180+ // Strip metadata if present (format: "text\0...")
181+ if nullIndex := strings.Index(selection, "\x00"); nullIndex > 0 {
182+ selection = selection[:nullIndex]
183+ }
184+
185+ // Legacy: strip emoji prefix if present
186+ selection = strings.TrimPrefix(selection, "🕒 ")
187+ selection = strings.TrimPrefix(selection, "🔍 ")
188+ selection = strings.TrimPrefix(selection, "🌐 ")
189+
190+ // If it looks like a URL, return it
191+ if strings.Contains(selection, "://") || strings.Contains(selection, ".") {
192+ return selection
193+ }
194+
195+ return selection
196+}
197+
177198 // runDmenuInteractive runs the interactive TUI dmenu.
178199 func runDmenuInteractive() error {
179200 app := GetApp()
@@ -107,7 +107,7 @@ func runDmenuPipe() error {
107 return fmt.Errorf("get history: %w", err)107 return fmt.Errorf("get history: %w", err)
108 }108 }
109 109
110- // Output in rofi-compatible format110+ // Output in rofi/fuzzel compatible format
111 for _, entry := range entries {111 for _, entry := range entries {
112 title := entry.Title112 title := entry.Title
113 if title == "" {113 if title == "" {
@@ -119,27 +119,23 @@ func runDmenuPipe() error {
119 title = title[:77] + "..."119 title = title[:77] + "..."
120 }120 }
121 121
122- // Build output line based on config122+ // Check if we have a favicon
123- var parts []string123+ faviconPath := getFaviconPath(entry.URL)
124- parts = append(parts, cfg.HistoryPrefix+" "+title)
125-
126- if cfg.ShowVisitCount && entry.VisitCount > 0 {
127- parts[0] = fmt.Sprintf("%s (%d)", parts[0], entry.VisitCount)
128- }
129 124
130- if cfg.ShowLastVisited && !entry.LastVisited.IsZero() {125+ // Build display text
131- dateStr := entry.LastVisited.Format(cfg.DateFormat)126+ var displayText string
132- parts[0] = fmt.Sprintf("%s [%s]", parts[0], dateStr)127+ if faviconPath != "" {
128+ displayText = title
129+ } else {
130+ displayText = cfg.HistoryPrefix + " " + title
133 }131 }
134 132
135- // Output with URL as the actual value (for selection)133+ // Format: "DisplayText\0icon\x1f/path\x1finfo\x1fURL\n"
136- // Using tab separator so rofi can use -d '\t'134+ // Using info field to store URL for selection
137- // Format: EntryName\0icon\x1f/path/to/favicon\tURL (rofi/fuzzel compatible)
138- faviconPath := getFaviconPath(entry.URL)
139 if faviconPath != "" {135 if faviconPath != "" {
140- fmt.Printf("%s\x00icon\x1f%s\t%s\n", parts[0], faviconPath, entry.URL)136+ fmt.Printf("%s\x00icon\x1f%s\x1finfo\x1f%s\n", displayText, faviconPath, entry.URL)
141 } else {137 } else {
142- fmt.Printf("%s\t%s\n", parts[0], entry.URL)138+ fmt.Printf("%s\x00info\x1f%s\n", displayText, entry.URL)
143 }139 }
144 }140 }
145 141
@@ -159,14 +155,7 @@ func runDmenuSelect() error {
159 return nil // No selection155 return nil // No selection
160 }156 }
161 157
162- // If the line contains a tab, the URL is after it158+ url := parseSelection(line)
163- // Otherwise, try to extract URL or use the whole line
164- url := line
165- if idx := strings.Index(line, "\t"); idx != -1 {
166- url = strings.TrimSpace(line[idx+1:])
167- }
168-
169- // Try to open the URL
170 if url != "" {159 if url != "" {
171 return openInBrowser(url)160 return openInBrowser(url)
172 }161 }
@@ -174,6 +163,38 @@ func runDmenuSelect() error {
174 return nil163 return nil
175 }164 }
176 165
166+// parseSelection extracts the URL from a dmenu selection.
167+func parseSelection(selection string) string {
168+ selection = strings.TrimSpace(selection)
169+
170+ // Extract info field if present (format: "text\0...info\x1fURL")
171+ if infoIndex := strings.Index(selection, "info\x1f"); infoIndex > 0 {
172+ info := selection[infoIndex+5:] // Skip "info\x1f"
173+ // Info might have more fields after, but URL should be first
174+ if endIndex := strings.Index(info, "\x1f"); endIndex > 0 {
175+ return strings.TrimSpace(info[:endIndex])
176+ }
177+ return strings.TrimSpace(info)
178+ }
179+
180+ // Strip metadata if present (format: "text\0...")
181+ if nullIndex := strings.Index(selection, "\x00"); nullIndex > 0 {
182+ selection = selection[:nullIndex]
183+ }
184+
185+ // Legacy: strip emoji prefix if present
186+ selection = strings.TrimPrefix(selection, "🕒 ")
187+ selection = strings.TrimPrefix(selection, "🔍 ")
188+ selection = strings.TrimPrefix(selection, "🌐 ")
189+
190+ // If it looks like a URL, return it
191+ if strings.Contains(selection, "://") || strings.Contains(selection, ".") {
192+ return selection
193+ }
194+
195+ return selection
196+}
197+
177 // runDmenuInteractive runs the interactive TUI dmenu.198 // runDmenuInteractive runs the interactive TUI dmenu.
178 func runDmenuInteractive() error {199 func runDmenuInteractive() error {
179 app := GetApp()200 app := GetApp()

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