detached 6990cc0

feat(search): implement fuzzy matching on history title and URL

commit
6990cc0a84ffe5a1246958b44346fff10337930b
author
brice · · 2025-12-16 18:24 UTC
parent
83d87fb
signature
unsigned
1 file · +47 -3
M internal/application/usecase/search_history.go +47 -3
@@ -8,6 +8,7 @@ import (
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/domain/repository"
1010 "github.com/bnema/dumber/internal/logging"
11+ "github.com/sahilm/fuzzy"
1112 )
1213
1314 // SearchHistoryUseCase handles history search and retrieval operations.
@@ -34,7 +35,10 @@ type SearchOutput struct {
3435 }
3536
3637 // Search performs a fuzzy search on history entries.
38+// Uses in-memory fuzzy matching on title + URL for token-based matching in any order.
3739 func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {
40+ log := logging.FromContext(ctx)
41+
3842 if input.Query == "" {
3943 return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
4044 }
@@ -44,12 +48,52 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
4448 limit = 20 // Default limit
4549 }
4650
47- matches, err := uc.historyRepo.Search(ctx, input.Query, limit)
51+ // Fetch more candidates for fuzzy filtering (10x the limit)
52+ candidateLimit := limit * 10
53+ if candidateLimit < 100 {
54+ candidateLimit = 100
55+ }
56+
57+ candidates, err := uc.historyRepo.GetRecent(ctx, candidateLimit, 0)
4858 if err != nil {
49- return nil, fmt.Errorf("failed to search history: %w", err)
59+ return nil, fmt.Errorf("failed to get history candidates: %w", err)
60+ }
61+
62+ if len(candidates) == 0 {
63+ return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
64+ }
65+
66+ // Build searchable strings: "title url" for each entry
67+ searchStrings := make([]string, len(candidates))
68+ for i, e := range candidates {
69+ searchStrings[i] = e.Title + " " + e.URL
70+ }
71+
72+ // Fuzzy match
73+ fuzzyMatches := fuzzy.Find(input.Query, searchStrings)
74+
75+ log.Debug().
76+ Str("query", input.Query).
77+ Int("candidates", len(candidates)).
78+ Int("matches", len(fuzzyMatches)).
79+ Msg("fuzzy search completed")
80+
81+ // Convert to HistoryMatch with scores, limited to requested limit
82+ resultLimit := limit
83+ if len(fuzzyMatches) < resultLimit {
84+ resultLimit = len(fuzzyMatches)
85+ }
86+
87+ results := make([]entity.HistoryMatch, 0, resultLimit)
88+ for i := 0; i < resultLimit; i++ {
89+ m := fuzzyMatches[i]
90+ results = append(results, entity.HistoryMatch{
91+ Entry: candidates[m.Index],
92+ Score: float64(m.Score),
93+ })
5094 }
5195
52- return &SearchOutput{Matches: matches}, nil
96+ return &SearchOutput{Matches: results}, nil
5397 }
5498
5599 // GetRecent retrieves recent history entries with pagination.
@@ -8,6 +8,7 @@ import (
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/domain/repository"9 "github.com/bnema/dumber/internal/domain/repository"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11+ "github.com/sahilm/fuzzy"
11 )12 )
12 13
13 // SearchHistoryUseCase handles history search and retrieval operations.14 // SearchHistoryUseCase handles history search and retrieval operations.
@@ -34,7 +35,10 @@ type SearchOutput struct {
34 }35 }
35 36
36 // Search performs a fuzzy search on history entries.37 // Search performs a fuzzy search on history entries.
38+// Uses in-memory fuzzy matching on title + URL for token-based matching in any order.
37 func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {39 func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {
40+ log := logging.FromContext(ctx)
41+
38 if input.Query == "" {42 if input.Query == "" {
39 return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil43 return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
40 }44 }
@@ -44,12 +48,52 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
44 limit = 20 // Default limit48 limit = 20 // Default limit
45 }49 }
46 50
47- matches, err := uc.historyRepo.Search(ctx, input.Query, limit)51+ // Fetch more candidates for fuzzy filtering (10x the limit)
52+ candidateLimit := limit * 10
53+ if candidateLimit < 100 {
54+ candidateLimit = 100
55+ }
56+
57+ candidates, err := uc.historyRepo.GetRecent(ctx, candidateLimit, 0)
48 if err != nil {58 if err != nil {
49- return nil, fmt.Errorf("failed to search history: %w", err)59+ return nil, fmt.Errorf("failed to get history candidates: %w", err)
60+ }
61+
62+ if len(candidates) == 0 {
63+ return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
64+ }
65+
66+ // Build searchable strings: "title url" for each entry
67+ searchStrings := make([]string, len(candidates))
68+ for i, e := range candidates {
69+ searchStrings[i] = e.Title + " " + e.URL
70+ }
71+
72+ // Fuzzy match
73+ fuzzyMatches := fuzzy.Find(input.Query, searchStrings)
74+
75+ log.Debug().
76+ Str("query", input.Query).
77+ Int("candidates", len(candidates)).
78+ Int("matches", len(fuzzyMatches)).
79+ Msg("fuzzy search completed")
80+
81+ // Convert to HistoryMatch with scores, limited to requested limit
82+ resultLimit := limit
83+ if len(fuzzyMatches) < resultLimit {
84+ resultLimit = len(fuzzyMatches)
85+ }
86+
87+ results := make([]entity.HistoryMatch, 0, resultLimit)
88+ for i := 0; i < resultLimit; i++ {
89+ m := fuzzyMatches[i]
90+ results = append(results, entity.HistoryMatch{
91+ Entry: candidates[m.Index],
92+ Score: float64(m.Score),
93+ })
50 }94 }
51 95
52- return &SearchOutput{Matches: matches}, nil96+ return &SearchOutput{Matches: results}, nil
53 }97 }
54 98
55 // GetRecent retrieves recent history entries with pagination.99 // GetRecent retrieves recent history entries with pagination.

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