fuzzy_test.go RawHistoryFolder

1package palette
2
3import (
4	"testing"
5	"time"
6
7	"github.com/bnema/vev/internal/protocol"
8	"github.com/bnema/vev/internal/usecase/command"
9	"github.com/stretchr/testify/require"
10)
11
12func cmd(code, name, desc string) command.Command {
13	return command.Command{Code: code, Name: name, Desc: desc}
14}
15
16func codes(matches []Match) []string {
17	out := make([]string, len(matches))
18	for i, match := range matches {
19		cmd, ok := match.Result.Command()
20		if !ok {
21			continue
22		}
23		out[i] = cmd.Code
24	}
25	return out
26}
27
28func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {
29	matches := Fuzzy(CommandResults([]command.Command{
30		cmd("ZQQ", "unused", "alpha beta"),
31		cmd("XAB", "unused", "subsequence code"),
32		cmd("ABX", "unused", "prefix code"),
33		cmd("AB", "unused", "exact code"),
34	}), "ab")
35
36	require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
37	require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")
38}
39
40func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {
41	matches := Fuzzy(CommandResults([]command.Command{
42		cmd("CPY", "Create Alpha Beta", "Enter copy mode"),
43		cmd("ZQQ", "unused", "Alpha Beta tools"),
44	}), "ab")
45
46	require.Equal(t, []string{"ZQQ"}, codes(matches))
47}
48
49func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {
50	matches := Fuzzy(CommandResults([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}), "copy")
51
52	require.Len(t, matches, 1)
53	require.Empty(t, matches[0].Positions)
54}
55
56func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
57	matches := Fuzzy(CommandResults([]command.Command{
58		cmd("ZAXQB", "", ""), // span 3, first 1
59		cmd("BAXQ", "", ""),  // span 3, first 1, code sorts first
60		cmd("PXAQ", "", ""),  // span 2, first 2
61		cmd("BAQ", "", ""),   // span 2, first 0
62	}), "aq")
63
64	require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches))
65}
66
67func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
68	matches := Fuzzy(CommandResults([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}), "cy")
69
70	require.Len(t, matches, 1)
71	require.Equal(t, []int{0, 2}, matches[0].Positions)
72}
73
74func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
75	commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")}
76	matches := Fuzzy(CommandResults(commands), "")
77
78	require.Equal(t, []string{"B", "A"}, codes(matches))
79}
80
81func TestFuzzyRanksExactSessionNameAheadOfPrefix(t *testing.T) {
82	tests := []struct {
83		name      string
84		results   []Result
85		wantFirst string
86		offset    int
87	}{
88		{
89			name: "active session",
90			results: []Result{
91				NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"),
92				NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"),
93			},
94			wantFirst: "Switch to session vev@arch",
95			offset:    18,
96		},
97		{
98			name: "stopped session",
99			results: []Result{
100				NewStoppedSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"),
101				NewStoppedSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"),
102			},
103			wantFirst: "Resume session vev@arch",
104			offset:    15,
105		},
106		{
107			name: "recent route",
108			results: []Result{
109				NewRecentRouteResult("vev-vt", "vev-vt@arch", protocol.RouteNavigationAction{Key: 1}),
110				NewRecentRouteResult("vev", "vev@arch", protocol.RouteNavigationAction{Key: 2}),
111			},
112			wantFirst: "Switch to session vev@arch",
113			offset:    18,
114		},
115	}
116
117	for _, tt := range tests {
118		t.Run(tt.name, func(t *testing.T) {
119			matches := Fuzzy(tt.results, "vev")
120
121			require.Len(t, matches, 2)
122			require.Equal(t, tt.wantFirst, matches[0].Result.DisplayText())
123			require.Equal(t, []int{tt.offset, tt.offset + 1, tt.offset + 2}, matches[0].Positions)
124		})
125	}
126}
127
128func TestFuzzyOrdersMixedResults(t *testing.T) {
129	created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
130	tests := []struct {
131		name              string
132		results           []Result
133		query             string
134		wantText          []string
135		wantKinds         []ResultKind
136		wantPositions     [][]int
137		wantActiveTargets map[int]protocol.ExactSessionTarget
138	}{
139		{
140			name: "command shortcode precedes exact session names and broader matches",
141			results: []Result{
142				NewStoppedSessionResult(testExactTarget("work", 1), created),
143				NewCommandResult(cmd("WORK", "", "Create a workspace")),
144				NewActiveSessionResult(testExactTarget("work", 2), created),
145				NewCommandResult(cmd("WQORRK", "", "")),
146				NewCommandResult(cmd("ZZZ", "", "work tools")),
147			},
148			query:         "work",
149			wantText:      []string{"WORK", "Switch to session work", "Resume session work", "WQORRK", "ZZZ"},
150			wantKinds:     []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand},
151			wantPositions: [][]int{{0, 1, 2, 3}, {18, 19, 20, 21}, {15, 16, 17, 18}, {0, 2, 3, 5}, nil},
152		},
153		{
154			name: "equivalent sessions sort by normalized text",
155			results: []Result{
156				NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}),
157				NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}),
158			},
159			query:         "a",
160			wantText:      []string{"Resume session aAlpha", "Resume session aBravo"},
161			wantKinds:     []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession},
162			wantPositions: [][]int{{15}, {15}},
163		},
164		{
165			name: "semantic session prefixes sort by kind then normalized label",
166			results: []Result{
167				NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}),
168				NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}),
169				NewActiveSessionResult(testExactTarget("aZulu", 3), time.Time{}),
170				NewActiveSessionResult(testExactTarget("aEcho", 4), time.Time{}),
171				NewActiveSessionResult(testExactTarget("aEcho", 5), time.Time{}),
172				NewCommandResult(cmd("AX", "", "")),
173			},
174			query:         "a",
175			wantText:      []string{"AX", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu", "Resume session aAlpha", "Resume session aBravo"},
176			wantKinds:     []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession},
177			wantPositions: [][]int{{0}, {18}, {18}, {18}, {15}, {15}},
178			wantActiveTargets: map[int]protocol.ExactSessionTarget{
179				1: testExactTarget("aEcho", 4), 2: testExactTarget("aEcho", 5), 3: testExactTarget("aZulu", 3),
180			},
181		},
182	}
183
184	for _, tt := range tests {
185		t.Run(tt.name, func(t *testing.T) {
186			matches := Fuzzy(tt.results, tt.query)
187			require.Equal(t, tt.wantText, matchSearchText(matches))
188			require.Equal(t, tt.wantKinds, matchKinds(matches))
189			positions := make([][]int, len(matches))
190			for i := range matches {
191				positions[i] = matches[i].Positions
192			}
193			require.Equal(t, tt.wantPositions, positions)
194			for index, wantTarget := range tt.wantActiveTargets {
195				gotTarget, ok := matches[index].Result.SessionTarget()
196				require.True(t, ok)
197				require.Equal(t, wantTarget, gotTarget)
198			}
199		})
200	}
201}
202
203func matchSearchText(matches []Match) []string {
204	out := make([]string, len(matches))
205	for i, match := range matches {
206		out[i] = match.Result.SearchText()
207	}
208	return out
209}
210
211func matchKinds(matches []Match) []ResultKind {
212	out := make([]ResultKind, len(matches))
213	for i, match := range matches {
214		out[i] = match.Result.Kind()
215	}
216	return out
217}

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