Merge pull request #25 from rgasper/feat/fuzzy-recursive-file-suggest
feat(tui): fuzzy @-file matching with toggleable recursive search
12 files · +676 -111
M go.mod +3 -1
| @@ -6,12 +6,14 @@ require ( | |||
| 6 | 6 | github.com/alecthomas/chroma/v2 v2.23.1 | |
| 7 | 7 | github.com/google/uuid v1.6.0 | |
| 8 | 8 | 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 | 11 | golang.org/x/sys v0.26.0 | |
| 10 | 12 | golang.org/x/term v0.25.0 | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | require ( | |
| 14 | 16 | github.com/dlclark/regexp2 v1.11.5 // indirect | |
| 17 | + | github.com/kylelemons/godebug v1.1.0 // indirect | |
| 15 | 18 | github.com/rivo/uniseg v0.2.0 // indirect | |
| 16 | - | golang.org/x/image v0.18.0 // indirect | |
| 17 | 19 | ) | |
| @@ -6,12 +6,14 @@ require ( | |||||
| 6 | github.com/alecthomas/chroma/v2 v2.23.1 | 6 | github.com/alecthomas/chroma/v2 v2.23.1 | ||
| 7 | github.com/google/uuid v1.6.0 | 7 | github.com/google/uuid v1.6.0 | ||
| 8 | github.com/mattn/go-runewidth v0.0.16 | 8 | 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.0 | 11 | golang.org/x/sys v0.26.0 | ||
| 10 | golang.org/x/term v0.25.0 | 12 | golang.org/x/term v0.25.0 | ||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | require ( | 15 | require ( | ||
| 14 | github.com/dlclark/regexp2 v1.11.5 // indirect | 16 | 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 // indirect | 18 | 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 { | |||
| 944 | 944 | Theme: theme, | |
| 945 | 945 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 946 | 946 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 947 | + | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 948 | + | RespectGitignore: initialCfg.RespectGitignore, | |
| 947 | 949 | ThemeName: initialCfg.Theme, | |
| 948 | 950 | ExtensionThemes: extMgr.ThemeOptions, | |
| 949 | 951 | 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 { | |||
| 32 | 32 | // default; nil/missing means disabled. Toggle from /settings. | |
| 33 | 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 | 47 | // LastChangelogShown is the version whose release-notes | |
| 36 | 48 | // dialog the user has already seen. When the running binary's | |
| 37 | 49 | // 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-notes | 47 | // LastChangelogShown is the version whose release-notes | ||
| 36 | // dialog the user has already seen. When the running binary's | 48 | // dialog the user has already seen. When the running binary's | ||
| 37 | // version differs, the next interactive run shows the | 49 | // version differs, the next interactive run shows the | ||
M packages/agent/extcmd.go +9 -94
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | "os/exec" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/patriceckhart/zot/packages/ignore" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | // runExtCommand dispatches `zot ext ...` subcommands. Returns | |
| @@ -344,7 +346,7 @@ func copyDir(src, dst string) error { | |||
| 344 | 346 | if info.IsDir() && name == ".git" { | |
| 345 | 347 | return filepath.SkipDir | |
| 346 | 348 | } | |
| 347 | - | if ig.match(filepath.ToSlash(rel), info.IsDir()) { | |
| 349 | + | if ig.Match(filepath.ToSlash(rel), info.IsDir()) { | |
| 348 | 350 | if info.IsDir() { | |
| 349 | 351 | 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 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 | |
| 427 | 379 | ||
| 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) } | |
| 443 | 381 | ||
| 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. Returns | 15 | // 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.SkipDir | 347 | 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.SkipDir | 351 | 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 common | 375 | + | // 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 /), anchored | 377 | + | // thin aliases keep the existing call sites (and tests) terse. |
| 376 | - | // patterns (leading /), and the * / ? / [..] wildcards via | 378 | + | 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 be | 380 | + | 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) { | |||
| 117 | 117 | ||
| 118 | 118 | func TestGitignoreNegation(t *testing.T) { | |
| 119 | 119 | g := loadGitignoreFromString("build/\n!build/keep.txt\n") | |
| 120 | - | if !g.match("build", true) { | |
| 120 | + | if !g.Match("build", true) { | |
| 121 | 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 | 124 | t.Fatal("expected build/keep.txt to be re-included by negation") | |
| 125 | 125 | } | |
| 126 | 126 | } | |
| @@ -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 ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | "github.com/sahilm/fuzzy" | |
| 12 | + | ||
| 13 | + | "github.com/patriceckhart/zot/packages/ignore" | |
| 11 | 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 | 31 | // fileSuggester provides an @-triggered file/directory picker popup. | |
| 15 | 32 | // Type "@" followed by an optional filter to list files in the working | |
| 16 | 33 | // directory. Arrow up/down navigate, enter selects, esc cancels. | |
| @@ -20,8 +37,17 @@ type fileSuggester struct { | |||
| 20 | 37 | lastMatches []fileEntry | |
| 21 | 38 | cwd string // project root | |
| 22 | 39 | 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 | |
| 25 | 51 | // cachedMTime is the mtime of cachedDir at the time of the scan. | |
| 26 | 52 | // scan() compares the current mtime against this on every call and | |
| 27 | 53 | // re-reads the directory if it has changed, so files or folders | |
| @@ -37,7 +63,10 @@ type fileEntry struct { | |||
| 37 | 63 | 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 | 71 | // SetCWD updates the project root. | |
| 43 | 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 | 107 | // browseDir returns the absolute directory currently being browsed. | |
| 53 | 108 | func (s *fileSuggester) browseDir() string { | |
| 54 | 109 | if s.browseRel == "" { | |
| @@ -66,6 +121,9 @@ func (s *fileSuggester) browseDir() string { | |||
| 66 | 121 | // supports). A failed stat falls through to a fresh ReadDir rather | |
| 67 | 122 | // than returning a stale cache so transient errors self-heal. | |
| 68 | 123 | func (s *fileSuggester) scan() []fileEntry { | |
| 124 | + | if s.recursive { | |
| 125 | + | return s.scanRecursive() | |
| 126 | + | } | |
| 69 | 127 | dir := s.browseDir() | |
| 70 | 128 | var mtime time.Time | |
| 71 | 129 | if info, err := os.Stat(dir); err == nil { | |
| @@ -78,6 +136,10 @@ func (s *fileSuggester) scan() []fileEntry { | |||
| 78 | 136 | if err != nil { | |
| 79 | 137 | return nil | |
| 80 | 138 | } | |
| 139 | + | var ig *ignore.Gitignore | |
| 140 | + | if s.respectGitignore { | |
| 141 | + | ig = ignore.Load(s.cwd) | |
| 142 | + | } | |
| 81 | 143 | var all []fileEntry | |
| 82 | 144 | for _, e := range entries { | |
| 83 | 145 | name := e.Name() | |
| @@ -85,6 +147,14 @@ func (s *fileSuggester) scan() []fileEntry { | |||
| 85 | 147 | if s.browseRel != "" { | |
| 86 | 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 | 158 | all = append(all, fileEntry{ | |
| 89 | 159 | name: name, | |
| 90 | 160 | rel: rel, | |
| @@ -103,6 +173,85 @@ func (s *fileSuggester) scan() []fileEntry { | |||
| 103 | 173 | 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 | 255 | // extractAtQuery returns the filter string after "@". | |
| 107 | 256 | func extractAtQuery(input string) (string, bool) { | |
| 108 | 257 | input = strings.TrimRight(input, " ") | |
| @@ -121,6 +270,11 @@ func extractAtQuery(input string) (string, bool) { | |||
| 121 | 270 | } | |
| 122 | 271 | ||
| 123 | 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 | 278 | func (s *fileSuggester) matches(input string) []fileEntry { | |
| 125 | 279 | query, ok := extractAtQuery(input) | |
| 126 | 280 | if !ok { | |
| @@ -130,14 +284,22 @@ func (s *fileSuggester) matches(input string) []fileEntry { | |||
| 130 | 284 | if len(all) == 0 { | |
| 131 | 285 | return nil | |
| 132 | 286 | } | |
| 133 | - | needle := strings.ToLower(query) | |
| 134 | - | if needle == "" { | |
| 287 | + | if query == "" { | |
| 135 | 288 | return all | |
| 136 | 289 | } | |
| 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]) | |
| 141 | 303 | } | |
| 142 | 304 | } | |
| 143 | 305 | return out | |
| @@ -176,8 +338,12 @@ func (s *fileSuggester) Down() { | |||
| 176 | 338 | } | |
| 177 | 339 | ||
| 178 | 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 | 343 | func (s *fileSuggester) Right() bool { | |
| 344 | + | if s.recursive { | |
| 345 | + | return false | |
| 346 | + | } | |
| 181 | 347 | m := s.lastMatches | |
| 182 | 348 | if len(m) == 0 || s.cursor >= len(m) { | |
| 183 | 349 | return false | |
| @@ -194,8 +360,11 @@ func (s *fileSuggester) Right() bool { | |||
| 194 | 360 | } | |
| 195 | 361 | ||
| 196 | 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 | 364 | func (s *fileSuggester) Left() bool { | |
| 365 | + | if s.recursive { | |
| 366 | + | return false | |
| 367 | + | } | |
| 199 | 368 | if s.browseRel == "" { | |
| 200 | 369 | 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 | 454 | 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 != "": | |
| 288 | 460 | hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel" | |
| 289 | - | } else { | |
| 461 | + | default: | |
| 290 | 462 | hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel" | |
| 291 | 463 | } | |
| 292 | 464 | 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 working | 32 | // 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 []fileEntry | 37 | lastMatches []fileEntry | ||
| 21 | cwd string // project root | 38 | 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 scanned | 40 | + | // recursive enables a whole-tree fuzzy search instead of |
| 24 | - | cachedAll []fileEntry | 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 | |||
| 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 and | 52 | // scan() compares the current mtime against this on every call and | ||
| 27 | // re-reads the directory if it has changed, so files or folders | 53 | // re-reads the directory if it has changed, so files or folders | ||
| @@ -37,7 +63,10 @@ type fileEntry struct { | |||||
| 37 | isDir bool | 63 | 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 rather | 121 | // 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.Time | 128 | 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 nil | 137 | return nil | ||
| 80 | } | 138 | } | ||
| 139 | + | var ig *ignore.Gitignore | |||
| 140 | + | if s.respectGitignore { | |||
| 141 | + | ig = ignore.Load(s.cwd) | |||
| 142 | + | } | |||
| 81 | var all []fileEntry | 143 | 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 all | 173 | 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 nil | 285 | return nil | ||
| 132 | } | 286 | } | ||
| 133 | - | needle := strings.ToLower(query) | 287 | + | if query == "" { |
| 134 | - | if needle == "" { | |||
| 135 | return all | 288 | return all | ||
| 136 | } | 289 | } | ||
| 137 | - | var out []fileEntry | 290 | + | 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 out | 305 | 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.lastMatches | 347 | m := s.lastMatches | ||
| 182 | if len(m) == 0 || s.cursor >= len(m) { | 348 | if len(m) == 0 || s.cursor >= len(m) { | ||
| 183 | return false | 349 | 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 false | 369 | 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 ( | |||
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "sort" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ) | |
| @@ -97,3 +98,216 @@ func (b byDirsFirst) Less(i, j int) bool { | |||
| 97 | 98 | } | |
| 98 | 99 | 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 | + | } | |
| @@ -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].name | 99 | 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 { | |||
| 46 | 46 | // re-reading config.json on every open. | |
| 47 | 47 | 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 | 59 | // ThemeName mirrors the persisted config theme value. Empty means auto. | |
| 50 | 60 | ThemeName string | |
| 51 | 61 | // ExtensionThemes returns themes bundled with loaded extensions. | |
| @@ -228,6 +238,8 @@ type chatCacheKey struct { | |||
| 228 | 238 | type SettingsStore interface { | |
| 229 | 239 | SetInlineImages(enabled bool) error | |
| 230 | 240 | SetAutoSwarm(enabled bool) error | |
| 241 | + | SetRecursiveFileSuggest(enabled bool) error | |
| 242 | + | SetRespectGitignore(enabled bool) error | |
| 231 | 243 | SetReasoning(level string) error | |
| 232 | 244 | SetTheme(name string) error | |
| 233 | 245 | } | |
| @@ -474,6 +486,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 474 | 486 | spin: newSpinner(cfg.Theme), | |
| 475 | 487 | inputHistoryIndex: -1, | |
| 476 | 488 | } | |
| 489 | + | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| 490 | + | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | |
| 477 | 491 | if cfg.Agent != nil { | |
| 478 | 492 | i.agent = cfg.Agent | |
| 479 | 493 | i.view.Messages = cfg.Agent.Messages() | |
| @@ -2622,6 +2636,9 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2622 | 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 | 2642 | reasoningOptions := []settingsOption{ | |
| 2626 | 2643 | {value: "", label: "off", desc: "no reasoning"}, | |
| 2627 | 2644 | {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"}, | |
| @@ -2685,6 +2702,18 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2685 | 2702 | disabled: autoSwarmDisabled, | |
| 2686 | 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 | 2718 | key: "reasoning", | |
| 2690 | 2719 | label: "thinking level", | |
| @@ -2767,6 +2796,40 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 2767 | 2796 | i.statusOK = "auto-swarm " + onOff(value) | |
| 2768 | 2797 | i.statusErr = "" | |
| 2769 | 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 | ||
| @@ -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 *bool | 47 | 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 string | 60 | 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) error | 239 | SetInlineImages(enabled bool) error | ||
| 230 | SetAutoSwarm(enabled bool) error | 240 | SetAutoSwarm(enabled bool) error | ||
| 241 | + | SetRecursiveFileSuggest(enabled bool) error | |||
| 242 | + | SetRespectGitignore(enabled bool) error | |||
| 231 | SetReasoning(level string) error | 243 | SetReasoning(level string) error | ||
| 232 | SetTheme(name string) error | 244 | 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.Agent | 492 | 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 { | |||
| 22 | 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 | 43 | func (configSettingsStore) SetReasoning(level string) error { | |
| 26 | 44 | cfg, err := LoadConfig() | |
| 27 | 45 | 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 | + | } | |||