v0.29.0 21b3150

normalize_test.go RawHistoryFolder

1package url
2
3import (
4	"os"
5	"path/filepath"
6	"testing"
7)
8
9func TestNormalize(t *testing.T) {
10	tests := []struct {
11		name  string
12		input string
13		want  string
14	}{
15		{
16			name:  "empty string",
17			input: "",
18			want:  "",
19		},
20		{
21			name:  "http scheme unchanged",
22			input: "http://example.com",
23			want:  "http://example.com",
24		},
25		{
26			name:  "https scheme unchanged",
27			input: "https://example.com",
28			want:  "https://example.com",
29		},
30		{
31			name:  "file scheme unchanged",
32			input: "file:///path/to/file.html",
33			want:  "file:///path/to/file.html",
34		},
35		{
36			name:  "dumb scheme unchanged",
37			input: "dumb://history",
38			want:  "dumb://history",
39		},
40		{
41			name:  "about scheme unchanged",
42			input: "about:blank",
43			want:  "about:blank",
44		},
45		{
46			name:  "mailto scheme unchanged",
47			input: "mailto:foo@example.com",
48			want:  "mailto:foo@example.com",
49		},
50		{
51			name:  "magnet scheme unchanged",
52			input: "magnet:?xt=urn:btih:123",
53			want:  "magnet:?xt=urn:btih:123",
54		},
55		{
56			name:  "obsidian scheme unchanged",
57			input: "obsidian://open?vault=foo.bar",
58			want:  "obsidian://open?vault=foo.bar",
59		},
60		{
61			name:  "domain gets https",
62			input: "example.com",
63			want:  "https://example.com",
64		},
65		{
66			name:  "domain with path gets https",
67			input: "example.com/path",
68			want:  "https://example.com/path",
69		},
70		{
71			name:  "search query unchanged",
72			input: "hello world",
73			want:  "hello world",
74		},
75		{
76			name:  "single word unchanged",
77			input: "hello",
78			want:  "hello",
79		},
80		{
81			name:  "localhost",
82			input: "localhost",
83			want:  "http://localhost",
84		},
85		{
86			name:  "localhost with port",
87			input: "localhost:5173",
88			want:  "http://localhost:5173",
89		},
90		{
91			name:  "localhost with alt port",
92			input: "localhost:8080",
93			want:  "http://localhost:8080",
94		},
95		{
96			name:  "localhost with path",
97			input: "localhost/api/v1",
98			want:  "http://localhost/api/v1",
99		},
100		{
101			name:  "localhost.com is a domain not localhost",
102			input: "localhost.com",
103			want:  "https://localhost.com",
104		},
105		{
106			name:  "localhostevil.com is a domain not localhost",
107			input: "localhostevil.com",
108			want:  "https://localhostevil.com",
109		},
110	}
111
112	for _, tt := range tests {
113		t.Run(tt.name, func(t *testing.T) {
114			got := Normalize(tt.input)
115			if got != tt.want {
116				t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
117			}
118		})
119	}
120}
121
122func TestNormalize_LocalFiles(t *testing.T) {
123	// Create a temporary file for testing
124	tmpDir := t.TempDir()
125	tmpFile := filepath.Join(tmpDir, "test.html")
126	if err := os.WriteFile(tmpFile, []byte("<html></html>"), 0644); err != nil {
127		t.Fatalf("failed to create temp file: %v", err)
128	}
129
130	// Create a subdirectory with a file
131	subDir := filepath.Join(tmpDir, "subdir")
132	if err := os.Mkdir(subDir, 0755); err != nil {
133		t.Fatalf("failed to create subdir: %v", err)
134	}
135	subFile := filepath.Join(subDir, "page.html")
136	if err := os.WriteFile(subFile, []byte("<html></html>"), 0644); err != nil {
137		t.Fatalf("failed to create subdir file: %v", err)
138	}
139
140	tests := []struct {
141		name  string
142		input string
143		want  string
144	}{
145		{
146			name:  "absolute path to existing file",
147			input: tmpFile,
148			want:  "file://" + tmpFile,
149		},
150		{
151			name:  "absolute path to existing directory",
152			input: tmpDir,
153			want:  "file://" + tmpDir,
154		},
155		{
156			name:  "non-existent absolute path returned unchanged",
157			input: "/nonexistent/file.html",
158			want:  "/nonexistent/file.html",
159		},
160		{
161			name:  "non-existent relative path with dot prefix returned unchanged",
162			input: "./nonexistent/file.html",
163			want:  "./nonexistent/file.html",
164		},
165		{
166			name:  "non-existent home path returned unchanged",
167			input: "~/nonexistent/file.html",
168			want:  "~/nonexistent/file.html",
169		},
170	}
171
172	for _, tt := range tests {
173		t.Run(tt.name, func(t *testing.T) {
174			got := Normalize(tt.input)
175			if got != tt.want {
176				t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
177			}
178		})
179	}
180}
181
182func TestNormalize_RelativePaths(t *testing.T) {
183	// Create a temp file in current directory
184	tmpFile, err := os.CreateTemp(".", "test-*.html")
185	if err != nil {
186		t.Fatalf("failed to create temp file: %v", err)
187	}
188	tmpFile.Close()
189	defer os.Remove(tmpFile.Name())
190
191	// Get absolute path for comparison
192	absPath, err := filepath.Abs(tmpFile.Name())
193	if err != nil {
194		t.Fatalf("failed to get abs path: %v", err)
195	}
196
197	t.Run("relative path to existing file", func(t *testing.T) {
198		got := Normalize(tmpFile.Name())
199		want := "file://" + absPath
200		if got != want {
201			t.Errorf("Normalize(%q) = %q, want %q", tmpFile.Name(), got, want)
202		}
203	})
204}
205
206func TestExpandHome(t *testing.T) {
207	home, err := os.UserHomeDir()
208	if err != nil {
209		t.Skip("cannot get home directory")
210	}
211
212	tests := []struct {
213		name  string
214		input string
215		want  string
216	}{
217		{
218			name:  "tilde expansion",
219			input: "~/Documents",
220			want:  filepath.Join(home, "Documents"),
221		},
222		{
223			name:  "no tilde unchanged",
224			input: "/absolute/path",
225			want:  "/absolute/path",
226		},
227		{
228			name:  "relative path unchanged",
229			input: "./relative/path",
230			want:  "./relative/path",
231		},
232		{
233			name:  "tilde in middle unchanged",
234			input: "/path/~/file",
235			want:  "/path/~/file",
236		},
237	}
238
239	for _, tt := range tests {
240		t.Run(tt.name, func(t *testing.T) {
241			got := expandHome(tt.input)
242			if got != tt.want {
243				t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
244			}
245		})
246	}
247}
248
249func TestTrimLeadingSpacesIfURL(t *testing.T) {
250	tests := []struct {
251		name  string
252		input string
253		want  string
254	}{
255		{
256			name:  "single space before https URL",
257			input: " https://example.com",
258			want:  "https://example.com",
259		},
260		{
261			name:  "multiple spaces before https URL",
262			input: "   https://example.com",
263			want:  "https://example.com",
264		},
265		{
266			name:  "tab before URL",
267			input: "\thttps://example.com",
268			want:  "https://example.com",
269		},
270		{
271			name:  "mixed whitespace before URL",
272			input: " \t https://example.com",
273			want:  "https://example.com",
274		},
275		{
276			name:  "space before domain",
277			input: " example.com",
278			want:  "example.com",
279		},
280		{
281			name:  "spaces before localhost",
282			input: "  localhost:8080",
283			want:  "localhost:8080",
284		},
285		{
286			name:  "no leading space URL unchanged",
287			input: "https://example.com",
288			want:  "https://example.com",
289		},
290		{
291			name:  "search query with leading space unchanged",
292			input: " hello world",
293			want:  " hello world",
294		},
295		{
296			name:  "single word with leading space unchanged",
297			input: " hello",
298			want:  " hello",
299		},
300		{
301			name:  "empty string unchanged",
302			input: "",
303			want:  "",
304		},
305		{
306			name:  "only spaces unchanged",
307			input: "   ",
308			want:  "   ",
309		},
310	}
311
312	for _, tt := range tests {
313		t.Run(tt.name, func(t *testing.T) {
314			got := TrimLeadingSpacesIfURL(tt.input)
315			if got != tt.want {
316				t.Errorf("TrimLeadingSpacesIfURL(%q) = %q, want %q", tt.input, got, tt.want)
317			}
318		})
319	}
320}
321
322func TestLooksLikeURL(t *testing.T) {
323	tests := []struct {
324		name  string
325		input string
326		want  bool
327	}{
328		{
329			name:  "empty string",
330			input: "",
331			want:  false,
332		},
333		{
334			name:  "http URL",
335			input: "http://example.com",
336			want:  true,
337		},
338		{
339			name:  "https URL",
340			input: "https://example.com",
341			want:  true,
342		},
343		{
344			name:  "file URL",
345			input: "file:///path/to/file",
346			want:  true,
347		},
348		{
349			name:  "dumb URL",
350			input: "dumb://history",
351			want:  true,
352		},
353		{
354			name:  "obsidian URL",
355			input: "obsidian://open?vault=foo.bar",
356			want:  true,
357		},
358		{
359			name:  "about URL",
360			input: "about:blank",
361			want:  true,
362		},
363		{
364			name:  "spotify URL",
365			input: "spotify://track/123",
366			want:  true,
367		},
368		{
369			name:  "mailto URL",
370			input: "mailto:foo@example.com",
371			want:  true,
372		},
373		{
374			name:  "magnet URL",
375			input: "magnet:?xt=urn:btih:123",
376			want:  true,
377		},
378		{
379			name:  "vscode URL",
380			input: "vscode://file/tmp/demo",
381			want:  true,
382		},
383		{
384			name:  "domain",
385			input: "example.com",
386			want:  true,
387		},
388		{
389			name:  "domain with path",
390			input: "example.com/path",
391			want:  true,
392		},
393		{
394			name:  "search query",
395			input: "hello world",
396			want:  false,
397		},
398		{
399			name:  "single word",
400			input: "hello",
401			want:  false,
402		},
403		{
404			name:  "localhost",
405			input: "localhost",
406			want:  true,
407		},
408		{
409			name:  "localhost with port",
410			input: "localhost:5173",
411			want:  true,
412		},
413		{
414			name:  "localhost with alt port",
415			input: "localhost:8080",
416			want:  true,
417		},
418		{
419			name:  "localhost with path",
420			input: "localhost/api/v1",
421			want:  true,
422		},
423		{
424			name:  "localhost.com is a domain not localhost",
425			input: "localhost.com",
426			want:  true,
427		},
428		{
429			name:  "localhostevil.com is a domain not localhost",
430			input: "localhostevil.com",
431			want:  true,
432		},
433		{
434			name:  "ipv4 with port",
435			input: "100.64.0.10:3000",
436			want:  true,
437		},
438		{
439			name:  "ipv4",
440			input: "127.0.0.1",
441			want:  true,
442		},
443		{
444			name:  "ipv6 bracketed",
445			input: "[::1]",
446			want:  true,
447		},
448	}
449
450	for _, tt := range tests {
451		t.Run(tt.name, func(t *testing.T) {
452			got := LooksLikeURL(tt.input)
453			if got != tt.want {
454				t.Errorf("LooksLikeURL(%q) = %v, want %v", tt.input, got, tt.want)
455			}
456		})
457	}
458}
459
460func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) {
461	tests := []struct {
462		name  string
463		input string
464		want  string
465	}{
466		{name: "tailnet ipv4", input: "100.64.0.10", want: "http://100.64.0.10"},
467		{name: "tailnet ipv4 with port", input: "100.64.0.10:8080", want: "http://100.64.0.10:8080"},
468		{name: "ipv4 with path", input: "100.64.0.10/api", want: "http://100.64.0.10/api"},
469	}
470
471	for _, tt := range tests {
472		t.Run(tt.name, func(t *testing.T) {
473			got := Normalize(tt.input)
474			if got != tt.want {
475				t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
476			}
477		})
478	}
479}
480
481func TestIsExternalScheme(t *testing.T) {
482	tests := []struct {
483		name  string
484		input string
485		want  bool
486	}{
487		{
488			name:  "empty string",
489			input: "",
490			want:  false,
491		},
492		{
493			name:  "http scheme",
494			input: "http://example.com",
495			want:  false,
496		},
497		{
498			name:  "https scheme",
499			input: "https://example.com",
500			want:  false,
501		},
502		{
503			name:  "file scheme",
504			input: "file:///path/to/file.html",
505			want:  false,
506		},
507		{
508			name:  "dumb scheme",
509			input: "dumb://history",
510			want:  false,
511		},
512		{
513			name:  "about scheme",
514			input: "about:blank",
515			want:  false,
516		},
517		{
518			name:  "data scheme",
519			input: "data:text/plain;base64,SGVsbG8gV29ybGQ==",
520			want:  false,
521		},
522		{
523			name:  "blob scheme",
524			input: "blob:https://example.com/blob-id",
525			want:  false,
526		},
527		{
528			name:  "javascript scheme",
529			input: "javascript:alert('hello')",
530			want:  false,
531		},
532		{
533			name:  "vscode scheme",
534			input: "vscode://open",
535			want:  true,
536		},
537		{
538			name:  "spotify scheme",
539			input: "spotify:track:123",
540			want:  true,
541		},
542		{
543			name:  "steam scheme",
544			input: "steam://run/123",
545			want:  true,
546		},
547		{
548			name:  "uppercase HTTP scheme",
549			input: "HTTP://example.com",
550			want:  false,
551		},
552		{
553			name:  "uppercase ABOUT scheme",
554			input: "ABOUT:blank",
555			want:  false,
556		},
557		{
558			name:  "uppercase VSCODE scheme",
559			input: "VSCODE://open",
560			want:  true,
561		},
562		{
563			name:  "geo scheme",
564			input: "geo:37.786971,-122.399677",
565			want:  true,
566		},
567		{
568			name:  "xmpp scheme",
569			input: "xmpp:user@example.com",
570			want:  true,
571		},
572		{
573			name:  "malformed URL",
574			input: "://invalid",
575			want:  false,
576		},
577		{
578			name:  "no scheme",
579			input: "example.com",
580			want:  false,
581		},
582		{
583			name:  "localhost port is not external scheme",
584			input: "localhost:5173",
585			want:  false,
586		},
587		{
588			name:  "scheme only",
589			input: "custom://",
590			want:  true,
591		},
592	}
593
594	for _, tt := range tests {
595		t.Run(tt.name, func(t *testing.T) {
596			got := IsExternalScheme(tt.input)
597			if got != tt.want {
598				t.Errorf("IsExternalScheme(%q) = %v, want %v", tt.input, got, tt.want)
599			}
600		})
601	}
602}
603
604func TestExtractOrigin_ValidURI(t *testing.T) {
605	tests := []struct {
606		name     string
607		uri      string
608		expected string
609	}{
610		{
611			name:     "https with port",
612			uri:      "https://example.com:8443/path?query=1",
613			expected: "https://example.com:8443",
614		},
615		{
616			name:     "https without port",
617			uri:      "https://example.com/path",
618			expected: "https://example.com",
619		},
620		{
621			name:     "http",
622			uri:      "http://localhost:8080/app",
623			expected: "http://localhost:8080",
624		},
625		{
626			name:     "uppercase host",
627			uri:      "https://EXAMPLE.COM/path",
628			expected: "https://example.com",
629		},
630		{
631			name:     "uppercase scheme",
632			uri:      "HTTPS://example.com/path",
633			expected: "https://example.com",
634		},
635		{
636			name:     "https default port omitted",
637			uri:      "https://example.com:443/path",
638			expected: "https://example.com",
639		},
640		{
641			name:     "http default port omitted",
642			uri:      "http://example.com:80/path",
643			expected: "http://example.com",
644		},
645		{
646			name:     "https explicit non-default port kept",
647			uri:      "https://example.com:8080/path",
648			expected: "https://example.com:8080",
649		},
650		{
651			name:     "mixed case with default port",
652			uri:      "HTTPS://EXAMPLE.COM:443/path",
653			expected: "https://example.com",
654		},
655		{
656			name:     "ipv6 with explicit port",
657			uri:      "https://[::1]:8443/path",
658			expected: "https://[::1]:8443",
659		},
660	}
661
662	for _, tt := range tests {
663		t.Run(tt.name, func(t *testing.T) {
664			origin, err := ExtractOrigin(tt.uri)
665			if err != nil {
666				t.Errorf("ExtractOrigin(%q) returned error: %v", tt.uri, err)
667				return
668			}
669			if origin != tt.expected {
670				t.Errorf("ExtractOrigin(%q) = %q, want %q", tt.uri, origin, tt.expected)
671			}
672		})
673	}
674}
675
676func TestExtractOrigin_InvalidURI(t *testing.T) {
677	tests := []struct {
678		name string
679		uri  string
680	}{
681		{name: "empty", uri: ""},
682		{name: "no scheme", uri: "example.com"},
683		{name: "no host", uri: "https://"},
684	}
685
686	for _, tt := range tests {
687		t.Run(tt.name, func(t *testing.T) {
688			origin, err := ExtractOrigin(tt.uri)
689			if err == nil {
690				t.Errorf("ExtractOrigin(%q) should return error, got origin: %q", tt.uri, origin)
691			}
692		})
693	}
694}
695
696func TestCanonicalDomain(t *testing.T) {
697	tests := []struct {
698		name  string
699		input string
700		want  string
701	}{
702		{name: "empty", input: "", want: ""},
703		{name: "whitespace only", input: "   ", want: ""},
704		{name: "url", input: "https://www.example.com/path", want: "example.com"},
705		{name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"},
706		{name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
707		{name: "url with userinfo and port", input: "https://user:pass@example.com:8443/path", want: "example.com:8443"},
708		{name: "domain", input: "www.example.com", want: "example.com"},
709		{name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"},
710	}
711
712	for _, tt := range tests {
713		t.Run(tt.name, func(t *testing.T) {
714			if got := CanonicalDomain(tt.input); got != tt.want {
715				t.Errorf("CanonicalDomain(%q) = %q, want %q", tt.input, got, tt.want)
716			}
717		})
718	}
719}
720
721func TestDisplayDomain(t *testing.T) {
722	tests := []struct {
723		name  string
724		input string
725		want  string
726	}{
727		{name: "empty", input: "", want: ""},
728		{name: "whitespace only", input: "   ", want: ""},
729		{name: "url with port", input: "https://www.example.com:8443/path", want: "example.com"},
730		{name: "url without port", input: "https://www.example.com/path", want: "example.com"},
731		{name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
732		{name: "no www prefix", input: "https://docs.example.com/path", want: "docs.example.com"},
733		{name: "host only", input: "www.example.org:9443/path?q=1", want: "example.org"},
734		{name: "invalid url", input: "%zz", want: "%zz"},
735		{name: "non-http scheme", input: "ftp://www.example.net:21/file", want: "example.net"},
736		{name: "ipv6 url", input: "https://[2001:db8::1]:8443/path", want: "2001:db8::1"},
737		{name: "uppercase host", input: "https://WWW.EXAMPLE.COM/path", want: "example.com"},
738	}
739
740	for _, tt := range tests {
741		t.Run(tt.name, func(t *testing.T) {
742			if got := DisplayDomain(tt.input); got != tt.want {
743				t.Errorf("DisplayDomain(%q) = %q, want %q", tt.input, got, tt.want)
744			}
745		})
746	}
747}

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