v0.11.0 dc50343

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	// Apply search options
126	searchConfig := &SearchConfig{
127		Limit:                  hs.fuzzyConfig.MaxResults,
128		MinSimilarityThreshold: hs.fuzzyConfig.MinSimilarityThreshold,
129		IncludeExactMatches:    true,
130		IncludeFuzzyMatches:    true,
131		SortByRelevance:        true,
132	}
133
134	for _, opt := range options {
135		opt(searchConfig)
136	}
137
138	// Get all history entries
139	allHistory, err := hs.provider.GetAllHistory()
140	if err != nil {
141		return nil, fmt.Errorf("failed to retrieve history: %w", err)
142	}
143
144	// Filter by domain and apply fuzzy matching
145	domainEntries := make([]*db.History, 0)
146	domain = strings.ToLower(domain)
147
148	for _, entry := range allHistory {
149		entryDomain := hs.extractDomain(entry.Url)
150		if strings.Contains(strings.ToLower(entryDomain), domain) {
151			domainEntries = append(domainEntries, entry)
152		}
153	}
154
155	// If we have domain matches, rank them by fuzzy score
156	matches := make([]FuzzyMatch, 0)
157	for _, entry := range domainEntries {
158		match := hs.fuzzyMatcher.matchHistoryEntry(domain, entry)
159		// Apply similarity threshold filter
160		if match.Score >= searchConfig.MinSimilarityThreshold {
161			matches = append(matches, match)
162		}
163	}
164
165	// Sort by score if requested
166	if searchConfig.SortByRelevance {
167		matches = hs.fuzzyMatcher.RankMatches(matches, domain)
168	}
169
170	// Apply limit
171	if searchConfig.Limit > 0 && len(matches) > searchConfig.Limit {
172		matches = matches[:searchConfig.Limit]
173	}
174
175	return &HistorySearchResult{
176		Query:         domain,
177		TotalEntries:  len(allHistory),
178		MatchCount:    len(matches),
179		RankedMatches: matches,
180		SearchTime:    0, // Set by caller if needed
181	}, nil
182}
183
184// SearchByTimeRange searches history entries within a time range with fuzzy matching.
185func (hs *HistorySearcher) SearchByTimeRange(query string, startTime, endTime time.Time) (*HistorySearchResult, error) {
186	// Get all history entries
187	allHistory, err := hs.provider.GetAllHistory()
188	if err != nil {
189		return nil, fmt.Errorf("failed to retrieve history: %w", err)
190	}
191
192	// Filter by time range
193	filteredHistory := make([]*db.History, 0)
194	for _, entry := range allHistory {
195		if entry.LastVisited.Valid {
196			visitTime := entry.LastVisited.Time
197			if visitTime.After(startTime) && visitTime.Before(endTime) {
198				filteredHistory = append(filteredHistory, entry)
199			}
200		}
201	}
202
203	// Perform fuzzy search on filtered entries
204	matches := hs.fuzzyMatcher.SearchHistory(query, filteredHistory)
205
206	return &HistorySearchResult{
207		Query:         query,
208		TotalEntries:  len(filteredHistory),
209		MatchCount:    len(matches),
210		RankedMatches: matches,
211		SearchTime:    time.Since(time.Now()),
212	}, nil
213}
214
215// GetTopVisited returns the most visited sites with optional fuzzy filtering.
216func (hs *HistorySearcher) GetTopVisited(limit int, filterQuery string) ([]FuzzyMatch, error) {
217	allHistory, err := hs.provider.GetAllHistory()
218	if err != nil {
219		return nil, fmt.Errorf("failed to retrieve history: %w", err)
220	}
221
222	// Sort by visit count
223	matches := make([]FuzzyMatch, 0)
224	for _, entry := range allHistory {
225		// Apply filter if provided
226		if filterQuery != "" {
227			match := hs.fuzzyMatcher.matchHistoryEntry(filterQuery, entry)
228			if match.Score < hs.fuzzyConfig.MinSimilarityThreshold {
229				continue
230			}
231			matches = append(matches, match)
232		} else {
233			// No filter, just create basic match with visit score
234			match := FuzzyMatch{
235				HistoryEntry: entry,
236				Score:        hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
237				VisitScore:   hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
238				RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
239				MatchedField: "visit_count",
240			}
241			matches = append(matches, match)
242		}
243	}
244
245	// Sort by visit count (descending)
246	for i := 0; i < len(matches); i++ {
247		for j := i + 1; j < len(matches); j++ {
248			iVisits := int64(0)
249			jVisits := int64(0)
250			if matches[i].HistoryEntry.VisitCount.Valid {
251				iVisits = matches[i].HistoryEntry.VisitCount.Int64
252			}
253			if matches[j].HistoryEntry.VisitCount.Valid {
254				jVisits = matches[j].HistoryEntry.VisitCount.Int64
255			}
256			if jVisits > iVisits {
257				matches[i], matches[j] = matches[j], matches[i]
258			}
259		}
260	}
261
262	// Limit results
263	if len(matches) > limit {
264		matches = matches[:limit]
265	}
266
267	return matches, nil
268}
269
270// GetRecentWithFuzzy returns recent entries with optional fuzzy filtering.
271func (hs *HistorySearcher) GetRecentWithFuzzy(limit int, filterQuery string) ([]FuzzyMatch, error) {
272	recentHistory, err := hs.provider.GetRecentHistory(limit * 2) // Get more to filter
273	if err != nil {
274		return nil, fmt.Errorf("failed to retrieve recent history: %w", err)
275	}
276
277	if filterQuery == "" {
278		// No filter, convert to FuzzyMatch format
279		matches := make([]FuzzyMatch, 0, len(recentHistory))
280		for _, entry := range recentHistory {
281			match := FuzzyMatch{
282				HistoryEntry: entry,
283				Score:        1.0, // Recent entries get full score
284				RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
285				VisitScore:   hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
286				MatchedField: "recent",
287			}
288			matches = append(matches, match)
289		}
290
291		if len(matches) > limit {
292			matches = matches[:limit]
293		}
294
295		return matches, nil
296	}
297
298	// Apply fuzzy filter
299	matches := hs.fuzzyMatcher.SearchHistory(filterQuery, recentHistory)
300	if len(matches) > limit {
301		matches = matches[:limit]
302	}
303
304	return matches, nil
305}
306
307// combineAndRankMatches combines exact and fuzzy matches with proper ranking.
308func (hs *HistorySearcher) combineAndRankMatches(exactMatches, fuzzyMatches []FuzzyMatch, query string) []FuzzyMatch {
309	combined := make([]FuzzyMatch, 0, len(exactMatches)+len(fuzzyMatches))
310
311	// Add exact matches first (they get priority)
312	combined = append(combined, exactMatches...)
313
314	// Add fuzzy matches
315	combined = append(combined, fuzzyMatches...)
316
317	// Re-rank all matches
318	return hs.fuzzyMatcher.RankMatches(combined, query)
319}
320
321// extractDomain extracts domain from URL for domain-based searches.
322func (hs *HistorySearcher) extractDomain(url string) string {
323	// Simple domain extraction
324	domain := url
325	if strings.HasPrefix(domain, "http://") {
326		domain = domain[7:]
327	} else if strings.HasPrefix(domain, "https://") {
328		domain = domain[8:]
329	}
330
331	// Remove path
332	if idx := strings.Index(domain, "/"); idx != -1 {
333		domain = domain[:idx]
334	}
335
336	// Remove port
337	if idx := strings.LastIndex(domain, ":"); idx != -1 {
338		domain = domain[:idx]
339	}
340
341	return domain
342}
343
344// HistorySearchResult represents the result of a history search operation.
345type HistorySearchResult struct {
346	Query         string        `json:"query"`
347	TotalEntries  int           `json:"total_entries"`
348	MatchCount    int           `json:"match_count"`
349	ExactMatches  []FuzzyMatch  `json:"exact_matches,omitempty"`
350	FuzzyMatches  []FuzzyMatch  `json:"fuzzy_matches,omitempty"`
351	RankedMatches []FuzzyMatch  `json:"ranked_matches"`
352	SearchTime    time.Duration `json:"search_time"`
353}
354
355// SearchConfig holds configuration for history search operations.
356type SearchConfig struct {
357	Limit                  int     `json:"limit"`
358	MinSimilarityThreshold float64 `json:"min_similarity_threshold"`
359	IncludeExactMatches    bool    `json:"include_exact_matches"`
360	IncludeFuzzyMatches    bool    `json:"include_fuzzy_matches"`
361	SortByRelevance        bool    `json:"sort_by_relevance"`
362}
363
364// SearchOption is a functional option for configuring search operations.
365type SearchOption func(*SearchConfig)
366
367// WithLimit sets the maximum number of results to return.
368func WithLimit(limit int) SearchOption {
369	return func(config *SearchConfig) {
370		config.Limit = limit
371	}
372}
373
374// WithSimilarityThreshold sets the minimum similarity threshold for matches.
375func WithSimilarityThreshold(threshold float64) SearchOption {
376	return func(config *SearchConfig) {
377		config.MinSimilarityThreshold = threshold
378	}
379}
380
381// WithExactMatches enables/disables exact match inclusion.
382func WithExactMatches(include bool) SearchOption {
383	return func(config *SearchConfig) {
384		config.IncludeExactMatches = include
385	}
386}
387
388// WithFuzzyMatches enables/disables fuzzy match inclusion.
389func WithFuzzyMatches(include bool) SearchOption {
390	return func(config *SearchConfig) {
391		config.IncludeFuzzyMatches = include
392	}
393}
394
395// WithRelevanceSort enables/disables relevance-based sorting.
396func WithRelevanceSort(enable bool) SearchOption {
397	return func(config *SearchConfig) {
398		config.SortByRelevance = enable
399	}
400}
401
402// GetBestMatch returns the single best match for a query.
403func (hs *HistorySearcher) GetBestMatch(query string) (*FuzzyMatch, error) {
404	result, err := hs.SearchWithFuzzy(query, WithLimit(1))
405	if err != nil {
406		return nil, err
407	}
408
409	if len(result.RankedMatches) == 0 {
410		return nil, nil
411	}
412
413	return &result.RankedMatches[0], nil
414}
415
416// GetMatchesByScore returns matches above a specific score threshold.
417func (hs *HistorySearcher) GetMatchesByScore(query string, minScore float64) ([]FuzzyMatch, error) {
418	result, err := hs.SearchWithFuzzy(query, WithSimilarityThreshold(minScore))
419	if err != nil {
420		return nil, err
421	}
422
423	return result.RankedMatches, nil
424}
425
426// UpdateFuzzyConfig updates the fuzzy matching configuration.
427func (hs *HistorySearcher) UpdateFuzzyConfig(config *FuzzyConfig) {
428	hs.fuzzyConfig = config
429	hs.fuzzyMatcher = NewFuzzyMatcher(config)
430}

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