Merge pull request #25 from rgasper/feat/fuzzy-recursive-file-suggest

feat(tui): fuzzy @-file matching with toggleable recursive search
This commit is contained in:
Patric Eckhart 2026-06-10 07:37:25 +02:00 committed by GitHub
commit 4c2e835f45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 676 additions and 111 deletions

4
go.mod
View file

@ -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
)

4
go.sum
View file

@ -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=

View file

@ -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,

View file

@ -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

View file

@ -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) }

View file

@ -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")
}
}

View file

@ -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))

View file

@ -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")
}
}

View file

@ -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()
}
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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
}