v0.29.0 21b3150

normalize.go RawHistoryFolder

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

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