detached ed73d49

fix: sanitize FTS5 special chars and interleave URL/title results

commit
ed73d491c3c87dc9cf969bd606f382a605000c4a
author
brice · · 2026-01-01 13:53 UTC
parent
8ba0228
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +43 -24
M internal/infrastructure/persistence/sqlite/history_repo.go +43 -24
@@ -56,9 +56,10 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
5656 }
5757
5858 // Build FTS5 query: "word1* word2*" (implicit AND between terms)
59+ // Sanitize each word to remove FTS5 special characters
5960 parts := make([]string, len(words))
6061 for i, word := range words {
61- parts[i] = word + "*"
62+ parts[i] = sanitizeFTS5Word(word) + "*"
6263 }
6364 ftsQuery := strings.Join(parts, " ")
6465
@@ -81,40 +82,58 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
8182 titleRows = []sqlc.History{}
8283 }
8384
84- // Merge results, deduplicating by ID
85+ // Merge results by interleaving URL and title matches for balanced results
8586 seen := make(map[int64]bool)
8687 var matches []entity.HistoryMatch
8788
88- for i := range urlRows {
89- row := urlRows[i]
90- if !seen[row.ID] {
91- seen[row.ID] = true
92- matches = append(matches, entity.HistoryMatch{
93- Entry: historyFromRow(row),
94- Score: 1.0,
95- })
89+ i, j := 0, 0
90+ for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) {
91+ // Take next URL match if available
92+ if i < len(urlRows) {
93+ row := urlRows[i]
94+ i++
95+ if !seen[row.ID] {
96+ seen[row.ID] = true
97+ matches = append(matches, entity.HistoryMatch{
98+ Entry: historyFromRow(row),
99+ Score: 1.0,
100+ })
101+ }
96102 }
97- }
98103
99- for i := range titleRows {
100- row := titleRows[i]
101- if !seen[row.ID] {
102- seen[row.ID] = true
103- matches = append(matches, entity.HistoryMatch{
104- Entry: historyFromRow(row),
105- Score: 1.0,
106- })
104+ // Take next title match if available
105+ if j < len(titleRows) && len(matches) < limit {
106+ row := titleRows[j]
107+ j++
108+ if !seen[row.ID] {
109+ seen[row.ID] = true
110+ matches = append(matches, entity.HistoryMatch{
111+ Entry: historyFromRow(row),
112+ Score: 1.0,
113+ })
114+ }
107115 }
108116 }
109117
110- // Limit results
111- if len(matches) > limit {
112- matches = matches[:limit]
113- }
114-
115118 return matches, nil
116119 }
117120
121+// sanitizeFTS5Word removes FTS5 special characters from a search word.
122+// FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR
123+func sanitizeFTS5Word(word string) string {
124+ // Remove characters that have special meaning in FTS5
125+ var result strings.Builder
126+ for _, r := range word {
127+ switch r {
128+ case '"', '*', '(', ')', ':', '^', '-':
129+ // Skip FTS5 special characters
130+ default:
131+ result.WriteRune(r)
132+ }
133+ }
134+ return result.String()
135+}
136+
118137 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
119138 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
120139 Limit: int64(limit),
@@ -56,9 +56,10 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
56 }56 }
57 57
58 // Build FTS5 query: "word1* word2*" (implicit AND between terms)58 // Build FTS5 query: "word1* word2*" (implicit AND between terms)
59+ // Sanitize each word to remove FTS5 special characters
59 parts := make([]string, len(words))60 parts := make([]string, len(words))
60 for i, word := range words {61 for i, word := range words {
61- parts[i] = word + "*"62+ parts[i] = sanitizeFTS5Word(word) + "*"
62 }63 }
63 ftsQuery := strings.Join(parts, " ")64 ftsQuery := strings.Join(parts, " ")
64 65
@@ -81,40 +82,58 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
81 titleRows = []sqlc.History{}82 titleRows = []sqlc.History{}
82 }83 }
83 84
84- // Merge results, deduplicating by ID85+ // Merge results by interleaving URL and title matches for balanced results
85 seen := make(map[int64]bool)86 seen := make(map[int64]bool)
86 var matches []entity.HistoryMatch87 var matches []entity.HistoryMatch
87 88
88- for i := range urlRows {89+ i, j := 0, 0
89- row := urlRows[i]90+ for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) {
90- if !seen[row.ID] {91+ // Take next URL match if available
91- seen[row.ID] = true92+ if i < len(urlRows) {
92- matches = append(matches, entity.HistoryMatch{93+ row := urlRows[i]
93- Entry: historyFromRow(row),94+ i++
94- Score: 1.0,95+ if !seen[row.ID] {
95- })96+ seen[row.ID] = true
97+ matches = append(matches, entity.HistoryMatch{
98+ Entry: historyFromRow(row),
99+ Score: 1.0,
100+ })
101+ }
96 }102 }
97- }
98 103
99- for i := range titleRows {104+ // Take next title match if available
100- row := titleRows[i]105+ if j < len(titleRows) && len(matches) < limit {
101- if !seen[row.ID] {106+ row := titleRows[j]
102- seen[row.ID] = true107+ j++
103- matches = append(matches, entity.HistoryMatch{108+ if !seen[row.ID] {
104- Entry: historyFromRow(row),109+ seen[row.ID] = true
105- Score: 1.0,110+ matches = append(matches, entity.HistoryMatch{
106- })111+ Entry: historyFromRow(row),
112+ Score: 1.0,
113+ })
114+ }
107 }115 }
108 }116 }
109 117
110- // Limit results
111- if len(matches) > limit {
112- matches = matches[:limit]
113- }
114-
115 return matches, nil118 return matches, nil
116 }119 }
117 120
121+// sanitizeFTS5Word removes FTS5 special characters from a search word.
122+// FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR
123+func sanitizeFTS5Word(word string) string {
124+ // Remove characters that have special meaning in FTS5
125+ var result strings.Builder
126+ for _, r := range word {
127+ switch r {
128+ case '"', '*', '(', ')', ':', '^', '-':
129+ // Skip FTS5 special characters
130+ default:
131+ result.WriteRune(r)
132+ }
133+ }
134+ return result.String()
135+}
136+
118 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {137 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
119 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{138 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
120 Limit: int64(limit),139 Limit: int64(limit),

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