v0.2.27 9b298e6

file_suggest.go RawHistoryFolder

1package modes
2
3import (
4	"os"
5	"path/filepath"
6	"regexp"
7	"sort"
8	"strings"
9	"time"
10
11	"github.com/sahilm/fuzzy"
12
13	"github.com/patriceckhart/zot/packages/ignore"
14	"github.com/patriceckhart/zot/packages/tui"
15)
16
17// recursiveScanLimits bound the recursive walk so the picker stays
18// responsive in very large repos. Hitting either cap stops the walk
19// early; the entries gathered so far are still searchable.
20const (
21	maxRecursiveEntries = 5000
22	maxRecursiveDepth   = 12
23)
24
25// alwaysSkipDir is never descended into during a recursive scan,
26// regardless of .gitignore. .git is a repo-internal directory that
27// real projects rarely list in their own .gitignore yet never want
28// surfaced as an @-mention target.
29const alwaysSkipDir = ".git"
30
31// fileSuggester provides an @-triggered file/directory picker popup.
32// Type "@" followed by an optional filter to list files in the working
33// directory. Arrow up/down navigate, enter selects, esc cancels.
34// Right arrow opens a directory, left arrow goes back to the parent.
35type fileSuggester struct {
36	cursor      int
37	lastMatches []fileEntry
38	cwd         string // project root
39	browseRel   string // relative path from cwd we're currently browsing ("" = cwd itself)
40	// recursive enables a whole-tree fuzzy search instead of
41	// directory-by-directory browsing. Mirrors the persisted
42	// recursive_file_suggest setting; toggled live from /settings.
43	recursive bool
44	// respectGitignore drops entries matched by the project's root
45	// .gitignore (and always .git) from both the flat and recursive
46	// listings. Mirrors the persisted respect_gitignore setting; on by
47	// default. Toggled live from /settings.
48	respectGitignore bool
49	cachedDir        string // absolute directory we last scanned
50	cachedAll        []fileEntry
51	// cachedMTime is the mtime of cachedDir at the time of the scan.
52	// scan() compares the current mtime against this on every call and
53	// re-reads the directory if it has changed, so files or folders
54	// created mid-session show up in the picker without restarting zot.
55	// Stat is cheap (single syscall) so doing it per keystroke while
56	// the popup is open does not impact responsiveness.
57	cachedMTime time.Time
58}
59
60type fileEntry struct {
61	name  string // display name
62	rel   string // path relative to cwd (used for chip insertion)
63	isDir bool
64}
65
66// newFileSuggester returns a picker that respects .gitignore by
67// default. Callers override via SetRespectGitignore once the persisted
68// setting is known.
69func newFileSuggester() *fileSuggester { return &fileSuggester{respectGitignore: true} }
70
71// SetCWD updates the project root.
72func (s *fileSuggester) SetCWD(cwd string) {
73	if s.cwd != cwd {
74		s.cwd = cwd
75		s.browseRel = ""
76		s.cachedDir = ""
77		s.cachedAll = nil
78	}
79}
80
81// SetRecursive toggles whole-tree fuzzy search. Switching modes drops
82// the cache and resets the browse position so the next render reflects
83// the new mode immediately.
84func (s *fileSuggester) SetRecursive(on bool) {
85	if s.recursive == on {
86		return
87	}
88	s.recursive = on
89	s.browseRel = ""
90	s.cachedDir = ""
91	s.cachedAll = nil
92	s.cursor = 0
93}
94
95// SetRespectGitignore toggles .gitignore filtering for both modes.
96// Switching drops the cache so the next scan reflects the new state.
97func (s *fileSuggester) SetRespectGitignore(on bool) {
98	if s.respectGitignore == on {
99		return
100	}
101	s.respectGitignore = on
102	s.cachedDir = ""
103	s.cachedAll = nil
104	s.cursor = 0
105}
106
107// browseDir returns the absolute directory currently being browsed.
108func (s *fileSuggester) browseDir() string {
109	if s.browseRel == "" {
110		return s.cwd
111	}
112	return filepath.Join(s.cwd, s.browseRel)
113}
114
115// scan reads entries from the current browse directory.
116//
117// Results are cached by absolute path + mtime: a repeated call against
118// the same directory returns the cached slice when nothing has changed
119// on disk, and re-reads when an entry was added, removed, or renamed
120// (any of which bumps the directory's mtime on every filesystem zot
121// supports). A failed stat falls through to a fresh ReadDir rather
122// than returning a stale cache so transient errors self-heal.
123func (s *fileSuggester) scan() []fileEntry {
124	if s.recursive {
125		return s.scanRecursive()
126	}
127	dir := s.browseDir()
128	var mtime time.Time
129	if info, err := os.Stat(dir); err == nil {
130		mtime = info.ModTime()
131	}
132	if s.cachedDir == dir && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) {
133		return s.cachedAll
134	}
135	entries, err := os.ReadDir(dir)
136	if err != nil {
137		return nil
138	}
139	var ig *ignore.Gitignore
140	if s.respectGitignore {
141		ig = ignore.Load(s.cwd)
142	}
143	var all []fileEntry
144	for _, e := range entries {
145		name := e.Name()
146		rel := name
147		if s.browseRel != "" {
148			rel = filepath.Join(s.browseRel, name)
149		}
150		if s.respectGitignore {
151			if e.IsDir() && name == alwaysSkipDir {
152				continue
153			}
154			if ig.Match(filepath.ToSlash(rel), e.IsDir()) {
155				continue
156			}
157		}
158		all = append(all, fileEntry{
159			name:  name,
160			rel:   rel,
161			isDir: e.IsDir(),
162		})
163	}
164	sort.Slice(all, func(i, j int) bool {
165		if all[i].isDir != all[j].isDir {
166			return all[i].isDir
167		}
168		return strings.ToLower(all[i].name) < strings.ToLower(all[j].name)
169	})
170	s.cachedAll = all
171	s.cachedDir = dir
172	s.cachedMTime = mtime
173	return all
174}
175
176// scanRecursive walks the whole project tree below cwd and returns
177// every file and directory as a fileEntry whose rel is the path
178// relative to cwd. The walk honors the project's root .gitignore (so
179// build outputs, dependency directories, and tool caches like
180// .terraform/.terragrunt-cache stay out of the picker) plus an
181// unconditional .git skip, and stops once it hits the entry/depth caps.
182//
183// Results are cached by cwd + mtime of cwd. Unlike the flat scan a
184// single mtime can't catch every nested change, so the cache is best
185// effort; Invalidate() (called on each keystroke path that matters)
186// and the explicit cache drops on toggle keep it fresh enough for an
187// interactive picker.
188func (s *fileSuggester) scanRecursive() []fileEntry {
189	root := s.cwd
190	var mtime time.Time
191	if info, err := os.Stat(root); err == nil {
192		mtime = info.ModTime()
193	}
194	if s.cachedDir == root && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) {
195		return s.cachedAll
196	}
197
198	var ig *ignore.Gitignore
199	if s.respectGitignore {
200		ig = ignore.Load(root)
201	}
202	var all []fileEntry
203	rootSep := strings.Count(root, string(os.PathSeparator))
204	_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
205		if err != nil {
206			if d != nil && d.IsDir() {
207				return filepath.SkipDir
208			}
209			return nil
210		}
211		if path == root {
212			return nil
213		}
214		rel, relErr := filepath.Rel(root, path)
215		if relErr != nil {
216			return nil
217		}
218		if s.respectGitignore {
219			// .gitignore patterns are matched against slash-separated paths.
220			if ig.Match(filepath.ToSlash(rel), d.IsDir()) {
221				if d.IsDir() {
222					return filepath.SkipDir
223				}
224				return nil
225			}
226		}
227		if d.IsDir() {
228			// .git is always pruned: it's never a useful @-mention target
229			// and would otherwise blow the entry budget even when
230			// gitignore filtering is off.
231			if d.Name() == alwaysSkipDir {
232				return filepath.SkipDir
233			}
234			if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth {
235				return filepath.SkipDir
236			}
237		}
238		all = append(all, fileEntry{
239			name:  rel,
240			rel:   rel,
241			isDir: d.IsDir(),
242		})
243		if len(all) >= maxRecursiveEntries {
244			return filepath.SkipAll
245		}
246		return nil
247	})
248
249	s.cachedAll = all
250	s.cachedDir = root
251	s.cachedMTime = mtime
252	return all
253}
254
255// extractAtQuery returns the filter string after "@".
256func extractAtQuery(input string) (string, bool) {
257	input = strings.TrimRight(input, " ")
258	idx := strings.LastIndex(input, "@")
259	if idx < 0 {
260		return "", false
261	}
262	if idx > 0 && input[idx-1] != ' ' {
263		return "", false
264	}
265	query := input[idx+1:]
266	if strings.ContainsAny(query, " \t\n") {
267		return "", false
268	}
269	return query, true
270}
271
272// matches returns file entries matching the current @-query.
273//
274// An empty query returns every entry in scan order. A non-empty query
275// is ranked with sahilm/fuzzy: in recursive mode the pattern is matched
276// against each entry's path relative to cwd (so "foobar" can find
277// "src/foo/bar.go"); in flat mode it matches the entry's display name.
278func (s *fileSuggester) matches(input string) []fileEntry {
279	query, ok := extractAtQuery(input)
280	if !ok {
281		return nil
282	}
283	all := s.scan()
284	if len(all) == 0 {
285		return nil
286	}
287	if query == "" {
288		return all
289	}
290	haystack := make([]string, len(all))
291	for i, e := range all {
292		if s.recursive {
293			haystack[i] = e.rel
294		} else {
295			haystack[i] = e.name
296		}
297	}
298	ranked := fuzzy.Find(query, haystack)
299	out := make([]fileEntry, 0, len(ranked))
300	for _, m := range ranked {
301		if m.Index >= 0 && m.Index < len(all) {
302			out = append(out, all[m.Index])
303		}
304	}
305	return out
306}
307
308// Active reports whether the popup should be visible.
309func (s *fileSuggester) Active(input string) bool {
310	return len(s.matches(input)) > 0
311}
312
313// Reset clears state.
314func (s *fileSuggester) Reset() {
315	s.cursor = 0
316	s.lastMatches = nil
317	s.browseRel = ""
318	s.cachedDir = ""
319	s.cachedAll = nil
320}
321
322// Invalidate forces a rescan.
323func (s *fileSuggester) Invalidate() {
324	s.cachedDir = ""
325	s.cachedAll = nil
326}
327
328func (s *fileSuggester) Up() {
329	if s.cursor > 0 {
330		s.cursor--
331	}
332}
333
334func (s *fileSuggester) Down() {
335	if s.cursor < len(s.lastMatches)-1 {
336		s.cursor++
337	}
338}
339
340// Right opens the selected directory, descending into it.
341// Returns true if a directory was entered. Disabled in recursive mode,
342// where the whole tree is already flattened into the result list.
343func (s *fileSuggester) Right() bool {
344	if s.recursive {
345		return false
346	}
347	m := s.lastMatches
348	if len(m) == 0 || s.cursor >= len(m) {
349		return false
350	}
351	e := m[s.cursor]
352	if !e.isDir {
353		return false
354	}
355	s.browseRel = e.rel
356	s.cachedDir = ""
357	s.cachedAll = nil
358	s.cursor = 0
359	return true
360}
361
362// Left goes back to the parent directory.
363// Returns true if we moved up. Disabled in recursive mode.
364func (s *fileSuggester) Left() bool {
365	if s.recursive {
366		return false
367	}
368	if s.browseRel == "" {
369		return false
370	}
371	parent := filepath.Dir(s.browseRel)
372	if parent == "." {
373		parent = ""
374	}
375	s.browseRel = parent
376	s.cachedDir = ""
377	s.cachedAll = nil
378	s.cursor = 0
379	return true
380}
381
382// Selection returns the relative path of the currently highlighted entry.
383func (s *fileSuggester) Selection(input string) string {
384	m := s.matches(input)
385	if len(m) == 0 {
386		return ""
387	}
388	if s.cursor >= len(m) {
389		s.cursor = len(m) - 1
390	}
391	return m[s.cursor].rel
392}
393
394// SelectedEntry returns the currently highlighted entry.
395func (s *fileSuggester) SelectedEntry(input string) (fileEntry, bool) {
396	m := s.matches(input)
397	if len(m) == 0 {
398		return fileEntry{}, false
399	}
400	if s.cursor >= len(m) {
401		s.cursor = len(m) - 1
402	}
403	return m[s.cursor], true
404}
405
406// Render returns the popup lines.
407func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
408	m := s.matches(input)
409	if len(m) == 0 {
410		return nil
411	}
412	s.lastMatches = m
413	if s.cursor >= len(m) {
414		s.cursor = len(m) - 1
415	}
416
417	const maxVisible = 12
418	visible := m
419	offset := 0
420	if len(m) > maxVisible {
421		if s.cursor >= maxVisible {
422			offset = s.cursor - maxVisible + 1
423		}
424		end := offset + maxVisible
425		if end > len(m) {
426			end = len(m)
427			offset = end - maxVisible
428		}
429		visible = m[offset:end]
430	}
431
432	var out []string
433
434	// Show breadcrumb when browsing a subdirectory.
435	if s.browseRel != "" {
436		crumb := th.FG256(th.Accent, "  "+s.browseRel+"/")
437		out = append(out, crumb)
438	}
439
440	for i, e := range visible {
441		idx := offset + i
442		name := e.name
443		if e.isDir {
444			name += "/"
445		}
446		plain := "  " + name
447		if idx == s.cursor {
448			out = append(out, th.PadHighlight(plain, width))
449		} else {
450			out = append(out, th.FG256(th.Muted, plain))
451		}
452	}
453
454	out = append(out, "")
455	var hint string
456	switch {
457	case s.recursive:
458		hint = "  \u2191/\u2193 navigate - enter select - esc cancel (recursive)"
459	case s.browseRel != "":
460		hint = "  \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
461	default:
462		hint = "  \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
463	}
464	out = append(out, th.FG256(th.Muted, hint))
465	out = append(out, "")
466	return out
467}
468
469// fileChipRE matches @-picker chips inserted by modes:
470// [file:relative/path] and [dir:relative/path/]. The lower-level
471// tui.Editor has its own drag/drop chips ([file:N:basename]) which
472// Editor.SubmitValue expands before callers reach this helper; if one
473// leaks through, leave it untouched rather than guessing at a path.
474var fileChipRE = regexp.MustCompile(`\[(file|dir):([^\]]+)\]`)
475var editorFileChipRE = regexp.MustCompile(`^\d+:`)
476
477// expandFileChips replaces [file:name] and [dir:name/] chips with
478// the full path relative to cwd.
479func expandFileChips(text, cwd string) string {
480	return fileChipRE.ReplaceAllStringFunc(text, func(match string) string {
481		groups := fileChipRE.FindStringSubmatch(match)
482		if len(groups) < 3 {
483			return match
484		}
485		name := groups[2]
486		if editorFileChipRE.MatchString(name) {
487			return match
488		}
489		// Strip trailing slash from dir names.
490		name = strings.TrimRight(name, "/")
491		return filepath.Join(cwd, name)
492	})
493}

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 477

↑↓ moveenter openesc close