diff --git a/go.mod b/go.mod index 2feb44a..6e622d1 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,14 @@ require ( github.com/alecthomas/chroma/v2 v2.23.1 github.com/google/uuid v1.6.0 github.com/mattn/go-runewidth v0.0.16 + github.com/sahilm/fuzzy v0.1.1 + golang.org/x/image v0.18.0 golang.org/x/sys v0.26.0 golang.org/x/term v0.25.0 ) require ( github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect - golang.org/x/image v0.18.0 // indirect ) diff --git a/go.sum b/go.sum index 6cd123c..9a0ffd8 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= diff --git a/packages/agent/cli.go b/packages/agent/cli.go index ede1d0f..2a6720b 100644 --- a/packages/agent/cli.go +++ b/packages/agent/cli.go @@ -944,6 +944,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { Theme: theme, InlineImagesEnabled: initialCfg.InlineImagesEnabled, AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, + RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, + RespectGitignore: initialCfg.RespectGitignore, ThemeName: initialCfg.Theme, ExtensionThemes: extMgr.ThemeOptions, AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, diff --git a/packages/agent/config.go b/packages/agent/config.go index f74b5c9..4e1a359 100644 --- a/packages/agent/config.go +++ b/packages/agent/config.go @@ -32,6 +32,18 @@ type Config struct { // default; nil/missing means disabled. Toggle from /settings. AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"` + // RecursiveFileSuggest controls the @-mention file picker. When true + // the picker fuzzy-searches the whole project tree below the working + // directory; nil/missing/false keeps the default directory-by- + // directory browse. Toggle from /settings. + RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"` + + // RespectGitignore controls whether the @-mention file picker hides + // files and directories matched by the project's root .gitignore (in + // both flat and recursive modes). nil/missing means the default, + // which is on; false shows ignored entries. Toggle from /settings. + RespectGitignore *bool `json:"respect_gitignore,omitempty"` + // LastChangelogShown is the version whose release-notes // dialog the user has already seen. When the running binary's // version differs, the next interactive run shows the diff --git a/packages/agent/extcmd.go b/packages/agent/extcmd.go index d286130..1c9822c 100644 --- a/packages/agent/extcmd.go +++ b/packages/agent/extcmd.go @@ -8,6 +8,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/patriceckhart/zot/packages/ignore" ) // runExtCommand dispatches `zot ext ...` subcommands. Returns @@ -344,7 +346,7 @@ func copyDir(src, dst string) error { if info.IsDir() && name == ".git" { return filepath.SkipDir } - if ig.match(filepath.ToSlash(rel), info.IsDir()) { + if ig.Match(filepath.ToSlash(rel), info.IsDir()) { if info.IsDir() { return filepath.SkipDir } @@ -370,98 +372,11 @@ func copyDir(src, dst string) error { }) } -// gitignore is a minimal .gitignore matcher. It supports the common -// patterns used in real extension repos: blank lines, comments (#), -// negation (!), directory-only patterns (trailing /), anchored -// patterns (leading /), and the * / ? / [..] wildcards via -// filepath.Match. It intentionally does not implement ** globstar or -// nested per-directory .gitignore files; the goal is to drop obvious -// non-portable directories, not to be a faithful git reimplementation. -type gitignore struct { - rules []gitignoreRule -} +// gitignore matching lives in packages/ignore so the @-file picker in +// packages/agent/modes can share it without an import cycle. These +// thin aliases keep the existing call sites (and tests) terse. +type gitignore = ignore.Gitignore -type gitignoreRule struct { - pattern string - negate bool - dirOnly bool - anchored bool -} +func loadGitignore(root string) *gitignore { return ignore.Load(root) } -func loadGitignore(root string) *gitignore { - data, err := os.ReadFile(filepath.Join(root, ".gitignore")) - if err != nil { - return &gitignore{} - } - return loadGitignoreFromString(string(data)) -} - -func loadGitignoreFromString(data string) *gitignore { - g := &gitignore{} - for _, line := range strings.Split(data, "\n") { - line = strings.TrimRight(line, "\r") - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - r := gitignoreRule{pattern: trimmed} - if strings.HasPrefix(r.pattern, "!") { - r.negate = true - r.pattern = r.pattern[1:] - } - if strings.HasSuffix(r.pattern, "/") { - r.dirOnly = true - r.pattern = strings.TrimSuffix(r.pattern, "/") - } - if strings.HasPrefix(r.pattern, "/") { - r.anchored = true - r.pattern = strings.TrimPrefix(r.pattern, "/") - } - if r.pattern == "" { - continue - } - g.rules = append(g.rules, r) - } - return g -} - -// match reports whether the slash-separated relative path should be -// ignored. Later rules win, so a trailing negation can re-include a -// previously ignored path. -func (g *gitignore) match(rel string, isDir bool) bool { - ignored := false - for _, r := range g.rules { - if r.dirOnly && !isDir { - continue - } - if r.matchPath(rel) { - ignored = !r.negate - } - } - return ignored -} - -func (r gitignoreRule) matchPath(rel string) bool { - if r.anchored || strings.Contains(r.pattern, "/") { - if ok, _ := filepath.Match(r.pattern, rel); ok { - return true - } - // Anchored directory pattern also matches everything beneath it. - return strings.HasPrefix(rel, r.pattern+"/") - } - // Unanchored: match the basename of any path component. - base := rel - if i := strings.LastIndex(rel, "/"); i >= 0 { - base = rel[i+1:] - } - if ok, _ := filepath.Match(r.pattern, base); ok { - return true - } - // Match a directory component anywhere in the path. - for _, part := range strings.Split(rel, "/") { - if ok, _ := filepath.Match(r.pattern, part); ok { - return true - } - } - return false -} +func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) } diff --git a/packages/agent/extcmd_test.go b/packages/agent/extcmd_test.go index 45c54dc..b43fe20 100644 --- a/packages/agent/extcmd_test.go +++ b/packages/agent/extcmd_test.go @@ -117,10 +117,10 @@ func TestCopyDirRespectsGitignore(t *testing.T) { func TestGitignoreNegation(t *testing.T) { g := loadGitignoreFromString("build/\n!build/keep.txt\n") - if !g.match("build", true) { + if !g.Match("build", true) { t.Fatal("expected build/ dir to be ignored") } - if g.match("build/keep.txt", false) { + if g.Match("build/keep.txt", false) { t.Fatal("expected build/keep.txt to be re-included by negation") } } diff --git a/packages/agent/modes/file_suggest.go b/packages/agent/modes/file_suggest.go index 8a4a718..e740a5f 100644 --- a/packages/agent/modes/file_suggest.go +++ b/packages/agent/modes/file_suggest.go @@ -8,9 +8,26 @@ import ( "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. @@ -20,8 +37,17 @@ type fileSuggester struct { lastMatches []fileEntry cwd string // project root browseRel string // relative path from cwd we're currently browsing ("" = cwd itself) - cachedDir string // absolute directory we last scanned - cachedAll []fileEntry + // 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 @@ -37,7 +63,10 @@ type fileEntry struct { isDir bool } -func newFileSuggester() *fileSuggester { return &fileSuggester{} } +// 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) { @@ -49,6 +78,32 @@ func (s *fileSuggester) SetCWD(cwd string) { } } +// 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 == "" { @@ -66,6 +121,9 @@ func (s *fileSuggester) browseDir() string { // 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 { @@ -78,6 +136,10 @@ func (s *fileSuggester) scan() []fileEntry { 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() @@ -85,6 +147,14 @@ func (s *fileSuggester) scan() []fileEntry { 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, @@ -103,6 +173,85 @@ func (s *fileSuggester) scan() []fileEntry { 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, " ") @@ -121,6 +270,11 @@ func extractAtQuery(input string) (string, bool) { } // 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 { @@ -130,14 +284,22 @@ func (s *fileSuggester) matches(input string) []fileEntry { if len(all) == 0 { return nil } - needle := strings.ToLower(query) - if needle == "" { + if query == "" { return all } - var out []fileEntry - for _, e := range all { - if strings.Contains(strings.ToLower(e.name), needle) { - out = append(out, e) + 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 @@ -176,8 +338,12 @@ func (s *fileSuggester) Down() { } // Right opens the selected directory, descending into it. -// Returns true if a directory was entered. +// 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 @@ -194,8 +360,11 @@ func (s *fileSuggester) Right() bool { } // Left goes back to the parent directory. -// Returns true if we moved up. +// 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 } @@ -283,10 +452,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string { } out = append(out, "") - hint := " \u2191/\u2193 navigate - enter select - esc cancel" - if s.browseRel != "" { + 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" - } else { + default: hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel" } out = append(out, th.FG256(th.Muted, hint)) diff --git a/packages/agent/modes/file_suggest_test.go b/packages/agent/modes/file_suggest_test.go index f3d364d..6a43117 100644 --- a/packages/agent/modes/file_suggest_test.go +++ b/packages/agent/modes/file_suggest_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "time" ) @@ -97,3 +98,216 @@ func (b byDirsFirst) Less(i, j int) bool { } return b[i].name < b[j].name } + +// TestFileSuggesterFuzzyMatch verifies the @-query ranks entries with +// a fuzzy subsequence match rather than a plain substring, so a +// non-contiguous pattern like "fsg" still finds "file_suggest.go". +func TestFileSuggesterFuzzyMatch(t *testing.T) { + tmp := t.TempDir() + for _, name := range []string{"file_suggest.go", "interactive.go", "README.md"} { + if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + s := newFileSuggester() + s.SetCWD(tmp) + + got := s.matches("@fsg") + if !containsEntry(got, "file_suggest.go", false) { + t.Fatalf("fuzzy query @fsg did not match file_suggest.go: %#v", got) + } + if len(got) == 0 || got[0].name != "file_suggest.go" { + t.Fatalf("file_suggest.go not ranked first for @fsg: %#v", got) + } +} + +// TestFileSuggesterRecursiveMatch verifies recursive mode flattens the +// tree and matches against the cwd-relative path, so a pattern can +// span directory boundaries. +func TestFileSuggesterRecursiveMatch(t *testing.T) { + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, "src", "foo"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "src", "foo", "bar.go"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + s := newFileSuggester() + s.SetCWD(tmp) + s.SetRecursive(true) + + rel := filepath.Join("src", "foo", "bar.go") + got := s.matches("@foobar") + if !containsEntry(got, rel, false) { + t.Fatalf("recursive @foobar did not match %s: %#v", rel, got) + } +} + +// TestFileSuggesterRecursiveSkipsHeavyDirs ensures the walk prunes +// directories like .git that would otherwise dominate the budget. +func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) { + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + s := newFileSuggester() + s.SetCWD(tmp) + s.SetRecursive(true) + + all := s.scan() + for _, e := range all { + if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) { + t.Fatalf("recursive scan descended into .git: %#v", e) + } + } + if !containsEntry(all, "main.go", false) { + t.Fatalf("recursive scan missing main.go: %#v", all) + } +} + +// TestFileSuggesterRecursiveHonorsGitignore ensures the recursive walk +// prunes anything listed in the project's root .gitignore — build +// outputs, dependency dirs, and IaC tool caches like +// .terraform/.terragrunt-cache — while still surfacing tracked files. +func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) { + tmp := t.TempDir() + if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), + []byte(".terraform/\n.terragrunt-cache/\nnode_modules/\n*.log\n"), 0o644); err != nil { + t.Fatal(err) + } + ignored := []string{".terraform", ".terragrunt-cache", "node_modules"} + for _, dir := range ignored { + nested := filepath.Join(tmp, dir, "deep") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nested, "junk"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + s := newFileSuggester() + s.SetCWD(tmp) + s.SetRecursive(true) + + all := s.scan() + for _, e := range all { + for _, skip := range ignored { + if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) { + t.Fatalf("recursive scan descended into gitignored %s: %#v", skip, e) + } + } + if e.rel == "debug.log" { + t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e) + } + } + if !containsEntry(all, "main.tf", false) { + t.Fatalf("recursive scan missing tracked main.tf: %#v", all) + } +} + +// TestFileSuggesterFlatModeHonorsGitignore verifies the default +// directory-by-directory browse also hides gitignored entries and +// always hides .git. +func TestFileSuggesterFlatModeHonorsGitignore(t *testing.T) { + tmp := t.TempDir() + if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("node_modules/\n*.log\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(tmp, "node_modules"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + s := newFileSuggester() + s.SetCWD(tmp) // flat mode, respectGitignore on by default + + all := s.scan() + if containsEntry(all, "node_modules", true) { + t.Fatal("flat scan surfaced gitignored node_modules/") + } + if containsEntry(all, ".git", true) { + t.Fatal("flat scan surfaced .git/") + } + if containsEntry(all, "debug.log", false) { + t.Fatal("flat scan surfaced gitignored *.log file") + } + if !containsEntry(all, "main.go", false) { + t.Fatalf("flat scan missing tracked main.go: %#v", all) + } + // .gitignore itself is not ignored, so it should remain visible. + if !containsEntry(all, ".gitignore", false) { + t.Fatalf("flat scan missing .gitignore: %#v", all) + } +} + +// TestFileSuggesterRespectGitignoreToggle verifies disabling the +// setting surfaces gitignored entries in both modes (while .git stays +// hidden in recursive mode to protect the entry budget). +func TestFileSuggesterRespectGitignoreToggle(t *testing.T) { + tmp := t.TempDir() + if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(tmp, "dist"), 0o755); err != nil { + t.Fatal(err) + } + s := newFileSuggester() + s.SetCWD(tmp) + + // On by default: dist/ hidden. + if containsEntry(s.scan(), "dist", true) { + t.Fatal("dist/ should be hidden while respectGitignore is on") + } + // Toggle off: dist/ visible (flat mode). + s.SetRespectGitignore(false) + if !containsEntry(s.scan(), "dist", true) { + t.Fatal("dist/ should be visible after disabling respectGitignore (flat)") + } + // And in recursive mode. + s.SetRecursive(true) + if !containsEntry(s.scan(), "dist", true) { + t.Fatal("dist/ should be visible after disabling respectGitignore (recursive)") + } +} + +// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the +// cached scan so the next matches() reflects the new mode. +func TestFileSuggesterToggleResetsCache(t *testing.T) { + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + s := newFileSuggester() + s.SetCWD(tmp) + + rel := filepath.Join("pkg", "nested.go") + if containsEntry(s.matches("@nested"), rel, false) { + t.Fatal("flat mode unexpectedly saw nested.go") + } + s.SetRecursive(true) + if !containsEntry(s.matches("@nested"), rel, false) { + t.Fatal("recursive mode did not surface nested.go after toggle") + } +} diff --git a/packages/agent/modes/interactive.go b/packages/agent/modes/interactive.go index cc26cd0..f19dbdb 100644 --- a/packages/agent/modes/interactive.go +++ b/packages/agent/modes/interactive.go @@ -46,6 +46,16 @@ type InteractiveConfig struct { // re-reading config.json on every open. AutoSwarmEnabled *bool + // RecursiveFileSuggest mirrors the persisted recursive_file_suggest + // flag at startup. When true the @-mention picker fuzzy-searches the + // whole project tree instead of browsing one directory at a time. + RecursiveFileSuggest *bool + + // RespectGitignore mirrors the persisted respect_gitignore flag at + // startup. nil means the default (on); when false the @-mention + // picker shows files matched by the project's root .gitignore. + RespectGitignore *bool + // ThemeName mirrors the persisted config theme value. Empty means auto. ThemeName string // ExtensionThemes returns themes bundled with loaded extensions. @@ -228,6 +238,8 @@ type chatCacheKey struct { type SettingsStore interface { SetInlineImages(enabled bool) error SetAutoSwarm(enabled bool) error + SetRecursiveFileSuggest(enabled bool) error + SetRespectGitignore(enabled bool) error SetReasoning(level string) error SetTheme(name string) error } @@ -474,6 +486,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { spin: newSpinner(cfg.Theme), inputHistoryIndex: -1, } + i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) + i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) if cfg.Agent != nil { i.agent = cfg.Agent i.view.Messages = cfg.Agent.Messages() @@ -2622,6 +2636,9 @@ func (i *Interactive) openSettingsDialog() { autoSwarmHint = "swarm supervisor not available in this mode" } + recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest + respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore + reasoningOptions := []settingsOption{ {value: "", label: "off", desc: "no reasoning"}, {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"}, @@ -2685,6 +2702,18 @@ func (i *Interactive) openSettingsDialog() { disabled: autoSwarmDisabled, hint: autoSwarmHint, }, + { + key: "recursive_file_suggest", + label: "recursive @-file search", + desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time", + value: recursiveFiles, + }, + { + key: "respect_gitignore", + label: "hide gitignored files in @-picker", + desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @", + value: respectGitignore, + }, { key: "reasoning", label: "thinking level", @@ -2767,6 +2796,40 @@ func (i *Interactive) applySettingToggle(key string, value bool) { i.statusOK = "auto-swarm " + onOff(value) i.statusErr = "" i.mu.Unlock() + case "recursive_file_suggest": + val := value + i.cfg.RecursiveFileSuggest = &val + if i.cfg.SettingsStore != nil { + if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil { + i.mu.Lock() + i.statusErr = "settings: " + err.Error() + i.mu.Unlock() + return + } + } + // Flip the live picker so the next @ reflects the new mode + // without restarting zot. SetRecursive drops its cache. + i.fileSuggest.SetRecursive(value) + i.mu.Lock() + i.statusOK = "recursive @-file search " + onOff(value) + i.statusErr = "" + i.mu.Unlock() + case "respect_gitignore": + val := value + i.cfg.RespectGitignore = &val + if i.cfg.SettingsStore != nil { + if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil { + i.mu.Lock() + i.statusErr = "settings: " + err.Error() + i.mu.Unlock() + return + } + } + i.fileSuggest.SetRespectGitignore(value) + i.mu.Lock() + i.statusOK = "hide gitignored files in @-picker " + onOff(value) + i.statusErr = "" + i.mu.Unlock() } } diff --git a/packages/agent/settings_store.go b/packages/agent/settings_store.go index 2234d21..cee0c25 100644 --- a/packages/agent/settings_store.go +++ b/packages/agent/settings_store.go @@ -22,6 +22,24 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error { return SaveConfig(cfg) } +func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error { + cfg, err := LoadConfig() + if err != nil { + return err + } + cfg.RecursiveFileSuggest = &enabled + return SaveConfig(cfg) +} + +func (configSettingsStore) SetRespectGitignore(enabled bool) error { + cfg, err := LoadConfig() + if err != nil { + return err + } + cfg.RespectGitignore = &enabled + return SaveConfig(cfg) +} + func (configSettingsStore) SetReasoning(level string) error { cfg, err := LoadConfig() if err != nil { diff --git a/packages/ignore/gitignore.go b/packages/ignore/gitignore.go new file mode 100644 index 0000000..8c44168 --- /dev/null +++ b/packages/ignore/gitignore.go @@ -0,0 +1,109 @@ +// Package ignore provides a minimal .gitignore matcher shared across +// zot. It is intentionally small: enough to drop obvious non-source +// directories (build outputs, dependency and tool caches) from +// recursive walks, not a faithful git reimplementation. +package ignore + +import ( + "os" + "path/filepath" + "strings" +) + +// Gitignore is a minimal .gitignore matcher. It supports the common +// patterns used in real repos: blank lines, comments (#), negation (!), +// directory-only patterns (trailing /), anchored patterns (leading /), +// and the * / ? / [..] wildcards via filepath.Match. It intentionally +// does not implement ** globstar or nested per-directory .gitignore +// files. +type Gitignore struct { + rules []rule +} + +type rule struct { + pattern string + negate bool + dirOnly bool + anchored bool +} + +// Load reads the .gitignore at the root directory. A missing or +// unreadable file yields an empty matcher that ignores nothing. +func Load(root string) *Gitignore { + data, err := os.ReadFile(filepath.Join(root, ".gitignore")) + if err != nil { + return &Gitignore{} + } + return Parse(string(data)) +} + +// Parse builds a matcher from raw .gitignore file contents. +func Parse(data string) *Gitignore { + g := &Gitignore{} + for _, line := range strings.Split(data, "\n") { + line = strings.TrimRight(line, "\r") + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + r := rule{pattern: trimmed} + if strings.HasPrefix(r.pattern, "!") { + r.negate = true + r.pattern = r.pattern[1:] + } + if strings.HasSuffix(r.pattern, "/") { + r.dirOnly = true + r.pattern = strings.TrimSuffix(r.pattern, "/") + } + if strings.HasPrefix(r.pattern, "/") { + r.anchored = true + r.pattern = strings.TrimPrefix(r.pattern, "/") + } + if r.pattern == "" { + continue + } + g.rules = append(g.rules, r) + } + return g +} + +// Match reports whether the slash-separated relative path should be +// ignored. Later rules win, so a trailing negation can re-include a +// previously ignored path. +func (g *Gitignore) Match(rel string, isDir bool) bool { + ignored := false + for _, r := range g.rules { + if r.dirOnly && !isDir { + continue + } + if r.matchPath(rel) { + ignored = !r.negate + } + } + return ignored +} + +func (r rule) matchPath(rel string) bool { + if r.anchored || strings.Contains(r.pattern, "/") { + if ok, _ := filepath.Match(r.pattern, rel); ok { + return true + } + // Anchored directory pattern also matches everything beneath it. + return strings.HasPrefix(rel, r.pattern+"/") + } + // Unanchored: match the basename of any path component. + base := rel + if i := strings.LastIndex(rel, "/"); i >= 0 { + base = rel[i+1:] + } + if ok, _ := filepath.Match(r.pattern, base); ok { + return true + } + // Match a directory component anywhere in the path. + for _, part := range strings.Split(rel, "/") { + if ok, _ := filepath.Match(r.pattern, part); ok { + return true + } + } + return false +} diff --git a/packages/ignore/gitignore_test.go b/packages/ignore/gitignore_test.go new file mode 100644 index 0000000..e455cd3 --- /dev/null +++ b/packages/ignore/gitignore_test.go @@ -0,0 +1,54 @@ +package ignore + +import "testing" + +func TestParseAndMatch(t *testing.T) { + g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log")) + + cases := []struct { + rel string + isDir bool + want bool + }{ + {".terraform", true, true}, + // A dirOnly rule matches the directory itself; the walk prunes + // descent on that match, so children are never tested. A file + // path under it is therefore not matched directly by the rule. + {".terraform/providers/x", false, false}, + {".terragrunt-cache", true, true}, + {"modules/.terragrunt-cache", true, true}, + {"node_modules", true, true}, + {"src/node_modules/pkg", true, true}, + {"debug.log", false, true}, + {"keep.log", false, false}, // re-included by negation + {"build", true, true}, // anchored + {"sub/build", true, false}, // anchored: only at root + {"main.tf", false, false}, + {"src/app.go", false, false}, + } + for _, c := range cases { + if got := g.Match(c.rel, c.isDir); got != c.want { + t.Errorf("Match(%q, dir=%v) = %v, want %v", c.rel, c.isDir, got, c.want) + } + } +} + +func TestEmptyIgnoresNothing(t *testing.T) { + g := Parse("") + if g.Match("anything", false) || g.Match("dir", true) { + t.Fatal("empty matcher should ignore nothing") + } +} + +// lines joins fixture lines with newlines for readable .gitignore +// fixtures. +func lines(ls ...string) string { + out := "" + for i, l := range ls { + if i > 0 { + out += "\n" + } + out += l + } + return out +}