v0.27.2 5960dd8

normalize.go RawHistoryFolder

1// Package url provides URL manipulation utilities for the browser.
2package url
3
4import (
5	"fmt"
6	"net/url"
7	"os"
8	"path/filepath"
9	"strings"
10)
11
12// expandHome expands ~ prefix to user's home directory.
13func expandHome(path string) string {
14	if strings.HasPrefix(path, "~/") {
15		if home, err := os.UserHomeDir(); err == nil {
16			return filepath.Join(home, path[2:])
17		}
18	}
19	return path
20}
21
22// looksLikeFilePath returns true if the input looks like a filesystem path.
23// This includes absolute paths (/path), relative paths (./path, ../path),
24// and home-relative paths (~/path).
25func looksLikeFilePath(input string) bool {
26	switch {
27	case strings.HasPrefix(input, "/"):
28		return true
29	case strings.HasPrefix(input, "./"):
30		return true
31	case strings.HasPrefix(input, "../"):
32		return true
33	case strings.HasPrefix(input, "~/"):
34		return true
35	}
36	return false
37}
38
39// Normalize adds https:// prefix if missing for URL-like inputs.
40// Returns the input unchanged if it already has a scheme or doesn't look like a URL.
41// If the input is an existing local file, returns a file:// URL.
42func Normalize(input string) string {
43	if input == "" {
44		return ""
45	}
46
47	// Already has scheme
48	switch {
49	case strings.HasPrefix(input, "http://"):
50		return input
51	case strings.HasPrefix(input, "https://"):
52		return input
53	case strings.HasPrefix(input, "dumb://"):
54		return input
55	case strings.HasPrefix(input, "file://"):
56		return input
57	case strings.HasPrefix(input, "about:"):
58		return input
59	}
60
61	// Check if input looks like a file path
62	if looksLikeFilePath(input) {
63		expanded := expandHome(input)
64		absPath, err := filepath.Abs(expanded)
65		if err == nil {
66			if _, statErr := os.Stat(absPath); statErr == nil {
67				// File exists, convert to file:// URL
68				return "file://" + absPath
69			}
70		}
71		// Path-like input but file doesn't exist - return unchanged
72		// Don't treat as URL to avoid malformed URLs like https:///path
73		return input
74	}
75
76	// Check if input is an existing file in current directory (relative without ./ prefix)
77	absPath, err := filepath.Abs(input)
78	if err == nil {
79		if _, statErr := os.Stat(absPath); statErr == nil {
80			return "file://" + absPath
81		}
82	}
83
84	// Looks like a URL (contains . and no spaces)
85	if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
86		return "http://" + input
87	}
88	if strings.Contains(input, ".") && !strings.Contains(input, " ") {
89		return "https://" + input
90	}
91
92	return input
93}
94
95// LooksLikeURL checks if the input appears to be a URL (not a search query).
96// Returns true for strings like "github.com", "google.com/search", etc.
97// Also returns true for URLs with explicit schemes like "dumb://".
98func LooksLikeURL(input string) bool {
99	if input == "" {
100		return false
101	}
102
103	// Explicit schemes should always be treated as URLs.
104	switch {
105	case strings.HasPrefix(input, "http://"):
106		return true
107	case strings.HasPrefix(input, "https://"):
108		return true
109	case strings.HasPrefix(input, "dumb://"):
110		return true
111	case strings.HasPrefix(input, "file://"):
112		return true
113	case strings.HasPrefix(input, "about:"):
114		return true
115	}
116
117	// Contains a dot and no spaces = likely a URL
118	if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
119		return true
120	}
121	return strings.Contains(input, ".") && !strings.Contains(input, " ")
122}
123
124// ExtractDomain extracts the normalized domain (host) from a URL string.
125// Normalizes by stripping "www." prefix so youtube.com and www.youtube.com
126// resolve to the same value.
127func ExtractDomain(rawURL string) string {
128	if rawURL == "" {
129		return ""
130	}
131	parsed, err := url.Parse(rawURL)
132	if err != nil || parsed.Host == "" {
133		return ""
134	}
135	return strings.TrimPrefix(parsed.Host, "www.")
136}
137
138// SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
139// Replaces unsafe filesystem characters with underscores.
140func SanitizeDomainForFilename(domain string) string {
141	return sanitizeDomain(domain) + ".ico"
142}
143
144// SanitizeDomainForPNG converts a domain to a safe filename with .png extension.
145// Used for favicon export for tools like rofi/fuzzel that require PNG format.
146func SanitizeDomainForPNG(domain string) string {
147	return sanitizeDomain(domain) + ".png"
148}
149
150// sanitizeDomain replaces unsafe filesystem characters with underscores.
151func sanitizeDomain(domain string) string {
152	replacer := strings.NewReplacer(
153		":", "_",
154		"/", "_",
155		"\\", "_",
156		"*", "_",
157		"?", "_",
158		"\"", "_",
159		"<", "_",
160		">", "_",
161		"|", "_",
162	)
163	return replacer.Replace(domain)
164}
165
166// SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.
167// Example: "google.com" with size 32 -> "google.com.32.png"
168// Used for normalized favicon export for tools like rofi/fuzzel.
169func SanitizeDomainForPNGSized(domain string, size int) string {
170	return fmt.Sprintf("%s.%d.png", sanitizeDomain(domain), size)
171}
172
173// TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed
174// result looks like a URL. Returns the original input unchanged if it doesn't
175// contain leading spaces or if the trimmed result is not a URL.
176// This handles cases like pasting "  https://example.com" from clipboard.
177func TrimLeadingSpacesIfURL(input string) string {
178	trimmed := strings.TrimLeft(input, " \t")
179	if trimmed != input && LooksLikeURL(trimmed) {
180		return trimmed
181	}
182	return input
183}
184
185// IsExternalScheme returns true if the URL uses a scheme that should be
186// launched externally (e.g., vscode://, vscode-insiders://, spotify://, steam://)
187// rather than handled by the browser.
188// URL schemes are case-insensitive per RFC 3986.
189func IsExternalScheme(uri string) bool {
190	if uri == "" {
191		return false
192	}
193
194	parsed, err := url.Parse(uri)
195	if err != nil || parsed.Scheme == "" {
196		return false
197	}
198
199	switch strings.ToLower(parsed.Scheme) {
200	case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":
201		return false
202	default:
203		return true
204	}
205}
206
207// ExtractOrigin extracts the origin (scheme://host) from a URI.
208// This normalizes URIs to origins for permission storage and comparison.
209// Canonicalizes by lowercasing scheme and host, and omitting default ports.
210// Example: "https://example.com/path?query" -> "https://example.com"
211// Example: "HTTPS://EXAMPLE.COM:443/" -> "https://example.com"
212func ExtractOrigin(uri string) (string, error) {
213	parsed, err := url.Parse(uri)
214	if err != nil {
215		return "", fmt.Errorf("invalid URI: %w", err)
216	}
217
218	if parsed.Scheme == "" || parsed.Host == "" {
219		return "", fmt.Errorf("URI missing scheme or host: %s", uri)
220	}
221
222	// Canonicalize: lowercase scheme and hostname
223	scheme := strings.ToLower(parsed.Scheme)
224	hostname := strings.ToLower(parsed.Hostname())
225	if strings.Contains(hostname, ":") && !strings.HasPrefix(hostname, "[") {
226		hostname = "[" + hostname + "]"
227	}
228
229	// Determine if we should include the port
230	port := parsed.Port()
231	if port != "" {
232		// Omit default ports
233		if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
234			port = ""
235		}
236	}
237
238	// Build canonical origin
239	origin := scheme + "://" + hostname
240	if port != "" {
241		origin = origin + ":" + port
242	}
243
244	return origin, nil
245}

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