search.go RawHistoryFolder

1package picker
2
3import (
4	"slices"
5	"strings"
6	"unicode/utf8"
7
8	renderer "github.com/bnema/vev-vt"
9	"github.com/bnema/vev/internal/usecase/fuzzy"
10)
11
12const MaxSearchQueryRunes = 256
13
14type matchField uint8
15
16const (
17	matchName matchField = iota
18	matchDetail
19	matchContext
20)
21
22type fieldMatch struct {
23	field     matchField
24	positions []int
25	rank      int
26	span      int
27	first     int
28}
29
30type searchMatch struct {
31	fieldMatches []fieldMatch
32	best         fieldMatch
33}
34
35func (m searchMatch) positions(field matchField) []int {
36	for _, candidate := range m.fieldMatches {
37		if candidate.field == field {
38			return candidate.positions
39		}
40	}
41	return nil
42}
43
44func (m *Model) EnterSearch() {
45	if m == nil || m.searchActive {
46		return
47	}
48	m.searchActive = true
49	m.query.SetValue("")
50	m.refreshSearch(false)
51}
52
53func (m *Model) ExitSearch() {
54	if m == nil {
55		return
56	}
57	m.searchActive = false
58	m.query.SetValue("")
59	m.searchMatches = nil
60	m.matchRows = nil
61}
62
63func (m *Model) SearchActive() bool { return m != nil && m.searchActive }
64
65func (m *Model) Query() string {
66	if m == nil {
67		return ""
68	}
69	return m.query.Value()
70}
71
72func (m *Model) SearchTitle(width ...int) string {
73	if m == nil || !m.searchActive {
74		return ""
75	}
76	full := " Search sessions & tabs: " + m.query.Value() + "_ "
77	if len(width) == 0 || textCellWidth(full) <= width[0] {
78		return full
79	}
80	const (
81		prefix = " / "
82		suffix = "_ "
83	)
84	fixedWidth := textCellWidth(prefix) + textCellWidth(suffix)
85	if width[0] < fixedWidth {
86		return tailCells("_", width[0])
87	}
88	available := width[0] - fixedWidth
89	return prefix + tailCells(m.query.Value(), available) + suffix
90}
91
92func tailCells(text string, width int) string {
93	runes := []rune(text)
94	used := 0
95	start := len(runes)
96	for start > 0 {
97		cellWidth := renderer.RuneWidth(runes[start-1])
98		if used+cellWidth > width {
99			break
100		}
101		used += cellWidth
102		start--
103	}
104	return string(runes[start:])
105}
106
107func textCellWidth(text string) int {
108	width := 0
109	for _, r := range text {
110		width += renderer.RuneWidth(r)
111	}
112	return width
113}
114
115func (m *Model) InsertSearch(r rune) {
116	if m == nil || !m.searchActive || utf8.RuneCountInString(m.query.Value()) >= MaxSearchQueryRunes {
117		return
118	}
119	m.query.Insert(r)
120	m.refreshSearch(true)
121}
122
123func (m *Model) BackspaceSearch() {
124	if m == nil || !m.searchActive || m.query.Value() == "" {
125		return
126	}
127	m.query.Backspace()
128	m.refreshSearch(true)
129}
130
131func (m *Model) ClearSearch() {
132	if m == nil || !m.searchActive || m.query.Value() == "" {
133		return
134	}
135	m.query.SetValue("")
136	m.refreshSearch(false)
137}
138
139func (m *Model) MatchCount() int {
140	if m == nil || !m.searchActive {
141		return 0
142	}
143	return len(m.matchRows)
144}
145
146// ReplaceFrom applies a fresh canonical row snapshot while retaining the
147// attachment-local search editor and exact cursor when that key still exists.
148// Callers serialize this mutation with their picker ownership lock.
149func (m *Model) ReplaceFrom(next *Model) {
150	if m == nil || next == nil {
151		return
152	}
153	key, hadKey := m.cursorKey()
154	searchActive, query := m.searchActive, m.query.Value()
155	m.intent = next.intent
156	m.sort = next.sort
157	m.lines = append(m.lines[:0], next.lines...)
158	m.searchActive = searchActive
159	m.query.SetValue(query)
160	m.rebuild(key, hadKey, next.selected)
161}
162
163func (m *Model) rowMatches(idx int) bool {
164	if m == nil || !m.searchActive || m.query.Value() == "" {
165		return true
166	}
167	_, ok := m.searchMatches[idx]
168	return ok
169}
170
171func (m *Model) moveSearch(delta int) {
172	if m == nil {
173		return
174	}
175	if len(m.matchRows) == 0 {
176		// The query shows nothing, so no row may hold the cursor.
177		m.selected = -1
178		return
179	}
180	position := slices.Index(m.matchRows, m.selected)
181	if position < 0 {
182		if delta > 0 {
183			m.selected = m.matchRows[0]
184		} else {
185			m.selected = m.matchRows[len(m.matchRows)-1]
186		}
187		return
188	}
189	next := position + delta
190	if next >= 0 && next < len(m.matchRows) {
191		m.selected = m.matchRows[next]
192	}
193}
194
195// refreshSearch recomputes the rows the active query shows and re-places the
196// cursor: the editor never leaves it on a row the query hides.
197func (m *Model) refreshSearch(selectBest bool) int {
198	m.searchMatches = make(map[int]searchMatch)
199	m.matchRows = make([]int, 0, len(m.rows))
200	query := strings.ToLower(m.query.Value())
201	if query == "" {
202		for idx, row := range m.rows {
203			if row.focusable() {
204				m.matchRows = append(m.matchRows, idx)
205			}
206		}
207		if selectBest {
208			m.normalizeCursor(-1)
209		}
210		return -1
211	}
212
213	needleRunes := []rune(query)
214	bestIdx := -1
215	var best fieldMatch
216	for idx, row := range m.rows {
217		if !row.focusable() {
218			continue
219		}
220		matched, ok := matchRow(row, query, needleRunes)
221		if !ok {
222			continue
223		}
224		m.searchMatches[idx] = matched
225		m.matchRows = append(m.matchRows, idx)
226		if bestIdx < 0 || lessFieldMatch(matched.best, best) {
227			bestIdx, best = idx, matched.best
228		}
229	}
230	if selectBest {
231		if bestIdx >= 0 {
232			m.selected = bestIdx
233		} else {
234			m.selected = -1
235		}
236		m.normalizeCursor(bestIdx)
237	}
238	return bestIdx
239}
240
241// normalizeCursor places the cursor on a row the active query still shows. It
242// keeps the current row when it qualifies, otherwise it takes the best match,
243// then the nearest eligible row, and finally clears the cursor when nothing
244// qualifies.
245func (m *Model) normalizeCursor(best int) {
246	if m == nil || m.eligible(m.selected) {
247		return
248	}
249	if best >= 0 {
250		m.selected = best
251		return
252	}
253	hint := m.selected
254	m.selected = -1
255	m.SelectNearestRow(hint)
256	if m.selected < 0 {
257		m.selected = m.firstEligible()
258	}
259}
260
261func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
262	fields := []struct {
263		kind matchField
264		text string
265	}{
266		{matchName, row.foldedLabel},
267		{matchDetail, row.foldedDetail},
268		{matchContext, row.foldedContext},
269	}
270
271	var result searchMatch
272	for _, field := range fields {
273		match, ok := scoreField(field.kind, field.text, query, needleRunes)
274		if !ok {
275			continue
276		}
277		result.fieldMatches = append(result.fieldMatches, match)
278		if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) {
279			result.best = match
280		}
281	}
282	return result, len(result.fieldMatches) != 0
283}
284
285func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {
286	matched, ok := fuzzy.Match(text, query, needle)
287	if !ok {
288		return fieldMatch{}, false
289	}
290	return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true
291}
292
293func lessFieldMatch(left, right fieldMatch) bool {
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})
296	}
297	return left.field < right.field
298}

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