v0.33.0 62a09f5

autocomplete.go RawHistoryFolder

1// Package autocomplete provides domain types and logic for URL autocompletion.
2package autocomplete
3
4import (
5	stdurl "net/url"
6	"strings"
7	"unicode/utf8"
8)
9
10// SuggestionSource indicates the origin of an autocomplete suggestion.
11type SuggestionSource int
12
13const (
14	SourceHistory SuggestionSource = iota
15	SourceFavorite
16	SourceBangShortcut
17)
18
19// Suggestion represents a single autocomplete suggestion with its completion suffix.
20type Suggestion struct {
21	FullText string           // The complete text (URL or bang shortcut)
22	Suffix   string           // The suffix to append after the user's input
23	Source   SuggestionSource // Where this suggestion came from
24	Title    string           // Display title (page title or description)
25}
26
27// ComputeCompletionSuffix returns the suffix if input is a case-insensitive prefix of fullText.
28// Returns the suffix and true if input matches as a prefix, otherwise empty string and false.
29func ComputeCompletionSuffix(input, fullText string) (string, bool) {
30	if input == "" || fullText == "" {
31		return "", false
32	}
33
34	inputLower := strings.ToLower(input)
35	fullLower := strings.ToLower(fullText)
36
37	if !strings.HasPrefix(fullLower, inputLower) {
38		return "", false
39	}
40
41	// Return the original-case suffix from fullText
42	suffix := fullText[len(input):]
43	return suffix, suffix != ""
44}
45
46// StripProtocol removes http:// or https:// prefix from a URL for matching.
47func StripProtocol(url string) string {
48	if strings.HasPrefix(url, "https://") {
49		return url[8:]
50	}
51	if strings.HasPrefix(url, "http://") {
52		return url[7:]
53	}
54	return url
55}
56
57// ComputeURLCompletionSuffix computes the completion suffix for URLs,
58// handling protocol stripping for better matching.
59// It tries matching with and without protocol prefixes.
60func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL string, ok bool) {
61	// First try direct match
62	if suffix, ok := ComputeCompletionSuffix(input, fullURL); ok {
63		return suffix, fullURL, true
64	}
65
66	// Try matching against URL without protocol
67	strippedURL := StripProtocol(fullURL)
68	if suffix, ok := ComputeCompletionSuffix(input, strippedURL); ok {
69		return suffix, strippedURL, true
70	}
71
72	// Try matching if input has www. but URL doesn't (or vice versa)
73	inputNoWWW := strings.TrimPrefix(input, "www.")
74	strippedNoWWW := strings.TrimPrefix(strippedURL, "www.")
75	if suffix, ok := ComputeCompletionSuffix(inputNoWWW, strippedNoWWW); ok {
76		// Return suffix relative to input
77		if strings.HasPrefix(strings.ToLower(strippedURL), strings.ToLower(input)) {
78			return strippedURL[len(input):], strippedURL, true
79		}
80		return suffix, strippedNoWWW, true
81	}
82
83	return "", "", false
84}
85
86// BestURLCompletion returns the best completion candidate from a list of URLs.
87// For host-like queries (for example "goo"), it prefers short host completions
88// over deep redirect paths.
89func BestURLCompletion(input string, urls []string) (suffix, matchedURL string, ok bool) {
90	suffix, matchedURL, _, ok = BestURLCompletionWithIndex(input, urls)
91	return suffix, matchedURL, ok
92}
93
94// BestURLCompletionWithIndex returns the best completion candidate and its source URL index.
95func BestURLCompletionWithIndex(input string, urls []string) (suffix, matchedURL string, index int, ok bool) {
96	bestScore := completionScore{
97		valid: false,
98	}
99	bestSuffix := ""
100	bestMatched := ""
101	bestIndex := -1
102
103	for idx, rawURL := range urls {
104		if rawURL == "" {
105			continue
106		}
107
108		candidateText, candidateSuffix, candidateOK := completionCandidateForURL(input, rawURL)
109		if !candidateOK {
110			continue
111		}
112
113		score := rankCompletion(candidateText, candidateSuffix, idx)
114		if !bestScore.valid || score.less(bestScore) {
115			bestScore = score
116			bestSuffix = candidateSuffix
117			bestMatched = candidateText
118			bestIndex = idx
119		}
120	}
121
122	if !bestScore.valid {
123		return "", "", -1, false
124	}
125	return bestSuffix, bestMatched, bestIndex, true
126}
127
128func completionCandidateForURL(input, rawURL string) (matchedURL, suffix string, ok bool) {
129	if looksLikeHostInput(input) {
130		if host := hostForCompletion(rawURL); host != "" {
131			for _, candidate := range []string{host, strings.TrimPrefix(host, "www.")} {
132				if completionSuffix, completionOK := ComputeCompletionSuffix(input, candidate); completionOK {
133					return candidate, completionSuffix, true
134				}
135			}
136		}
137	}
138
139	completionSuffix, completionMatched, completionOK := ComputeURLCompletionSuffix(input, rawURL)
140	if !completionOK {
141		return "", "", false
142	}
143	return completionMatched, completionSuffix, true
144}
145
146func looksLikeHostInput(input string) bool {
147	if input == "" || strings.HasPrefix(input, "!") {
148		return false
149	}
150	return !strings.ContainsAny(input, "/?#=& ")
151}
152
153func hostForCompletion(raw string) string {
154	if raw == "" {
155		return ""
156	}
157	if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
158		return strings.ToLower(parsed.Hostname())
159	}
160
161	trimmed := strings.ToLower(StripProtocol(raw))
162	for _, sep := range []string{"/", "?", "#"} {
163		if idx := strings.Index(trimmed, sep); idx >= 0 {
164			trimmed = trimmed[:idx]
165		}
166	}
167	return strings.TrimSpace(trimmed)
168}
169
170type completionScore struct {
171	valid         bool
172	hasPathOrArgs bool
173	suffixLen     int
174	totalLen      int
175	index         int
176}
177
178func rankCompletion(matchedURL, suffix string, index int) completionScore {
179	return completionScore{
180		valid:         true,
181		hasPathOrArgs: strings.ContainsAny(matchedURL, "/?#"),
182		suffixLen:     utf8.RuneCountInString(suffix),
183		totalLen:      utf8.RuneCountInString(matchedURL),
184		index:         index,
185	}
186}
187
188func (s completionScore) less(other completionScore) bool {
189	if s.hasPathOrArgs != other.hasPathOrArgs {
190		return !s.hasPathOrArgs
191	}
192	if s.index != other.index {
193		return s.index < other.index
194	}
195	if s.suffixLen != other.suffixLen {
196		return s.suffixLen < other.suffixLen
197	}
198	if s.totalLen != other.totalLen {
199		return s.totalLen < other.totalLen
200	}
201	return false
202}

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