v0.8.1 1f54f61

search.go RawHistoryFolder

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

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