package modes import ( "os" "path/filepath" "regexp" "sort" "strings" "time" "github.com/sahilm/fuzzy" "github.com/patriceckhart/zot/packages/ignore" "github.com/patriceckhart/zot/packages/tui" ) // recursiveScanLimits bound the recursive walk so the picker stays // responsive in very large repos. Hitting either cap stops the walk // early; the entries gathered so far are still searchable. const ( maxRecursiveEntries = 5000 maxRecursiveDepth = 12 ) // alwaysSkipDir is never descended into during a recursive scan, // regardless of .gitignore. .git is a repo-internal directory that // real projects rarely list in their own .gitignore yet never want // surfaced as an @-mention target. const alwaysSkipDir = ".git" // fileSuggester provides an @-triggered file/directory picker popup. // Type "@" followed by an optional filter to list files in the working // directory. Arrow up/down navigate, enter selects, esc cancels. // Right arrow opens a directory, left arrow goes back to the parent. type fileSuggester struct { cursor int lastMatches []fileEntry cwd string // project root browseRel string // relative path from cwd we're currently browsing ("" = cwd itself) // recursive enables a whole-tree fuzzy search instead of // directory-by-directory browsing. Mirrors the persisted // recursive_file_suggest setting; toggled live from /settings. recursive bool // respectGitignore drops entries matched by the project's root // .gitignore (and always .git) from both the flat and recursive // listings. Mirrors the persisted respect_gitignore setting; on by // default. Toggled live from /settings. respectGitignore bool cachedDir string // absolute directory we last scanned cachedAll []fileEntry // cachedMTime is the mtime of cachedDir at the time of the scan. // scan() compares the current mtime against this on every call and // re-reads the directory if it has changed, so files or folders // created mid-session show up in the picker without restarting zot. // Stat is cheap (single syscall) so doing it per keystroke while // the popup is open does not impact responsiveness. cachedMTime time.Time } type fileEntry struct { name string // display name rel string // path relative to cwd (used for chip insertion) isDir bool } // newFileSuggester returns a picker that respects .gitignore by // default. Callers override via SetRespectGitignore once the persisted // setting is known. func newFileSuggester() *fileSuggester { return &fileSuggester{respectGitignore: true} } // SetCWD updates the project root. func (s *fileSuggester) SetCWD(cwd string) { if s.cwd != cwd { s.cwd = cwd s.browseRel = "" s.cachedDir = "" s.cachedAll = nil } } // SetRecursive toggles whole-tree fuzzy search. Switching modes drops // the cache and resets the browse position so the next render reflects // the new mode immediately. func (s *fileSuggester) SetRecursive(on bool) { if s.recursive == on { return } s.recursive = on s.browseRel = "" s.cachedDir = "" s.cachedAll = nil s.cursor = 0 } // SetRespectGitignore toggles .gitignore filtering for both modes. // Switching drops the cache so the next scan reflects the new state. func (s *fileSuggester) SetRespectGitignore(on bool) { if s.respectGitignore == on { return } s.respectGitignore = on s.cachedDir = "" s.cachedAll = nil s.cursor = 0 } // browseDir returns the absolute directory currently being browsed. func (s *fileSuggester) browseDir() string { if s.browseRel == "" { return s.cwd } return filepath.Join(s.cwd, s.browseRel) } // scan reads entries from the current browse directory. // // Results are cached by absolute path + mtime: a repeated call against // the same directory returns the cached slice when nothing has changed // on disk, and re-reads when an entry was added, removed, or renamed // (any of which bumps the directory's mtime on every filesystem zot // supports). A failed stat falls through to a fresh ReadDir rather // than returning a stale cache so transient errors self-heal. func (s *fileSuggester) scan() []fileEntry { if s.recursive { return s.scanRecursive() } dir := s.browseDir() var mtime time.Time if info, err := os.Stat(dir); err == nil { mtime = info.ModTime() } if s.cachedDir == dir && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) { return s.cachedAll } entries, err := os.ReadDir(dir) if err != nil { return nil } var ig *ignore.Gitignore if s.respectGitignore { ig = ignore.Load(s.cwd) } var all []fileEntry for _, e := range entries { name := e.Name() rel := name if s.browseRel != "" { rel = filepath.Join(s.browseRel, name) } if s.respectGitignore { if e.IsDir() && name == alwaysSkipDir { continue } if ig.Match(filepath.ToSlash(rel), e.IsDir()) { continue } } all = append(all, fileEntry{ name: name, rel: rel, isDir: e.IsDir(), }) } sort.Slice(all, func(i, j int) bool { if all[i].isDir != all[j].isDir { return all[i].isDir } return strings.ToLower(all[i].name) < strings.ToLower(all[j].name) }) s.cachedAll = all s.cachedDir = dir s.cachedMTime = mtime return all } // scanRecursive walks the whole project tree below cwd and returns // every file and directory as a fileEntry whose rel is the path // relative to cwd. The walk honors the project's root .gitignore (so // build outputs, dependency directories, and tool caches like // .terraform/.terragrunt-cache stay out of the picker) plus an // unconditional .git skip, and stops once it hits the entry/depth caps. // // Results are cached by cwd + mtime of cwd. Unlike the flat scan a // single mtime can't catch every nested change, so the cache is best // effort; Invalidate() (called on each keystroke path that matters) // and the explicit cache drops on toggle keep it fresh enough for an // interactive picker. func (s *fileSuggester) scanRecursive() []fileEntry { root := s.cwd var mtime time.Time if info, err := os.Stat(root); err == nil { mtime = info.ModTime() } if s.cachedDir == root && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) { return s.cachedAll } var ig *ignore.Gitignore if s.respectGitignore { ig = ignore.Load(root) } var all []fileEntry rootSep := strings.Count(root, string(os.PathSeparator)) _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { if d != nil && d.IsDir() { return filepath.SkipDir } return nil } if path == root { return nil } rel, relErr := filepath.Rel(root, path) if relErr != nil { return nil } if s.respectGitignore { // .gitignore patterns are matched against slash-separated paths. if ig.Match(filepath.ToSlash(rel), d.IsDir()) { if d.IsDir() { return filepath.SkipDir } return nil } } if d.IsDir() { // .git is always pruned: it's never a useful @-mention target // and would otherwise blow the entry budget even when // gitignore filtering is off. if d.Name() == alwaysSkipDir { return filepath.SkipDir } if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth { return filepath.SkipDir } } all = append(all, fileEntry{ name: rel, rel: rel, isDir: d.IsDir(), }) if len(all) >= maxRecursiveEntries { return filepath.SkipAll } return nil }) s.cachedAll = all s.cachedDir = root s.cachedMTime = mtime return all } // extractAtQuery returns the filter string after "@". func extractAtQuery(input string) (string, bool) { input = strings.TrimRight(input, " ") idx := strings.LastIndex(input, "@") if idx < 0 { return "", false } if idx > 0 && input[idx-1] != ' ' { return "", false } query := input[idx+1:] if strings.ContainsAny(query, " \t\n") { return "", false } return query, true } // matches returns file entries matching the current @-query. // // An empty query returns every entry in scan order. A non-empty query // is ranked with sahilm/fuzzy: in recursive mode the pattern is matched // against each entry's path relative to cwd (so "foobar" can find // "src/foo/bar.go"); in flat mode it matches the entry's display name. func (s *fileSuggester) matches(input string) []fileEntry { query, ok := extractAtQuery(input) if !ok { return nil } all := s.scan() if len(all) == 0 { return nil } if query == "" { return all } haystack := make([]string, len(all)) for i, e := range all { if s.recursive { haystack[i] = e.rel } else { haystack[i] = e.name } } ranked := fuzzy.Find(query, haystack) out := make([]fileEntry, 0, len(ranked)) for _, m := range ranked { if m.Index >= 0 && m.Index < len(all) { out = append(out, all[m.Index]) } } return out } // Active reports whether the popup should be visible. func (s *fileSuggester) Active(input string) bool { return len(s.matches(input)) > 0 } // Reset clears state. func (s *fileSuggester) Reset() { s.cursor = 0 s.lastMatches = nil s.browseRel = "" s.cachedDir = "" s.cachedAll = nil } // Invalidate forces a rescan. func (s *fileSuggester) Invalidate() { s.cachedDir = "" s.cachedAll = nil } func (s *fileSuggester) Up() { if s.cursor > 0 { s.cursor-- } } func (s *fileSuggester) Down() { if s.cursor < len(s.lastMatches)-1 { s.cursor++ } } // Right opens the selected directory, descending into it. // Returns true if a directory was entered. Disabled in recursive mode, // where the whole tree is already flattened into the result list. func (s *fileSuggester) Right() bool { if s.recursive { return false } m := s.lastMatches if len(m) == 0 || s.cursor >= len(m) { return false } e := m[s.cursor] if !e.isDir { return false } s.browseRel = e.rel s.cachedDir = "" s.cachedAll = nil s.cursor = 0 return true } // Left goes back to the parent directory. // Returns true if we moved up. Disabled in recursive mode. func (s *fileSuggester) Left() bool { if s.recursive { return false } if s.browseRel == "" { return false } parent := filepath.Dir(s.browseRel) if parent == "." { parent = "" } s.browseRel = parent s.cachedDir = "" s.cachedAll = nil s.cursor = 0 return true } // Selection returns the relative path of the currently highlighted entry. func (s *fileSuggester) Selection(input string) string { m := s.matches(input) if len(m) == 0 { return "" } if s.cursor >= len(m) { s.cursor = len(m) - 1 } return m[s.cursor].rel } // SelectedEntry returns the currently highlighted entry. func (s *fileSuggester) SelectedEntry(input string) (fileEntry, bool) { m := s.matches(input) if len(m) == 0 { return fileEntry{}, false } if s.cursor >= len(m) { s.cursor = len(m) - 1 } return m[s.cursor], true } // Render returns the popup lines. func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string { m := s.matches(input) if len(m) == 0 { return nil } s.lastMatches = m if s.cursor >= len(m) { s.cursor = len(m) - 1 } const maxVisible = 12 visible := m offset := 0 if len(m) > maxVisible { if s.cursor >= maxVisible { offset = s.cursor - maxVisible + 1 } end := offset + maxVisible if end > len(m) { end = len(m) offset = end - maxVisible } visible = m[offset:end] } var out []string // Show breadcrumb when browsing a subdirectory. if s.browseRel != "" { crumb := th.FG256(th.Accent, " "+s.browseRel+"/") out = append(out, crumb) } for i, e := range visible { idx := offset + i name := e.name if e.isDir { name += "/" } plain := " " + name if idx == s.cursor { out = append(out, th.PadHighlight(plain, width)) } else { out = append(out, th.FG256(th.Muted, plain)) } } out = append(out, "") var hint string switch { case s.recursive: hint = " \u2191/\u2193 navigate - enter select - esc cancel (recursive)" case s.browseRel != "": hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel" default: hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel" } out = append(out, th.FG256(th.Muted, hint)) out = append(out, "") return out } // fileChipRE matches @-picker chips inserted by modes: // [file:relative/path] and [dir:relative/path/]. The lower-level // tui.Editor has its own drag/drop chips ([file:N:basename]) which // Editor.SubmitValue expands before callers reach this helper; if one // leaks through, leave it untouched rather than guessing at a path. var fileChipRE = regexp.MustCompile(`\[(file|dir):([^\]]+)\]`) var editorFileChipRE = regexp.MustCompile(`^\d+:`) // expandFileChips replaces [file:name] and [dir:name/] chips with // the full path relative to cwd. func expandFileChips(text, cwd string) string { return fileChipRE.ReplaceAllStringFunc(text, func(match string) string { groups := fileChipRE.FindStringSubmatch(match) if len(groups) < 3 { return match } name := groups[2] if editorFileChipRE.MatchString(name) { return match } // Strip trailing slash from dir names. name = strings.TrimRight(name, "/") return filepath.Join(cwd, name) }) }