detached 27c9c62

fix: handle FTS5 periods and prioritize selected omnibox results

commit
27c9c62ef751205954ea4fff492f2ffd69f1efec
author
brice · · 2026-01-23 17:40 UTC
parent
d079703
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +93 -21
M internal/infrastructure/persistence/sqlite/history_repo.go +9 -2
@@ -50,9 +50,14 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
5050 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
5151 log := logging.FromContext(ctx)
5252
53+ // Replace periods with spaces before splitting - FTS5 tokenizer treats periods as separators
54+ // so "gordon.bne" in URLs is indexed as "gordon" + "bne" tokens
55+ // This allows searching "gordon.bne" to match URLs containing both terms
56+ normalizedQuery := strings.ReplaceAll(query, ".", " ")
57+
5358 // Split query into words and add prefix matching to each
5459 // This enables multi-word searches like "github issues" -> "github* issues*"
55- words := strings.Fields(query)
60+ words := strings.Fields(normalizedQuery)
5661 if len(words) == 0 {
5762 return []entity.HistoryMatch{}, nil
5863 }
@@ -67,8 +72,9 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
6772
6873 // Search both URL and title columns, then merge results
6974 // Use domain-boosted query for URL search to prioritize domain matches
75+ // Use sanitized first word for domain boost (intentional: domain boost targets first search term)
7076 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{
71- Term: sql.NullString{String: words[0], Valid: true},
77+ Term: sql.NullString{String: sanitizeFTS5Word(words[0]), Valid: true},
7278 Query: ftsQuery,
7379 Limit: int64(limit),
7480 })
@@ -136,6 +142,7 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *
136142
137143 // sanitizeFTS5Word removes FTS5 special characters from a search word.
138144 // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR
145+// Note: Periods are handled earlier by replacing with spaces (tokenizer separator)
139146 func sanitizeFTS5Word(word string) string {
140147 // Remove characters that have special meaning in FTS5
141148 var result strings.Builder
@@ -50,9 +50,14 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
50 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {50 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
51 log := logging.FromContext(ctx)51 log := logging.FromContext(ctx)
52 52
53+ // Replace periods with spaces before splitting - FTS5 tokenizer treats periods as separators
54+ // so "gordon.bne" in URLs is indexed as "gordon" + "bne" tokens
55+ // This allows searching "gordon.bne" to match URLs containing both terms
56+ normalizedQuery := strings.ReplaceAll(query, ".", " ")
57+
53 // Split query into words and add prefix matching to each58 // Split query into words and add prefix matching to each
54 // This enables multi-word searches like "github issues" -> "github* issues*"59 // This enables multi-word searches like "github issues" -> "github* issues*"
55- words := strings.Fields(query)60+ words := strings.Fields(normalizedQuery)
56 if len(words) == 0 {61 if len(words) == 0 {
57 return []entity.HistoryMatch{}, nil62 return []entity.HistoryMatch{}, nil
58 }63 }
@@ -67,8 +72,9 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
67 72
68 // Search both URL and title columns, then merge results73 // Search both URL and title columns, then merge results
69 // Use domain-boosted query for URL search to prioritize domain matches74 // Use domain-boosted query for URL search to prioritize domain matches
75+ // Use sanitized first word for domain boost (intentional: domain boost targets first search term)
70 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{76 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{
71- Term: sql.NullString{String: words[0], Valid: true},77+ Term: sql.NullString{String: sanitizeFTS5Word(words[0]), Valid: true},
72 Query: ftsQuery,78 Query: ftsQuery,
73 Limit: int64(limit),79 Limit: int64(limit),
74 })80 })
@@ -136,6 +142,7 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *
136 142
137 // sanitizeFTS5Word removes FTS5 special characters from a search word.143 // sanitizeFTS5Word removes FTS5 special characters from a search word.
138 // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR144 // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR
145+// Note: Periods are handled earlier by replacing with spaces (tokenizer separator)
139 func sanitizeFTS5Word(word string) string {146 func sanitizeFTS5Word(word string) string {
140 // Remove characters that have special meaning in FTS5147 // Remove characters that have special meaning in FTS5
141 var result strings.Builder148 var result strings.Builder
M internal/infrastructure/persistence/sqlite/history_repo_test.go +71 -0
@@ -484,3 +484,74 @@ func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
484484 assert.True(t, urls["https://github.com"])
485485 assert.True(t, urls["https://gitlab.com"])
486486 }
487+
488+func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) {
489+ ctx := historyTestCtx()
490+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
491+
492+ db, err := sqlite.NewConnection(ctx, dbPath)
493+ require.NoError(t, err)
494+ t.Cleanup(func() { _ = db.Close() })
495+
496+ repo := sqlite.NewHistoryRepository(db)
497+
498+ // Save entries with domain-like URLs
499+ entries := []*entity.HistoryEntry{
500+ {URL: "https://gordon.bnema.dev/", Title: "Gordon - Self-hosted Deployment Platform"},
501+ {URL: "https://example.com", Title: "Example Site"},
502+ {URL: "https://other.gordon.io", Title: "Another Gordon Site"},
503+ }
504+ for _, e := range entries {
505+ require.NoError(t, repo.Save(ctx, e))
506+ }
507+
508+ // Search with period in query (domain-like) - periods should be treated as separators
509+ // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema)
510+ results, err := repo.Search(ctx, "gordon.bnem", 10)
511+ require.NoError(t, err)
512+ require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev")
513+
514+ // Verify the gordon.bnema.dev entry is found
515+ found := false
516+ for _, r := range results {
517+ if r.Entry.URL == "https://gordon.bnema.dev/" {
518+ found = true
519+ break
520+ }
521+ }
522+ assert.True(t, found, "gordon.bnema.dev should be in results")
523+}
524+
525+func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
526+ ctx := historyTestCtx()
527+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
528+
529+ db, err := sqlite.NewConnection(ctx, dbPath)
530+ require.NoError(t, err)
531+ t.Cleanup(func() { _ = db.Close() })
532+
533+ repo := sqlite.NewHistoryRepository(db)
534+
535+ // Save entry
536+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
537+ URL: "https://example.com/path",
538+ Title: "Example Page",
539+ }))
540+
541+ // Search with FTS5 special characters - should not cause errors
542+ specialQueries := []string{
543+ "example:", // colon
544+ "example*", // asterisk
545+ "example()", // parentheses
546+ `example"test`, // quotes
547+ "example^", // caret
548+ "example-test", // hyphen
549+ }
550+
551+ for _, q := range specialQueries {
552+ results, err := repo.Search(ctx, q, 10)
553+ require.NoError(t, err, "query %q should not cause error", q)
554+ // May or may not find results, but should not error
555+ _ = results
556+ }
557+}
@@ -484,3 +484,74 @@ func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
484 assert.True(t, urls["https://github.com"])484 assert.True(t, urls["https://github.com"])
485 assert.True(t, urls["https://gitlab.com"])485 assert.True(t, urls["https://gitlab.com"])
486 }486 }
487+
488+func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) {
489+ ctx := historyTestCtx()
490+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
491+
492+ db, err := sqlite.NewConnection(ctx, dbPath)
493+ require.NoError(t, err)
494+ t.Cleanup(func() { _ = db.Close() })
495+
496+ repo := sqlite.NewHistoryRepository(db)
497+
498+ // Save entries with domain-like URLs
499+ entries := []*entity.HistoryEntry{
500+ {URL: "https://gordon.bnema.dev/", Title: "Gordon - Self-hosted Deployment Platform"},
501+ {URL: "https://example.com", Title: "Example Site"},
502+ {URL: "https://other.gordon.io", Title: "Another Gordon Site"},
503+ }
504+ for _, e := range entries {
505+ require.NoError(t, repo.Save(ctx, e))
506+ }
507+
508+ // Search with period in query (domain-like) - periods should be treated as separators
509+ // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema)
510+ results, err := repo.Search(ctx, "gordon.bnem", 10)
511+ require.NoError(t, err)
512+ require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev")
513+
514+ // Verify the gordon.bnema.dev entry is found
515+ found := false
516+ for _, r := range results {
517+ if r.Entry.URL == "https://gordon.bnema.dev/" {
518+ found = true
519+ break
520+ }
521+ }
522+ assert.True(t, found, "gordon.bnema.dev should be in results")
523+}
524+
525+func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
526+ ctx := historyTestCtx()
527+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
528+
529+ db, err := sqlite.NewConnection(ctx, dbPath)
530+ require.NoError(t, err)
531+ t.Cleanup(func() { _ = db.Close() })
532+
533+ repo := sqlite.NewHistoryRepository(db)
534+
535+ // Save entry
536+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
537+ URL: "https://example.com/path",
538+ Title: "Example Page",
539+ }))
540+
541+ // Search with FTS5 special characters - should not cause errors
542+ specialQueries := []string{
543+ "example:", // colon
544+ "example*", // asterisk
545+ "example()", // parentheses
546+ `example"test`, // quotes
547+ "example^", // caret
548+ "example-test", // hyphen
549+ }
550+
551+ for _, q := range specialQueries {
552+ results, err := repo.Search(ctx, q, 10)
553+ require.NoError(t, err, "query %q should not cause error", q)
554+ // May or may not find results, but should not error
555+ _ = results
556+ }
557+}
M internal/ui/component/omnibox.go +13 -19
@@ -1272,21 +1272,21 @@ func (o *Omnibox) navigateToSelected() {
12721272
12731273 var targetURL string
12741274
1275- // Check if user typed a URL-like string (contains . and no spaces)
1276- // If so, navigate to that URL directly instead of the selection
1277- if o.looksLikeURL(entryText) {
1278- targetURL = o.buildURL(entryText)
1279- } else if idx < 0 {
1280- // No selection - use entry text as URL/search
1281- targetURL = o.buildURL(entryText)
1282- } else if mode == ViewModeHistory {
1283- if idx < len(suggestions) {
1284- targetURL = suggestions[idx].URL
1275+ // If user has selected a result, navigate to that result
1276+ // Otherwise, treat entry text as URL/search
1277+ if idx >= 0 {
1278+ if mode == ViewModeHistory {
1279+ if idx < len(suggestions) {
1280+ targetURL = suggestions[idx].URL
1281+ }
1282+ } else {
1283+ if idx < len(favorites) {
1284+ targetURL = favorites[idx].URL
1285+ }
12851286 }
12861287 } else {
1287- if idx < len(favorites) {
1288- targetURL = favorites[idx].URL
1289- }
1288+ // No selection - use entry text as URL/search
1289+ targetURL = o.buildURL(entryText)
12901290 }
12911291
12921292 if targetURL == "" {
@@ -1300,12 +1300,6 @@ func (o *Omnibox) navigateToSelected() {
13001300 }
13011301 }
13021302
1303-// looksLikeURL checks if the text appears to be a URL (not a search query).
1304-// Returns true for strings like "github.com", "google.com/search", etc.
1305-func (o *Omnibox) looksLikeURL(text string) bool {
1306- return url.LooksLikeURL(text)
1307-}
1308-
13091303 // toggleFavorite adds or removes the selected item from favorites.
13101304 // In History mode: adds the selected item to favorites
13111305 // In Favorites mode: removes the selected item from favorites
@@ -1272,21 +1272,21 @@ func (o *Omnibox) navigateToSelected() {
1272 1272
1273 var targetURL string1273 var targetURL string
1274 1274
1275- // Check if user typed a URL-like string (contains . and no spaces)1275+ // If user has selected a result, navigate to that result
1276- // If so, navigate to that URL directly instead of the selection1276+ // Otherwise, treat entry text as URL/search
1277- if o.looksLikeURL(entryText) {1277+ if idx >= 0 {
1278- targetURL = o.buildURL(entryText)1278+ if mode == ViewModeHistory {
1279- } else if idx < 0 {1279+ if idx < len(suggestions) {
1280- // No selection - use entry text as URL/search1280+ targetURL = suggestions[idx].URL
1281- targetURL = o.buildURL(entryText)1281+ }
1282- } else if mode == ViewModeHistory {1282+ } else {
1283- if idx < len(suggestions) {1283+ if idx < len(favorites) {
1284- targetURL = suggestions[idx].URL1284+ targetURL = favorites[idx].URL
1285+ }
1285 }1286 }
1286 } else {1287 } else {
1287- if idx < len(favorites) {1288+ // No selection - use entry text as URL/search
1288- targetURL = favorites[idx].URL1289+ targetURL = o.buildURL(entryText)
1289- }
1290 }1290 }
1291 1291
1292 if targetURL == "" {1292 if targetURL == "" {
@@ -1300,12 +1300,6 @@ func (o *Omnibox) navigateToSelected() {
1300 }1300 }
1301 }1301 }
1302 1302
1303-// looksLikeURL checks if the text appears to be a URL (not a search query).
1304-// Returns true for strings like "github.com", "google.com/search", etc.
1305-func (o *Omnibox) looksLikeURL(text string) bool {
1306- return url.LooksLikeURL(text)
1307-}
1308-
1309 // toggleFavorite adds or removes the selected item from favorites.1303 // toggleFavorite adds or removes the selected item from favorites.
1310 // In History mode: adds the selected item to favorites1304 // In History mode: adds the selected item to favorites
1311 // In Favorites mode: removes the selected item from favorites1305 // In Favorites mode: removes the selected item from favorites

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