v0.28.0 3cb3770

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	}
61
62	// Check if input looks like a file path
63	if looksLikeFilePath(input) {
64		expanded := expandHome(input)
65		absPath, err := filepath.Abs(expanded)
66		if err == nil {
67			if _, statErr := os.Stat(absPath); statErr == nil {
68				// File exists, convert to file:// URL
69				return "file://" + absPath
70			}
71		}
72		// Path-like input but file doesn't exist - return unchanged
73		// Don't treat as URL to avoid malformed URLs like https:///path
74		return input
75	}
76
77	// Check if input is an existing file in current directory (relative without ./ prefix)
78	absPath, err := filepath.Abs(input)
79	if err == nil {
80		if _, statErr := os.Stat(absPath); statErr == nil {
81			return "file://" + absPath
82		}
83	}
84
85	// Looks like a URL (contains . and no spaces)
86	if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
87		return "http://" + input
88	}
89	if looksLikeIPAddressWithOptionalPortOrPath(input) {
90		return "http://" + input
91	}
92	if strings.Contains(input, ".") && !strings.Contains(input, " ") {
93		return "https://" + input
94	}
95
96	return input
97}
98
99func looksLikeIPAddressWithOptionalPortOrPath(input string) bool {
100	if input == "" || strings.Contains(input, " ") {
101		return false
102	}
103
104	parsed, err := url.Parse("http://" + input)
105	if err != nil {
106		return false
107	}
108
109	hostname := parsed.Hostname()
110	if hostname == "" {
111		return false
112	}
113
114	return net.ParseIP(hostname) != nil
115}
116
117// LooksLikeURL checks if the input appears to be a URL (not a search query).
118// Returns true for strings like "github.com", "google.com/search", etc.
119// Also returns true for URLs with explicit schemes like "dumb://".
120func LooksLikeURL(input string) bool {
121	if input == "" {
122		return false
123	}
124
125	// Explicit schemes should always be treated as URLs.
126	switch {
127	case strings.HasPrefix(input, "http://"):
128		return true
129	case strings.HasPrefix(input, "https://"):
130		return true
131	case strings.HasPrefix(input, "dumb://"):
132		return true
133	case strings.HasPrefix(input, "file://"):
134		return true
135	case strings.HasPrefix(input, "about:"):
136		return true
137	}
138
139	// Contains a dot and no spaces = likely a URL
140	if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
141		return true
142	}
143	if looksLikeIPAddressWithOptionalPortOrPath(input) {
144		return true
145	}
146	return strings.Contains(input, ".") && !strings.Contains(input, " ")
147}
148
149// ExtractDomain extracts the normalized domain (host) from a URL string.
150// Normalizes by stripping "www." prefix so youtube.com and www.youtube.com
151// resolve to the same value.
152func ExtractDomain(rawURL string) string {
153	if rawURL == "" {
154		return ""
155	}
156	parsed, err := url.Parse(rawURL)
157	if err != nil || parsed.Host == "" {
158		return ""
159	}
160	return strings.TrimPrefix(parsed.Host, "www.")
161}
162
163// SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
164// Replaces unsafe filesystem characters with underscores.
165func SanitizeDomainForFilename(domain string) string {
166	return sanitizeDomain(domain) + ".ico"
167}
168
169// SanitizeDomainForPNG converts a domain to a safe filename with .png extension.
170// Used for favicon export for tools like rofi/fuzzel that require PNG format.
171func SanitizeDomainForPNG(domain string) string {
172	return sanitizeDomain(domain) + ".png"
173}
174
175// sanitizeDomain replaces unsafe filesystem characters with underscores.
176func sanitizeDomain(domain string) string {
177	replacer := strings.NewReplacer(
178		":", "_",
179		"/", "_",
180		"\\", "_",
181		"*", "_",
182		"?", "_",
183		"\"", "_",
184		"<", "_",
185		">", "_",
186		"|", "_",
187	)
188	return replacer.Replace(domain)
189}
190
191// SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.
192// Example: "google.com" with size 32 -> "google.com.32.png"
193// Used for normalized favicon export for tools like rofi/fuzzel.
194func SanitizeDomainForPNGSized(domain string, size int) string {
195	return fmt.Sprintf("%s.%d.png", sanitizeDomain(domain), size)
196}
197
198// TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed
199// result looks like a URL. Returns the original input unchanged if it doesn't
200// contain leading spaces or if the trimmed result is not a URL.
201// This handles cases like pasting "  https://example.com" from clipboard.
202func TrimLeadingSpacesIfURL(input string) string {
203	trimmed := strings.TrimLeft(input, " \t")
204	if trimmed != input && LooksLikeURL(trimmed) {
205		return trimmed
206	}
207	return input
208}
209
210// IsExternalScheme returns true if the URL uses a scheme that should be
211// launched externally (e.g., vscode://, vscode-insiders://, spotify://, steam://)
212// rather than handled by the browser.
213// URL schemes are case-insensitive per RFC 3986.
214func IsExternalScheme(uri string) bool {
215	if uri == "" {
216		return false
217	}
218
219	parsed, err := url.Parse(uri)
220	if err != nil || parsed.Scheme == "" {
221		return false
222	}
223
224	switch strings.ToLower(parsed.Scheme) {
225	case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":
226		return false
227	default:
228		return true
229	}
230}
231
232// ExtractOrigin extracts the origin (scheme://host) from a URI.
233// This normalizes URIs to origins for permission storage and comparison.
234// Canonicalizes by lowercasing scheme and host, and omitting default ports.
235// Example: "https://example.com/path?query" -> "https://example.com"
236// Example: "HTTPS://EXAMPLE.COM:443/" -> "https://example.com"
237func ExtractOrigin(uri string) (string, error) {
238	parsed, err := url.Parse(uri)
239	if err != nil {
240		return "", fmt.Errorf("invalid URI: %w", err)
241	}
242
243	if parsed.Scheme == "" || parsed.Host == "" {
244		return "", fmt.Errorf("URI missing scheme or host: %s", uri)
245	}
246
247	// Canonicalize: lowercase scheme and hostname
248	scheme := strings.ToLower(parsed.Scheme)
249	hostname := strings.ToLower(parsed.Hostname())
250	if strings.Contains(hostname, ":") && !strings.HasPrefix(hostname, "[") {
251		hostname = "[" + hostname + "]"
252	}
253
254	// Determine if we should include the port
255	port := parsed.Port()
256	if port != "" {
257		// Omit default ports
258		if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
259			port = ""
260		}
261	}
262
263	// Build canonical origin
264	origin := scheme + "://" + hostname
265	if port != "" {
266		origin = origin + ":" + port
267	}
268
269	return origin, nil
270}

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