v0.14.0 76d1892

parser.go RawHistoryFolder

1package parser
2
3import (
4	"fmt"
5	neturl "net/url"
6	"regexp"
7	"strings"
8	"time"
9
10	"github.com/bnema/dumber/internal/config"
11)
12
13// ParseInput parses user input and returns a ParseResult with URL and metadata.
14// Now uses the proven CLI parsing logic for consistent behavior.
15func (p *Parser) ParseInput(input string) (*ParseResult, error) {
16	startTime := time.Now()
17
18	if input == "" {
19		return &ParseResult{
20			Type:           InputTypeFallbackSearch,
21			URL:            fmt.Sprintf(p.config.DefaultSearchEngine, ""),
22			Query:          input,
23			Confidence:     0.0,
24			ProcessingTime: time.Since(startTime),
25		}, nil
26	}
27
28	// Use the working CLI parsing logic
29	finalURL, err := p.parseInputUsingCLILogic(input)
30	if err != nil {
31		// If parsing fails, fall back to search
32		finalURL = fmt.Sprintf(p.config.DefaultSearchEngine, neturl.QueryEscape(input))
33	}
34
35	// Determine result type based on the URL
36	var resultType InputType
37	var confidence float64
38
39	// Check if this is a search query result
40	isSearchResult := strings.Contains(finalURL, neturl.QueryEscape(input))
41
42	switch {
43	case isSearchResult:
44		resultType = InputTypeFallbackSearch
45		confidence = 0.1
46	case strings.HasPrefix(finalURL, "http://"),
47	     strings.HasPrefix(finalURL, "https://"),
48	     strings.HasPrefix(finalURL, "dumb://"),
49	     strings.HasPrefix(finalURL, "file://"):
50		resultType = InputTypeDirectURL
51		confidence = 1.0
52	default:
53		resultType = InputTypeFallbackSearch
54		confidence = 0.1
55	}
56
57	// Check if it was a shortcut
58	if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") {
59		shortcut := strings.TrimSpace(input[:idx])
60		if _, exists := p.config.SearchShortcuts[shortcut]; exists {
61			resultType = InputTypeSearchShortcut
62			confidence = 0.95
63		}
64	}
65
66	return &ParseResult{
67		Type:           resultType,
68		URL:            finalURL,
69		Query:          input,
70		Confidence:     confidence,
71		ProcessingTime: time.Since(startTime),
72	}, nil
73}
74
75// parseDirectURL handles direct URL inputs using CLI logic.
76func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
77	// Use the working CLI parsing logic instead
78	finalURL, err := p.parseInputUsingCLILogic(cleanInput)
79	if err != nil {
80		return nil, err
81	}
82
83	return &ParseResult{
84		Type:           InputTypeDirectURL,
85		URL:            finalURL,
86		Query:          originalInput,
87		Confidence:     1.0,
88		ProcessingTime: time.Since(startTime),
89	}, nil
90}
91
92// parseInputUsingCLILogic implements the working CLI parsing logic
93func (p *Parser) parseInputUsingCLILogic(input string) (string, error) {
94	input = strings.TrimSpace(input)
95
96	// 1. Check if it's a shortcut (format: "prefix:query")
97	if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") {
98		shortcut := strings.TrimSpace(input[:idx])
99		query := strings.TrimSpace(input[idx+1:])
100
101		if query != "" {
102			// First check configuration-based shortcuts
103			if shortcutCfg, exists := p.config.SearchShortcuts[shortcut]; exists {
104				return fmt.Sprintf(shortcutCfg.URL, neturl.QueryEscape(query)), nil
105			}
106
107			return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
108		}
109	}
110
111	// 2. Already a full URL with protocol
112	if regexp.MustCompile(`^https?://`).MatchString(input) {
113		return input, nil
114	}
115
116	// 3. File protocol
117	if strings.HasPrefix(input, "file://") {
118		return input, nil
119	}
120
121	// 4. Dumb protocol for custom browser pages
122	if strings.HasPrefix(input, "dumb://") {
123		return input, nil
124	}
125
126	// 5. Localhost/development URLs
127	if regexp.MustCompile(`^(localhost|127\.0\.0\.1|0\.0\.0\.0|::1)(:\d+)?(/.*)?$`).MatchString(input) {
128		return "http://" + input, nil
129	}
130
131	// 6. Local network IPs
132	if regexp.MustCompile(`^(192\.168|10\.|172\.(1[6-9]|2[0-9]|3[01]))\.\d+\.\d+(:\d+)?(/.*)?$`).MatchString(input) {
133		return "http://" + input, nil
134	}
135
136	// 7. Domain with TLD (must have dot and valid TLD)
137	// More comprehensive TLD pattern for better URL detection
138	domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(/.*)?$`)
139	if domainPattern.MatchString(input) {
140		// Additional check for common TLDs to reduce false positives
141		commonTLDs := regexp.MustCompile(`\.(com|org|net|edu|gov|io|co|uk|de|fr|jp|cn|in|br|au|ca|ru|nl|it|es|se|no|fi|dk|pl|ch|at|be|cz|gr|il|mx|nz|sg|kr|tw|hk|my|th|vn|id|ph|za|eg|ng|ke|dev|app|xyz|tech|site|online|store|blog|info|biz|name|pro)(/.*)?$`)
142		if commonTLDs.MatchString(input) {
143			return "https://" + input, nil
144		}
145
146		// If it has a dot but uncommon TLD, still try as URL
147		return "https://" + input, nil
148	}
149
150	// 8. Everything else is a search query
151	// This includes: single words, multi-word phrases, questions, special characters, etc.
152	// Single words without TLDs should be treated as search queries, not hostnames
153	return fmt.Sprintf(p.config.DefaultSearchEngine, neturl.QueryEscape(input)), nil
154}
155
156// parseSearchShortcut handles search shortcut inputs.
157func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
158	validator := NewURLValidator()
159	isShortcut, shortcutKey, query := validator.IsSearchShortcut(cleanInput)
160
161	if !isShortcut {
162		return nil, fmt.Errorf("input is not a valid search shortcut")
163	}
164
165	// Look up the shortcut in configuration
166	shortcuts := p.config.SearchShortcuts
167	shortcutConfig, exists := shortcuts[shortcutKey]
168	if !exists {
169		// Unknown shortcut, treat as search query
170		return p.parseHistorySearch(cleanInput, originalInput, startTime)
171	}
172
173	// Build URL from shortcut template
174	finalURL, err := p.processShortcut(shortcutKey, query, shortcutConfig)
175	if err != nil {
176		return nil, fmt.Errorf("failed to process shortcut: %w", err)
177	}
178
179	return &ParseResult{
180		Type:       InputTypeSearchShortcut,
181		URL:        finalURL,
182		Query:      originalInput,
183		Confidence: 0.95, // High confidence for known shortcuts
184		Shortcut: &DetectedShortcut{
185			Key:         shortcutKey,
186			Query:       query,
187			URL:         finalURL,
188			Description: shortcutConfig.Description,
189		},
190		ProcessingTime: time.Since(startTime),
191	}, nil
192}
193
194// parseHistorySearch handles history search with fuzzy finding.
195func (p *Parser) parseHistorySearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
196	// Get history entries for fuzzy search
197	history, err := p.historyProvider.GetAllHistory()
198	if err != nil {
199		// If history search fails, fall back to web search
200		return p.parseFallbackSearch(cleanInput, originalInput, startTime)
201	}
202
203	// Perform fuzzy search
204	fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
205	matches := fuzzyMatcher.SearchHistory(cleanInput, history)
206
207	// Check if we have good matches
208	if len(matches) > 0 && matches[0].Score >= p.fuzzyConfig.MinSimilarityThreshold {
209		// Use the best match
210		bestMatch := matches[0]
211
212		return &ParseResult{
213			Type:           InputTypeHistorySearch,
214			URL:            bestMatch.HistoryEntry.Url,
215			Query:          originalInput,
216			Confidence:     bestMatch.Score,
217			FuzzyMatches:   matches,
218			ProcessingTime: time.Since(startTime),
219		}, nil
220	}
221
222	// No good history matches, fall back to search
223	fallbackResult, err := p.parseFallbackSearch(cleanInput, originalInput, startTime)
224	if err != nil {
225		return nil, err
226	}
227
228	// Include the fuzzy matches even though we're falling back
229	fallbackResult.FuzzyMatches = matches
230
231	return fallbackResult, nil
232}
233
234// parseFallbackSearch handles fallback web search.
235func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
236	// Use default search shortcut (usually "g" for Google)
237	searchURL := p.buildSearchURL("", cleanInput)
238
239	return &ParseResult{
240		Type:           InputTypeFallbackSearch,
241		URL:            searchURL,
242		Query:          originalInput,
243		Confidence:     0.1, // Low confidence for fallback
244		ProcessingTime: time.Since(startTime),
245	}, nil
246}
247
248// processShortcut processes a search shortcut and builds the final URL.
249func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) {
250	urlTemplate := shortcut.URL
251
252	// Replace placeholder with query
253	q := neturl.QueryEscape(query)
254	finalURL := strings.ReplaceAll(urlTemplate, "{query}", q)
255	finalURL = strings.ReplaceAll(finalURL, "%s", q)
256
257	// Validate the resulting URL
258	validator := NewURLValidator()
259	if !validator.IsValidURL(finalURL) && !strings.HasPrefix(finalURL, "http") {
260		return "", fmt.Errorf("invalid URL generated from shortcut '%s' template: %s", shortcutKey, finalURL)
261	}
262
263	return finalURL, nil
264}
265
266// buildSearchURL builds a search URL using the default search engine.
267func (p *Parser) buildSearchURL(searchEngine, query string) string {
268	q := neturl.QueryEscape(query)
269	// If no specific search engine, use the configured default
270	if searchEngine == "" {
271		return fmt.Sprintf(p.config.DefaultSearchEngine, q)
272	}
273
274	// Use specified search engine
275	shortcut, exists := p.config.SearchShortcuts[searchEngine]
276	if !exists {
277		// Fallback to default search engine if shortcut not found
278		return fmt.Sprintf(p.config.DefaultSearchEngine, q)
279	}
280
281	u := strings.ReplaceAll(shortcut.URL, "{query}", q)
282	u = strings.ReplaceAll(u, "%s", q)
283	return u
284}
285
286// FuzzySearchHistory performs fuzzy search on history and returns ranked matches.
287func (p *Parser) FuzzySearchHistory(query string, threshold float64) ([]FuzzyMatch, error) {
288	if query == "" {
289		return nil, nil
290	}
291
292	// Get all history entries
293	history, err := p.historyProvider.GetAllHistory()
294	if err != nil {
295		return nil, fmt.Errorf("failed to retrieve history: %w", err)
296	}
297
298	// Create fuzzy config with custom threshold if provided
299	fuzzyConfig := *p.fuzzyConfig
300	if threshold > 0 && threshold <= 1.0 {
301		fuzzyConfig.MinSimilarityThreshold = threshold
302	}
303
304	// Perform fuzzy search
305	fuzzyMatcher := NewFuzzyMatcher(&fuzzyConfig)
306	matches := fuzzyMatcher.SearchHistory(query, history)
307
308	// Apply additional ranking
309	matches = fuzzyMatcher.RankMatches(matches, query)
310
311	return matches, nil
312}
313
314// CalculateSimilarity calculates similarity between two strings.
315func (p *Parser) CalculateSimilarity(s1, s2 string) float64 {
316	fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
317	return fuzzyMatcher.calculateSimilarity(s1, s2)
318}
319
320// RankMatches re-ranks fuzzy matches based on additional criteria.
321func (p *Parser) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch {
322	fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
323	return fuzzyMatcher.RankMatches(matches, query)
324}
325
326// IsValidURL checks if the input represents a valid URL.
327func (p *Parser) IsValidURL(input string) bool {
328	validator := NewURLValidator()
329	return validator.IsValidURL(input)
330}
331
332// ProcessShortcut processes a shortcut and returns the resulting URL.
333func (p *Parser) ProcessShortcut(shortcut, query string, shortcuts map[string]config.SearchShortcut) (string, error) {
334	shortcutConfig, exists := shortcuts[shortcut]
335	if !exists {
336		return "", fmt.Errorf("shortcut '%s' not found", shortcut)
337	}
338
339	return p.processShortcut(shortcut, query, shortcutConfig)
340}
341
342// GetSupportedShortcuts returns a list of configured search shortcuts.
343func (p *Parser) GetSupportedShortcuts() map[string]config.SearchShortcut {
344	return p.config.SearchShortcuts
345}
346
347// UpdateFuzzyConfig updates the fuzzy matching configuration.
348func (p *Parser) UpdateFuzzyConfig(newConfig *FuzzyConfig) error {
349	if !newConfig.IsValid() {
350		return fmt.Errorf("invalid fuzzy configuration")
351	}
352
353	p.fuzzyConfig = newConfig
354	return nil
355}
356
357// GetFuzzyConfig returns the current fuzzy matching configuration.
358func (p *Parser) GetFuzzyConfig() *FuzzyConfig {
359	return p.fuzzyConfig
360}
361
362// PreviewParse provides a preview of what ParseInput would return without side effects.
363func (p *Parser) PreviewParse(input string) (*ParseResult, error) {
364	// This is essentially the same as ParseInput but could be extended
365	// to avoid any side effects if needed in the future
366	return p.ParseInput(input)
367}
368
369// SuggestCompletions provides URL/search completions based on input.
370func (p *Parser) SuggestCompletions(input string, limit int) ([]string, error) {
371	if input == "" || limit <= 0 {
372		return nil, nil
373	}
374
375	// Get fuzzy matches from history
376	matches, err := p.FuzzySearchHistory(input, 0.2) // Lower threshold for suggestions
377	if err != nil {
378		return nil, err
379	}
380
381	completions := make([]string, 0, limit)
382	seen := make(map[string]bool)
383
384	// Add URLs from fuzzy matches
385	for _, match := range matches {
386		if len(completions) >= limit {
387			break
388		}
389
390		url := match.HistoryEntry.Url
391		if !seen[url] {
392			completions = append(completions, url)
393			seen[url] = true
394		}
395	}
396
397	// Add search shortcuts if input looks like it might be a shortcut
398	if strings.Contains(input, ":") || len(input) <= 3 {
399		for shortcut, config := range p.config.SearchShortcuts {
400			if len(completions) >= limit {
401				break
402			}
403
404			if strings.HasPrefix(shortcut, strings.ToLower(input)) {
405				suggestion := fmt.Sprintf("%s: %s", shortcut, config.Description)
406				if !seen[suggestion] {
407					completions = append(completions, suggestion)
408					seen[suggestion] = true
409				}
410			}
411		}
412	}
413
414	return completions, nil
415}
416
417// ValidateConfig validates the parser configuration.
418func (p *Parser) ValidateConfig() error {
419	if p.config == nil {
420		return fmt.Errorf("parser config is nil")
421	}
422
423	if p.fuzzyConfig == nil {
424		return fmt.Errorf("fuzzy config is nil")
425	}
426
427	if !p.fuzzyConfig.IsValid() {
428		return fmt.Errorf("invalid fuzzy config")
429	}
430
431	if p.historyProvider == nil {
432		return fmt.Errorf("history provider is nil")
433	}
434
435	// Validate search shortcuts
436	if len(p.config.SearchShortcuts) == 0 {
437		return fmt.Errorf("no search shortcuts configured")
438	}
439
440	// Check for default search engine
441	if p.config.DefaultSearchEngine == "" {
442		return fmt.Errorf("default search engine is not configured")
443	}
444
445	if !strings.Contains(p.config.DefaultSearchEngine, "%s") {
446		return fmt.Errorf("default search engine must contain %%s placeholder")
447	}
448
449	return nil
450}

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