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}