search.go RawHistoryFolder

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