v0.1 bf7c821

history.go RawHistoryFolder

1package parser
2
3import (
4	"fmt"
5	"strings"
6	"time"
7
8	"github.com/bnema/dumber/internal/db"
9)
10
11// HistorySearcher provides history search integration with fuzzy matching.
12type HistorySearcher struct {
13	provider     HistoryProvider
14	fuzzyMatcher *FuzzyMatcher
15	fuzzyConfig  *FuzzyConfig
16}
17
18// NewHistorySearcher creates a new HistorySearcher.
19func NewHistorySearcher(provider HistoryProvider, fuzzyConfig *FuzzyConfig) *HistorySearcher {
20	if fuzzyConfig == nil {
21		fuzzyConfig = DefaultFuzzyConfig()
22	}
23
24	return &HistorySearcher{
25		provider:     provider,
26		fuzzyMatcher: NewFuzzyMatcher(fuzzyConfig),
27		fuzzyConfig:  fuzzyConfig,
28	}
29}
30
31// SearchWithFuzzy performs fuzzy search on history entries.
32func (hs *HistorySearcher) SearchWithFuzzy(query string, options ...SearchOption) (*HistorySearchResult, error) {
33	if query == "" {
34		return &HistorySearchResult{}, nil
35	}
36
37	// Apply search options
38	searchConfig := &SearchConfig{
39		Limit:                  hs.fuzzyConfig.MaxResults,
40		MinSimilarityThreshold: hs.fuzzyConfig.MinSimilarityThreshold,
41		IncludeExactMatches:    true,
42		IncludeFuzzyMatches:    true,
43		SortByRelevance:        true,
44	}
45
46	for _, opt := range options {
47		opt(searchConfig)
48	}
49
50	startTime := time.Now()
51
52	// Get all history for fuzzy search
53	allHistory, err := hs.provider.GetAllHistory()
54	if err != nil {
55		return nil, fmt.Errorf("failed to retrieve history: %w", err)
56	}
57
58	result := &HistorySearchResult{
59		Query:        query,
60		TotalEntries: len(allHistory),
61		SearchTime:   0, // Will be set below
62	}
63
64	// Perform exact search first if requested
65	if searchConfig.IncludeExactMatches {
66		exactMatches, err := hs.provider.SearchHistory(query, searchConfig.Limit)
67		if err == nil && len(exactMatches) > 0 {
68			// Convert to FuzzyMatch format with perfect scores
69			for _, entry := range exactMatches {
70				match := FuzzyMatch{
71					HistoryEntry: entry,
72					Score:        1.0,
73					URLScore:     1.0,
74					TitleScore:   1.0,
75					RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
76					VisitScore:   hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
77					MatchedField: "exact",
78				}
79				result.ExactMatches = append(result.ExactMatches, match)
80			}
81		}
82	}
83
84	// Perform fuzzy search if requested
85	if searchConfig.IncludeFuzzyMatches {
86		// Create temporary config with search options
87		tempConfig := *hs.fuzzyConfig
88		tempConfig.MinSimilarityThreshold = searchConfig.MinSimilarityThreshold
89		tempConfig.MaxResults = searchConfig.Limit
90
91		tempMatcher := NewFuzzyMatcher(&tempConfig)
92		fuzzyMatches := tempMatcher.SearchHistory(query, allHistory)
93
94		// Filter out matches that are already in exact matches
95		exactURLs := make(map[string]bool)
96		for _, exact := range result.ExactMatches {
97			exactURLs[exact.HistoryEntry.Url] = true
98		}
99
100		for _, match := range fuzzyMatches {
101			if !exactURLs[match.HistoryEntry.Url] {
102				result.FuzzyMatches = append(result.FuzzyMatches, match)
103			}
104		}
105	}
106
107	// Combine and sort results if requested
108	if searchConfig.SortByRelevance {
109		result.RankedMatches = hs.combineAndRankMatches(result.ExactMatches, result.FuzzyMatches, query)
110	}
111
112	// Limit final results
113	if len(result.RankedMatches) > searchConfig.Limit {
114		result.RankedMatches = result.RankedMatches[:searchConfig.Limit]
115	}
116
117	result.MatchCount = len(result.RankedMatches)
118	result.SearchTime = time.Since(startTime)
119
120	return result, nil
121}
122
123// SearchByDomain searches history entries by domain with fuzzy matching.
124func (hs *HistorySearcher) SearchByDomain(domain string, options ...SearchOption) (*HistorySearchResult, error) {
125	// Get all history entries
126	allHistory, err := hs.provider.GetAllHistory()
127	if err != nil {
128		return nil, fmt.Errorf("failed to retrieve history: %w", err)
129	}
130
131	// Filter by domain and apply fuzzy matching
132	domainEntries := make([]*db.History, 0)
133	domain = strings.ToLower(domain)
134
135	for _, entry := range allHistory {
136		entryDomain := hs.extractDomain(entry.Url)
137		if strings.Contains(strings.ToLower(entryDomain), domain) {
138			domainEntries = append(domainEntries, entry)
139		}
140	}
141
142	// If we have domain matches, rank them by fuzzy score
143	matches := make([]FuzzyMatch, 0)
144	for _, entry := range domainEntries {
145		match := hs.fuzzyMatcher.matchHistoryEntry(domain, entry)
146		matches = append(matches, match)
147	}
148
149	// Sort by score
150	matches = hs.fuzzyMatcher.RankMatches(matches, domain)
151
152	return &HistorySearchResult{
153		Query:         domain,
154		TotalEntries:  len(allHistory),
155		MatchCount:    len(matches),
156		RankedMatches: matches,
157		SearchTime:    time.Since(time.Now()),
158	}, nil
159}
160
161// SearchByTimeRange searches history entries within a time range with fuzzy matching.
162func (hs *HistorySearcher) SearchByTimeRange(query string, startTime, endTime time.Time) (*HistorySearchResult, error) {
163	// Get all history entries
164	allHistory, err := hs.provider.GetAllHistory()
165	if err != nil {
166		return nil, fmt.Errorf("failed to retrieve history: %w", err)
167	}
168
169	// Filter by time range
170	filteredHistory := make([]*db.History, 0)
171	for _, entry := range allHistory {
172		if entry.LastVisited.Valid {
173			visitTime := entry.LastVisited.Time
174			if visitTime.After(startTime) && visitTime.Before(endTime) {
175				filteredHistory = append(filteredHistory, entry)
176			}
177		}
178	}
179
180	// Perform fuzzy search on filtered entries
181	matches := hs.fuzzyMatcher.SearchHistory(query, filteredHistory)
182
183	return &HistorySearchResult{
184		Query:         query,
185		TotalEntries:  len(filteredHistory),
186		MatchCount:    len(matches),
187		RankedMatches: matches,
188		SearchTime:    time.Since(time.Now()),
189	}, nil
190}
191
192// GetTopVisited returns the most visited sites with optional fuzzy filtering.
193func (hs *HistorySearcher) GetTopVisited(limit int, filterQuery string) ([]FuzzyMatch, error) {
194	allHistory, err := hs.provider.GetAllHistory()
195	if err != nil {
196		return nil, fmt.Errorf("failed to retrieve history: %w", err)
197	}
198
199	// Sort by visit count
200	matches := make([]FuzzyMatch, 0)
201	for _, entry := range allHistory {
202		// Apply filter if provided
203		if filterQuery != "" {
204			match := hs.fuzzyMatcher.matchHistoryEntry(filterQuery, entry)
205			if match.Score < hs.fuzzyConfig.MinSimilarityThreshold {
206				continue
207			}
208			matches = append(matches, match)
209		} else {
210			// No filter, just create basic match with visit score
211			match := FuzzyMatch{
212				HistoryEntry: entry,
213				Score:        hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
214				VisitScore:   hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
215				RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
216				MatchedField: "visit_count",
217			}
218			matches = append(matches, match)
219		}
220	}
221
222	// Sort by visit count (descending)
223	for i := 0; i < len(matches); i++ {
224		for j := i + 1; j < len(matches); j++ {
225			iVisits := int64(0)
226			jVisits := int64(0)
227			if matches[i].HistoryEntry.VisitCount.Valid {
228				iVisits = matches[i].HistoryEntry.VisitCount.Int64
229			}
230			if matches[j].HistoryEntry.VisitCount.Valid {
231				jVisits = matches[j].HistoryEntry.VisitCount.Int64
232			}
233			if jVisits > iVisits {
234				matches[i], matches[j] = matches[j], matches[i]
235			}
236		}
237	}
238
239	// Limit results
240	if len(matches) > limit {
241		matches = matches[:limit]
242	}
243
244	return matches, nil
245}
246
247// GetRecentWithFuzzy returns recent entries with optional fuzzy filtering.
248func (hs *HistorySearcher) GetRecentWithFuzzy(limit int, filterQuery string) ([]FuzzyMatch, error) {
249	recentHistory, err := hs.provider.GetRecentHistory(limit * 2) // Get more to filter
250	if err != nil {
251		return nil, fmt.Errorf("failed to retrieve recent history: %w", err)
252	}
253
254	if filterQuery == "" {
255		// No filter, convert to FuzzyMatch format
256		matches := make([]FuzzyMatch, 0, len(recentHistory))
257		for _, entry := range recentHistory {
258			match := FuzzyMatch{
259				HistoryEntry: entry,
260				Score:        1.0, // Recent entries get full score
261				RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
262				VisitScore:   hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
263				MatchedField: "recent",
264			}
265			matches = append(matches, match)
266		}
267
268		if len(matches) > limit {
269			matches = matches[:limit]
270		}
271
272		return matches, nil
273	}
274
275	// Apply fuzzy filter
276	matches := hs.fuzzyMatcher.SearchHistory(filterQuery, recentHistory)
277	if len(matches) > limit {
278		matches = matches[:limit]
279	}
280
281	return matches, nil
282}
283
284// combineAndRankMatches combines exact and fuzzy matches with proper ranking.
285func (hs *HistorySearcher) combineAndRankMatches(exactMatches, fuzzyMatches []FuzzyMatch, query string) []FuzzyMatch {
286	combined := make([]FuzzyMatch, 0, len(exactMatches)+len(fuzzyMatches))
287
288	// Add exact matches first (they get priority)
289	combined = append(combined, exactMatches...)
290
291	// Add fuzzy matches
292	combined = append(combined, fuzzyMatches...)
293
294	// Re-rank all matches
295	return hs.fuzzyMatcher.RankMatches(combined, query)
296}
297
298// extractDomain extracts domain from URL for domain-based searches.
299func (hs *HistorySearcher) extractDomain(url string) string {
300	// Simple domain extraction
301	domain := url
302	if strings.HasPrefix(domain, "http://") {
303		domain = domain[7:]
304	} else if strings.HasPrefix(domain, "https://") {
305		domain = domain[8:]
306	}
307
308	// Remove path
309	if idx := strings.Index(domain, "/"); idx != -1 {
310		domain = domain[:idx]
311	}
312
313	// Remove port
314	if idx := strings.LastIndex(domain, ":"); idx != -1 {
315		domain = domain[:idx]
316	}
317
318	return domain
319}
320
321// HistorySearchResult represents the result of a history search operation.
322type HistorySearchResult struct {
323	Query         string        `json:"query"`
324	TotalEntries  int           `json:"total_entries"`
325	MatchCount    int           `json:"match_count"`
326	ExactMatches  []FuzzyMatch  `json:"exact_matches,omitempty"`
327	FuzzyMatches  []FuzzyMatch  `json:"fuzzy_matches,omitempty"`
328	RankedMatches []FuzzyMatch  `json:"ranked_matches"`
329	SearchTime    time.Duration `json:"search_time"`
330}
331
332// SearchConfig holds configuration for history search operations.
333type SearchConfig struct {
334	Limit                  int     `json:"limit"`
335	MinSimilarityThreshold float64 `json:"min_similarity_threshold"`
336	IncludeExactMatches    bool    `json:"include_exact_matches"`
337	IncludeFuzzyMatches    bool    `json:"include_fuzzy_matches"`
338	SortByRelevance        bool    `json:"sort_by_relevance"`
339}
340
341// SearchOption is a functional option for configuring search operations.
342type SearchOption func(*SearchConfig)
343
344// WithLimit sets the maximum number of results to return.
345func WithLimit(limit int) SearchOption {
346	return func(config *SearchConfig) {
347		config.Limit = limit
348	}
349}
350
351// WithSimilarityThreshold sets the minimum similarity threshold for matches.
352func WithSimilarityThreshold(threshold float64) SearchOption {
353	return func(config *SearchConfig) {
354		config.MinSimilarityThreshold = threshold
355	}
356}
357
358// WithExactMatches enables/disables exact match inclusion.
359func WithExactMatches(include bool) SearchOption {
360	return func(config *SearchConfig) {
361		config.IncludeExactMatches = include
362	}
363}
364
365// WithFuzzyMatches enables/disables fuzzy match inclusion.
366func WithFuzzyMatches(include bool) SearchOption {
367	return func(config *SearchConfig) {
368		config.IncludeFuzzyMatches = include
369	}
370}
371
372// WithRelevanceSort enables/disables relevance-based sorting.
373func WithRelevanceSort(enable bool) SearchOption {
374	return func(config *SearchConfig) {
375		config.SortByRelevance = enable
376	}
377}
378
379// GetBestMatch returns the single best match for a query.
380func (hs *HistorySearcher) GetBestMatch(query string) (*FuzzyMatch, error) {
381	result, err := hs.SearchWithFuzzy(query, WithLimit(1))
382	if err != nil {
383		return nil, err
384	}
385
386	if len(result.RankedMatches) == 0 {
387		return nil, nil
388	}
389
390	return &result.RankedMatches[0], nil
391}
392
393// GetMatchesByScore returns matches above a specific score threshold.
394func (hs *HistorySearcher) GetMatchesByScore(query string, minScore float64) ([]FuzzyMatch, error) {
395	result, err := hs.SearchWithFuzzy(query, WithSimilarityThreshold(minScore))
396	if err != nil {
397		return nil, err
398	}
399
400	return result.RankedMatches, nil
401}
402
403// UpdateFuzzyConfig updates the fuzzy matching configuration.
404func (hs *HistorySearcher) UpdateFuzzyConfig(config *FuzzyConfig) {
405	hs.fuzzyConfig = config
406	hs.fuzzyMatcher = NewFuzzyMatcher(config)
407}

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