v0.33.2 57f21d6

normalize.go RawHistoryFolder

1// Package url provides URL manipulation utilities for the browser.
2package url
3
4import (
5	"crypto/sha256"
6	"encoding/hex"
7	"fmt"
8	"net"
9	"net/url"
10	"strings"
11)
12
13// looksLikeFilePath returns true if the input looks like a filesystem path.
14// This includes absolute paths (/path), relative paths (./path, ../path),
15// and home-relative paths (~/path).
16func looksLikeFilePath(input string) bool {
17	switch {
18	case strings.HasPrefix(input, "/"):
19		return true
20	case strings.HasPrefix(input, "./"):
21		return true
22	case strings.HasPrefix(input, "../"):
23		return true
24	case strings.HasPrefix(input, "~/"):
25		return true
26	}
27	return false
28}
29
30// Normalize adds https:// prefix if missing for URL-like inputs.
31// Returns the input unchanged if it already has a scheme or doesn't look like a URL.
32// Path-like inputs are returned unchanged; filesystem probing belongs in application adapters.
33func Normalize(input string) string {
34	if input == "" {
35		return ""
36	}
37
38	// Already has scheme
39	switch {
40	case strings.HasPrefix(input, "http://"):
41		return input
42	case strings.HasPrefix(input, "https://"):
43		return input
44	case strings.HasPrefix(input, "dumb://"):
45		return input
46	case strings.HasPrefix(input, "file://"):
47		return input
48	case strings.HasPrefix(input, "about:"):
49		return input
50	case IsExternalScheme(input):
51		return input
52	case hasExplicitSchemeURL(input):
53		return input
54	}
55
56	// Path-like input is not treated as a URL to avoid malformed URLs like https:///path.
57	if looksLikeFilePath(input) {
58		return input
59	}
60
61	// Looks like a URL (contains . and no spaces)
62	if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
63		return "http://" + input
64	}
65	if looksLikeIPAddressWithOptionalPortOrPath(input) {
66		return "http://" + input
67	}
68	if strings.Contains(input, ".") && !strings.Contains(input, " ") {
69		return "https://" + input
70	}
71
72	return input
73}
74
75func looksLikeIPAddressWithOptionalPortOrPath(input string) bool {
76	if input == "" || strings.Contains(input, " ") {
77		return false
78	}
79
80	parsed, err := url.Parse("http://" + input)
81	if err != nil {
82		return false
83	}
84
85	hostname := parsed.Hostname()
86	if hostname == "" {
87		return false
88	}
89
90	return net.ParseIP(hostname) != nil
91}
92
93// LooksLikeURL checks if the input appears to be a URL (not a search query).
94// Returns true for strings like "github.com", "google.com/search", etc.
95// Also returns true for URLs with explicit schemes like "dumb://".
96func LooksLikeURL(input string) bool {
97	if input == "" {
98		return false
99	}
100
101	// Explicit schemes should always be treated as URLs.
102	switch {
103	case strings.HasPrefix(input, "http://"):
104		return true
105	case strings.HasPrefix(input, "https://"):
106		return true
107	case strings.HasPrefix(input, "dumb://"):
108		return true
109	case strings.HasPrefix(input, "file://"):
110		return true
111	case strings.HasPrefix(input, "about:"):
112		return true
113	case IsExternalScheme(input):
114		return true
115	case hasExplicitSchemeURL(input):
116		return true
117	}
118
119	// Contains a dot and no spaces = likely a URL
120	if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
121		return true
122	}
123	if looksLikeIPAddressWithOptionalPortOrPath(input) {
124		return true
125	}
126	return strings.Contains(input, ".") && !strings.Contains(input, " ")
127}
128
129func hasExplicitSchemeURL(input string) bool {
130	if !strings.Contains(input, "://") {
131		return false
132	}
133
134	parsed, err := url.Parse(input)
135	if err != nil {
136		return false
137	}
138
139	return parsed.Scheme != ""
140}
141
142// ExtractDomain extracts the normalized domain (host) from a URL string.
143// Normalizes by stripping "www." prefix so youtube.com and www.youtube.com
144// resolve to the same value.
145func ExtractDomain(rawURL string) string {
146	if rawURL == "" {
147		return ""
148	}
149	parsed, err := url.Parse(rawURL)
150	if err != nil || parsed.Host == "" {
151		return ""
152	}
153	return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
154}
155
156// CanonicalDomain extracts a normalized domain from either a URL string or a
157// host-like domain string. It keeps non-default ports so callers that need to
158// round-trip repository domain keys do not lose identity.
159func CanonicalDomain(raw string) string {
160	raw = strings.TrimSpace(strings.ToLower(raw))
161	if raw == "" {
162		return ""
163	}
164	if domain := ExtractDomain(raw); domain != "" {
165		return domain
166	}
167	if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
168		raw = raw[:idx]
169	}
170	return strings.TrimPrefix(raw, "www.")
171}
172
173// DisplayDomain returns a user-facing domain label with any port removed.
174func DisplayDomain(raw string) string {
175	domain := CanonicalDomain(raw)
176	if host, _, err := net.SplitHostPort(domain); err == nil {
177		return host
178	}
179	return domain
180}
181
182// maxSanitizedFilenameLen leaves headroom under common 255-byte filename
183// limits for favicon sidecars such as ".content-type".
184const maxSanitizedFilenameLen = 240
185
186// SanitizeDomainForFilename converts a domain/key to a safe bounded filename with .ico extension.
187func SanitizeDomainForFilename(domain string) string {
188	return sanitizeDomainWithSuffix(domain, ".ico")
189}
190
191// SanitizeDomainForPNG converts a domain/key to a safe bounded filename with .png extension.
192// Used for favicon export for tools like rofi/fuzzel that require PNG format.
193func SanitizeDomainForPNG(domain string) string {
194	return sanitizeDomainWithSuffix(domain, ".png")
195}
196
197// sanitizeDomain percent-escapes filesystem-unsafe characters while preserving
198// ordinary domain filenames. Percent escapes avoid collisions between path keys
199// such as example.com/a_b and example.com/a/b.
200func sanitizeDomain(domain string) string {
201	replacer := strings.NewReplacer(
202		"%", "%25",
203		":", "%3A",
204		"/", "%2F",
205		"\\", "%5C",
206		"*", "%2A",
207		"?", "%3F",
208		"\"", "%22",
209		"<", "%3C",
210		">", "%3E",
211		"|", "%7C",
212	)
213	return replacer.Replace(domain)
214}
215
216func sanitizeDomainWithSuffix(domain, suffix string) string {
217	sanitized := sanitizeDomain(domain)
218	if len(sanitized)+len(suffix) <= maxSanitizedFilenameLen {
219		return sanitized + suffix
220	}
221	sum := sha256.Sum256([]byte(domain))
222	return "sha256-" + hex.EncodeToString(sum[:]) + suffix
223}
224
225// SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.
226// Example: "google.com" with size 32 -> "google.com.32.png"
227// Used for normalized favicon export for tools like rofi/fuzzel.
228func SanitizeDomainForPNGSized(domain string, size int) string {
229	return sanitizeDomainWithSuffix(domain, fmt.Sprintf(".%d.png", size))
230}
231
232// TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed
233// result looks like a URL. Returns the original input unchanged if it doesn't
234// contain leading spaces or if the trimmed result is not a URL.
235// This handles cases like pasting "  https://example.com" from clipboard.
236func TrimLeadingSpacesIfURL(input string) string {
237	trimmed := strings.TrimLeft(input, " \t")
238	if trimmed != input && LooksLikeURL(trimmed) {
239		return trimmed
240	}
241	return input
242}
243
244// IsExternalScheme returns true if the URL uses a scheme that should be
245// launched externally (e.g., vscode://, vscode-insiders://, spotify://, steam://)
246// rather than handled by the browser.
247// URL schemes are case-insensitive per RFC 3986.
248func IsExternalScheme(uri string) bool {
249	if uri == "" {
250		return false
251	}
252
253	parsed, err := url.Parse(uri)
254	if err != nil || parsed.Scheme == "" {
255		return false
256	}
257	if !strings.Contains(uri, "://") {
258		rest := strings.TrimPrefix(uri, parsed.Scheme+":")
259		if strings.EqualFold(parsed.Scheme, "localhost") || strings.Contains(parsed.Scheme, ".") || isDecimalPort(rest) {
260			return false
261		}
262	}
263
264	switch strings.ToLower(parsed.Scheme) {
265	case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":
266		return false
267	default:
268		return true
269	}
270}
271
272func isDecimalPort(value string) bool {
273	if value == "" {
274		return false
275	}
276	for _, r := range value {
277		if r < '0' || r > '9' {
278			return false
279		}
280	}
281	return true
282}
283
284// ExtractOrigin extracts the origin (scheme://host) from a URI.
285// This normalizes URIs to origins for permission storage and comparison.
286// Canonicalizes by lowercasing scheme and host, and omitting default ports.
287// Example: "https://example.com/path?query" -> "https://example.com"
288// Example: "HTTPS://EXAMPLE.COM:443/" -> "https://example.com"
289func ExtractOrigin(uri string) (string, error) {
290	parsed, err := url.Parse(uri)
291	if err != nil {
292		return "", fmt.Errorf("invalid URI: %w", err)
293	}
294
295	if parsed.Scheme == "" || parsed.Host == "" {
296		return "", fmt.Errorf("URI missing scheme or host: %s", uri)
297	}
298
299	// Canonicalize: lowercase scheme and hostname
300	scheme := strings.ToLower(parsed.Scheme)
301	hostname := strings.ToLower(parsed.Hostname())
302	if strings.Contains(hostname, ":") && !strings.HasPrefix(hostname, "[") {
303		hostname = "[" + hostname + "]"
304	}
305
306	// Determine if we should include the port
307	port := parsed.Port()
308	if port != "" {
309		// Omit default ports
310		if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
311			port = ""
312		}
313	}
314
315	// Build canonical origin
316	origin := scheme + "://" + hostname
317	if port != "" {
318		origin = origin + ":" + port
319	}
320
321	return origin, nil
322}

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