detached b034df2

refactor(fuzzy): route palette ordering and description matches through the shared matcher

commit
b034df2b5cbd7f7942e7b69749bea8a7729454ce
author
brice · · 2026-10-08 09:03 UTC
committer
brice · · 2026-10-08 09:31 UTC
parent
1c51966
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +22 -17
M internal/usecase/fuzzy/fuzzy.go +8 -7
@@ -9,6 +9,7 @@ import "strings"
99 // Kind is how a field matched the query.
1010 type Kind uint8
1111
12+// Kinds are ordered best first, so a caller may use the Kind itself as a rank.
1213 const (
1314 Exact Kind = iota
1415 Prefix
@@ -33,11 +34,11 @@ func Match(text, query string, needle []rune) (Score, bool) {
3334 var score Score
3435 switch {
3536 case text == query:
36- score = Score{Kind: Exact, Positions: Range(len(needle))}
37+ score = Score{Kind: Exact, Positions: rangePositions(len(needle))}
3738 case strings.HasPrefix(text, query):
38- score = Score{Kind: Prefix, Positions: Range(len(needle))}
39+ score = Score{Kind: Prefix, Positions: rangePositions(len(needle))}
3940 default:
40- positions, ok := SubsequencePositions([]rune(text), needle)
41+ positions, ok := subsequencePositions([]rune(text), needle)
4142 if !ok {
4243 return Score{}, false
4344 }
@@ -60,8 +61,8 @@ func Less(leftRank int, left Score, rightRank int, right Score) bool {
6061 return left.First < right.First
6162 }
6263
63-// Range returns the positions 0..n-1.
64-func Range(n int) []int {
64+// rangePositions returns the positions 0..n-1.
65+func rangePositions(n int) []int {
6566 positions := make([]int, n)
6667 for i := range positions {
6768 positions[i] = i
@@ -69,9 +70,9 @@ func Range(n int) []int {
6970 return positions
7071 }
7172
72-// SubsequencePositions returns the first in-order positions of needle in
73+// subsequencePositions returns the first in-order positions of needle in
7374 // haystack.
74-func SubsequencePositions(haystack, needle []rune) ([]int, bool) {
75+func subsequencePositions(haystack, needle []rune) ([]int, bool) {
7576 if len(needle) == 0 {
7677 return nil, true
7778 }
@@ -9,6 +9,7 @@ import "strings"
9 // Kind is how a field matched the query.9 // Kind is how a field matched the query.
10 type Kind uint810 type Kind uint8
11 11
12+// Kinds are ordered best first, so a caller may use the Kind itself as a rank.
12 const (13 const (
13 Exact Kind = iota14 Exact Kind = iota
14 Prefix15 Prefix
@@ -33,11 +34,11 @@ func Match(text, query string, needle []rune) (Score, bool) {
33 var score Score34 var score Score
34 switch {35 switch {
35 case text == query:36 case text == query:
36- score = Score{Kind: Exact, Positions: Range(len(needle))}37+ score = Score{Kind: Exact, Positions: rangePositions(len(needle))}
37 case strings.HasPrefix(text, query):38 case strings.HasPrefix(text, query):
38- score = Score{Kind: Prefix, Positions: Range(len(needle))}39+ score = Score{Kind: Prefix, Positions: rangePositions(len(needle))}
39 default:40 default:
40- positions, ok := SubsequencePositions([]rune(text), needle)41+ positions, ok := subsequencePositions([]rune(text), needle)
41 if !ok {42 if !ok {
42 return Score{}, false43 return Score{}, false
43 }44 }
@@ -60,8 +61,8 @@ func Less(leftRank int, left Score, rightRank int, right Score) bool {
60 return left.First < right.First61 return left.First < right.First
61 }62 }
62 63
63-// Range returns the positions 0..n-1.64+// rangePositions returns the positions 0..n-1.
64-func Range(n int) []int {65+func rangePositions(n int) []int {
65 positions := make([]int, n)66 positions := make([]int, n)
66 for i := range positions {67 for i := range positions {
67 positions[i] = i68 positions[i] = i
@@ -69,9 +70,9 @@ func Range(n int) []int {
69 return positions70 return positions
70 }71 }
71 72
72-// SubsequencePositions returns the first in-order positions of needle in73+// subsequencePositions returns the first in-order positions of needle in
73 // haystack.74 // haystack.
74-func SubsequencePositions(haystack, needle []rune) ([]int, bool) {75+func subsequencePositions(haystack, needle []rune) ([]int, bool) {
75 if len(needle) == 0 {76 if len(needle) == 0 {
76 return nil, true77 return nil, true
77 }78 }
M internal/usecase/fuzzy/fuzzy_test.go +8 -0
@@ -19,6 +19,7 @@ func TestMatch(t *testing.T) {
1919 {name: "prefix", text: "workspace", query: "wor", want: Score{Kind: Prefix, Positions: []int{0, 1, 2}, Span: 3}, ok: true},
2020 {name: "subsequence", text: "a-b-c", query: "bc", want: Score{Kind: Subsequence, Positions: []int{2, 4}, Span: 3, First: 2}, ok: true},
2121 {name: "runes not bytes", text: "été-x", query: "x", want: Score{Kind: Subsequence, Positions: []int{4}, Span: 1, First: 4}, ok: true},
22+ {name: "multi-byte prefix counts runes", text: "été", query: "ét", want: Score{Kind: Prefix, Positions: []int{0, 1}, Span: 2}, ok: true},
2223 {name: "no match", text: "work", query: "z", ok: false},
2324 {name: "out of order", text: "ab", query: "ba", ok: false},
2425 }
@@ -31,6 +32,13 @@ func TestMatch(t *testing.T) {
3132 }
3233 }
3334
35+// TestKindOrder pins the best-first order callers rely on to use a Kind as a
36+// rank.
37+func TestKindOrder(t *testing.T) {
38+ require.Less(t, Exact, Prefix)
39+ require.Less(t, Prefix, Subsequence)
40+}
41+
3442 func TestLess(t *testing.T) {
3543 tests := []struct {
3644 name string
@@ -19,6 +19,7 @@ func TestMatch(t *testing.T) {
19 {name: "prefix", text: "workspace", query: "wor", want: Score{Kind: Prefix, Positions: []int{0, 1, 2}, Span: 3}, ok: true},19 {name: "prefix", text: "workspace", query: "wor", want: Score{Kind: Prefix, Positions: []int{0, 1, 2}, Span: 3}, ok: true},
20 {name: "subsequence", text: "a-b-c", query: "bc", want: Score{Kind: Subsequence, Positions: []int{2, 4}, Span: 3, First: 2}, ok: true},20 {name: "subsequence", text: "a-b-c", query: "bc", want: Score{Kind: Subsequence, Positions: []int{2, 4}, Span: 3, First: 2}, ok: true},
21 {name: "runes not bytes", text: "été-x", query: "x", want: Score{Kind: Subsequence, Positions: []int{4}, Span: 1, First: 4}, ok: true},21 {name: "runes not bytes", text: "été-x", query: "x", want: Score{Kind: Subsequence, Positions: []int{4}, Span: 1, First: 4}, ok: true},
22+ {name: "multi-byte prefix counts runes", text: "été", query: "ét", want: Score{Kind: Prefix, Positions: []int{0, 1}, Span: 2}, ok: true},
22 {name: "no match", text: "work", query: "z", ok: false},23 {name: "no match", text: "work", query: "z", ok: false},
23 {name: "out of order", text: "ab", query: "ba", ok: false},24 {name: "out of order", text: "ab", query: "ba", ok: false},
24 }25 }
@@ -31,6 +32,13 @@ func TestMatch(t *testing.T) {
31 }32 }
32 }33 }
33 34
35+// TestKindOrder pins the best-first order callers rely on to use a Kind as a
36+// rank.
37+func TestKindOrder(t *testing.T) {
38+ require.Less(t, Exact, Prefix)
39+ require.Less(t, Prefix, Subsequence)
40+}
41+
34 func TestLess(t *testing.T) {42 func TestLess(t *testing.T) {
35 tests := []struct {43 tests := []struct {
36 name string44 name string
M internal/usecase/palette/fuzzy.go +6 -10
@@ -38,14 +38,8 @@ func Fuzzy(results []Result, query string) []Match {
3838 }
3939 sort.SliceStable(out, func(i, j int) bool {
4040 a, b := out[i], out[j]
41- if a.rank != b.rank {
42- return a.rank < b.rank
43- }
44- if a.span != b.span {
45- return a.span < b.span
46- }
47- if a.first != b.first {
48- return a.first < b.first
41+ if a.rank != b.rank || a.span != b.span || a.first != b.first {
42+ return fuzzy.Less(a.rank, fuzzy.Score{Span: a.span, First: a.first}, b.rank, fuzzy.Score{Span: b.span, First: b.first})
4943 }
5044 if a.Result.Kind() != b.Result.Kind() {
5145 return a.Result.Kind() < b.Result.Kind()
@@ -86,8 +80,10 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match,
8680 return match, true
8781 }
8882 if cmd, ok := result.Command(); ok {
89- if positions, ok := fuzzy.SubsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
90- match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0]
83+ // Description matches only rank and never highlight, so Positions
84+ // stays empty.
85+ if matched, ok := fuzzy.Match(strings.ToLower(cmd.Desc), needle, needleRunes); ok {
86+ match.rank, match.span, match.first = 5, matched.Span, matched.First
9187 return match, true
9288 }
9389 }
@@ -38,14 +38,8 @@ func Fuzzy(results []Result, query string) []Match {
38 }38 }
39 sort.SliceStable(out, func(i, j int) bool {39 sort.SliceStable(out, func(i, j int) bool {
40 a, b := out[i], out[j]40 a, b := out[i], out[j]
41- if a.rank != b.rank {41+ if a.rank != b.rank || a.span != b.span || a.first != b.first {
42- return a.rank < b.rank42+ return fuzzy.Less(a.rank, fuzzy.Score{Span: a.span, First: a.first}, b.rank, fuzzy.Score{Span: b.span, First: b.first})
43- }
44- if a.span != b.span {
45- return a.span < b.span
46- }
47- if a.first != b.first {
48- return a.first < b.first
49 }43 }
50 if a.Result.Kind() != b.Result.Kind() {44 if a.Result.Kind() != b.Result.Kind() {
51 return a.Result.Kind() < b.Result.Kind()45 return a.Result.Kind() < b.Result.Kind()
@@ -86,8 +80,10 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match,
86 return match, true80 return match, true
87 }81 }
88 if cmd, ok := result.Command(); ok {82 if cmd, ok := result.Command(); ok {
89- if positions, ok := fuzzy.SubsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {83+ // Description matches only rank and never highlight, so Positions
90- match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0]84+ // stays empty.
85+ if matched, ok := fuzzy.Match(strings.ToLower(cmd.Desc), needle, needleRunes); ok {
86+ match.rank, match.span, match.first = 5, matched.Span, matched.First
91 return match, true87 return match, true
92 }88 }
93 }89 }

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 12

Tags 11

↑↓ moveenter openesc close