detached 4c2e835

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

feat(tui): fuzzy @-file matching with toggleable recursive search
commit
4c2e835f459b490baead5bbafaf9ffc695012299
author
Patric Eckhart · · 2026-06-10 05:37 UTC
committer
GitHub · · 2026-06-10 05:37 UTC
merge of
15f76e0 + fb08ad3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.26
12 files · +676 -111
M go.mod +3 -1
@@ -6,12 +6,14 @@ require (
66 github.com/alecthomas/chroma/v2 v2.23.1
77 github.com/google/uuid v1.6.0
88 github.com/mattn/go-runewidth v0.0.16
9+ github.com/sahilm/fuzzy v0.1.1
10+ golang.org/x/image v0.18.0
911 golang.org/x/sys v0.26.0
1012 golang.org/x/term v0.25.0
1113 )
1214
1315 require (
1416 github.com/dlclark/regexp2 v1.11.5 // indirect
17+ github.com/kylelemons/godebug v1.1.0 // indirect
1518 github.com/rivo/uniseg v0.2.0 // indirect
16- golang.org/x/image v0.18.0 // indirect
1719 )
@@ -6,12 +6,14 @@ require (
6 github.com/alecthomas/chroma/v2 v2.23.16 github.com/alecthomas/chroma/v2 v2.23.1
7 github.com/google/uuid v1.6.07 github.com/google/uuid v1.6.0
8 github.com/mattn/go-runewidth v0.0.168 github.com/mattn/go-runewidth v0.0.16
9+ github.com/sahilm/fuzzy v0.1.1
10+ golang.org/x/image v0.18.0
9 golang.org/x/sys v0.26.011 golang.org/x/sys v0.26.0
10 golang.org/x/term v0.25.012 golang.org/x/term v0.25.0
11 )13 )
12 14
13 require (15 require (
14 github.com/dlclark/regexp2 v1.11.5 // indirect16 github.com/dlclark/regexp2 v1.11.5 // indirect
17+ github.com/kylelemons/godebug v1.1.0 // indirect
15 github.com/rivo/uniseg v0.2.0 // indirect18 github.com/rivo/uniseg v0.2.0 // indirect
16- golang.org/x/image v0.18.0 // indirect
17 )19 )
M go.sum +4 -0 generated file: collapsed
M packages/agent/cli.go +2 -0
@@ -944,6 +944,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
944944 Theme: theme,
945945 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
946946 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
947+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
948+ RespectGitignore: initialCfg.RespectGitignore,
947949 ThemeName: initialCfg.Theme,
948950 ExtensionThemes: extMgr.ThemeOptions,
949951 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
@@ -944,6 +944,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
944 Theme: theme,944 Theme: theme,
945 InlineImagesEnabled: initialCfg.InlineImagesEnabled,945 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
946 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,946 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
947+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
948+ RespectGitignore: initialCfg.RespectGitignore,
947 ThemeName: initialCfg.Theme,949 ThemeName: initialCfg.Theme,
948 ExtensionThemes: extMgr.ThemeOptions,950 ExtensionThemes: extMgr.ThemeOptions,
949 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,951 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
M packages/agent/config.go +12 -0
@@ -32,6 +32,18 @@ type Config struct {
3232 // default; nil/missing means disabled. Toggle from /settings.
3333 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
3434
35+ // RecursiveFileSuggest controls the @-mention file picker. When true
36+ // the picker fuzzy-searches the whole project tree below the working
37+ // directory; nil/missing/false keeps the default directory-by-
38+ // directory browse. Toggle from /settings.
39+ RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
40+
41+ // RespectGitignore controls whether the @-mention file picker hides
42+ // files and directories matched by the project's root .gitignore (in
43+ // both flat and recursive modes). nil/missing means the default,
44+ // which is on; false shows ignored entries. Toggle from /settings.
45+ RespectGitignore *bool `json:"respect_gitignore,omitempty"`
46+
3547 // LastChangelogShown is the version whose release-notes
3648 // dialog the user has already seen. When the running binary's
3749 // version differs, the next interactive run shows the
@@ -32,6 +32,18 @@ type Config struct {
32 // default; nil/missing means disabled. Toggle from /settings.32 // default; nil/missing means disabled. Toggle from /settings.
33 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`33 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
34 34
35+ // RecursiveFileSuggest controls the @-mention file picker. When true
36+ // the picker fuzzy-searches the whole project tree below the working
37+ // directory; nil/missing/false keeps the default directory-by-
38+ // directory browse. Toggle from /settings.
39+ RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
40+
41+ // RespectGitignore controls whether the @-mention file picker hides
42+ // files and directories matched by the project's root .gitignore (in
43+ // both flat and recursive modes). nil/missing means the default,
44+ // which is on; false shows ignored entries. Toggle from /settings.
45+ RespectGitignore *bool `json:"respect_gitignore,omitempty"`
46+
35 // LastChangelogShown is the version whose release-notes47 // LastChangelogShown is the version whose release-notes
36 // dialog the user has already seen. When the running binary's48 // dialog the user has already seen. When the running binary's
37 // version differs, the next interactive run shows the49 // version differs, the next interactive run shows the
M packages/agent/extcmd.go +9 -94
@@ -8,6 +8,8 @@ import (
88 "os/exec"
99 "path/filepath"
1010 "strings"
11+
12+ "github.com/patriceckhart/zot/packages/ignore"
1113 )
1214
1315 // runExtCommand dispatches `zot ext ...` subcommands. Returns
@@ -344,7 +346,7 @@ func copyDir(src, dst string) error {
344346 if info.IsDir() && name == ".git" {
345347 return filepath.SkipDir
346348 }
347- if ig.match(filepath.ToSlash(rel), info.IsDir()) {
349+ if ig.Match(filepath.ToSlash(rel), info.IsDir()) {
348350 if info.IsDir() {
349351 return filepath.SkipDir
350352 }
@@ -370,98 +372,11 @@ func copyDir(src, dst string) error {
370372 })
371373 }
372374
373-// gitignore is a minimal .gitignore matcher. It supports the common
374-// patterns used in real extension repos: blank lines, comments (#),
375-// negation (!), directory-only patterns (trailing /), anchored
376-// patterns (leading /), and the * / ? / [..] wildcards via
377-// filepath.Match. It intentionally does not implement ** globstar or
378-// nested per-directory .gitignore files; the goal is to drop obvious
379-// non-portable directories, not to be a faithful git reimplementation.
380-type gitignore struct {
381- rules []gitignoreRule
382-}
383-
384-type gitignoreRule struct {
385- pattern string
386- negate bool
387- dirOnly bool
388- anchored bool
389-}
390-
391-func loadGitignore(root string) *gitignore {
392- data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
393- if err != nil {
394- return &gitignore{}
395- }
396- return loadGitignoreFromString(string(data))
397-}
398-
399-func loadGitignoreFromString(data string) *gitignore {
400- g := &gitignore{}
401- for _, line := range strings.Split(data, "\n") {
402- line = strings.TrimRight(line, "\r")
403- trimmed := strings.TrimSpace(line)
404- if trimmed == "" || strings.HasPrefix(trimmed, "#") {
405- continue
406- }
407- r := gitignoreRule{pattern: trimmed}
408- if strings.HasPrefix(r.pattern, "!") {
409- r.negate = true
410- r.pattern = r.pattern[1:]
411- }
412- if strings.HasSuffix(r.pattern, "/") {
413- r.dirOnly = true
414- r.pattern = strings.TrimSuffix(r.pattern, "/")
415- }
416- if strings.HasPrefix(r.pattern, "/") {
417- r.anchored = true
418- r.pattern = strings.TrimPrefix(r.pattern, "/")
419- }
420- if r.pattern == "" {
421- continue
422- }
423- g.rules = append(g.rules, r)
424- }
425- return g
426-}
375+// gitignore matching lives in packages/ignore so the @-file picker in
376+// packages/agent/modes can share it without an import cycle. These
377+// thin aliases keep the existing call sites (and tests) terse.
378+type gitignore = ignore.Gitignore
427379
428-// match reports whether the slash-separated relative path should be
429-// ignored. Later rules win, so a trailing negation can re-include a
430-// previously ignored path.
431-func (g *gitignore) match(rel string, isDir bool) bool {
432- ignored := false
433- for _, r := range g.rules {
434- if r.dirOnly && !isDir {
435- continue
436- }
437- if r.matchPath(rel) {
438- ignored = !r.negate
439- }
440- }
441- return ignored
442-}
380+func loadGitignore(root string) *gitignore { return ignore.Load(root) }
443381
444-func (r gitignoreRule) matchPath(rel string) bool {
445- if r.anchored || strings.Contains(r.pattern, "/") {
446- if ok, _ := filepath.Match(r.pattern, rel); ok {
447- return true
448- }
449- // Anchored directory pattern also matches everything beneath it.
450- return strings.HasPrefix(rel, r.pattern+"/")
451- }
452- // Unanchored: match the basename of any path component.
453- base := rel
454- if i := strings.LastIndex(rel, "/"); i >= 0 {
455- base = rel[i+1:]
456- }
457- if ok, _ := filepath.Match(r.pattern, base); ok {
458- return true
459- }
460- // Match a directory component anywhere in the path.
461- for _, part := range strings.Split(rel, "/") {
462- if ok, _ := filepath.Match(r.pattern, part); ok {
463- return true
464- }
465- }
466- return false
467-}
382+func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) }
@@ -8,6 +8,8 @@ import (
8 "os/exec"8 "os/exec"
9 "path/filepath"9 "path/filepath"
10 "strings"10 "strings"
11+
12+ "github.com/patriceckhart/zot/packages/ignore"
11 )13 )
12 14
13 // runExtCommand dispatches `zot ext ...` subcommands. Returns15 // runExtCommand dispatches `zot ext ...` subcommands. Returns
@@ -344,7 +346,7 @@ func copyDir(src, dst string) error {
344 if info.IsDir() && name == ".git" {346 if info.IsDir() && name == ".git" {
345 return filepath.SkipDir347 return filepath.SkipDir
346 }348 }
347- if ig.match(filepath.ToSlash(rel), info.IsDir()) {349+ if ig.Match(filepath.ToSlash(rel), info.IsDir()) {
348 if info.IsDir() {350 if info.IsDir() {
349 return filepath.SkipDir351 return filepath.SkipDir
350 }352 }
@@ -370,98 +372,11 @@ func copyDir(src, dst string) error {
370 })372 })
371 }373 }
372 374
373-// gitignore is a minimal .gitignore matcher. It supports the common375+// gitignore matching lives in packages/ignore so the @-file picker in
374-// patterns used in real extension repos: blank lines, comments (#),376+// packages/agent/modes can share it without an import cycle. These
375-// negation (!), directory-only patterns (trailing /), anchored377+// thin aliases keep the existing call sites (and tests) terse.
376-// patterns (leading /), and the * / ? / [..] wildcards via378+type gitignore = ignore.Gitignore
377-// filepath.Match. It intentionally does not implement ** globstar or
378-// nested per-directory .gitignore files; the goal is to drop obvious
379-// non-portable directories, not to be a faithful git reimplementation.
380-type gitignore struct {
381- rules []gitignoreRule
382-}
383-
384-type gitignoreRule struct {
385- pattern string
386- negate bool
387- dirOnly bool
388- anchored bool
389-}
390-
391-func loadGitignore(root string) *gitignore {
392- data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
393- if err != nil {
394- return &gitignore{}
395- }
396- return loadGitignoreFromString(string(data))
397-}
398-
399-func loadGitignoreFromString(data string) *gitignore {
400- g := &gitignore{}
401- for _, line := range strings.Split(data, "\n") {
402- line = strings.TrimRight(line, "\r")
403- trimmed := strings.TrimSpace(line)
404- if trimmed == "" || strings.HasPrefix(trimmed, "#") {
405- continue
406- }
407- r := gitignoreRule{pattern: trimmed}
408- if strings.HasPrefix(r.pattern, "!") {
409- r.negate = true
410- r.pattern = r.pattern[1:]
411- }
412- if strings.HasSuffix(r.pattern, "/") {
413- r.dirOnly = true
414- r.pattern = strings.TrimSuffix(r.pattern, "/")
415- }
416- if strings.HasPrefix(r.pattern, "/") {
417- r.anchored = true
418- r.pattern = strings.TrimPrefix(r.pattern, "/")
419- }
420- if r.pattern == "" {
421- continue
422- }
423- g.rules = append(g.rules, r)
424- }
425- return g
426-}
427 379
428-// match reports whether the slash-separated relative path should be380+func loadGitignore(root string) *gitignore { return ignore.Load(root) }
429-// ignored. Later rules win, so a trailing negation can re-include a
430-// previously ignored path.
431-func (g *gitignore) match(rel string, isDir bool) bool {
432- ignored := false
433- for _, r := range g.rules {
434- if r.dirOnly && !isDir {
435- continue
436- }
437- if r.matchPath(rel) {
438- ignored = !r.negate
439- }
440- }
441- return ignored
442-}
443 381
444-func (r gitignoreRule) matchPath(rel string) bool {382+func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) }
445- if r.anchored || strings.Contains(r.pattern, "/") {
446- if ok, _ := filepath.Match(r.pattern, rel); ok {
447- return true
448- }
449- // Anchored directory pattern also matches everything beneath it.
450- return strings.HasPrefix(rel, r.pattern+"/")
451- }
452- // Unanchored: match the basename of any path component.
453- base := rel
454- if i := strings.LastIndex(rel, "/"); i >= 0 {
455- base = rel[i+1:]
456- }
457- if ok, _ := filepath.Match(r.pattern, base); ok {
458- return true
459- }
460- // Match a directory component anywhere in the path.
461- for _, part := range strings.Split(rel, "/") {
462- if ok, _ := filepath.Match(r.pattern, part); ok {
463- return true
464- }
465- }
466- return false
467-}
M packages/agent/extcmd_test.go +2 -2
@@ -117,10 +117,10 @@ func TestCopyDirRespectsGitignore(t *testing.T) {
117117
118118 func TestGitignoreNegation(t *testing.T) {
119119 g := loadGitignoreFromString("build/\n!build/keep.txt\n")
120- if !g.match("build", true) {
120+ if !g.Match("build", true) {
121121 t.Fatal("expected build/ dir to be ignored")
122122 }
123- if g.match("build/keep.txt", false) {
123+ if g.Match("build/keep.txt", false) {
124124 t.Fatal("expected build/keep.txt to be re-included by negation")
125125 }
126126 }
@@ -117,10 +117,10 @@ func TestCopyDirRespectsGitignore(t *testing.T) {
117 117
118 func TestGitignoreNegation(t *testing.T) {118 func TestGitignoreNegation(t *testing.T) {
119 g := loadGitignoreFromString("build/\n!build/keep.txt\n")119 g := loadGitignoreFromString("build/\n!build/keep.txt\n")
120- if !g.match("build", true) {120+ if !g.Match("build", true) {
121 t.Fatal("expected build/ dir to be ignored")121 t.Fatal("expected build/ dir to be ignored")
122 }122 }
123- if g.match("build/keep.txt", false) {123+ if g.Match("build/keep.txt", false) {
124 t.Fatal("expected build/keep.txt to be re-included by negation")124 t.Fatal("expected build/keep.txt to be re-included by negation")
125 }125 }
126 }126 }
M packages/agent/modes/file_suggest.go +186 -14
@@ -8,9 +8,26 @@ import (
88 "strings"
99 "time"
1010
11+ "github.com/sahilm/fuzzy"
12+
13+ "github.com/patriceckhart/zot/packages/ignore"
1114 "github.com/patriceckhart/zot/packages/tui"
1215 )
1316
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.
20+const (
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.
29+const alwaysSkipDir = ".git"
30+
1431 // fileSuggester provides an @-triggered file/directory picker popup.
1532 // Type "@" followed by an optional filter to list files in the working
1633 // directory. Arrow up/down navigate, enter selects, esc cancels.
@@ -20,8 +37,17 @@ type fileSuggester struct {
2037 lastMatches []fileEntry
2138 cwd string // project root
2239 browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)
23- cachedDir string // absolute directory we last scanned
24- cachedAll []fileEntry
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
2551 // cachedMTime is the mtime of cachedDir at the time of the scan.
2652 // scan() compares the current mtime against this on every call and
2753 // re-reads the directory if it has changed, so files or folders
@@ -37,7 +63,10 @@ type fileEntry struct {
3763 isDir bool
3864 }
3965
40-func newFileSuggester() *fileSuggester { return &fileSuggester{} }
66+// newFileSuggester returns a picker that respects .gitignore by
67+// default. Callers override via SetRespectGitignore once the persisted
68+// setting is known.
69+func newFileSuggester() *fileSuggester { return &fileSuggester{respectGitignore: true} }
4170
4271 // SetCWD updates the project root.
4372 func (s *fileSuggester) SetCWD(cwd string) {
@@ -49,6 +78,32 @@ func (s *fileSuggester) SetCWD(cwd string) {
4978 }
5079 }
5180
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.
84+func (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.
97+func (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+
52107 // browseDir returns the absolute directory currently being browsed.
53108 func (s *fileSuggester) browseDir() string {
54109 if s.browseRel == "" {
@@ -66,6 +121,9 @@ func (s *fileSuggester) browseDir() string {
66121 // supports). A failed stat falls through to a fresh ReadDir rather
67122 // than returning a stale cache so transient errors self-heal.
68123 func (s *fileSuggester) scan() []fileEntry {
124+ if s.recursive {
125+ return s.scanRecursive()
126+ }
69127 dir := s.browseDir()
70128 var mtime time.Time
71129 if info, err := os.Stat(dir); err == nil {
@@ -78,6 +136,10 @@ func (s *fileSuggester) scan() []fileEntry {
78136 if err != nil {
79137 return nil
80138 }
139+ var ig *ignore.Gitignore
140+ if s.respectGitignore {
141+ ig = ignore.Load(s.cwd)
142+ }
81143 var all []fileEntry
82144 for _, e := range entries {
83145 name := e.Name()
@@ -85,6 +147,14 @@ func (s *fileSuggester) scan() []fileEntry {
85147 if s.browseRel != "" {
86148 rel = filepath.Join(s.browseRel, name)
87149 }
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+ }
88158 all = append(all, fileEntry{
89159 name: name,
90160 rel: rel,
@@ -103,6 +173,85 @@ func (s *fileSuggester) scan() []fileEntry {
103173 return all
104174 }
105175
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.
188+func (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+
106255 // extractAtQuery returns the filter string after "@".
107256 func extractAtQuery(input string) (string, bool) {
108257 input = strings.TrimRight(input, " ")
@@ -121,6 +270,11 @@ func extractAtQuery(input string) (string, bool) {
121270 }
122271
123272 // 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.
124278 func (s *fileSuggester) matches(input string) []fileEntry {
125279 query, ok := extractAtQuery(input)
126280 if !ok {
@@ -130,14 +284,22 @@ func (s *fileSuggester) matches(input string) []fileEntry {
130284 if len(all) == 0 {
131285 return nil
132286 }
133- needle := strings.ToLower(query)
134- if needle == "" {
287+ if query == "" {
135288 return all
136289 }
137- var out []fileEntry
138- for _, e := range all {
139- if strings.Contains(strings.ToLower(e.name), needle) {
140- out = append(out, e)
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])
141303 }
142304 }
143305 return out
@@ -176,8 +338,12 @@ func (s *fileSuggester) Down() {
176338 }
177339
178340 // Right opens the selected directory, descending into it.
179-// Returns true if a directory was entered.
341+// Returns true if a directory was entered. Disabled in recursive mode,
342+// where the whole tree is already flattened into the result list.
180343 func (s *fileSuggester) Right() bool {
344+ if s.recursive {
345+ return false
346+ }
181347 m := s.lastMatches
182348 if len(m) == 0 || s.cursor >= len(m) {
183349 return false
@@ -194,8 +360,11 @@ func (s *fileSuggester) Right() bool {
194360 }
195361
196362 // Left goes back to the parent directory.
197-// Returns true if we moved up.
363+// Returns true if we moved up. Disabled in recursive mode.
198364 func (s *fileSuggester) Left() bool {
365+ if s.recursive {
366+ return false
367+ }
199368 if s.browseRel == "" {
200369 return false
201370 }
@@ -283,10 +452,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
283452 }
284453
285454 out = append(out, "")
286- hint := " \u2191/\u2193 navigate - enter select - esc cancel"
287- if s.browseRel != "" {
455+ var hint string
456+ switch {
457+ case s.recursive:
458+ hint = " \u2191/\u2193 navigate - enter select - esc cancel (recursive)"
459+ case s.browseRel != "":
288460 hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
289- } else {
461+ default:
290462 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
291463 }
292464 out = append(out, th.FG256(th.Muted, hint))
@@ -8,9 +8,26 @@ import (
8 "strings"8 "strings"
9 "time"9 "time"
10 10
11+ "github.com/sahilm/fuzzy"
12+
13+ "github.com/patriceckhart/zot/packages/ignore"
11 "github.com/patriceckhart/zot/packages/tui"14 "github.com/patriceckhart/zot/packages/tui"
12 )15 )
13 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.
20+const (
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.
29+const alwaysSkipDir = ".git"
30+
14 // fileSuggester provides an @-triggered file/directory picker popup.31 // fileSuggester provides an @-triggered file/directory picker popup.
15 // Type "@" followed by an optional filter to list files in the working32 // Type "@" followed by an optional filter to list files in the working
16 // directory. Arrow up/down navigate, enter selects, esc cancels.33 // directory. Arrow up/down navigate, enter selects, esc cancels.
@@ -20,8 +37,17 @@ type fileSuggester struct {
20 lastMatches []fileEntry37 lastMatches []fileEntry
21 cwd string // project root38 cwd string // project root
22 browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)39 browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)
23- cachedDir string // absolute directory we last scanned40+ // recursive enables a whole-tree fuzzy search instead of
24- cachedAll []fileEntry41+ // 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
25 // cachedMTime is the mtime of cachedDir at the time of the scan.51 // cachedMTime is the mtime of cachedDir at the time of the scan.
26 // scan() compares the current mtime against this on every call and52 // scan() compares the current mtime against this on every call and
27 // re-reads the directory if it has changed, so files or folders53 // re-reads the directory if it has changed, so files or folders
@@ -37,7 +63,10 @@ type fileEntry struct {
37 isDir bool63 isDir bool
38 }64 }
39 65
40-func newFileSuggester() *fileSuggester { return &fileSuggester{} }66+// newFileSuggester returns a picker that respects .gitignore by
67+// default. Callers override via SetRespectGitignore once the persisted
68+// setting is known.
69+func newFileSuggester() *fileSuggester { return &fileSuggester{respectGitignore: true} }
41 70
42 // SetCWD updates the project root.71 // SetCWD updates the project root.
43 func (s *fileSuggester) SetCWD(cwd string) {72 func (s *fileSuggester) SetCWD(cwd string) {
@@ -49,6 +78,32 @@ func (s *fileSuggester) SetCWD(cwd string) {
49 }78 }
50 }79 }
51 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.
84+func (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.
97+func (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+
52 // browseDir returns the absolute directory currently being browsed.107 // browseDir returns the absolute directory currently being browsed.
53 func (s *fileSuggester) browseDir() string {108 func (s *fileSuggester) browseDir() string {
54 if s.browseRel == "" {109 if s.browseRel == "" {
@@ -66,6 +121,9 @@ func (s *fileSuggester) browseDir() string {
66 // supports). A failed stat falls through to a fresh ReadDir rather121 // supports). A failed stat falls through to a fresh ReadDir rather
67 // than returning a stale cache so transient errors self-heal.122 // than returning a stale cache so transient errors self-heal.
68 func (s *fileSuggester) scan() []fileEntry {123 func (s *fileSuggester) scan() []fileEntry {
124+ if s.recursive {
125+ return s.scanRecursive()
126+ }
69 dir := s.browseDir()127 dir := s.browseDir()
70 var mtime time.Time128 var mtime time.Time
71 if info, err := os.Stat(dir); err == nil {129 if info, err := os.Stat(dir); err == nil {
@@ -78,6 +136,10 @@ func (s *fileSuggester) scan() []fileEntry {
78 if err != nil {136 if err != nil {
79 return nil137 return nil
80 }138 }
139+ var ig *ignore.Gitignore
140+ if s.respectGitignore {
141+ ig = ignore.Load(s.cwd)
142+ }
81 var all []fileEntry143 var all []fileEntry
82 for _, e := range entries {144 for _, e := range entries {
83 name := e.Name()145 name := e.Name()
@@ -85,6 +147,14 @@ func (s *fileSuggester) scan() []fileEntry {
85 if s.browseRel != "" {147 if s.browseRel != "" {
86 rel = filepath.Join(s.browseRel, name)148 rel = filepath.Join(s.browseRel, name)
87 }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+ }
88 all = append(all, fileEntry{158 all = append(all, fileEntry{
89 name: name,159 name: name,
90 rel: rel,160 rel: rel,
@@ -103,6 +173,85 @@ func (s *fileSuggester) scan() []fileEntry {
103 return all173 return all
104 }174 }
105 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.
188+func (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+
106 // extractAtQuery returns the filter string after "@".255 // extractAtQuery returns the filter string after "@".
107 func extractAtQuery(input string) (string, bool) {256 func extractAtQuery(input string) (string, bool) {
108 input = strings.TrimRight(input, " ")257 input = strings.TrimRight(input, " ")
@@ -121,6 +270,11 @@ func extractAtQuery(input string) (string, bool) {
121 }270 }
122 271
123 // matches returns file entries matching the current @-query.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.
124 func (s *fileSuggester) matches(input string) []fileEntry {278 func (s *fileSuggester) matches(input string) []fileEntry {
125 query, ok := extractAtQuery(input)279 query, ok := extractAtQuery(input)
126 if !ok {280 if !ok {
@@ -130,14 +284,22 @@ func (s *fileSuggester) matches(input string) []fileEntry {
130 if len(all) == 0 {284 if len(all) == 0 {
131 return nil285 return nil
132 }286 }
133- needle := strings.ToLower(query)287+ if query == "" {
134- if needle == "" {
135 return all288 return all
136 }289 }
137- var out []fileEntry290+ haystack := make([]string, len(all))
138- for _, e := range all {291+ for i, e := range all {
139- if strings.Contains(strings.ToLower(e.name), needle) {292+ if s.recursive {
140- out = append(out, e)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])
141 }303 }
142 }304 }
143 return out305 return out
@@ -176,8 +338,12 @@ func (s *fileSuggester) Down() {
176 }338 }
177 339
178 // Right opens the selected directory, descending into it.340 // Right opens the selected directory, descending into it.
179-// Returns true if a directory was entered.341+// Returns true if a directory was entered. Disabled in recursive mode,
342+// where the whole tree is already flattened into the result list.
180 func (s *fileSuggester) Right() bool {343 func (s *fileSuggester) Right() bool {
344+ if s.recursive {
345+ return false
346+ }
181 m := s.lastMatches347 m := s.lastMatches
182 if len(m) == 0 || s.cursor >= len(m) {348 if len(m) == 0 || s.cursor >= len(m) {
183 return false349 return false
@@ -194,8 +360,11 @@ func (s *fileSuggester) Right() bool {
194 }360 }
195 361
196 // Left goes back to the parent directory.362 // Left goes back to the parent directory.
197-// Returns true if we moved up.363+// Returns true if we moved up. Disabled in recursive mode.
198 func (s *fileSuggester) Left() bool {364 func (s *fileSuggester) Left() bool {
365+ if s.recursive {
366+ return false
367+ }
199 if s.browseRel == "" {368 if s.browseRel == "" {
200 return false369 return false
201 }370 }
@@ -283,10 +452,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
283 }452 }
284 453
285 out = append(out, "")454 out = append(out, "")
286- hint := " \u2191/\u2193 navigate - enter select - esc cancel"455+ var hint string
287- if s.browseRel != "" {456+ switch {
457+ case s.recursive:
458+ hint = " \u2191/\u2193 navigate - enter select - esc cancel (recursive)"
459+ case s.browseRel != "":
288 hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"460 hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
289- } else {461+ default:
290 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"462 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
291 }463 }
292 out = append(out, th.FG256(th.Muted, hint))464 out = append(out, th.FG256(th.Muted, hint))
M packages/agent/modes/file_suggest_test.go +214 -0
@@ -4,6 +4,7 @@ import (
44 "os"
55 "path/filepath"
66 "sort"
7+ "strings"
78 "testing"
89 "time"
910 )
@@ -97,3 +98,216 @@ func (b byDirsFirst) Less(i, j int) bool {
9798 }
9899 return b[i].name < b[j].name
99100 }
101+
102+// TestFileSuggesterFuzzyMatch verifies the @-query ranks entries with
103+// a fuzzy subsequence match rather than a plain substring, so a
104+// non-contiguous pattern like "fsg" still finds "file_suggest.go".
105+func TestFileSuggesterFuzzyMatch(t *testing.T) {
106+ tmp := t.TempDir()
107+ for _, name := range []string{"file_suggest.go", "interactive.go", "README.md"} {
108+ if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
109+ t.Fatal(err)
110+ }
111+ }
112+ s := newFileSuggester()
113+ s.SetCWD(tmp)
114+
115+ got := s.matches("@fsg")
116+ if !containsEntry(got, "file_suggest.go", false) {
117+ t.Fatalf("fuzzy query @fsg did not match file_suggest.go: %#v", got)
118+ }
119+ if len(got) == 0 || got[0].name != "file_suggest.go" {
120+ t.Fatalf("file_suggest.go not ranked first for @fsg: %#v", got)
121+ }
122+}
123+
124+// TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
125+// tree and matches against the cwd-relative path, so a pattern can
126+// span directory boundaries.
127+func TestFileSuggesterRecursiveMatch(t *testing.T) {
128+ tmp := t.TempDir()
129+ if err := os.MkdirAll(filepath.Join(tmp, "src", "foo"), 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ if err := os.WriteFile(filepath.Join(tmp, "src", "foo", "bar.go"), []byte("x"), 0o644); err != nil {
133+ t.Fatal(err)
134+ }
135+ s := newFileSuggester()
136+ s.SetCWD(tmp)
137+ s.SetRecursive(true)
138+
139+ rel := filepath.Join("src", "foo", "bar.go")
140+ got := s.matches("@foobar")
141+ if !containsEntry(got, rel, false) {
142+ t.Fatalf("recursive @foobar did not match %s: %#v", rel, got)
143+ }
144+}
145+
146+// TestFileSuggesterRecursiveSkipsHeavyDirs ensures the walk prunes
147+// directories like .git that would otherwise dominate the budget.
148+func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) {
149+ tmp := t.TempDir()
150+ if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil {
151+ t.Fatal(err)
152+ }
153+ if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil {
154+ t.Fatal(err)
155+ }
156+ if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
157+ t.Fatal(err)
158+ }
159+ s := newFileSuggester()
160+ s.SetCWD(tmp)
161+ s.SetRecursive(true)
162+
163+ all := s.scan()
164+ for _, e := range all {
165+ if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) {
166+ t.Fatalf("recursive scan descended into .git: %#v", e)
167+ }
168+ }
169+ if !containsEntry(all, "main.go", false) {
170+ t.Fatalf("recursive scan missing main.go: %#v", all)
171+ }
172+}
173+
174+// TestFileSuggesterRecursiveHonorsGitignore ensures the recursive walk
175+// prunes anything listed in the project's root .gitignore — build
176+// outputs, dependency dirs, and IaC tool caches like
177+// .terraform/.terragrunt-cache — while still surfacing tracked files.
178+func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) {
179+ tmp := t.TempDir()
180+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"),
181+ []byte(".terraform/\n.terragrunt-cache/\nnode_modules/\n*.log\n"), 0o644); err != nil {
182+ t.Fatal(err)
183+ }
184+ ignored := []string{".terraform", ".terragrunt-cache", "node_modules"}
185+ for _, dir := range ignored {
186+ nested := filepath.Join(tmp, dir, "deep")
187+ if err := os.MkdirAll(nested, 0o755); err != nil {
188+ t.Fatal(err)
189+ }
190+ if err := os.WriteFile(filepath.Join(nested, "junk"), []byte("x"), 0o644); err != nil {
191+ t.Fatal(err)
192+ }
193+ }
194+ if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
195+ t.Fatal(err)
196+ }
197+ if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil {
198+ t.Fatal(err)
199+ }
200+ s := newFileSuggester()
201+ s.SetCWD(tmp)
202+ s.SetRecursive(true)
203+
204+ all := s.scan()
205+ for _, e := range all {
206+ for _, skip := range ignored {
207+ if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) {
208+ t.Fatalf("recursive scan descended into gitignored %s: %#v", skip, e)
209+ }
210+ }
211+ if e.rel == "debug.log" {
212+ t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e)
213+ }
214+ }
215+ if !containsEntry(all, "main.tf", false) {
216+ t.Fatalf("recursive scan missing tracked main.tf: %#v", all)
217+ }
218+}
219+
220+// TestFileSuggesterFlatModeHonorsGitignore verifies the default
221+// directory-by-directory browse also hides gitignored entries and
222+// always hides .git.
223+func TestFileSuggesterFlatModeHonorsGitignore(t *testing.T) {
224+ tmp := t.TempDir()
225+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("node_modules/\n*.log\n"), 0o644); err != nil {
226+ t.Fatal(err)
227+ }
228+ if err := os.MkdirAll(filepath.Join(tmp, "node_modules"), 0o755); err != nil {
229+ t.Fatal(err)
230+ }
231+ if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil {
232+ t.Fatal(err)
233+ }
234+ if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
235+ t.Fatal(err)
236+ }
237+ if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
238+ t.Fatal(err)
239+ }
240+ s := newFileSuggester()
241+ s.SetCWD(tmp) // flat mode, respectGitignore on by default
242+
243+ all := s.scan()
244+ if containsEntry(all, "node_modules", true) {
245+ t.Fatal("flat scan surfaced gitignored node_modules/")
246+ }
247+ if containsEntry(all, ".git", true) {
248+ t.Fatal("flat scan surfaced .git/")
249+ }
250+ if containsEntry(all, "debug.log", false) {
251+ t.Fatal("flat scan surfaced gitignored *.log file")
252+ }
253+ if !containsEntry(all, "main.go", false) {
254+ t.Fatalf("flat scan missing tracked main.go: %#v", all)
255+ }
256+ // .gitignore itself is not ignored, so it should remain visible.
257+ if !containsEntry(all, ".gitignore", false) {
258+ t.Fatalf("flat scan missing .gitignore: %#v", all)
259+ }
260+}
261+
262+// TestFileSuggesterRespectGitignoreToggle verifies disabling the
263+// setting surfaces gitignored entries in both modes (while .git stays
264+// hidden in recursive mode to protect the entry budget).
265+func TestFileSuggesterRespectGitignoreToggle(t *testing.T) {
266+ tmp := t.TempDir()
267+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
268+ t.Fatal(err)
269+ }
270+ if err := os.MkdirAll(filepath.Join(tmp, "dist"), 0o755); err != nil {
271+ t.Fatal(err)
272+ }
273+ s := newFileSuggester()
274+ s.SetCWD(tmp)
275+
276+ // On by default: dist/ hidden.
277+ if containsEntry(s.scan(), "dist", true) {
278+ t.Fatal("dist/ should be hidden while respectGitignore is on")
279+ }
280+ // Toggle off: dist/ visible (flat mode).
281+ s.SetRespectGitignore(false)
282+ if !containsEntry(s.scan(), "dist", true) {
283+ t.Fatal("dist/ should be visible after disabling respectGitignore (flat)")
284+ }
285+ // And in recursive mode.
286+ s.SetRecursive(true)
287+ if !containsEntry(s.scan(), "dist", true) {
288+ t.Fatal("dist/ should be visible after disabling respectGitignore (recursive)")
289+ }
290+}
291+
292+// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
293+// cached scan so the next matches() reflects the new mode.
294+func TestFileSuggesterToggleResetsCache(t *testing.T) {
295+ tmp := t.TempDir()
296+ if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil {
297+ t.Fatal(err)
298+ }
299+ if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil {
300+ t.Fatal(err)
301+ }
302+ s := newFileSuggester()
303+ s.SetCWD(tmp)
304+
305+ rel := filepath.Join("pkg", "nested.go")
306+ if containsEntry(s.matches("@nested"), rel, false) {
307+ t.Fatal("flat mode unexpectedly saw nested.go")
308+ }
309+ s.SetRecursive(true)
310+ if !containsEntry(s.matches("@nested"), rel, false) {
311+ t.Fatal("recursive mode did not surface nested.go after toggle")
312+ }
313+}
@@ -4,6 +4,7 @@ import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6 "sort"6 "sort"
7+ "strings"
7 "testing"8 "testing"
8 "time"9 "time"
9 )10 )
@@ -97,3 +98,216 @@ func (b byDirsFirst) Less(i, j int) bool {
97 }98 }
98 return b[i].name < b[j].name99 return b[i].name < b[j].name
99 }100 }
101+
102+// TestFileSuggesterFuzzyMatch verifies the @-query ranks entries with
103+// a fuzzy subsequence match rather than a plain substring, so a
104+// non-contiguous pattern like "fsg" still finds "file_suggest.go".
105+func TestFileSuggesterFuzzyMatch(t *testing.T) {
106+ tmp := t.TempDir()
107+ for _, name := range []string{"file_suggest.go", "interactive.go", "README.md"} {
108+ if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
109+ t.Fatal(err)
110+ }
111+ }
112+ s := newFileSuggester()
113+ s.SetCWD(tmp)
114+
115+ got := s.matches("@fsg")
116+ if !containsEntry(got, "file_suggest.go", false) {
117+ t.Fatalf("fuzzy query @fsg did not match file_suggest.go: %#v", got)
118+ }
119+ if len(got) == 0 || got[0].name != "file_suggest.go" {
120+ t.Fatalf("file_suggest.go not ranked first for @fsg: %#v", got)
121+ }
122+}
123+
124+// TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
125+// tree and matches against the cwd-relative path, so a pattern can
126+// span directory boundaries.
127+func TestFileSuggesterRecursiveMatch(t *testing.T) {
128+ tmp := t.TempDir()
129+ if err := os.MkdirAll(filepath.Join(tmp, "src", "foo"), 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ if err := os.WriteFile(filepath.Join(tmp, "src", "foo", "bar.go"), []byte("x"), 0o644); err != nil {
133+ t.Fatal(err)
134+ }
135+ s := newFileSuggester()
136+ s.SetCWD(tmp)
137+ s.SetRecursive(true)
138+
139+ rel := filepath.Join("src", "foo", "bar.go")
140+ got := s.matches("@foobar")
141+ if !containsEntry(got, rel, false) {
142+ t.Fatalf("recursive @foobar did not match %s: %#v", rel, got)
143+ }
144+}
145+
146+// TestFileSuggesterRecursiveSkipsHeavyDirs ensures the walk prunes
147+// directories like .git that would otherwise dominate the budget.
148+func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) {
149+ tmp := t.TempDir()
150+ if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil {
151+ t.Fatal(err)
152+ }
153+ if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil {
154+ t.Fatal(err)
155+ }
156+ if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
157+ t.Fatal(err)
158+ }
159+ s := newFileSuggester()
160+ s.SetCWD(tmp)
161+ s.SetRecursive(true)
162+
163+ all := s.scan()
164+ for _, e := range all {
165+ if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) {
166+ t.Fatalf("recursive scan descended into .git: %#v", e)
167+ }
168+ }
169+ if !containsEntry(all, "main.go", false) {
170+ t.Fatalf("recursive scan missing main.go: %#v", all)
171+ }
172+}
173+
174+// TestFileSuggesterRecursiveHonorsGitignore ensures the recursive walk
175+// prunes anything listed in the project's root .gitignore — build
176+// outputs, dependency dirs, and IaC tool caches like
177+// .terraform/.terragrunt-cache — while still surfacing tracked files.
178+func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) {
179+ tmp := t.TempDir()
180+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"),
181+ []byte(".terraform/\n.terragrunt-cache/\nnode_modules/\n*.log\n"), 0o644); err != nil {
182+ t.Fatal(err)
183+ }
184+ ignored := []string{".terraform", ".terragrunt-cache", "node_modules"}
185+ for _, dir := range ignored {
186+ nested := filepath.Join(tmp, dir, "deep")
187+ if err := os.MkdirAll(nested, 0o755); err != nil {
188+ t.Fatal(err)
189+ }
190+ if err := os.WriteFile(filepath.Join(nested, "junk"), []byte("x"), 0o644); err != nil {
191+ t.Fatal(err)
192+ }
193+ }
194+ if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
195+ t.Fatal(err)
196+ }
197+ if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil {
198+ t.Fatal(err)
199+ }
200+ s := newFileSuggester()
201+ s.SetCWD(tmp)
202+ s.SetRecursive(true)
203+
204+ all := s.scan()
205+ for _, e := range all {
206+ for _, skip := range ignored {
207+ if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) {
208+ t.Fatalf("recursive scan descended into gitignored %s: %#v", skip, e)
209+ }
210+ }
211+ if e.rel == "debug.log" {
212+ t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e)
213+ }
214+ }
215+ if !containsEntry(all, "main.tf", false) {
216+ t.Fatalf("recursive scan missing tracked main.tf: %#v", all)
217+ }
218+}
219+
220+// TestFileSuggesterFlatModeHonorsGitignore verifies the default
221+// directory-by-directory browse also hides gitignored entries and
222+// always hides .git.
223+func TestFileSuggesterFlatModeHonorsGitignore(t *testing.T) {
224+ tmp := t.TempDir()
225+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("node_modules/\n*.log\n"), 0o644); err != nil {
226+ t.Fatal(err)
227+ }
228+ if err := os.MkdirAll(filepath.Join(tmp, "node_modules"), 0o755); err != nil {
229+ t.Fatal(err)
230+ }
231+ if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil {
232+ t.Fatal(err)
233+ }
234+ if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
235+ t.Fatal(err)
236+ }
237+ if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
238+ t.Fatal(err)
239+ }
240+ s := newFileSuggester()
241+ s.SetCWD(tmp) // flat mode, respectGitignore on by default
242+
243+ all := s.scan()
244+ if containsEntry(all, "node_modules", true) {
245+ t.Fatal("flat scan surfaced gitignored node_modules/")
246+ }
247+ if containsEntry(all, ".git", true) {
248+ t.Fatal("flat scan surfaced .git/")
249+ }
250+ if containsEntry(all, "debug.log", false) {
251+ t.Fatal("flat scan surfaced gitignored *.log file")
252+ }
253+ if !containsEntry(all, "main.go", false) {
254+ t.Fatalf("flat scan missing tracked main.go: %#v", all)
255+ }
256+ // .gitignore itself is not ignored, so it should remain visible.
257+ if !containsEntry(all, ".gitignore", false) {
258+ t.Fatalf("flat scan missing .gitignore: %#v", all)
259+ }
260+}
261+
262+// TestFileSuggesterRespectGitignoreToggle verifies disabling the
263+// setting surfaces gitignored entries in both modes (while .git stays
264+// hidden in recursive mode to protect the entry budget).
265+func TestFileSuggesterRespectGitignoreToggle(t *testing.T) {
266+ tmp := t.TempDir()
267+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
268+ t.Fatal(err)
269+ }
270+ if err := os.MkdirAll(filepath.Join(tmp, "dist"), 0o755); err != nil {
271+ t.Fatal(err)
272+ }
273+ s := newFileSuggester()
274+ s.SetCWD(tmp)
275+
276+ // On by default: dist/ hidden.
277+ if containsEntry(s.scan(), "dist", true) {
278+ t.Fatal("dist/ should be hidden while respectGitignore is on")
279+ }
280+ // Toggle off: dist/ visible (flat mode).
281+ s.SetRespectGitignore(false)
282+ if !containsEntry(s.scan(), "dist", true) {
283+ t.Fatal("dist/ should be visible after disabling respectGitignore (flat)")
284+ }
285+ // And in recursive mode.
286+ s.SetRecursive(true)
287+ if !containsEntry(s.scan(), "dist", true) {
288+ t.Fatal("dist/ should be visible after disabling respectGitignore (recursive)")
289+ }
290+}
291+
292+// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
293+// cached scan so the next matches() reflects the new mode.
294+func TestFileSuggesterToggleResetsCache(t *testing.T) {
295+ tmp := t.TempDir()
296+ if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil {
297+ t.Fatal(err)
298+ }
299+ if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil {
300+ t.Fatal(err)
301+ }
302+ s := newFileSuggester()
303+ s.SetCWD(tmp)
304+
305+ rel := filepath.Join("pkg", "nested.go")
306+ if containsEntry(s.matches("@nested"), rel, false) {
307+ t.Fatal("flat mode unexpectedly saw nested.go")
308+ }
309+ s.SetRecursive(true)
310+ if !containsEntry(s.matches("@nested"), rel, false) {
311+ t.Fatal("recursive mode did not surface nested.go after toggle")
312+ }
313+}
M packages/agent/modes/interactive.go +63 -0
@@ -46,6 +46,16 @@ type InteractiveConfig struct {
4646 // re-reading config.json on every open.
4747 AutoSwarmEnabled *bool
4848
49+ // RecursiveFileSuggest mirrors the persisted recursive_file_suggest
50+ // flag at startup. When true the @-mention picker fuzzy-searches the
51+ // whole project tree instead of browsing one directory at a time.
52+ RecursiveFileSuggest *bool
53+
54+ // RespectGitignore mirrors the persisted respect_gitignore flag at
55+ // startup. nil means the default (on); when false the @-mention
56+ // picker shows files matched by the project's root .gitignore.
57+ RespectGitignore *bool
58+
4959 // ThemeName mirrors the persisted config theme value. Empty means auto.
5060 ThemeName string
5161 // ExtensionThemes returns themes bundled with loaded extensions.
@@ -228,6 +238,8 @@ type chatCacheKey struct {
228238 type SettingsStore interface {
229239 SetInlineImages(enabled bool) error
230240 SetAutoSwarm(enabled bool) error
241+ SetRecursiveFileSuggest(enabled bool) error
242+ SetRespectGitignore(enabled bool) error
231243 SetReasoning(level string) error
232244 SetTheme(name string) error
233245 }
@@ -474,6 +486,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
474486 spin: newSpinner(cfg.Theme),
475487 inputHistoryIndex: -1,
476488 }
489+ i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
490+ i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
477491 if cfg.Agent != nil {
478492 i.agent = cfg.Agent
479493 i.view.Messages = cfg.Agent.Messages()
@@ -2622,6 +2636,9 @@ func (i *Interactive) openSettingsDialog() {
26222636 autoSwarmHint = "swarm supervisor not available in this mode"
26232637 }
26242638
2639+ recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
2640+ respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
2641+
26252642 reasoningOptions := []settingsOption{
26262643 {value: "", label: "off", desc: "no reasoning"},
26272644 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
@@ -2685,6 +2702,18 @@ func (i *Interactive) openSettingsDialog() {
26852702 disabled: autoSwarmDisabled,
26862703 hint: autoSwarmHint,
26872704 },
2705+ {
2706+ key: "recursive_file_suggest",
2707+ label: "recursive @-file search",
2708+ desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
2709+ value: recursiveFiles,
2710+ },
2711+ {
2712+ key: "respect_gitignore",
2713+ label: "hide gitignored files in @-picker",
2714+ desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
2715+ value: respectGitignore,
2716+ },
26882717 {
26892718 key: "reasoning",
26902719 label: "thinking level",
@@ -2767,6 +2796,40 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
27672796 i.statusOK = "auto-swarm " + onOff(value)
27682797 i.statusErr = ""
27692798 i.mu.Unlock()
2799+ case "recursive_file_suggest":
2800+ val := value
2801+ i.cfg.RecursiveFileSuggest = &val
2802+ if i.cfg.SettingsStore != nil {
2803+ if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
2804+ i.mu.Lock()
2805+ i.statusErr = "settings: " + err.Error()
2806+ i.mu.Unlock()
2807+ return
2808+ }
2809+ }
2810+ // Flip the live picker so the next @ reflects the new mode
2811+ // without restarting zot. SetRecursive drops its cache.
2812+ i.fileSuggest.SetRecursive(value)
2813+ i.mu.Lock()
2814+ i.statusOK = "recursive @-file search " + onOff(value)
2815+ i.statusErr = ""
2816+ i.mu.Unlock()
2817+ case "respect_gitignore":
2818+ val := value
2819+ i.cfg.RespectGitignore = &val
2820+ if i.cfg.SettingsStore != nil {
2821+ if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
2822+ i.mu.Lock()
2823+ i.statusErr = "settings: " + err.Error()
2824+ i.mu.Unlock()
2825+ return
2826+ }
2827+ }
2828+ i.fileSuggest.SetRespectGitignore(value)
2829+ i.mu.Lock()
2830+ i.statusOK = "hide gitignored files in @-picker " + onOff(value)
2831+ i.statusErr = ""
2832+ i.mu.Unlock()
27702833 }
27712834 }
27722835
@@ -46,6 +46,16 @@ type InteractiveConfig struct {
46 // re-reading config.json on every open.46 // re-reading config.json on every open.
47 AutoSwarmEnabled *bool47 AutoSwarmEnabled *bool
48 48
49+ // RecursiveFileSuggest mirrors the persisted recursive_file_suggest
50+ // flag at startup. When true the @-mention picker fuzzy-searches the
51+ // whole project tree instead of browsing one directory at a time.
52+ RecursiveFileSuggest *bool
53+
54+ // RespectGitignore mirrors the persisted respect_gitignore flag at
55+ // startup. nil means the default (on); when false the @-mention
56+ // picker shows files matched by the project's root .gitignore.
57+ RespectGitignore *bool
58+
49 // ThemeName mirrors the persisted config theme value. Empty means auto.59 // ThemeName mirrors the persisted config theme value. Empty means auto.
50 ThemeName string60 ThemeName string
51 // ExtensionThemes returns themes bundled with loaded extensions.61 // ExtensionThemes returns themes bundled with loaded extensions.
@@ -228,6 +238,8 @@ type chatCacheKey struct {
228 type SettingsStore interface {238 type SettingsStore interface {
229 SetInlineImages(enabled bool) error239 SetInlineImages(enabled bool) error
230 SetAutoSwarm(enabled bool) error240 SetAutoSwarm(enabled bool) error
241+ SetRecursiveFileSuggest(enabled bool) error
242+ SetRespectGitignore(enabled bool) error
231 SetReasoning(level string) error243 SetReasoning(level string) error
232 SetTheme(name string) error244 SetTheme(name string) error
233 }245 }
@@ -474,6 +486,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
474 spin: newSpinner(cfg.Theme),486 spin: newSpinner(cfg.Theme),
475 inputHistoryIndex: -1,487 inputHistoryIndex: -1,
476 }488 }
489+ i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
490+ i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
477 if cfg.Agent != nil {491 if cfg.Agent != nil {
478 i.agent = cfg.Agent492 i.agent = cfg.Agent
479 i.view.Messages = cfg.Agent.Messages()493 i.view.Messages = cfg.Agent.Messages()
@@ -2622,6 +2636,9 @@ func (i *Interactive) openSettingsDialog() {
2622 autoSwarmHint = "swarm supervisor not available in this mode"2636 autoSwarmHint = "swarm supervisor not available in this mode"
2623 }2637 }
2624 2638
2639+ recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
2640+ respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
2641+
2625 reasoningOptions := []settingsOption{2642 reasoningOptions := []settingsOption{
2626 {value: "", label: "off", desc: "no reasoning"},2643 {value: "", label: "off", desc: "no reasoning"},
2627 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},2644 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
@@ -2685,6 +2702,18 @@ func (i *Interactive) openSettingsDialog() {
2685 disabled: autoSwarmDisabled,2702 disabled: autoSwarmDisabled,
2686 hint: autoSwarmHint,2703 hint: autoSwarmHint,
2687 },2704 },
2705+ {
2706+ key: "recursive_file_suggest",
2707+ label: "recursive @-file search",
2708+ desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
2709+ value: recursiveFiles,
2710+ },
2711+ {
2712+ key: "respect_gitignore",
2713+ label: "hide gitignored files in @-picker",
2714+ desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
2715+ value: respectGitignore,
2716+ },
2688 {2717 {
2689 key: "reasoning",2718 key: "reasoning",
2690 label: "thinking level",2719 label: "thinking level",
@@ -2767,6 +2796,40 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
2767 i.statusOK = "auto-swarm " + onOff(value)2796 i.statusOK = "auto-swarm " + onOff(value)
2768 i.statusErr = ""2797 i.statusErr = ""
2769 i.mu.Unlock()2798 i.mu.Unlock()
2799+ case "recursive_file_suggest":
2800+ val := value
2801+ i.cfg.RecursiveFileSuggest = &val
2802+ if i.cfg.SettingsStore != nil {
2803+ if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
2804+ i.mu.Lock()
2805+ i.statusErr = "settings: " + err.Error()
2806+ i.mu.Unlock()
2807+ return
2808+ }
2809+ }
2810+ // Flip the live picker so the next @ reflects the new mode
2811+ // without restarting zot. SetRecursive drops its cache.
2812+ i.fileSuggest.SetRecursive(value)
2813+ i.mu.Lock()
2814+ i.statusOK = "recursive @-file search " + onOff(value)
2815+ i.statusErr = ""
2816+ i.mu.Unlock()
2817+ case "respect_gitignore":
2818+ val := value
2819+ i.cfg.RespectGitignore = &val
2820+ if i.cfg.SettingsStore != nil {
2821+ if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
2822+ i.mu.Lock()
2823+ i.statusErr = "settings: " + err.Error()
2824+ i.mu.Unlock()
2825+ return
2826+ }
2827+ }
2828+ i.fileSuggest.SetRespectGitignore(value)
2829+ i.mu.Lock()
2830+ i.statusOK = "hide gitignored files in @-picker " + onOff(value)
2831+ i.statusErr = ""
2832+ i.mu.Unlock()
2770 }2833 }
2771 }2834 }
2772 2835
M packages/agent/settings_store.go +18 -0
@@ -22,6 +22,24 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
2222 return SaveConfig(cfg)
2323 }
2424
25+func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
26+ cfg, err := LoadConfig()
27+ if err != nil {
28+ return err
29+ }
30+ cfg.RecursiveFileSuggest = &enabled
31+ return SaveConfig(cfg)
32+}
33+
34+func (configSettingsStore) SetRespectGitignore(enabled bool) error {
35+ cfg, err := LoadConfig()
36+ if err != nil {
37+ return err
38+ }
39+ cfg.RespectGitignore = &enabled
40+ return SaveConfig(cfg)
41+}
42+
2543 func (configSettingsStore) SetReasoning(level string) error {
2644 cfg, err := LoadConfig()
2745 if err != nil {
@@ -22,6 +22,24 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
22 return SaveConfig(cfg)22 return SaveConfig(cfg)
23 }23 }
24 24
25+func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
26+ cfg, err := LoadConfig()
27+ if err != nil {
28+ return err
29+ }
30+ cfg.RecursiveFileSuggest = &enabled
31+ return SaveConfig(cfg)
32+}
33+
34+func (configSettingsStore) SetRespectGitignore(enabled bool) error {
35+ cfg, err := LoadConfig()
36+ if err != nil {
37+ return err
38+ }
39+ cfg.RespectGitignore = &enabled
40+ return SaveConfig(cfg)
41+}
42+
25 func (configSettingsStore) SetReasoning(level string) error {43 func (configSettingsStore) SetReasoning(level string) error {
26 cfg, err := LoadConfig()44 cfg, err := LoadConfig()
27 if err != nil {45 if err != nil {
A packages/ignore/gitignore.go +109 -0
@@ -0,0 +1,109 @@
1+// Package ignore provides a minimal .gitignore matcher shared across
2+// zot. It is intentionally small: enough to drop obvious non-source
3+// directories (build outputs, dependency and tool caches) from
4+// recursive walks, not a faithful git reimplementation.
5+package ignore
6+
7+import (
8+ "os"
9+ "path/filepath"
10+ "strings"
11+)
12+
13+// Gitignore is a minimal .gitignore matcher. It supports the common
14+// patterns used in real repos: blank lines, comments (#), negation (!),
15+// directory-only patterns (trailing /), anchored patterns (leading /),
16+// and the * / ? / [..] wildcards via filepath.Match. It intentionally
17+// does not implement ** globstar or nested per-directory .gitignore
18+// files.
19+type Gitignore struct {
20+ rules []rule
21+}
22+
23+type rule struct {
24+ pattern string
25+ negate bool
26+ dirOnly bool
27+ anchored bool
28+}
29+
30+// Load reads the .gitignore at the root directory. A missing or
31+// unreadable file yields an empty matcher that ignores nothing.
32+func Load(root string) *Gitignore {
33+ data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
34+ if err != nil {
35+ return &Gitignore{}
36+ }
37+ return Parse(string(data))
38+}
39+
40+// Parse builds a matcher from raw .gitignore file contents.
41+func Parse(data string) *Gitignore {
42+ g := &Gitignore{}
43+ for _, line := range strings.Split(data, "\n") {
44+ line = strings.TrimRight(line, "\r")
45+ trimmed := strings.TrimSpace(line)
46+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
47+ continue
48+ }
49+ r := rule{pattern: trimmed}
50+ if strings.HasPrefix(r.pattern, "!") {
51+ r.negate = true
52+ r.pattern = r.pattern[1:]
53+ }
54+ if strings.HasSuffix(r.pattern, "/") {
55+ r.dirOnly = true
56+ r.pattern = strings.TrimSuffix(r.pattern, "/")
57+ }
58+ if strings.HasPrefix(r.pattern, "/") {
59+ r.anchored = true
60+ r.pattern = strings.TrimPrefix(r.pattern, "/")
61+ }
62+ if r.pattern == "" {
63+ continue
64+ }
65+ g.rules = append(g.rules, r)
66+ }
67+ return g
68+}
69+
70+// Match reports whether the slash-separated relative path should be
71+// ignored. Later rules win, so a trailing negation can re-include a
72+// previously ignored path.
73+func (g *Gitignore) Match(rel string, isDir bool) bool {
74+ ignored := false
75+ for _, r := range g.rules {
76+ if r.dirOnly && !isDir {
77+ continue
78+ }
79+ if r.matchPath(rel) {
80+ ignored = !r.negate
81+ }
82+ }
83+ return ignored
84+}
85+
86+func (r rule) matchPath(rel string) bool {
87+ if r.anchored || strings.Contains(r.pattern, "/") {
88+ if ok, _ := filepath.Match(r.pattern, rel); ok {
89+ return true
90+ }
91+ // Anchored directory pattern also matches everything beneath it.
92+ return strings.HasPrefix(rel, r.pattern+"/")
93+ }
94+ // Unanchored: match the basename of any path component.
95+ base := rel
96+ if i := strings.LastIndex(rel, "/"); i >= 0 {
97+ base = rel[i+1:]
98+ }
99+ if ok, _ := filepath.Match(r.pattern, base); ok {
100+ return true
101+ }
102+ // Match a directory component anywhere in the path.
103+ for _, part := range strings.Split(rel, "/") {
104+ if ok, _ := filepath.Match(r.pattern, part); ok {
105+ return true
106+ }
107+ }
108+ return false
109+}
@@ -0,0 +1,109 @@
1+// Package ignore provides a minimal .gitignore matcher shared across
2+// zot. It is intentionally small: enough to drop obvious non-source
3+// directories (build outputs, dependency and tool caches) from
4+// recursive walks, not a faithful git reimplementation.
5+package ignore
6+
7+import (
8+ "os"
9+ "path/filepath"
10+ "strings"
11+)
12+
13+// Gitignore is a minimal .gitignore matcher. It supports the common
14+// patterns used in real repos: blank lines, comments (#), negation (!),
15+// directory-only patterns (trailing /), anchored patterns (leading /),
16+// and the * / ? / [..] wildcards via filepath.Match. It intentionally
17+// does not implement ** globstar or nested per-directory .gitignore
18+// files.
19+type Gitignore struct {
20+ rules []rule
21+}
22+
23+type rule struct {
24+ pattern string
25+ negate bool
26+ dirOnly bool
27+ anchored bool
28+}
29+
30+// Load reads the .gitignore at the root directory. A missing or
31+// unreadable file yields an empty matcher that ignores nothing.
32+func Load(root string) *Gitignore {
33+ data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
34+ if err != nil {
35+ return &Gitignore{}
36+ }
37+ return Parse(string(data))
38+}
39+
40+// Parse builds a matcher from raw .gitignore file contents.
41+func Parse(data string) *Gitignore {
42+ g := &Gitignore{}
43+ for _, line := range strings.Split(data, "\n") {
44+ line = strings.TrimRight(line, "\r")
45+ trimmed := strings.TrimSpace(line)
46+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
47+ continue
48+ }
49+ r := rule{pattern: trimmed}
50+ if strings.HasPrefix(r.pattern, "!") {
51+ r.negate = true
52+ r.pattern = r.pattern[1:]
53+ }
54+ if strings.HasSuffix(r.pattern, "/") {
55+ r.dirOnly = true
56+ r.pattern = strings.TrimSuffix(r.pattern, "/")
57+ }
58+ if strings.HasPrefix(r.pattern, "/") {
59+ r.anchored = true
60+ r.pattern = strings.TrimPrefix(r.pattern, "/")
61+ }
62+ if r.pattern == "" {
63+ continue
64+ }
65+ g.rules = append(g.rules, r)
66+ }
67+ return g
68+}
69+
70+// Match reports whether the slash-separated relative path should be
71+// ignored. Later rules win, so a trailing negation can re-include a
72+// previously ignored path.
73+func (g *Gitignore) Match(rel string, isDir bool) bool {
74+ ignored := false
75+ for _, r := range g.rules {
76+ if r.dirOnly && !isDir {
77+ continue
78+ }
79+ if r.matchPath(rel) {
80+ ignored = !r.negate
81+ }
82+ }
83+ return ignored
84+}
85+
86+func (r rule) matchPath(rel string) bool {
87+ if r.anchored || strings.Contains(r.pattern, "/") {
88+ if ok, _ := filepath.Match(r.pattern, rel); ok {
89+ return true
90+ }
91+ // Anchored directory pattern also matches everything beneath it.
92+ return strings.HasPrefix(rel, r.pattern+"/")
93+ }
94+ // Unanchored: match the basename of any path component.
95+ base := rel
96+ if i := strings.LastIndex(rel, "/"); i >= 0 {
97+ base = rel[i+1:]
98+ }
99+ if ok, _ := filepath.Match(r.pattern, base); ok {
100+ return true
101+ }
102+ // Match a directory component anywhere in the path.
103+ for _, part := range strings.Split(rel, "/") {
104+ if ok, _ := filepath.Match(r.pattern, part); ok {
105+ return true
106+ }
107+ }
108+ return false
109+}
A packages/ignore/gitignore_test.go +54 -0
@@ -0,0 +1,54 @@
1+package ignore
2+
3+import "testing"
4+
5+func TestParseAndMatch(t *testing.T) {
6+ g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log"))
7+
8+ cases := []struct {
9+ rel string
10+ isDir bool
11+ want bool
12+ }{
13+ {".terraform", true, true},
14+ // A dirOnly rule matches the directory itself; the walk prunes
15+ // descent on that match, so children are never tested. A file
16+ // path under it is therefore not matched directly by the rule.
17+ {".terraform/providers/x", false, false},
18+ {".terragrunt-cache", true, true},
19+ {"modules/.terragrunt-cache", true, true},
20+ {"node_modules", true, true},
21+ {"src/node_modules/pkg", true, true},
22+ {"debug.log", false, true},
23+ {"keep.log", false, false}, // re-included by negation
24+ {"build", true, true}, // anchored
25+ {"sub/build", true, false}, // anchored: only at root
26+ {"main.tf", false, false},
27+ {"src/app.go", false, false},
28+ }
29+ for _, c := range cases {
30+ if got := g.Match(c.rel, c.isDir); got != c.want {
31+ t.Errorf("Match(%q, dir=%v) = %v, want %v", c.rel, c.isDir, got, c.want)
32+ }
33+ }
34+}
35+
36+func TestEmptyIgnoresNothing(t *testing.T) {
37+ g := Parse("")
38+ if g.Match("anything", false) || g.Match("dir", true) {
39+ t.Fatal("empty matcher should ignore nothing")
40+ }
41+}
42+
43+// lines joins fixture lines with newlines for readable .gitignore
44+// fixtures.
45+func lines(ls ...string) string {
46+ out := ""
47+ for i, l := range ls {
48+ if i > 0 {
49+ out += "\n"
50+ }
51+ out += l
52+ }
53+ return out
54+}
@@ -0,0 +1,54 @@
1+package ignore
2+
3+import "testing"
4+
5+func TestParseAndMatch(t *testing.T) {
6+ g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log"))
7+
8+ cases := []struct {
9+ rel string
10+ isDir bool
11+ want bool
12+ }{
13+ {".terraform", true, true},
14+ // A dirOnly rule matches the directory itself; the walk prunes
15+ // descent on that match, so children are never tested. A file
16+ // path under it is therefore not matched directly by the rule.
17+ {".terraform/providers/x", false, false},
18+ {".terragrunt-cache", true, true},
19+ {"modules/.terragrunt-cache", true, true},
20+ {"node_modules", true, true},
21+ {"src/node_modules/pkg", true, true},
22+ {"debug.log", false, true},
23+ {"keep.log", false, false}, // re-included by negation
24+ {"build", true, true}, // anchored
25+ {"sub/build", true, false}, // anchored: only at root
26+ {"main.tf", false, false},
27+ {"src/app.go", false, false},
28+ }
29+ for _, c := range cases {
30+ if got := g.Match(c.rel, c.isDir); got != c.want {
31+ t.Errorf("Match(%q, dir=%v) = %v, want %v", c.rel, c.isDir, got, c.want)
32+ }
33+ }
34+}
35+
36+func TestEmptyIgnoresNothing(t *testing.T) {
37+ g := Parse("")
38+ if g.Match("anything", false) || g.Match("dir", true) {
39+ t.Fatal("empty matcher should ignore nothing")
40+ }
41+}
42+
43+// lines joins fixture lines with newlines for readable .gitignore
44+// fixtures.
45+func lines(ls ...string) string {
46+ out := ""
47+ for i, l := range ls {
48+ if i > 0 {
49+ out += "\n"
50+ }
51+ out += l
52+ }
53+ return out
54+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close