main 5041353

omnibox_suggestions.go RawHistoryFolder

1package component
2
3import (
4	stdurl "net/url"
5	"strings"
6
7	"github.com/bnema/dumber/internal/domain/autocomplete"
8	"github.com/bnema/dumber/internal/domain/url"
9)
10
11// Omnibox suggestion logic: pure functions over the entry text and the
12// visible result list. The GTK widget in omnibox.go only feeds state in and
13// renders the answer, so this file is the test surface for ghost completion,
14// selection targets, and search-query resolution.
15
16// ghostCompletionRequest is everything ghost completion depends on.
17type ghostCompletionRequest struct {
18	EntryText            string
19	SelectedURL          string
20	HasExplicitSelection bool
21	Mode                 ViewMode
22	MaxResults           int
23	Suggestions          []Suggestion
24	Favorites            []Favorite
25}
26
27// ghostCompletion returns the inline completion suffix to show after the
28// entry text, and the full text it completes to. ok is false when nothing
29// should be shown. Host-like input completes to a domain rather than a full
30// redirect URL.
31func ghostCompletion(req ghostCompletionRequest) (fullText, suffix string, ok bool) {
32	_, input, ok := ghostCompletionInput(req.EntryText)
33	if !ok {
34		return "", "", false
35	}
36	fullText, suffix, ok = visibleGhostSuggestion(
37		input, req.SelectedURL, req.HasExplicitSelection, req.Mode, req.MaxResults, req.Suggestions, req.Favorites,
38	)
39	if !ok {
40		return "", "", false
41	}
42	fullText, suffix = normalizeGhostSuggestion(input, fullText, suffix)
43	if fullText == "" || suffix == "" {
44		return "", "", false
45	}
46	return fullText, suffix, true
47}
48
49// isGhostEcho reports whether the entry text is the debounced echo of Dumber's
50// own ghost completion: realInput plus the suffix currently displayed. A
51// matching echo must leave the ghost state untouched, but it cannot be told
52// apart from a paste that replaced the selected suffix with the same text, so a
53// deferred Enter still wins and the ghost state is rebuilt from the paste.
54func isGhostEcho(entryText, realInput, ghostSuffix string) bool {
55	return ghostSuffix != "" && entryText == realInput+ghostSuffix
56}
57
58// reconcileEntryState decides how to resync the entry buffer with the
59// realInput/ghostSuffix shadow state. The buffer is the source of truth:
60// search-changed is debounced, so realInput lags behind fresh keystrokes.
61// The buffer is only ever rewritten to strip a still-intact ghost suffix —
62// never to restore older text, which would delete characters the user just
63// typed.
64func reconcileEntryState(buffer, realInput, ghostSuffix string) (newRealInput string, rewrite bool) {
65	if ghostSuffix != "" && buffer == realInput+ghostSuffix {
66		return realInput, true
67	}
68	return buffer, false
69}
70
71func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
72	trimmed := strings.TrimLeft(entryText, " \t")
73	leadingWhitespace = entryText[:len(entryText)-len(trimmed)]
74	if trimmed == "" {
75		return leadingWhitespace, "", false
76	}
77	return leadingWhitespace, trimmed, true
78}
79
80// normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
81// appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
82func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
83	if queryText == "" || fullText == "" {
84		return "", ""
85	}
86
87	// If input already contains path/query-ish delimiters, keep original completion behavior.
88	if strings.ContainsAny(queryText, "/?#=& ") {
89		return fullText, fallbackSuffix
90	}
91
92	hostOnly := extractHostForCompletion(fullText)
93	if hostOnly != "" {
94		for _, candidate := range []string{hostOnly, strings.TrimPrefix(hostOnly, "www.")} {
95			if completionSuffix, ok := autocomplete.ComputeCompletionSuffix(queryText, candidate); ok {
96				return candidate, completionSuffix
97			}
98		}
99	}
100
101	return fullText, fallbackSuffix
102}
103
104func extractHostForCompletion(raw string) string {
105	if raw == "" {
106		return ""
107	}
108	if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
109		return strings.ToLower(parsed.Hostname())
110	}
111	trimmed := autocomplete.StripProtocol(raw)
112	trimmed = strings.ToLower(trimmed)
113	for _, sep := range []string{"/", "?", "#"} {
114		if idx := strings.Index(trimmed, sep); idx >= 0 {
115			trimmed = trimmed[:idx]
116		}
117	}
118	return strings.TrimSpace(trimmed)
119}
120
121func visibleGhostSuggestion(
122	query, selectedURL string,
123	hasExplicitSelection bool,
124	mode ViewMode,
125	maxResults int,
126	suggestions []Suggestion,
127	favorites []Favorite,
128) (fullText, suffix string, ok bool) {
129	if hasExplicitSelection {
130		suffix, fullText, ok = autocomplete.ComputeURLCompletionSuffix(query, selectedURL)
131		return fullText, suffix, ok
132	}
133
134	listedURLs := listedURLsForMode(mode, maxResults, suggestions, favorites)
135	suffix, fullText, ok = autocomplete.BestURLCompletion(query, listedURLs)
136	return fullText, suffix, ok
137}
138
139func listedURLsForMode(mode ViewMode, maxResults int, suggestions []Suggestion, favorites []Favorite) []string {
140	if mode == ViewModeHistory {
141		count := limitedCount(len(suggestions), maxResults)
142		urls := make([]string, 0, count)
143		for _, s := range suggestions[:count] {
144			if s.URL != "" {
145				urls = append(urls, s.URL)
146			}
147		}
148		return urls
149	}
150
151	count := limitedCount(len(favorites), maxResults)
152	urls := make([]string, 0, count)
153	for _, f := range favorites[:count] {
154		if f.URL != "" {
155			urls = append(urls, f.URL)
156		}
157	}
158	return urls
159}
160
161func selectedTargetURL(mode ViewMode, idx, maxResults int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
162	if idx < 0 {
163		return "", false
164	}
165	return resolveTargetURLForSelection(mode, idx, maxResults, suggestions, favorites), true
166}
167
168func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) {
169	if idx < 0 || idx >= len(bangSuggestions) {
170		return "", false
171	}
172	return "!" + bangSuggestions[idx].Key + " ", true
173}
174
175func limitedCount(total, maxResults int) int {
176	if total <= 0 {
177		return 0
178	}
179	if maxResults <= 0 || total < maxResults {
180		return total
181	}
182	return maxResults
183}
184
185// navigableCount returns how many rows of the active list are rendered and
186// reachable by keyboard: the active list length capped at MaxResults.
187func navigableCount(bangMode bool, mode ViewMode, bangs, suggestions, favorites int) int {
188	total := favorites
189	switch {
190	case bangMode:
191		total = bangs
192	case mode == ViewModeHistory:
193		total = suggestions
194	}
195	return limitedCount(total, OmniboxListDefaults.MaxResults)
196}
197
198func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
199	if hasGhost && realInput != "" {
200		return realInput
201	}
202	return entryText
203}
204
205func resolveTargetURLForSelection(mode ViewMode, idx, maxResults int, suggestions []Suggestion, favorites []Favorite) string {
206	if mode == ViewModeHistory {
207		count := limitedCount(len(suggestions), maxResults)
208		if idx >= 0 && idx < count {
209			return suggestions[idx].URL
210		}
211		return ""
212	}
213	count := limitedCount(len(favorites), maxResults)
214	if idx >= 0 && idx < count {
215		return favorites[idx].URL
216	}
217	return ""
218}
219
220func shouldPreferTypedURLNavigation(entryText string) bool {
221	entryText = strings.TrimSpace(entryText)
222	if entryText == "" {
223		return false
224	}
225	return url.LooksLikeURL(entryText)
226}

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