feat(tui): fuzzy @-file matching with toggleable recursive search
The @-mention file picker previously did a plain case-insensitive substring match within a single directory, only reachable nesting via arrow-key drill-down. - Rank matches with sahilm/fuzzy (pinned v0.1.1 to avoid the go 1.24.5 directive in v0.1.2, which would exceed CI's Go 1.23). - Add a recursive mode that walks the whole project tree below cwd, matching cwd-relative paths (e.g. @foobar finds src/foo/bar.go), skipping heavy dirs (.git, node_modules, ...) and bounded by entry and depth caps. Arrow drill-down is disabled in this mode. - Persist as recursive_file_suggest in config.json, surfaced as a /settings checkbox, plumbed through SettingsStore/InteractiveConfig/ cli. Toggling live flips the picker without a restart. - Tests for fuzzy ranking, recursive cross-dir match, heavy-dir pruning, and cache reset on toggle.
- commit
- 7ac6034d1dbd750e0d7dcfcef45b1f69666ef149
- author
- Raymond Gasper · · 2026-06-09 19:44 UTC
- parent
- 15f76e0
- signature
- unsigned
8 files · +298 -14
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
| @@ -10,10 +10,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |||
| 10 | 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | |
| 11 | 11 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= | |
| 12 | 12 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= | |
| 13 | + | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= | |
| 14 | + | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= | |
| 13 | 15 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= | |
| 14 | 16 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= | |
| 15 | 17 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= | |
| 16 | 18 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | |
| 19 | + | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= | |
| 20 | + | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= | |
| 17 | 21 | golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= | |
| 18 | 22 | golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= | |
| 19 | 23 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= | |
| @@ -10,10 +10,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |||||
| 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||
| 11 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= | 11 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= | ||
| 12 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= | 12 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= | ||
| 13 | + | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= | |||
| 14 | + | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= | |||
| 13 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= | 15 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= | ||
| 14 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= | 16 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= | ||
| 15 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= | 17 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= | ||
| 16 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | 18 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | ||
| 19 | + | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= | |||
| 20 | + | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= | |||
| 17 | golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= | 21 | golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= | ||
| 18 | golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= | 22 | golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= | ||
| 19 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= | 23 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= | ||
M packages/agent/cli.go +1 -0
| @@ -944,6 +944,7 @@ 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, | |
| 947 | 948 | ThemeName: initialCfg.Theme, | |
| 948 | 949 | ExtensionThemes: extMgr.ThemeOptions, | |
| 949 | 950 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | |
| @@ -944,6 +944,7 @@ 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, | |||
| 947 | ThemeName: initialCfg.Theme, | 948 | ThemeName: initialCfg.Theme, | ||
| 948 | ExtensionThemes: extMgr.ThemeOptions, | 949 | ExtensionThemes: extMgr.ThemeOptions, | ||
| 949 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | 950 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | ||
M packages/agent/config.go +6 -0
| @@ -32,6 +32,12 @@ 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 | + | ||
| 35 | 41 | // LastChangelogShown is the version whose release-notes | |
| 36 | 42 | // dialog the user has already seen. When the running binary's | |
| 37 | 43 | // version differs, the next interactive run shows the | |
| @@ -32,6 +32,12 @@ 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 | + | ||||
| 35 | // LastChangelogShown is the version whose release-notes | 41 | // LastChangelogShown is the version whose release-notes | ||
| 36 | // dialog the user has already seen. When the running binary's | 42 | // dialog the user has already seen. When the running binary's | ||
| 37 | // version differs, the next interactive run shows the | 43 | // version differs, the next interactive run shows the | ||
M packages/agent/modes/file_suggest.go +146 -13
| @@ -8,9 +8,36 @@ import ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | "github.com/sahilm/fuzzy" | |
| 12 | + | ||
| 11 | 13 | "github.com/patriceckhart/zot/packages/tui" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| 16 | + | // recursiveScanLimits bound the recursive walk so the picker stays | |
| 17 | + | // responsive in very large repos. Hitting either cap stops the walk | |
| 18 | + | // early; the entries gathered so far are still searchable. | |
| 19 | + | const ( | |
| 20 | + | maxRecursiveEntries = 5000 | |
| 21 | + | maxRecursiveDepth = 12 | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // recursiveSkipDirs are directory names never descended into during a | |
| 25 | + | // recursive scan. They dominate the entry budget without being useful | |
| 26 | + | // @-mention targets. | |
| 27 | + | var recursiveSkipDirs = map[string]bool{ | |
| 28 | + | ".git": true, | |
| 29 | + | "node_modules": true, | |
| 30 | + | ".venv": true, | |
| 31 | + | "venv": true, | |
| 32 | + | "vendor": true, | |
| 33 | + | "target": true, | |
| 34 | + | "dist": true, | |
| 35 | + | "build": true, | |
| 36 | + | ".idea": true, | |
| 37 | + | ".vscode": true, | |
| 38 | + | "__pycache__": true, | |
| 39 | + | } | |
| 40 | + | ||
| 14 | 41 | // fileSuggester provides an @-triggered file/directory picker popup. | |
| 15 | 42 | // Type "@" followed by an optional filter to list files in the working | |
| 16 | 43 | // directory. Arrow up/down navigate, enter selects, esc cancels. | |
| @@ -20,8 +47,12 @@ type fileSuggester struct { | |||
| 20 | 47 | lastMatches []fileEntry | |
| 21 | 48 | cwd string // project root | |
| 22 | 49 | browseRel string // relative path from cwd we're currently browsing ("" = cwd itself) | |
| 23 | - | cachedDir string // absolute directory we last scanned | |
| 24 | - | cachedAll []fileEntry | |
| 50 | + | // recursive enables a whole-tree fuzzy search instead of | |
| 51 | + | // directory-by-directory browsing. Mirrors the persisted | |
| 52 | + | // recursive_file_suggest setting; toggled live from /settings. | |
| 53 | + | recursive bool | |
| 54 | + | cachedDir string // absolute directory we last scanned | |
| 55 | + | cachedAll []fileEntry | |
| 25 | 56 | // cachedMTime is the mtime of cachedDir at the time of the scan. | |
| 26 | 57 | // scan() compares the current mtime against this on every call and | |
| 27 | 58 | // re-reads the directory if it has changed, so files or folders | |
| @@ -49,6 +80,20 @@ func (s *fileSuggester) SetCWD(cwd string) { | |||
| 49 | 80 | } | |
| 50 | 81 | } | |
| 51 | 82 | ||
| 83 | + | // SetRecursive toggles whole-tree fuzzy search. Switching modes drops | |
| 84 | + | // the cache and resets the browse position so the next render reflects | |
| 85 | + | // the new mode immediately. | |
| 86 | + | func (s *fileSuggester) SetRecursive(on bool) { | |
| 87 | + | if s.recursive == on { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | s.recursive = on | |
| 91 | + | s.browseRel = "" | |
| 92 | + | s.cachedDir = "" | |
| 93 | + | s.cachedAll = nil | |
| 94 | + | s.cursor = 0 | |
| 95 | + | } | |
| 96 | + | ||
| 52 | 97 | // browseDir returns the absolute directory currently being browsed. | |
| 53 | 98 | func (s *fileSuggester) browseDir() string { | |
| 54 | 99 | if s.browseRel == "" { | |
| @@ -66,6 +111,9 @@ func (s *fileSuggester) browseDir() string { | |||
| 66 | 111 | // supports). A failed stat falls through to a fresh ReadDir rather | |
| 67 | 112 | // than returning a stale cache so transient errors self-heal. | |
| 68 | 113 | func (s *fileSuggester) scan() []fileEntry { | |
| 114 | + | if s.recursive { | |
| 115 | + | return s.scanRecursive() | |
| 116 | + | } | |
| 69 | 117 | dir := s.browseDir() | |
| 70 | 118 | var mtime time.Time | |
| 71 | 119 | if info, err := os.Stat(dir); err == nil { | |
| @@ -103,6 +151,68 @@ func (s *fileSuggester) scan() []fileEntry { | |||
| 103 | 151 | return all | |
| 104 | 152 | } | |
| 105 | 153 | ||
| 154 | + | // scanRecursive walks the whole project tree below cwd and returns | |
| 155 | + | // every file and directory as a fileEntry whose rel is the path | |
| 156 | + | // relative to cwd. Common heavy directories (.git, node_modules, ...) | |
| 157 | + | // are skipped, and the walk stops once it hits the entry/depth caps. | |
| 158 | + | // | |
| 159 | + | // Results are cached by cwd + mtime of cwd. Unlike the flat scan a | |
| 160 | + | // single mtime can't catch every nested change, so the cache is best | |
| 161 | + | // effort; Invalidate() (called on each keystroke path that matters) | |
| 162 | + | // and the explicit cache drops on toggle keep it fresh enough for an | |
| 163 | + | // interactive picker. | |
| 164 | + | func (s *fileSuggester) scanRecursive() []fileEntry { | |
| 165 | + | root := s.cwd | |
| 166 | + | var mtime time.Time | |
| 167 | + | if info, err := os.Stat(root); err == nil { | |
| 168 | + | mtime = info.ModTime() | |
| 169 | + | } | |
| 170 | + | if s.cachedDir == root && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) { | |
| 171 | + | return s.cachedAll | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | var all []fileEntry | |
| 175 | + | rootSep := strings.Count(root, string(os.PathSeparator)) | |
| 176 | + | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |
| 177 | + | if err != nil { | |
| 178 | + | if d != nil && d.IsDir() { | |
| 179 | + | return filepath.SkipDir | |
| 180 | + | } | |
| 181 | + | return nil | |
| 182 | + | } | |
| 183 | + | if path == root { | |
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | name := d.Name() | |
| 187 | + | if d.IsDir() { | |
| 188 | + | if recursiveSkipDirs[name] { | |
| 189 | + | return filepath.SkipDir | |
| 190 | + | } | |
| 191 | + | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth { | |
| 192 | + | return filepath.SkipDir | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | rel, relErr := filepath.Rel(root, path) | |
| 196 | + | if relErr != nil { | |
| 197 | + | return nil | |
| 198 | + | } | |
| 199 | + | all = append(all, fileEntry{ | |
| 200 | + | name: rel, | |
| 201 | + | rel: rel, | |
| 202 | + | isDir: d.IsDir(), | |
| 203 | + | }) | |
| 204 | + | if len(all) >= maxRecursiveEntries { | |
| 205 | + | return filepath.SkipAll | |
| 206 | + | } | |
| 207 | + | return nil | |
| 208 | + | }) | |
| 209 | + | ||
| 210 | + | s.cachedAll = all | |
| 211 | + | s.cachedDir = root | |
| 212 | + | s.cachedMTime = mtime | |
| 213 | + | return all | |
| 214 | + | } | |
| 215 | + | ||
| 106 | 216 | // extractAtQuery returns the filter string after "@". | |
| 107 | 217 | func extractAtQuery(input string) (string, bool) { | |
| 108 | 218 | input = strings.TrimRight(input, " ") | |
| @@ -121,6 +231,11 @@ func extractAtQuery(input string) (string, bool) { | |||
| 121 | 231 | } | |
| 122 | 232 | ||
| 123 | 233 | // matches returns file entries matching the current @-query. | |
| 234 | + | // | |
| 235 | + | // An empty query returns every entry in scan order. A non-empty query | |
| 236 | + | // is ranked with sahilm/fuzzy: in recursive mode the pattern is matched | |
| 237 | + | // against each entry's path relative to cwd (so "foobar" can find | |
| 238 | + | // "src/foo/bar.go"); in flat mode it matches the entry's display name. | |
| 124 | 239 | func (s *fileSuggester) matches(input string) []fileEntry { | |
| 125 | 240 | query, ok := extractAtQuery(input) | |
| 126 | 241 | if !ok { | |
| @@ -130,14 +245,22 @@ func (s *fileSuggester) matches(input string) []fileEntry { | |||
| 130 | 245 | if len(all) == 0 { | |
| 131 | 246 | return nil | |
| 132 | 247 | } | |
| 133 | - | needle := strings.ToLower(query) | |
| 134 | - | if needle == "" { | |
| 248 | + | if query == "" { | |
| 135 | 249 | return all | |
| 136 | 250 | } | |
| 137 | - | var out []fileEntry | |
| 138 | - | for _, e := range all { | |
| 139 | - | if strings.Contains(strings.ToLower(e.name), needle) { | |
| 140 | - | out = append(out, e) | |
| 251 | + | haystack := make([]string, len(all)) | |
| 252 | + | for i, e := range all { | |
| 253 | + | if s.recursive { | |
| 254 | + | haystack[i] = e.rel | |
| 255 | + | } else { | |
| 256 | + | haystack[i] = e.name | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ranked := fuzzy.Find(query, haystack) | |
| 260 | + | out := make([]fileEntry, 0, len(ranked)) | |
| 261 | + | for _, m := range ranked { | |
| 262 | + | if m.Index >= 0 && m.Index < len(all) { | |
| 263 | + | out = append(out, all[m.Index]) | |
| 141 | 264 | } | |
| 142 | 265 | } | |
| 143 | 266 | return out | |
| @@ -176,8 +299,12 @@ func (s *fileSuggester) Down() { | |||
| 176 | 299 | } | |
| 177 | 300 | ||
| 178 | 301 | // Right opens the selected directory, descending into it. | |
| 179 | - | // Returns true if a directory was entered. | |
| 302 | + | // Returns true if a directory was entered. Disabled in recursive mode, | |
| 303 | + | // where the whole tree is already flattened into the result list. | |
| 180 | 304 | func (s *fileSuggester) Right() bool { | |
| 305 | + | if s.recursive { | |
| 306 | + | return false | |
| 307 | + | } | |
| 181 | 308 | m := s.lastMatches | |
| 182 | 309 | if len(m) == 0 || s.cursor >= len(m) { | |
| 183 | 310 | return false | |
| @@ -194,8 +321,11 @@ func (s *fileSuggester) Right() bool { | |||
| 194 | 321 | } | |
| 195 | 322 | ||
| 196 | 323 | // Left goes back to the parent directory. | |
| 197 | - | // Returns true if we moved up. | |
| 324 | + | // Returns true if we moved up. Disabled in recursive mode. | |
| 198 | 325 | func (s *fileSuggester) Left() bool { | |
| 326 | + | if s.recursive { | |
| 327 | + | return false | |
| 328 | + | } | |
| 199 | 329 | if s.browseRel == "" { | |
| 200 | 330 | return false | |
| 201 | 331 | } | |
| @@ -283,10 +413,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string { | |||
| 283 | 413 | } | |
| 284 | 414 | ||
| 285 | 415 | out = append(out, "") | |
| 286 | - | hint := " \u2191/\u2193 navigate - enter select - esc cancel" | |
| 287 | - | if s.browseRel != "" { | |
| 416 | + | var hint string | |
| 417 | + | switch { | |
| 418 | + | case s.recursive: | |
| 419 | + | hint = " \u2191/\u2193 navigate - enter select - esc cancel (recursive)" | |
| 420 | + | case s.browseRel != "": | |
| 288 | 421 | hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel" | |
| 289 | - | } else { | |
| 422 | + | default: | |
| 290 | 423 | hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel" | |
| 291 | 424 | } | |
| 292 | 425 | out = append(out, th.FG256(th.Muted, hint)) | |
| @@ -8,9 +8,36 @@ import ( | |||||
| 8 | "strings" | 8 | "strings" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/sahilm/fuzzy" | |||
| 12 | + | ||||
| 11 | "github.com/patriceckhart/zot/packages/tui" | 13 | "github.com/patriceckhart/zot/packages/tui" | ||
| 12 | ) | 14 | ) | ||
| 13 | 15 | ||||
| 16 | + | // recursiveScanLimits bound the recursive walk so the picker stays | |||
| 17 | + | // responsive in very large repos. Hitting either cap stops the walk | |||
| 18 | + | // early; the entries gathered so far are still searchable. | |||
| 19 | + | const ( | |||
| 20 | + | maxRecursiveEntries = 5000 | |||
| 21 | + | maxRecursiveDepth = 12 | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // recursiveSkipDirs are directory names never descended into during a | |||
| 25 | + | // recursive scan. They dominate the entry budget without being useful | |||
| 26 | + | // @-mention targets. | |||
| 27 | + | var recursiveSkipDirs = map[string]bool{ | |||
| 28 | + | ".git": true, | |||
| 29 | + | "node_modules": true, | |||
| 30 | + | ".venv": true, | |||
| 31 | + | "venv": true, | |||
| 32 | + | "vendor": true, | |||
| 33 | + | "target": true, | |||
| 34 | + | "dist": true, | |||
| 35 | + | "build": true, | |||
| 36 | + | ".idea": true, | |||
| 37 | + | ".vscode": true, | |||
| 38 | + | "__pycache__": true, | |||
| 39 | + | } | |||
| 40 | + | ||||
| 14 | // fileSuggester provides an @-triggered file/directory picker popup. | 41 | // fileSuggester provides an @-triggered file/directory picker popup. | ||
| 15 | // Type "@" followed by an optional filter to list files in the working | 42 | // Type "@" followed by an optional filter to list files in the working | ||
| 16 | // directory. Arrow up/down navigate, enter selects, esc cancels. | 43 | // directory. Arrow up/down navigate, enter selects, esc cancels. | ||
| @@ -20,8 +47,12 @@ type fileSuggester struct { | |||||
| 20 | lastMatches []fileEntry | 47 | lastMatches []fileEntry | ||
| 21 | cwd string // project root | 48 | cwd string // project root | ||
| 22 | browseRel string // relative path from cwd we're currently browsing ("" = cwd itself) | 49 | browseRel string // relative path from cwd we're currently browsing ("" = cwd itself) | ||
| 23 | - | cachedDir string // absolute directory we last scanned | 50 | + | // recursive enables a whole-tree fuzzy search instead of |
| 24 | - | cachedAll []fileEntry | 51 | + | // directory-by-directory browsing. Mirrors the persisted |
| 52 | + | // recursive_file_suggest setting; toggled live from /settings. | |||
| 53 | + | recursive bool | |||
| 54 | + | cachedDir string // absolute directory we last scanned | |||
| 55 | + | cachedAll []fileEntry | |||
| 25 | // cachedMTime is the mtime of cachedDir at the time of the scan. | 56 | // cachedMTime is the mtime of cachedDir at the time of the scan. | ||
| 26 | // scan() compares the current mtime against this on every call and | 57 | // scan() compares the current mtime against this on every call and | ||
| 27 | // re-reads the directory if it has changed, so files or folders | 58 | // re-reads the directory if it has changed, so files or folders | ||
| @@ -49,6 +80,20 @@ func (s *fileSuggester) SetCWD(cwd string) { | |||||
| 49 | } | 80 | } | ||
| 50 | } | 81 | } | ||
| 51 | 82 | ||||
| 83 | + | // SetRecursive toggles whole-tree fuzzy search. Switching modes drops | |||
| 84 | + | // the cache and resets the browse position so the next render reflects | |||
| 85 | + | // the new mode immediately. | |||
| 86 | + | func (s *fileSuggester) SetRecursive(on bool) { | |||
| 87 | + | if s.recursive == on { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | s.recursive = on | |||
| 91 | + | s.browseRel = "" | |||
| 92 | + | s.cachedDir = "" | |||
| 93 | + | s.cachedAll = nil | |||
| 94 | + | s.cursor = 0 | |||
| 95 | + | } | |||
| 96 | + | ||||
| 52 | // browseDir returns the absolute directory currently being browsed. | 97 | // browseDir returns the absolute directory currently being browsed. | ||
| 53 | func (s *fileSuggester) browseDir() string { | 98 | func (s *fileSuggester) browseDir() string { | ||
| 54 | if s.browseRel == "" { | 99 | if s.browseRel == "" { | ||
| @@ -66,6 +111,9 @@ func (s *fileSuggester) browseDir() string { | |||||
| 66 | // supports). A failed stat falls through to a fresh ReadDir rather | 111 | // supports). A failed stat falls through to a fresh ReadDir rather | ||
| 67 | // than returning a stale cache so transient errors self-heal. | 112 | // than returning a stale cache so transient errors self-heal. | ||
| 68 | func (s *fileSuggester) scan() []fileEntry { | 113 | func (s *fileSuggester) scan() []fileEntry { | ||
| 114 | + | if s.recursive { | |||
| 115 | + | return s.scanRecursive() | |||
| 116 | + | } | |||
| 69 | dir := s.browseDir() | 117 | dir := s.browseDir() | ||
| 70 | var mtime time.Time | 118 | var mtime time.Time | ||
| 71 | if info, err := os.Stat(dir); err == nil { | 119 | if info, err := os.Stat(dir); err == nil { | ||
| @@ -103,6 +151,68 @@ func (s *fileSuggester) scan() []fileEntry { | |||||
| 103 | return all | 151 | return all | ||
| 104 | } | 152 | } | ||
| 105 | 153 | ||||
| 154 | + | // scanRecursive walks the whole project tree below cwd and returns | |||
| 155 | + | // every file and directory as a fileEntry whose rel is the path | |||
| 156 | + | // relative to cwd. Common heavy directories (.git, node_modules, ...) | |||
| 157 | + | // are skipped, and the walk stops once it hits the entry/depth caps. | |||
| 158 | + | // | |||
| 159 | + | // Results are cached by cwd + mtime of cwd. Unlike the flat scan a | |||
| 160 | + | // single mtime can't catch every nested change, so the cache is best | |||
| 161 | + | // effort; Invalidate() (called on each keystroke path that matters) | |||
| 162 | + | // and the explicit cache drops on toggle keep it fresh enough for an | |||
| 163 | + | // interactive picker. | |||
| 164 | + | func (s *fileSuggester) scanRecursive() []fileEntry { | |||
| 165 | + | root := s.cwd | |||
| 166 | + | var mtime time.Time | |||
| 167 | + | if info, err := os.Stat(root); err == nil { | |||
| 168 | + | mtime = info.ModTime() | |||
| 169 | + | } | |||
| 170 | + | if s.cachedDir == root && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) { | |||
| 171 | + | return s.cachedAll | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | var all []fileEntry | |||
| 175 | + | rootSep := strings.Count(root, string(os.PathSeparator)) | |||
| 176 | + | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |||
| 177 | + | if err != nil { | |||
| 178 | + | if d != nil && d.IsDir() { | |||
| 179 | + | return filepath.SkipDir | |||
| 180 | + | } | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | if path == root { | |||
| 184 | + | return nil | |||
| 185 | + | } | |||
| 186 | + | name := d.Name() | |||
| 187 | + | if d.IsDir() { | |||
| 188 | + | if recursiveSkipDirs[name] { | |||
| 189 | + | return filepath.SkipDir | |||
| 190 | + | } | |||
| 191 | + | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth { | |||
| 192 | + | return filepath.SkipDir | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | rel, relErr := filepath.Rel(root, path) | |||
| 196 | + | if relErr != nil { | |||
| 197 | + | return nil | |||
| 198 | + | } | |||
| 199 | + | all = append(all, fileEntry{ | |||
| 200 | + | name: rel, | |||
| 201 | + | rel: rel, | |||
| 202 | + | isDir: d.IsDir(), | |||
| 203 | + | }) | |||
| 204 | + | if len(all) >= maxRecursiveEntries { | |||
| 205 | + | return filepath.SkipAll | |||
| 206 | + | } | |||
| 207 | + | return nil | |||
| 208 | + | }) | |||
| 209 | + | ||||
| 210 | + | s.cachedAll = all | |||
| 211 | + | s.cachedDir = root | |||
| 212 | + | s.cachedMTime = mtime | |||
| 213 | + | return all | |||
| 214 | + | } | |||
| 215 | + | ||||
| 106 | // extractAtQuery returns the filter string after "@". | 216 | // extractAtQuery returns the filter string after "@". | ||
| 107 | func extractAtQuery(input string) (string, bool) { | 217 | func extractAtQuery(input string) (string, bool) { | ||
| 108 | input = strings.TrimRight(input, " ") | 218 | input = strings.TrimRight(input, " ") | ||
| @@ -121,6 +231,11 @@ func extractAtQuery(input string) (string, bool) { | |||||
| 121 | } | 231 | } | ||
| 122 | 232 | ||||
| 123 | // matches returns file entries matching the current @-query. | 233 | // matches returns file entries matching the current @-query. | ||
| 234 | + | // | |||
| 235 | + | // An empty query returns every entry in scan order. A non-empty query | |||
| 236 | + | // is ranked with sahilm/fuzzy: in recursive mode the pattern is matched | |||
| 237 | + | // against each entry's path relative to cwd (so "foobar" can find | |||
| 238 | + | // "src/foo/bar.go"); in flat mode it matches the entry's display name. | |||
| 124 | func (s *fileSuggester) matches(input string) []fileEntry { | 239 | func (s *fileSuggester) matches(input string) []fileEntry { | ||
| 125 | query, ok := extractAtQuery(input) | 240 | query, ok := extractAtQuery(input) | ||
| 126 | if !ok { | 241 | if !ok { | ||
| @@ -130,14 +245,22 @@ func (s *fileSuggester) matches(input string) []fileEntry { | |||||
| 130 | if len(all) == 0 { | 245 | if len(all) == 0 { | ||
| 131 | return nil | 246 | return nil | ||
| 132 | } | 247 | } | ||
| 133 | - | needle := strings.ToLower(query) | 248 | + | if query == "" { |
| 134 | - | if needle == "" { | |||
| 135 | return all | 249 | return all | ||
| 136 | } | 250 | } | ||
| 137 | - | var out []fileEntry | 251 | + | haystack := make([]string, len(all)) |
| 138 | - | for _, e := range all { | 252 | + | for i, e := range all { |
| 139 | - | if strings.Contains(strings.ToLower(e.name), needle) { | 253 | + | if s.recursive { |
| 140 | - | out = append(out, e) | 254 | + | haystack[i] = e.rel |
| 255 | + | } else { | |||
| 256 | + | haystack[i] = e.name | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ranked := fuzzy.Find(query, haystack) | |||
| 260 | + | out := make([]fileEntry, 0, len(ranked)) | |||
| 261 | + | for _, m := range ranked { | |||
| 262 | + | if m.Index >= 0 && m.Index < len(all) { | |||
| 263 | + | out = append(out, all[m.Index]) | |||
| 141 | } | 264 | } | ||
| 142 | } | 265 | } | ||
| 143 | return out | 266 | return out | ||
| @@ -176,8 +299,12 @@ func (s *fileSuggester) Down() { | |||||
| 176 | } | 299 | } | ||
| 177 | 300 | ||||
| 178 | // Right opens the selected directory, descending into it. | 301 | // Right opens the selected directory, descending into it. | ||
| 179 | - | // Returns true if a directory was entered. | 302 | + | // Returns true if a directory was entered. Disabled in recursive mode, |
| 303 | + | // where the whole tree is already flattened into the result list. | |||
| 180 | func (s *fileSuggester) Right() bool { | 304 | func (s *fileSuggester) Right() bool { | ||
| 305 | + | if s.recursive { | |||
| 306 | + | return false | |||
| 307 | + | } | |||
| 181 | m := s.lastMatches | 308 | m := s.lastMatches | ||
| 182 | if len(m) == 0 || s.cursor >= len(m) { | 309 | if len(m) == 0 || s.cursor >= len(m) { | ||
| 183 | return false | 310 | return false | ||
| @@ -194,8 +321,11 @@ func (s *fileSuggester) Right() bool { | |||||
| 194 | } | 321 | } | ||
| 195 | 322 | ||||
| 196 | // Left goes back to the parent directory. | 323 | // Left goes back to the parent directory. | ||
| 197 | - | // Returns true if we moved up. | 324 | + | // Returns true if we moved up. Disabled in recursive mode. |
| 198 | func (s *fileSuggester) Left() bool { | 325 | func (s *fileSuggester) Left() bool { | ||
| 326 | + | if s.recursive { | |||
| 327 | + | return false | |||
| 328 | + | } | |||
| 199 | if s.browseRel == "" { | 329 | if s.browseRel == "" { | ||
| 200 | return false | 330 | return false | ||
| 201 | } | 331 | } | ||
| @@ -283,10 +413,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string { | |||||
| 283 | } | 413 | } | ||
| 284 | 414 | ||||
| 285 | out = append(out, "") | 415 | out = append(out, "") | ||
| 286 | - | hint := " \u2191/\u2193 navigate - enter select - esc cancel" | 416 | + | var hint string |
| 287 | - | if s.browseRel != "" { | 417 | + | switch { |
| 418 | + | case s.recursive: | |||
| 419 | + | hint = " \u2191/\u2193 navigate - enter select - esc cancel (recursive)" | |||
| 420 | + | case s.browseRel != "": | |||
| 288 | hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel" | 421 | hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel" | ||
| 289 | - | } else { | 422 | + | default: |
| 290 | hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel" | 423 | hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel" | ||
| 291 | } | 424 | } | ||
| 292 | out = append(out, th.FG256(th.Muted, hint)) | 425 | out = append(out, th.FG256(th.Muted, hint)) | ||
M packages/agent/modes/file_suggest_test.go +96 -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,98 @@ 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 | + | // TestFileSuggesterToggleResetsCache verifies SetRecursive drops the | |
| 175 | + | // cached scan so the next matches() reflects the new mode. | |
| 176 | + | func TestFileSuggesterToggleResetsCache(t *testing.T) { | |
| 177 | + | tmp := t.TempDir() | |
| 178 | + | if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil { | |
| 179 | + | t.Fatal(err) | |
| 180 | + | } | |
| 181 | + | if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil { | |
| 182 | + | t.Fatal(err) | |
| 183 | + | } | |
| 184 | + | s := newFileSuggester() | |
| 185 | + | s.SetCWD(tmp) | |
| 186 | + | ||
| 187 | + | rel := filepath.Join("pkg", "nested.go") | |
| 188 | + | if containsEntry(s.matches("@nested"), rel, false) { | |
| 189 | + | t.Fatal("flat mode unexpectedly saw nested.go") | |
| 190 | + | } | |
| 191 | + | s.SetRecursive(true) | |
| 192 | + | if !containsEntry(s.matches("@nested"), rel, false) { | |
| 193 | + | t.Fatal("recursive mode did not surface nested.go after toggle") | |
| 194 | + | } | |
| 195 | + | } | |
| @@ -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,98 @@ 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 | + | // TestFileSuggesterToggleResetsCache verifies SetRecursive drops the | |||
| 175 | + | // cached scan so the next matches() reflects the new mode. | |||
| 176 | + | func TestFileSuggesterToggleResetsCache(t *testing.T) { | |||
| 177 | + | tmp := t.TempDir() | |||
| 178 | + | if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil { | |||
| 179 | + | t.Fatal(err) | |||
| 180 | + | } | |||
| 181 | + | if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil { | |||
| 182 | + | t.Fatal(err) | |||
| 183 | + | } | |||
| 184 | + | s := newFileSuggester() | |||
| 185 | + | s.SetCWD(tmp) | |||
| 186 | + | ||||
| 187 | + | rel := filepath.Join("pkg", "nested.go") | |||
| 188 | + | if containsEntry(s.matches("@nested"), rel, false) { | |||
| 189 | + | t.Fatal("flat mode unexpectedly saw nested.go") | |||
| 190 | + | } | |||
| 191 | + | s.SetRecursive(true) | |||
| 192 | + | if !containsEntry(s.matches("@nested"), rel, false) { | |||
| 193 | + | t.Fatal("recursive mode did not surface nested.go after toggle") | |||
| 194 | + | } | |||
| 195 | + | } | |||
M packages/agent/modes/interactive.go +33 -0
| @@ -46,6 +46,11 @@ 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 | + | ||
| 49 | 54 | // ThemeName mirrors the persisted config theme value. Empty means auto. | |
| 50 | 55 | ThemeName string | |
| 51 | 56 | // ExtensionThemes returns themes bundled with loaded extensions. | |
| @@ -228,6 +233,7 @@ type chatCacheKey struct { | |||
| 228 | 233 | type SettingsStore interface { | |
| 229 | 234 | SetInlineImages(enabled bool) error | |
| 230 | 235 | SetAutoSwarm(enabled bool) error | |
| 236 | + | SetRecursiveFileSuggest(enabled bool) error | |
| 231 | 237 | SetReasoning(level string) error | |
| 232 | 238 | SetTheme(name string) error | |
| 233 | 239 | } | |
| @@ -474,6 +480,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 474 | 480 | spin: newSpinner(cfg.Theme), | |
| 475 | 481 | inputHistoryIndex: -1, | |
| 476 | 482 | } | |
| 483 | + | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| 477 | 484 | if cfg.Agent != nil { | |
| 478 | 485 | i.agent = cfg.Agent | |
| 479 | 486 | i.view.Messages = cfg.Agent.Messages() | |
| @@ -2622,6 +2629,8 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2622 | 2629 | autoSwarmHint = "swarm supervisor not available in this mode" | |
| 2623 | 2630 | } | |
| 2624 | 2631 | ||
| 2632 | + | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| 2633 | + | ||
| 2625 | 2634 | reasoningOptions := []settingsOption{ | |
| 2626 | 2635 | {value: "", label: "off", desc: "no reasoning"}, | |
| 2627 | 2636 | {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"}, | |
| @@ -2685,6 +2694,12 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2685 | 2694 | disabled: autoSwarmDisabled, | |
| 2686 | 2695 | hint: autoSwarmHint, | |
| 2687 | 2696 | }, | |
| 2697 | + | { | |
| 2698 | + | key: "recursive_file_suggest", | |
| 2699 | + | label: "recursive @-file search", | |
| 2700 | + | desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time", | |
| 2701 | + | value: recursiveFiles, | |
| 2702 | + | }, | |
| 2688 | 2703 | { | |
| 2689 | 2704 | key: "reasoning", | |
| 2690 | 2705 | label: "thinking level", | |
| @@ -2767,6 +2782,24 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 2767 | 2782 | i.statusOK = "auto-swarm " + onOff(value) | |
| 2768 | 2783 | i.statusErr = "" | |
| 2769 | 2784 | i.mu.Unlock() | |
| 2785 | + | case "recursive_file_suggest": | |
| 2786 | + | val := value | |
| 2787 | + | i.cfg.RecursiveFileSuggest = &val | |
| 2788 | + | if i.cfg.SettingsStore != nil { | |
| 2789 | + | if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil { | |
| 2790 | + | i.mu.Lock() | |
| 2791 | + | i.statusErr = "settings: " + err.Error() | |
| 2792 | + | i.mu.Unlock() | |
| 2793 | + | return | |
| 2794 | + | } | |
| 2795 | + | } | |
| 2796 | + | // Flip the live picker so the next @ reflects the new mode | |
| 2797 | + | // without restarting zot. SetRecursive drops its cache. | |
| 2798 | + | i.fileSuggest.SetRecursive(value) | |
| 2799 | + | i.mu.Lock() | |
| 2800 | + | i.statusOK = "recursive @-file search " + onOff(value) | |
| 2801 | + | i.statusErr = "" | |
| 2802 | + | i.mu.Unlock() | |
| 2770 | 2803 | } | |
| 2771 | 2804 | } | |
| 2772 | 2805 | ||
| @@ -46,6 +46,11 @@ 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 | + | ||||
| 49 | // ThemeName mirrors the persisted config theme value. Empty means auto. | 54 | // ThemeName mirrors the persisted config theme value. Empty means auto. | ||
| 50 | ThemeName string | 55 | ThemeName string | ||
| 51 | // ExtensionThemes returns themes bundled with loaded extensions. | 56 | // ExtensionThemes returns themes bundled with loaded extensions. | ||
| @@ -228,6 +233,7 @@ type chatCacheKey struct { | |||||
| 228 | type SettingsStore interface { | 233 | type SettingsStore interface { | ||
| 229 | SetInlineImages(enabled bool) error | 234 | SetInlineImages(enabled bool) error | ||
| 230 | SetAutoSwarm(enabled bool) error | 235 | SetAutoSwarm(enabled bool) error | ||
| 236 | + | SetRecursiveFileSuggest(enabled bool) error | |||
| 231 | SetReasoning(level string) error | 237 | SetReasoning(level string) error | ||
| 232 | SetTheme(name string) error | 238 | SetTheme(name string) error | ||
| 233 | } | 239 | } | ||
| @@ -474,6 +480,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 474 | spin: newSpinner(cfg.Theme), | 480 | spin: newSpinner(cfg.Theme), | ||
| 475 | inputHistoryIndex: -1, | 481 | inputHistoryIndex: -1, | ||
| 476 | } | 482 | } | ||
| 483 | + | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |||
| 477 | if cfg.Agent != nil { | 484 | if cfg.Agent != nil { | ||
| 478 | i.agent = cfg.Agent | 485 | i.agent = cfg.Agent | ||
| 479 | i.view.Messages = cfg.Agent.Messages() | 486 | i.view.Messages = cfg.Agent.Messages() | ||
| @@ -2622,6 +2629,8 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 2622 | autoSwarmHint = "swarm supervisor not available in this mode" | 2629 | autoSwarmHint = "swarm supervisor not available in this mode" | ||
| 2623 | } | 2630 | } | ||
| 2624 | 2631 | ||||
| 2632 | + | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |||
| 2633 | + | ||||
| 2625 | reasoningOptions := []settingsOption{ | 2634 | reasoningOptions := []settingsOption{ | ||
| 2626 | {value: "", label: "off", desc: "no reasoning"}, | 2635 | {value: "", label: "off", desc: "no reasoning"}, | ||
| 2627 | {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"}, | 2636 | {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"}, | ||
| @@ -2685,6 +2694,12 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 2685 | disabled: autoSwarmDisabled, | 2694 | disabled: autoSwarmDisabled, | ||
| 2686 | hint: autoSwarmHint, | 2695 | hint: autoSwarmHint, | ||
| 2687 | }, | 2696 | }, | ||
| 2697 | + | { | |||
| 2698 | + | key: "recursive_file_suggest", | |||
| 2699 | + | label: "recursive @-file search", | |||
| 2700 | + | desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time", | |||
| 2701 | + | value: recursiveFiles, | |||
| 2702 | + | }, | |||
| 2688 | { | 2703 | { | ||
| 2689 | key: "reasoning", | 2704 | key: "reasoning", | ||
| 2690 | label: "thinking level", | 2705 | label: "thinking level", | ||
| @@ -2767,6 +2782,24 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 2767 | i.statusOK = "auto-swarm " + onOff(value) | 2782 | i.statusOK = "auto-swarm " + onOff(value) | ||
| 2768 | i.statusErr = "" | 2783 | i.statusErr = "" | ||
| 2769 | i.mu.Unlock() | 2784 | i.mu.Unlock() | ||
| 2785 | + | case "recursive_file_suggest": | |||
| 2786 | + | val := value | |||
| 2787 | + | i.cfg.RecursiveFileSuggest = &val | |||
| 2788 | + | if i.cfg.SettingsStore != nil { | |||
| 2789 | + | if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil { | |||
| 2790 | + | i.mu.Lock() | |||
| 2791 | + | i.statusErr = "settings: " + err.Error() | |||
| 2792 | + | i.mu.Unlock() | |||
| 2793 | + | return | |||
| 2794 | + | } | |||
| 2795 | + | } | |||
| 2796 | + | // Flip the live picker so the next @ reflects the new mode | |||
| 2797 | + | // without restarting zot. SetRecursive drops its cache. | |||
| 2798 | + | i.fileSuggest.SetRecursive(value) | |||
| 2799 | + | i.mu.Lock() | |||
| 2800 | + | i.statusOK = "recursive @-file search " + onOff(value) | |||
| 2801 | + | i.statusErr = "" | |||
| 2802 | + | i.mu.Unlock() | |||
| 2770 | } | 2803 | } | ||
| 2771 | } | 2804 | } | ||
| 2772 | 2805 | ||||
M packages/agent/settings_store.go +9 -0
| @@ -22,6 +22,15 @@ 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 | + | ||
| 25 | 34 | func (configSettingsStore) SetReasoning(level string) error { | |
| 26 | 35 | cfg, err := LoadConfig() | |
| 27 | 36 | if err != nil { | |
| @@ -22,6 +22,15 @@ 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 | + | ||||
| 25 | func (configSettingsStore) SetReasoning(level string) error { | 34 | func (configSettingsStore) SetReasoning(level string) error { | ||
| 26 | cfg, err := LoadConfig() | 35 | cfg, err := LoadConfig() | ||
| 27 | if err != nil { | 36 | if err != nil { | ||