detached 8e56f16

refactor(usecase): share one fuzzy matcher between picker and palette

internal/usecase/fuzzy owns exact, prefix and subsequence matching and the
span/first tie-break. The session picker and command palette keep their own
rank tables on top of it.
commit
8e56f1629b5eadc9cf8ec49f8cc056347d1de8c3
author
brice · · 2026-10-08 05:06 UTC
parent
e15d552
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +164 -97
M docs/architecture.md +1 -0
@@ -25,6 +25,7 @@ Every result keeps its request ID for strict correlation.
2525 envelope, no type byte).
2626 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
2727 - `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer.
28+- `internal/usecase/fuzzy`: the exact/prefix/subsequence matcher and tie-break order shared by the session picker and the command palette; each caller maps match kinds to its own ranks.
2829 - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition.
2930 - `internal/adapters/sessionwire`: translates between typed session
3031 connections and raw wire transports: directional `oneof` envelope
@@ -25,6 +25,7 @@ Every result keeps its request ID for strict correlation.
25 envelope, no type byte).25 envelope, no type byte).
26 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.26 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
27 - `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer.27 - `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer.
28+- `internal/usecase/fuzzy`: the exact/prefix/subsequence matcher and tie-break order shared by the session picker and the command palette; each caller maps match kinds to its own ranks.
28 - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition.29 - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition.
29 - `internal/adapters/sessionwire`: translates between typed session30 - `internal/adapters/sessionwire`: translates between typed session
30 connections and raw wire transports: directional `oneof` envelope31 connections and raw wire transports: directional `oneof` envelope
A internal/usecase/fuzzy/fuzzy.go +91 -0
@@ -0,0 +1,91 @@
1+// Package fuzzy scores one already case-folded text against a folded query.
2+//
3+// A field matches exactly, by prefix, or as an in-order rune subsequence.
4+// Callers map each Kind to their own rank and order matches with Less.
5+package fuzzy
6+
7+import "strings"
8+
9+// Kind is how a field matched the query.
10+type Kind uint8
11+
12+const (
13+ Exact Kind = iota
14+ Prefix
15+ Subsequence
16+)
17+
18+// Score is one field match. Positions are rune indexes into the text.
19+type Score struct {
20+ Kind Kind
21+ Positions []int
22+ // Span is the rune distance from the first to the last matched position.
23+ Span int
24+ First int
25+}
26+
27+// Match scores folded text against query, whose runes are needle. An empty
28+// needle matches as Exact with no positions.
29+func Match(text, query string, needle []rune) (Score, bool) {
30+ if len(needle) == 0 {
31+ return Score{Kind: Exact}, true
32+ }
33+ var score Score
34+ switch {
35+ case text == query:
36+ score = Score{Kind: Exact, Positions: Range(len(needle))}
37+ case strings.HasPrefix(text, query):
38+ score = Score{Kind: Prefix, Positions: Range(len(needle))}
39+ default:
40+ positions, ok := SubsequencePositions([]rune(text), needle)
41+ if !ok {
42+ return Score{}, false
43+ }
44+ score = Score{Kind: Subsequence, Positions: positions}
45+ }
46+ score.Span = score.Positions[len(score.Positions)-1] - score.Positions[0] + 1
47+ score.First = score.Positions[0]
48+ return score, true
49+}
50+
51+// Less orders two scores whose ranks the caller derived from their Kind:
52+// lower rank, then tighter span, then earlier first position.
53+func Less(leftRank int, left Score, rightRank int, right Score) bool {
54+ if leftRank != rightRank {
55+ return leftRank < rightRank
56+ }
57+ if left.Span != right.Span {
58+ return left.Span < right.Span
59+ }
60+ return left.First < right.First
61+}
62+
63+// Range returns the positions 0..n-1.
64+func Range(n int) []int {
65+ positions := make([]int, n)
66+ for i := range positions {
67+ positions[i] = i
68+ }
69+ return positions
70+}
71+
72+// SubsequencePositions returns the first in-order positions of needle in
73+// haystack.
74+func SubsequencePositions(haystack, needle []rune) ([]int, bool) {
75+ if len(needle) == 0 {
76+ return nil, true
77+ }
78+ positions := make([]int, 0, len(needle))
79+ next := 0
80+ for idx, r := range haystack {
81+ if r != needle[next] {
82+ continue
83+ }
84+ positions = append(positions, idx)
85+ next++
86+ if next == len(needle) {
87+ return positions, true
88+ }
89+ }
90+ return nil, false
91+}
@@ -0,0 +1,91 @@
1+// Package fuzzy scores one already case-folded text against a folded query.
2+//
3+// A field matches exactly, by prefix, or as an in-order rune subsequence.
4+// Callers map each Kind to their own rank and order matches with Less.
5+package fuzzy
6+
7+import "strings"
8+
9+// Kind is how a field matched the query.
10+type Kind uint8
11+
12+const (
13+ Exact Kind = iota
14+ Prefix
15+ Subsequence
16+)
17+
18+// Score is one field match. Positions are rune indexes into the text.
19+type Score struct {
20+ Kind Kind
21+ Positions []int
22+ // Span is the rune distance from the first to the last matched position.
23+ Span int
24+ First int
25+}
26+
27+// Match scores folded text against query, whose runes are needle. An empty
28+// needle matches as Exact with no positions.
29+func Match(text, query string, needle []rune) (Score, bool) {
30+ if len(needle) == 0 {
31+ return Score{Kind: Exact}, true
32+ }
33+ var score Score
34+ switch {
35+ case text == query:
36+ score = Score{Kind: Exact, Positions: Range(len(needle))}
37+ case strings.HasPrefix(text, query):
38+ score = Score{Kind: Prefix, Positions: Range(len(needle))}
39+ default:
40+ positions, ok := SubsequencePositions([]rune(text), needle)
41+ if !ok {
42+ return Score{}, false
43+ }
44+ score = Score{Kind: Subsequence, Positions: positions}
45+ }
46+ score.Span = score.Positions[len(score.Positions)-1] - score.Positions[0] + 1
47+ score.First = score.Positions[0]
48+ return score, true
49+}
50+
51+// Less orders two scores whose ranks the caller derived from their Kind:
52+// lower rank, then tighter span, then earlier first position.
53+func Less(leftRank int, left Score, rightRank int, right Score) bool {
54+ if leftRank != rightRank {
55+ return leftRank < rightRank
56+ }
57+ if left.Span != right.Span {
58+ return left.Span < right.Span
59+ }
60+ return left.First < right.First
61+}
62+
63+// Range returns the positions 0..n-1.
64+func Range(n int) []int {
65+ positions := make([]int, n)
66+ for i := range positions {
67+ positions[i] = i
68+ }
69+ return positions
70+}
71+
72+// SubsequencePositions returns the first in-order positions of needle in
73+// haystack.
74+func SubsequencePositions(haystack, needle []rune) ([]int, bool) {
75+ if len(needle) == 0 {
76+ return nil, true
77+ }
78+ positions := make([]int, 0, len(needle))
79+ next := 0
80+ for idx, r := range haystack {
81+ if r != needle[next] {
82+ continue
83+ }
84+ positions = append(positions, idx)
85+ next++
86+ if next == len(needle) {
87+ return positions, true
88+ }
89+ }
90+ return nil, false
91+}
A internal/usecase/fuzzy/fuzzy_test.go +55 -0
@@ -0,0 +1,55 @@
1+package fuzzy
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestMatch(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ text string
13+ query string
14+ want Score
15+ ok bool
16+ }{
17+ {name: "empty query", text: "work", query: "", want: Score{Kind: Exact}, ok: true},
18+ {name: "exact", text: "work", query: "work", want: Score{Kind: Exact, Positions: []int{0, 1, 2, 3}, Span: 4}, 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},
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: "no match", text: "work", query: "z", ok: false},
23+ {name: "out of order", text: "ab", query: "ba", ok: false},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got, ok := Match(tt.text, tt.query, []rune(tt.query))
28+ require.Equal(t, tt.ok, ok)
29+ require.Equal(t, tt.want, got)
30+ })
31+ }
32+}
33+
34+func TestLess(t *testing.T) {
35+ tests := []struct {
36+ name string
37+ leftRank int
38+ left Score
39+ rightRank int
40+ right Score
41+ wantLess bool
42+ wantGreater bool
43+ }{
44+ {name: "rank wins", leftRank: 0, left: Score{Span: 9}, rightRank: 1, right: Score{Span: 1}, wantLess: true},
45+ {name: "span breaks rank tie", left: Score{Span: 2, First: 5}, right: Score{Span: 3}, wantLess: true},
46+ {name: "first breaks span tie", left: Score{Span: 2, First: 1}, right: Score{Span: 2, First: 3}, wantLess: true},
47+ {name: "equal", left: Score{Span: 2}, right: Score{Span: 2}},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ require.Equal(t, tt.wantLess, Less(tt.leftRank, tt.left, tt.rightRank, tt.right))
52+ require.Equal(t, tt.wantGreater, Less(tt.rightRank, tt.right, tt.leftRank, tt.left))
53+ })
54+ }
55+}
@@ -0,0 +1,55 @@
1+package fuzzy
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestMatch(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ text string
13+ query string
14+ want Score
15+ ok bool
16+ }{
17+ {name: "empty query", text: "work", query: "", want: Score{Kind: Exact}, ok: true},
18+ {name: "exact", text: "work", query: "work", want: Score{Kind: Exact, Positions: []int{0, 1, 2, 3}, Span: 4}, 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},
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: "no match", text: "work", query: "z", ok: false},
23+ {name: "out of order", text: "ab", query: "ba", ok: false},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got, ok := Match(tt.text, tt.query, []rune(tt.query))
28+ require.Equal(t, tt.ok, ok)
29+ require.Equal(t, tt.want, got)
30+ })
31+ }
32+}
33+
34+func TestLess(t *testing.T) {
35+ tests := []struct {
36+ name string
37+ leftRank int
38+ left Score
39+ rightRank int
40+ right Score
41+ wantLess bool
42+ wantGreater bool
43+ }{
44+ {name: "rank wins", leftRank: 0, left: Score{Span: 9}, rightRank: 1, right: Score{Span: 1}, wantLess: true},
45+ {name: "span breaks rank tie", left: Score{Span: 2, First: 5}, right: Score{Span: 3}, wantLess: true},
46+ {name: "first breaks span tie", left: Score{Span: 2, First: 1}, right: Score{Span: 2, First: 3}, wantLess: true},
47+ {name: "equal", left: Score{Span: 2}, right: Score{Span: 2}},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ require.Equal(t, tt.wantLess, Less(tt.leftRank, tt.left, tt.rightRank, tt.right))
52+ require.Equal(t, tt.wantGreater, Less(tt.rightRank, tt.right, tt.leftRank, tt.left))
53+ })
54+ }
55+}
M internal/usecase/palette/fuzzy.go +12 -49
@@ -3,7 +3,8 @@ package palette
33 import (
44 "sort"
55 "strings"
6- "unicode/utf8"
6+
7+ "github.com/bnema/vev/internal/usecase/fuzzy"
78 )
89
910 // Match is a fuzzy-matched immutable palette result.
@@ -85,7 +86,7 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match,
8586 return match, true
8687 }
8788 if cmd, ok := result.Command(); ok {
88- if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
89+ if positions, ok := fuzzy.SubsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
8990 match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0]
9091 return match, true
9192 }
@@ -101,25 +102,19 @@ type fieldScore struct {
101102 }
102103
103104 func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) {
104- text = strings.ToLower(text)
105- var scored fieldScore
106- switch {
107- case text == needle:
108- scored.positions = rangePositions(utf8.RuneCountInString(text))
105+ matched, ok := fuzzy.Match(strings.ToLower(text), needle, needleRunes)
106+ if !ok {
107+ return fieldScore{}, false
108+ }
109+ scored := fieldScore{positions: matched.Positions, span: matched.Span, first: matched.First}
110+ switch matched.Kind {
111+ case fuzzy.Exact:
109112 scored.rank = exactRank
110- case strings.HasPrefix(text, needle):
111- scored.positions = rangePositions(utf8.RuneCountInString(needle))
113+ case fuzzy.Prefix:
112114 scored.rank = prefixRank
113115 default:
114- positions, ok := subsequencePositions([]rune(text), needleRunes)
115- if !ok {
116- return fieldScore{}, false
117- }
118- scored.positions = positions
119116 scored.rank = subsequenceRank
120117 }
121- scored.span = scored.positions[len(scored.positions)-1] - scored.positions[0] + 1
122- scored.first = scored.positions[0]
123118 for i := range scored.positions {
124119 scored.positions[i] += displayOffset
125120 }
@@ -127,13 +122,7 @@ func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRan
127122 }
128123
129124 func (s fieldScore) betterThan(other fieldScore) bool {
130- if s.rank != other.rank {
131- return s.rank < other.rank
132- }
133- if s.span != other.span {
134- return s.span < other.span
135- }
136- return s.first < other.first
125+ return fuzzy.Less(s.rank, fuzzy.Score{Span: s.span, First: s.first}, other.rank, fuzzy.Score{Span: other.span, First: other.first})
137126 }
138127
139128 func (s fieldScore) apply(match *Match) {
@@ -146,29 +135,3 @@ func (s fieldScore) apply(match *Match) {
146135 func newMatch(result Result, order int) Match {
147136 return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}
148137 }
149-
150-func rangePositions(n int) []int {
151- positions := make([]int, n)
152- for i := range positions {
153- positions[i] = i
154- }
155- return positions
156-}
157-
158-func subsequencePositions(haystack, needle []rune) ([]int, bool) {
159- if len(needle) == 0 {
160- return nil, true
161- }
162- positions := make([]int, 0, len(needle))
163- j := 0
164- for i, r := range haystack {
165- if r == needle[j] {
166- positions = append(positions, i)
167- j++
168- if j == len(needle) {
169- return positions, true
170- }
171- }
172- }
173- return nil, false
174-}
@@ -3,7 +3,8 @@ package palette
3 import (3 import (
4 "sort"4 "sort"
5 "strings"5 "strings"
6- "unicode/utf8"6+
7+ "github.com/bnema/vev/internal/usecase/fuzzy"
7 )8 )
8 9
9 // Match is a fuzzy-matched immutable palette result.10 // Match is a fuzzy-matched immutable palette result.
@@ -85,7 +86,7 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match,
85 return match, true86 return match, true
86 }87 }
87 if cmd, ok := result.Command(); ok {88 if cmd, ok := result.Command(); ok {
88- if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {89+ if positions, ok := fuzzy.SubsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
89 match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0]90 match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0]
90 return match, true91 return match, true
91 }92 }
@@ -101,25 +102,19 @@ type fieldScore struct {
101 }102 }
102 103
103 func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) {104 func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) {
104- text = strings.ToLower(text)105+ matched, ok := fuzzy.Match(strings.ToLower(text), needle, needleRunes)
105- var scored fieldScore106+ if !ok {
106- switch {107+ return fieldScore{}, false
107- case text == needle:108+ }
108- scored.positions = rangePositions(utf8.RuneCountInString(text))109+ scored := fieldScore{positions: matched.Positions, span: matched.Span, first: matched.First}
110+ switch matched.Kind {
111+ case fuzzy.Exact:
109 scored.rank = exactRank112 scored.rank = exactRank
110- case strings.HasPrefix(text, needle):113+ case fuzzy.Prefix:
111- scored.positions = rangePositions(utf8.RuneCountInString(needle))
112 scored.rank = prefixRank114 scored.rank = prefixRank
113 default:115 default:
114- positions, ok := subsequencePositions([]rune(text), needleRunes)
115- if !ok {
116- return fieldScore{}, false
117- }
118- scored.positions = positions
119 scored.rank = subsequenceRank116 scored.rank = subsequenceRank
120 }117 }
121- scored.span = scored.positions[len(scored.positions)-1] - scored.positions[0] + 1
122- scored.first = scored.positions[0]
123 for i := range scored.positions {118 for i := range scored.positions {
124 scored.positions[i] += displayOffset119 scored.positions[i] += displayOffset
125 }120 }
@@ -127,13 +122,7 @@ func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRan
127 }122 }
128 123
129 func (s fieldScore) betterThan(other fieldScore) bool {124 func (s fieldScore) betterThan(other fieldScore) bool {
130- if s.rank != other.rank {125+ return fuzzy.Less(s.rank, fuzzy.Score{Span: s.span, First: s.first}, other.rank, fuzzy.Score{Span: other.span, First: other.first})
131- return s.rank < other.rank
132- }
133- if s.span != other.span {
134- return s.span < other.span
135- }
136- return s.first < other.first
137 }126 }
138 127
139 func (s fieldScore) apply(match *Match) {128 func (s fieldScore) apply(match *Match) {
@@ -146,29 +135,3 @@ func (s fieldScore) apply(match *Match) {
146 func newMatch(result Result, order int) Match {135 func newMatch(result Result, order int) Match {
147 return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}136 return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}
148 }137 }
149-
150-func rangePositions(n int) []int {
151- positions := make([]int, n)
152- for i := range positions {
153- positions[i] = i
154- }
155- return positions
156-}
157-
158-func subsequencePositions(haystack, needle []rune) ([]int, bool) {
159- if len(needle) == 0 {
160- return nil, true
161- }
162- positions := make([]int, 0, len(needle))
163- j := 0
164- for i, r := range haystack {
165- if r == needle[j] {
166- positions = append(positions, i)
167- j++
168- if j == len(needle) {
169- return positions, true
170- }
171- }
172- }
173- return nil, false
174-}
M internal/usecase/picker/search.go +5 -48
@@ -6,6 +6,7 @@ import (
66 "unicode/utf8"
77
88 renderer "github.com/bnema/vev-vt"
9+ "github.com/bnema/vev/internal/usecase/fuzzy"
910 )
1011
1112 const MaxSearchQueryRunes = 256
@@ -282,60 +283,16 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
282283 }
283284
284285 func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {
285- if len(needle) == 0 {
286- return fieldMatch{field: field}, true
287- }
288- if text == query {
289- positions := rangeRunePositions(len(needle))
290- return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true
291- }
292- if strings.HasPrefix(text, query) {
293- positions := rangeRunePositions(len(needle))
294- return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true
295- }
296- positions, ok := subsequenceRunePositions([]rune(text), needle)
286+ matched, ok := fuzzy.Match(text, query, needle)
297287 if !ok {
298288 return fieldMatch{}, false
299289 }
300- return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true
290+ return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true
301291 }
302292
303293 func lessFieldMatch(left, right fieldMatch) bool {
304- if left.rank != right.rank {
305- return left.rank < right.rank
306- }
307- if left.span != right.span {
308- return left.span < right.span
309- }
310- if left.first != right.first {
311- return left.first < right.first
294+ if left.rank != right.rank || left.span != right.span || left.first != right.first {
295+ return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first})
312296 }
313297 return left.field < right.field
314298 }
315-
316-func rangeRunePositions(length int) []int {
317- positions := make([]int, length)
318- for i := range positions {
319- positions[i] = i
320- }
321- return positions
322-}
323-
324-func subsequenceRunePositions(haystack, needle []rune) ([]int, bool) {
325- if len(needle) == 0 {
326- return nil, true
327- }
328- positions := make([]int, 0, len(needle))
329- next := 0
330- for idx, r := range haystack {
331- if r != needle[next] {
332- continue
333- }
334- positions = append(positions, idx)
335- next++
336- if next == len(needle) {
337- return positions, true
338- }
339- }
340- return nil, false
341-}
@@ -6,6 +6,7 @@ import (
6 "unicode/utf8"6 "unicode/utf8"
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9+ "github.com/bnema/vev/internal/usecase/fuzzy"
9 )10 )
10 11
11 const MaxSearchQueryRunes = 25612 const MaxSearchQueryRunes = 256
@@ -282,60 +283,16 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
282 }283 }
283 284
284 func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {285 func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {
285- if len(needle) == 0 {286+ matched, ok := fuzzy.Match(text, query, needle)
286- return fieldMatch{field: field}, true
287- }
288- if text == query {
289- positions := rangeRunePositions(len(needle))
290- return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true
291- }
292- if strings.HasPrefix(text, query) {
293- positions := rangeRunePositions(len(needle))
294- return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true
295- }
296- positions, ok := subsequenceRunePositions([]rune(text), needle)
297 if !ok {287 if !ok {
298 return fieldMatch{}, false288 return fieldMatch{}, false
299 }289 }
300- return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true290+ return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true
301 }291 }
302 292
303 func lessFieldMatch(left, right fieldMatch) bool {293 func lessFieldMatch(left, right fieldMatch) bool {
304- if left.rank != right.rank {294+ if left.rank != right.rank || left.span != right.span || left.first != right.first {
305- return left.rank < right.rank295+ return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first})
306- }
307- if left.span != right.span {
308- return left.span < right.span
309- }
310- if left.first != right.first {
311- return left.first < right.first
312 }296 }
313 return left.field < right.field297 return left.field < right.field
314 }298 }
315-
316-func rangeRunePositions(length int) []int {
317- positions := make([]int, length)
318- for i := range positions {
319- positions[i] = i
320- }
321- return positions
322-}
323-
324-func subsequenceRunePositions(haystack, needle []rune) ([]int, bool) {
325- if len(needle) == 0 {
326- return nil, true
327- }
328- positions := make([]int, 0, len(needle))
329- next := 0
330- for idx, r := range haystack {
331- if r != needle[next] {
332- continue
333- }
334- positions = append(positions, idx)
335- next++
336- if next == len(needle) {
337- return positions, true
338- }
339- }
340- return nil, false
341-}

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