v0.1.58 25a4caf

file_suggest.go RawHistoryFolder

1package modes
2
3import (
4	"os"
5	"path/filepath"
6	"regexp"
7	"sort"
8	"strings"
9
10	"github.com/patriceckhart/zot/internal/tui"
11)
12
13// fileSuggester provides an @-triggered file/directory picker popup.
14// Type "@" followed by an optional filter to list files in the working
15// directory. Arrow up/down navigate, enter selects, esc cancels.
16// Right arrow opens a directory, left arrow goes back to the parent.
17type fileSuggester struct {
18	cursor      int
19	lastMatches []fileEntry
20	cwd         string // project root
21	browseRel   string // relative path from cwd we're currently browsing ("" = cwd itself)
22	cachedDir   string // absolute directory we last scanned
23	cachedAll   []fileEntry
24}
25
26type fileEntry struct {
27	name  string // display name
28	rel   string // path relative to cwd (used for chip insertion)
29	isDir bool
30}
31
32func newFileSuggester() *fileSuggester { return &fileSuggester{} }
33
34// SetCWD updates the project root.
35func (s *fileSuggester) SetCWD(cwd string) {
36	if s.cwd != cwd {
37		s.cwd = cwd
38		s.browseRel = ""
39		s.cachedDir = ""
40		s.cachedAll = nil
41	}
42}
43
44// browseDir returns the absolute directory currently being browsed.
45func (s *fileSuggester) browseDir() string {
46	if s.browseRel == "" {
47		return s.cwd
48	}
49	return filepath.Join(s.cwd, s.browseRel)
50}
51
52// scan reads entries from the current browse directory (cached).
53func (s *fileSuggester) scan() []fileEntry {
54	dir := s.browseDir()
55	if s.cachedDir == dir && s.cachedAll != nil {
56		return s.cachedAll
57	}
58	entries, err := os.ReadDir(dir)
59	if err != nil {
60		return nil
61	}
62	var all []fileEntry
63	for _, e := range entries {
64		name := e.Name()
65		rel := name
66		if s.browseRel != "" {
67			rel = filepath.Join(s.browseRel, name)
68		}
69		all = append(all, fileEntry{
70			name:  name,
71			rel:   rel,
72			isDir: e.IsDir(),
73		})
74	}
75	sort.Slice(all, func(i, j int) bool {
76		if all[i].isDir != all[j].isDir {
77			return all[i].isDir
78		}
79		return strings.ToLower(all[i].name) < strings.ToLower(all[j].name)
80	})
81	s.cachedAll = all
82	s.cachedDir = dir
83	return all
84}
85
86// extractAtQuery returns the filter string after "@".
87func extractAtQuery(input string) (string, bool) {
88	input = strings.TrimRight(input, " ")
89	idx := strings.LastIndex(input, "@")
90	if idx < 0 {
91		return "", false
92	}
93	if idx > 0 && input[idx-1] != ' ' {
94		return "", false
95	}
96	query := input[idx+1:]
97	if strings.ContainsAny(query, " \t\n") {
98		return "", false
99	}
100	return query, true
101}
102
103// matches returns file entries matching the current @-query.
104func (s *fileSuggester) matches(input string) []fileEntry {
105	query, ok := extractAtQuery(input)
106	if !ok {
107		return nil
108	}
109	all := s.scan()
110	if len(all) == 0 {
111		return nil
112	}
113	needle := strings.ToLower(query)
114	if needle == "" {
115		return all
116	}
117	var out []fileEntry
118	for _, e := range all {
119		if strings.Contains(strings.ToLower(e.name), needle) {
120			out = append(out, e)
121		}
122	}
123	return out
124}
125
126// Active reports whether the popup should be visible.
127func (s *fileSuggester) Active(input string) bool {
128	return len(s.matches(input)) > 0
129}
130
131// Reset clears state.
132func (s *fileSuggester) Reset() {
133	s.cursor = 0
134	s.lastMatches = nil
135	s.browseRel = ""
136	s.cachedDir = ""
137	s.cachedAll = nil
138}
139
140// Invalidate forces a rescan.
141func (s *fileSuggester) Invalidate() {
142	s.cachedDir = ""
143	s.cachedAll = nil
144}
145
146func (s *fileSuggester) Up() {
147	if s.cursor > 0 {
148		s.cursor--
149	}
150}
151
152func (s *fileSuggester) Down() {
153	if s.cursor < len(s.lastMatches)-1 {
154		s.cursor++
155	}
156}
157
158// Right opens the selected directory, descending into it.
159// Returns true if a directory was entered.
160func (s *fileSuggester) Right() bool {
161	m := s.lastMatches
162	if len(m) == 0 || s.cursor >= len(m) {
163		return false
164	}
165	e := m[s.cursor]
166	if !e.isDir {
167		return false
168	}
169	s.browseRel = e.rel
170	s.cachedDir = ""
171	s.cachedAll = nil
172	s.cursor = 0
173	return true
174}
175
176// Left goes back to the parent directory.
177// Returns true if we moved up.
178func (s *fileSuggester) Left() bool {
179	if s.browseRel == "" {
180		return false
181	}
182	parent := filepath.Dir(s.browseRel)
183	if parent == "." {
184		parent = ""
185	}
186	s.browseRel = parent
187	s.cachedDir = ""
188	s.cachedAll = nil
189	s.cursor = 0
190	return true
191}
192
193// Selection returns the relative path of the currently highlighted entry.
194func (s *fileSuggester) Selection(input string) string {
195	m := s.matches(input)
196	if len(m) == 0 {
197		return ""
198	}
199	if s.cursor >= len(m) {
200		s.cursor = len(m) - 1
201	}
202	return m[s.cursor].rel
203}
204
205// SelectedEntry returns the currently highlighted entry.
206func (s *fileSuggester) SelectedEntry(input string) (fileEntry, bool) {
207	m := s.matches(input)
208	if len(m) == 0 {
209		return fileEntry{}, false
210	}
211	if s.cursor >= len(m) {
212		s.cursor = len(m) - 1
213	}
214	return m[s.cursor], true
215}
216
217// Render returns the popup lines.
218func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
219	m := s.matches(input)
220	if len(m) == 0 {
221		return nil
222	}
223	s.lastMatches = m
224	if s.cursor >= len(m) {
225		s.cursor = len(m) - 1
226	}
227
228	const maxVisible = 12
229	visible := m
230	offset := 0
231	if len(m) > maxVisible {
232		if s.cursor >= maxVisible {
233			offset = s.cursor - maxVisible + 1
234		}
235		end := offset + maxVisible
236		if end > len(m) {
237			end = len(m)
238			offset = end - maxVisible
239		}
240		visible = m[offset:end]
241	}
242
243	var out []string
244
245	// Show breadcrumb when browsing a subdirectory.
246	if s.browseRel != "" {
247		crumb := th.FG256(th.Accent, "  "+s.browseRel+"/")
248		out = append(out, crumb)
249	}
250
251	for i, e := range visible {
252		idx := offset + i
253		name := e.name
254		if e.isDir {
255			name += "/"
256		}
257		plain := "  " + name
258		if idx == s.cursor {
259			out = append(out, th.PadHighlight(plain, width))
260		} else {
261			out = append(out, th.FG256(th.Muted, plain))
262		}
263	}
264
265	out = append(out, "")
266	hint := "  \u2191/\u2193 navigate - enter select - esc cancel"
267	if s.browseRel != "" {
268		hint = "  \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
269	} else {
270		hint = "  \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
271	}
272	out = append(out, th.FG256(th.Muted, hint))
273	out = append(out, "")
274	return out
275}
276
277// fileChipRE matches [file:N:name] and [dir:N:name/] chips.
278var fileChipRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`)
279
280// expandFileChips replaces [file:name] and [dir:name/] chips with
281// the full path relative to cwd.
282func expandFileChips(text, cwd string) string {
283	return fileChipRE.ReplaceAllStringFunc(text, func(match string) string {
284		groups := fileChipRE.FindStringSubmatch(match)
285		if len(groups) < 3 {
286			return match
287		}
288		name := groups[2]
289		// Strip trailing slash from dir names.
290		name = strings.TrimRight(name, "/")
291		return filepath.Join(cwd, name)
292	})
293}

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 1

Tags 476

↑↓ moveenter openesc close