v0.27.2 5960dd8

autocomplete.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"sync"
6	"time"
7
8	"github.com/bnema/dumber/internal/domain/autocomplete"
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/logging"
11)
12
13// AutocompleteUseCase handles inline autocomplete suggestions.
14type AutocompleteUseCase struct {
15	historyUC   *SearchHistoryUseCase
16	favoritesUC *ManageFavoritesUseCase
17	shortcutsUC *SearchShortcutsUseCase
18
19	cacheMu         sync.Mutex
20	favoritesCache  []*entity.Favorite
21	favoritesCached time.Time
22	historyCache    []*entity.HistoryEntry
23	historyCached   time.Time
24}
25
26const autocompleteHistoryLimit = 200
27const autocompleteCacheTTL = 2 * time.Second
28
29// NewAutocompleteUseCase creates a new autocomplete use case.
30func NewAutocompleteUseCase(
31	historyUC *SearchHistoryUseCase,
32	favoritesUC *ManageFavoritesUseCase,
33	shortcutsUC *SearchShortcutsUseCase,
34) *AutocompleteUseCase {
35	return &AutocompleteUseCase{
36		historyUC:   historyUC,
37		favoritesUC: favoritesUC,
38		shortcutsUC: shortcutsUC,
39	}
40}
41
42// GetSuggestionInput contains parameters for getting an autocomplete suggestion.
43type GetSuggestionInput struct {
44	Input string // Current user input
45}
46
47// GetSuggestionOutput contains the best autocomplete suggestion.
48type GetSuggestionOutput struct {
49	Suggestion *autocomplete.Suggestion
50	Found      bool
51}
52
53// CompletionOptions controls how URL completions are resolved.
54type CompletionOptions struct {
55	VisibleURLs []string
56	AllowBangs  bool
57}
58
59// GetSuggestion returns the best autocomplete suggestion for the given input.
60// Priority: Favorites > History (by frecency) > Bang shortcuts.
61func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput {
62	log := logging.FromContext(ctx)
63
64	if input.Input == "" {
65		return &GetSuggestionOutput{Found: false}
66	}
67
68	// Try favorites first (highest priority)
69	if suggestion := uc.findFavoriteSuggestion(ctx, input.Input); suggestion != nil {
70		log.Debug().
71			Str("input", input.Input).
72			Str("match", suggestion.FullText).
73			Msg("autocomplete: matched favorite")
74		return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
75	}
76
77	// Try history (second priority)
78	if suggestion := uc.findHistorySuggestion(ctx, input.Input); suggestion != nil {
79		log.Debug().
80			Str("input", input.Input).
81			Str("match", suggestion.FullText).
82			Msg("autocomplete: matched history")
83		return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
84	}
85
86	// Try bang shortcuts (lowest priority for URL-style completion)
87	if suggestion := uc.findBangSuggestion(ctx, input.Input); suggestion != nil {
88		log.Debug().
89			Str("input", input.Input).
90			Str("match", suggestion.FullText).
91			Msg("autocomplete: matched bang shortcut")
92		return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
93	}
94
95	return &GetSuggestionOutput{Found: false}
96}
97
98// ResolveCompletion resolves the best completion using visible list order first,
99// then cached favorites/history as fallback.
100func (uc *AutocompleteUseCase) ResolveCompletion(ctx context.Context, input string, opts CompletionOptions) *autocomplete.Suggestion {
101	log := logging.FromContext(ctx)
102
103	if input == "" {
104		return nil
105	}
106
107	if len(opts.VisibleURLs) > 0 {
108		if suggestion := findCompletionInURLs(ctx, input, opts.VisibleURLs, autocomplete.SourceHistory); suggestion != nil {
109			log.Debug().
110				Str("input", input).
111				Str("match", suggestion.FullText).
112				Msg("autocomplete: matched visible list")
113			return suggestion
114		}
115	}
116
117	if suggestion := uc.findFavoriteSuggestion(ctx, input); suggestion != nil {
118		log.Debug().
119			Str("input", input).
120			Str("match", suggestion.FullText).
121			Msg("autocomplete: matched favorite fallback")
122		return suggestion
123	}
124
125	if suggestion := uc.findHistorySuggestion(ctx, input); suggestion != nil {
126		log.Debug().
127			Str("input", input).
128			Str("match", suggestion.FullText).
129			Msg("autocomplete: matched history fallback")
130		return suggestion
131	}
132
133	if opts.AllowBangs {
134		if suggestion := uc.findBangSuggestion(ctx, input); suggestion != nil {
135			log.Debug().
136				Str("input", input).
137				Str("match", suggestion.FullText).
138				Msg("autocomplete: matched bang fallback")
139			return suggestion
140		}
141	}
142
143	return nil
144}
145
146// findFavoriteSuggestion finds the best matching favorite for the input.
147func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
148	log := logging.FromContext(ctx)
149
150	if uc.favoritesUC == nil {
151		return nil
152	}
153
154	favorites, err := uc.getCachedFavorites(ctx)
155	if err != nil {
156		log.Debug().Err(err).Msg("autocomplete: favorites cache fetch failed")
157		return nil
158	}
159
160	return bestSuggestionFromURLs(input, autocomplete.SourceFavorite,
161		len(favorites),
162		func(i int) string {
163			if favorites[i] == nil {
164				return ""
165			}
166			return favorites[i].URL
167		},
168		func(i int) string {
169			if favorites[i] == nil {
170				return ""
171			}
172			return favorites[i].Title
173		},
174	)
175}
176
177// findHistorySuggestion finds the best matching history entry for the input.
178func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
179	log := logging.FromContext(ctx)
180
181	if uc.historyUC == nil {
182		return nil
183	}
184
185	entries, err := uc.getCachedHistory(ctx)
186	if err != nil {
187		log.Debug().Err(err).Msg("autocomplete: history cache fetch failed")
188		return nil
189	}
190
191	return bestSuggestionFromURLs(input, autocomplete.SourceHistory,
192		len(entries),
193		func(i int) string {
194			if entries[i] == nil {
195				return ""
196			}
197			return entries[i].URL
198		},
199		func(i int) string {
200			if entries[i] == nil {
201				return ""
202			}
203			return entries[i].Title
204		},
205	)
206}
207
208// findBangSuggestion finds the best matching bang shortcut for the input.
209func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
210	_ = logging.FromContext(ctx)
211
212	if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' {
213		return nil
214	}
215
216	// Get filtered bang suggestions
217	output := uc.shortcutsUC.FilterBangs(ctx, FilterBangsInput{Query: input})
218	if len(output.Suggestions) == 0 {
219		return nil
220	}
221
222	// Use the first (best) match
223	bang := output.Suggestions[0]
224	fullBang := "!" + bang.Key
225
226	suffix, ok := autocomplete.ComputeCompletionSuffix(input, fullBang)
227	if ok {
228		return &autocomplete.Suggestion{
229			FullText: fullBang,
230			Suffix:   suffix,
231			Source:   autocomplete.SourceBangShortcut,
232			Title:    bang.Description,
233		}
234	}
235
236	return nil
237}
238
239func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {
240	suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls)
241	if ok {
242		return &autocomplete.Suggestion{
243			FullText: matchedURL,
244			Suffix:   suffix,
245			Source:   source,
246		}
247	}
248	return nil
249}
250
251func bestSuggestionFromURLs(
252	input string,
253	source autocomplete.SuggestionSource,
254	count int,
255	urlAt func(int) string,
256	titleAt func(int) string,
257) *autocomplete.Suggestion {
258	if count <= 0 {
259		return nil
260	}
261
262	urls := make([]string, 0, count)
263	titles := make([]string, 0, count)
264	for i := 0; i < count; i++ {
265		url := urlAt(i)
266		if url == "" {
267			continue
268		}
269		urls = append(urls, url)
270		titles = append(titles, titleAt(i))
271	}
272	if len(urls) == 0 {
273		return nil
274	}
275
276	suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls)
277	if !ok {
278		return nil
279	}
280	title := ""
281	if idx >= 0 && idx < len(titles) {
282		title = titles[idx]
283	}
284	return &autocomplete.Suggestion{
285		FullText: matchedURL,
286		Suffix:   suffix,
287		Source:   source,
288		Title:    title,
289	}
290}
291
292func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {
293	log := logging.FromContext(ctx)
294
295	uc.cacheMu.Lock()
296	if time.Since(uc.favoritesCached) < autocompleteCacheTTL && uc.favoritesCache != nil {
297		favorites := uc.favoritesCache
298		uc.cacheMu.Unlock()
299		return favorites, nil
300	}
301	uc.cacheMu.Unlock()
302
303	favorites, err := uc.favoritesUC.GetAll(ctx)
304	if err != nil {
305		return nil, err
306	}
307
308	uc.cacheMu.Lock()
309	uc.favoritesCache = favorites
310	uc.favoritesCached = time.Now()
311	uc.cacheMu.Unlock()
312
313	log.Debug().Int("count", len(favorites)).Msg("autocomplete: favorites cache refreshed")
314	return favorites, nil
315}
316
317func (uc *AutocompleteUseCase) getCachedHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
318	log := logging.FromContext(ctx)
319
320	uc.cacheMu.Lock()
321	if time.Since(uc.historyCached) < autocompleteCacheTTL && uc.historyCache != nil {
322		history := uc.historyCache
323		uc.cacheMu.Unlock()
324		return history, nil
325	}
326	uc.cacheMu.Unlock()
327
328	entries, err := uc.historyUC.GetRecent(ctx, autocompleteHistoryLimit, 0)
329	if err != nil {
330		return nil, err
331	}
332
333	uc.cacheMu.Lock()
334	uc.historyCache = entries
335	uc.historyCached = time.Now()
336	uc.cacheMu.Unlock()
337
338	log.Debug().Int("count", len(entries)).Msg("autocomplete: history cache refreshed")
339	return entries, nil
340}
341
342// GetSuggestionForURL returns an autocomplete suggestion specifically for a URL.
343// This is used when the user selects a row from the list to update ghost text.
344func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput {
345	if input == "" || targetURL == "" {
346		return &GetSuggestionOutput{Found: false}
347	}
348
349	suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, targetURL)
350	if !ok {
351		return &GetSuggestionOutput{Found: false}
352	}
353
354	return &GetSuggestionOutput{
355		Suggestion: &autocomplete.Suggestion{
356			FullText: matchedURL,
357			Suffix:   suffix,
358			Source:   autocomplete.SourceHistory, // Default to history for selected rows
359		},
360		Found: true,
361	}
362}

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