feat(tui): apply .gitignore in both @-picker modes + add respect_gitignore toggle
Previously gitignore filtering ran only in recursive mode; the default flat directory browse showed .git/, node_modules/, etc. Apply it in both modes and make it user-controllable. - Flat scan() now also skips .git and gitignored entries. - New respectGitignore flag on the suggester (default on), persisted as respect_gitignore in config.json, surfaced as a /settings checkbox, and plumbed through SettingsStore/InteractiveConfig/cli. Toggling flips the picker live. - .git is always pruned in recursive mode regardless of the toggle, to protect the entry budget. - Tests for flat-mode filtering and the toggle across both modes.
- commit
- fb08ad382b82a1e0090df222c8358e6b57e09603
- author
- Raymond Gasper · · 2026-06-09 19:57 UTC
- parent
- 3ce9c28
- signature
- unsigned
6 files · +167 -9
M packages/agent/cli.go +1 -0
| @@ -945,6 +945,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 945 | 945 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 946 | 946 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 947 | 947 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 948 | + | RespectGitignore: initialCfg.RespectGitignore, | |
| 948 | 949 | ThemeName: initialCfg.Theme, | |
| 949 | 950 | ExtensionThemes: extMgr.ThemeOptions, | |
| 950 | 951 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | |
| @@ -945,6 +945,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 945 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | 945 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | ||
| 946 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | 946 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | ||
| 947 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | 947 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | ||
| 948 | + | RespectGitignore: initialCfg.RespectGitignore, | |||
| 948 | ThemeName: initialCfg.Theme, | 949 | ThemeName: initialCfg.Theme, | ||
| 949 | ExtensionThemes: extMgr.ThemeOptions, | 950 | ExtensionThemes: extMgr.ThemeOptions, | ||
| 950 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | 951 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | ||
M packages/agent/config.go +6 -0
| @@ -38,6 +38,12 @@ type Config struct { | |||
| 38 | 38 | // directory browse. Toggle from /settings. | |
| 39 | 39 | RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"` | |
| 40 | 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 | + | ||
| 41 | 47 | // LastChangelogShown is the version whose release-notes | |
| 42 | 48 | // dialog the user has already seen. When the running binary's | |
| 43 | 49 | // version differs, the next interactive run shows the | |
| @@ -38,6 +38,12 @@ type Config struct { | |||||
| 38 | // directory browse. Toggle from /settings. | 38 | // directory browse. Toggle from /settings. | ||
| 39 | RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"` | 39 | RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"` | ||
| 40 | 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 | + | ||||
| 41 | // LastChangelogShown is the version whose release-notes | 47 | // LastChangelogShown is the version whose release-notes | ||
| 42 | // dialog the user has already seen. When the running binary's | 48 | // dialog the user has already seen. When the running binary's | ||
| 43 | // version differs, the next interactive run shows the | 49 | // version differs, the next interactive run shows the | ||
M packages/agent/modes/file_suggest.go +49 -9
| @@ -41,8 +41,13 @@ type fileSuggester struct { | |||
| 41 | 41 | // directory-by-directory browsing. Mirrors the persisted | |
| 42 | 42 | // recursive_file_suggest setting; toggled live from /settings. | |
| 43 | 43 | recursive bool | |
| 44 | - | cachedDir string // absolute directory we last scanned | |
| 45 | - | cachedAll []fileEntry | |
| 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 | |
| 46 | 51 | // cachedMTime is the mtime of cachedDir at the time of the scan. | |
| 47 | 52 | // scan() compares the current mtime against this on every call and | |
| 48 | 53 | // re-reads the directory if it has changed, so files or folders | |
| @@ -58,7 +63,10 @@ type fileEntry struct { | |||
| 58 | 63 | isDir bool | |
| 59 | 64 | } | |
| 60 | 65 | ||
| 61 | - | 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} } | |
| 62 | 70 | ||
| 63 | 71 | // SetCWD updates the project root. | |
| 64 | 72 | func (s *fileSuggester) SetCWD(cwd string) { | |
| @@ -84,6 +92,18 @@ func (s *fileSuggester) SetRecursive(on bool) { | |||
| 84 | 92 | s.cursor = 0 | |
| 85 | 93 | } | |
| 86 | 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 | + | ||
| 87 | 107 | // browseDir returns the absolute directory currently being browsed. | |
| 88 | 108 | func (s *fileSuggester) browseDir() string { | |
| 89 | 109 | if s.browseRel == "" { | |
| @@ -116,6 +136,10 @@ func (s *fileSuggester) scan() []fileEntry { | |||
| 116 | 136 | if err != nil { | |
| 117 | 137 | return nil | |
| 118 | 138 | } | |
| 139 | + | var ig *ignore.Gitignore | |
| 140 | + | if s.respectGitignore { | |
| 141 | + | ig = ignore.Load(s.cwd) | |
| 142 | + | } | |
| 119 | 143 | var all []fileEntry | |
| 120 | 144 | for _, e := range entries { | |
| 121 | 145 | name := e.Name() | |
| @@ -123,6 +147,14 @@ func (s *fileSuggester) scan() []fileEntry { | |||
| 123 | 147 | if s.browseRel != "" { | |
| 124 | 148 | rel = filepath.Join(s.browseRel, name) | |
| 125 | 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 | + | } | |
| 126 | 158 | all = append(all, fileEntry{ | |
| 127 | 159 | name: name, | |
| 128 | 160 | rel: rel, | |
| @@ -163,7 +195,10 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||
| 163 | 195 | return s.cachedAll | |
| 164 | 196 | } | |
| 165 | 197 | ||
| 166 | - | ig := ignore.Load(root) | |
| 198 | + | var ig *ignore.Gitignore | |
| 199 | + | if s.respectGitignore { | |
| 200 | + | ig = ignore.Load(root) | |
| 201 | + | } | |
| 167 | 202 | var all []fileEntry | |
| 168 | 203 | rootSep := strings.Count(root, string(os.PathSeparator)) | |
| 169 | 204 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |
| @@ -180,14 +215,19 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||
| 180 | 215 | if relErr != nil { | |
| 181 | 216 | return nil | |
| 182 | 217 | } | |
| 183 | - | // .gitignore patterns are matched against slash-separated paths. | |
| 184 | - | if ig.Match(filepath.ToSlash(rel), d.IsDir()) { | |
| 185 | - | if d.IsDir() { | |
| 186 | - | return filepath.SkipDir | |
| 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 | |
| 187 | 225 | } | |
| 188 | - | return nil | |
| 189 | 226 | } | |
| 190 | 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. | |
| 191 | 231 | if d.Name() == alwaysSkipDir { | |
| 192 | 232 | return filepath.SkipDir | |
| 193 | 233 | } | |
| @@ -41,8 +41,13 @@ type fileSuggester struct { | |||||
| 41 | // directory-by-directory browsing. Mirrors the persisted | 41 | // directory-by-directory browsing. Mirrors the persisted | ||
| 42 | // recursive_file_suggest setting; toggled live from /settings. | 42 | // recursive_file_suggest setting; toggled live from /settings. | ||
| 43 | recursive bool | 43 | recursive bool | ||
| 44 | - | cachedDir string // absolute directory we last scanned | 44 | + | // respectGitignore drops entries matched by the project's root |
| 45 | - | cachedAll []fileEntry | 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 | |||
| 46 | // 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. | ||
| 47 | // scan() compares the current mtime against this on every call and | 52 | // scan() compares the current mtime against this on every call and | ||
| 48 | // 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 | ||
| @@ -58,7 +63,10 @@ type fileEntry struct { | |||||
| 58 | isDir bool | 63 | isDir bool | ||
| 59 | } | 64 | } | ||
| 60 | 65 | ||||
| 61 | - | 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} } | |||
| 62 | 70 | ||||
| 63 | // SetCWD updates the project root. | 71 | // SetCWD updates the project root. | ||
| 64 | func (s *fileSuggester) SetCWD(cwd string) { | 72 | func (s *fileSuggester) SetCWD(cwd string) { | ||
| @@ -84,6 +92,18 @@ func (s *fileSuggester) SetRecursive(on bool) { | |||||
| 84 | s.cursor = 0 | 92 | s.cursor = 0 | ||
| 85 | } | 93 | } | ||
| 86 | 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 | + | ||||
| 87 | // browseDir returns the absolute directory currently being browsed. | 107 | // browseDir returns the absolute directory currently being browsed. | ||
| 88 | func (s *fileSuggester) browseDir() string { | 108 | func (s *fileSuggester) browseDir() string { | ||
| 89 | if s.browseRel == "" { | 109 | if s.browseRel == "" { | ||
| @@ -116,6 +136,10 @@ func (s *fileSuggester) scan() []fileEntry { | |||||
| 116 | if err != nil { | 136 | if err != nil { | ||
| 117 | return nil | 137 | return nil | ||
| 118 | } | 138 | } | ||
| 139 | + | var ig *ignore.Gitignore | |||
| 140 | + | if s.respectGitignore { | |||
| 141 | + | ig = ignore.Load(s.cwd) | |||
| 142 | + | } | |||
| 119 | var all []fileEntry | 143 | var all []fileEntry | ||
| 120 | for _, e := range entries { | 144 | for _, e := range entries { | ||
| 121 | name := e.Name() | 145 | name := e.Name() | ||
| @@ -123,6 +147,14 @@ func (s *fileSuggester) scan() []fileEntry { | |||||
| 123 | if s.browseRel != "" { | 147 | if s.browseRel != "" { | ||
| 124 | rel = filepath.Join(s.browseRel, name) | 148 | rel = filepath.Join(s.browseRel, name) | ||
| 125 | } | 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 | + | } | |||
| 126 | all = append(all, fileEntry{ | 158 | all = append(all, fileEntry{ | ||
| 127 | name: name, | 159 | name: name, | ||
| 128 | rel: rel, | 160 | rel: rel, | ||
| @@ -163,7 +195,10 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||||
| 163 | return s.cachedAll | 195 | return s.cachedAll | ||
| 164 | } | 196 | } | ||
| 165 | 197 | ||||
| 166 | - | ig := ignore.Load(root) | 198 | + | var ig *ignore.Gitignore |
| 199 | + | if s.respectGitignore { | |||
| 200 | + | ig = ignore.Load(root) | |||
| 201 | + | } | |||
| 167 | var all []fileEntry | 202 | var all []fileEntry | ||
| 168 | rootSep := strings.Count(root, string(os.PathSeparator)) | 203 | rootSep := strings.Count(root, string(os.PathSeparator)) | ||
| 169 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | 204 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | ||
| @@ -180,14 +215,19 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||||
| 180 | if relErr != nil { | 215 | if relErr != nil { | ||
| 181 | return nil | 216 | return nil | ||
| 182 | } | 217 | } | ||
| 183 | - | // .gitignore patterns are matched against slash-separated paths. | 218 | + | if s.respectGitignore { |
| 184 | - | if ig.Match(filepath.ToSlash(rel), d.IsDir()) { | 219 | + | // .gitignore patterns are matched against slash-separated paths. |
| 185 | - | if d.IsDir() { | 220 | + | if ig.Match(filepath.ToSlash(rel), d.IsDir()) { |
| 186 | - | return filepath.SkipDir | 221 | + | if d.IsDir() { |
| 222 | + | return filepath.SkipDir | |||
| 223 | + | } | |||
| 224 | + | return nil | |||
| 187 | } | 225 | } | ||
| 188 | - | return nil | |||
| 189 | } | 226 | } | ||
| 190 | if d.IsDir() { | 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. | |||
| 191 | if d.Name() == alwaysSkipDir { | 231 | if d.Name() == alwaysSkipDir { | ||
| 192 | return filepath.SkipDir | 232 | return filepath.SkipDir | ||
| 193 | } | 233 | } | ||
M packages/agent/modes/file_suggest_test.go +72 -0
| @@ -217,6 +217,78 @@ func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) { | |||
| 217 | 217 | } | |
| 218 | 218 | } | |
| 219 | 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 | + | ||
| 220 | 292 | // TestFileSuggesterToggleResetsCache verifies SetRecursive drops the | |
| 221 | 293 | // cached scan so the next matches() reflects the new mode. | |
| 222 | 294 | func TestFileSuggesterToggleResetsCache(t *testing.T) { | |
| @@ -217,6 +217,78 @@ func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) { | |||||
| 217 | } | 217 | } | ||
| 218 | } | 218 | } | ||
| 219 | 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 | + | ||||
| 220 | // TestFileSuggesterToggleResetsCache verifies SetRecursive drops the | 292 | // TestFileSuggesterToggleResetsCache verifies SetRecursive drops the | ||
| 221 | // cached scan so the next matches() reflects the new mode. | 293 | // cached scan so the next matches() reflects the new mode. | ||
| 222 | func TestFileSuggesterToggleResetsCache(t *testing.T) { | 294 | func TestFileSuggesterToggleResetsCache(t *testing.T) { | ||
M packages/agent/modes/interactive.go +30 -0
| @@ -51,6 +51,11 @@ type InteractiveConfig struct { | |||
| 51 | 51 | // whole project tree instead of browsing one directory at a time. | |
| 52 | 52 | RecursiveFileSuggest *bool | |
| 53 | 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 | + | ||
| 54 | 59 | // ThemeName mirrors the persisted config theme value. Empty means auto. | |
| 55 | 60 | ThemeName string | |
| 56 | 61 | // ExtensionThemes returns themes bundled with loaded extensions. | |
| @@ -234,6 +239,7 @@ type SettingsStore interface { | |||
| 234 | 239 | SetInlineImages(enabled bool) error | |
| 235 | 240 | SetAutoSwarm(enabled bool) error | |
| 236 | 241 | SetRecursiveFileSuggest(enabled bool) error | |
| 242 | + | SetRespectGitignore(enabled bool) error | |
| 237 | 243 | SetReasoning(level string) error | |
| 238 | 244 | SetTheme(name string) error | |
| 239 | 245 | } | |
| @@ -481,6 +487,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 481 | 487 | inputHistoryIndex: -1, | |
| 482 | 488 | } | |
| 483 | 489 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| 490 | + | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | |
| 484 | 491 | if cfg.Agent != nil { | |
| 485 | 492 | i.agent = cfg.Agent | |
| 486 | 493 | i.view.Messages = cfg.Agent.Messages() | |
| @@ -2630,6 +2637,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2630 | 2637 | } | |
| 2631 | 2638 | ||
| 2632 | 2639 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| 2640 | + | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | |
| 2633 | 2641 | ||
| 2634 | 2642 | reasoningOptions := []settingsOption{ | |
| 2635 | 2643 | {value: "", label: "off", desc: "no reasoning"}, | |
| @@ -2700,6 +2708,12 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2700 | 2708 | desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time", | |
| 2701 | 2709 | value: recursiveFiles, | |
| 2702 | 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 | + | }, | |
| 2703 | 2717 | { | |
| 2704 | 2718 | key: "reasoning", | |
| 2705 | 2719 | label: "thinking level", | |
| @@ -2800,6 +2814,22 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 2800 | 2814 | i.statusOK = "recursive @-file search " + onOff(value) | |
| 2801 | 2815 | i.statusErr = "" | |
| 2802 | 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() | |
| 2803 | 2833 | } | |
| 2804 | 2834 | } | |
| 2805 | 2835 | ||
| @@ -51,6 +51,11 @@ type InteractiveConfig struct { | |||||
| 51 | // whole project tree instead of browsing one directory at a time. | 51 | // whole project tree instead of browsing one directory at a time. | ||
| 52 | RecursiveFileSuggest *bool | 52 | RecursiveFileSuggest *bool | ||
| 53 | 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 | + | ||||
| 54 | // ThemeName mirrors the persisted config theme value. Empty means auto. | 59 | // ThemeName mirrors the persisted config theme value. Empty means auto. | ||
| 55 | ThemeName string | 60 | ThemeName string | ||
| 56 | // ExtensionThemes returns themes bundled with loaded extensions. | 61 | // ExtensionThemes returns themes bundled with loaded extensions. | ||
| @@ -234,6 +239,7 @@ type SettingsStore interface { | |||||
| 234 | SetInlineImages(enabled bool) error | 239 | SetInlineImages(enabled bool) error | ||
| 235 | SetAutoSwarm(enabled bool) error | 240 | SetAutoSwarm(enabled bool) error | ||
| 236 | SetRecursiveFileSuggest(enabled bool) error | 241 | SetRecursiveFileSuggest(enabled bool) error | ||
| 242 | + | SetRespectGitignore(enabled bool) error | |||
| 237 | SetReasoning(level string) error | 243 | SetReasoning(level string) error | ||
| 238 | SetTheme(name string) error | 244 | SetTheme(name string) error | ||
| 239 | } | 245 | } | ||
| @@ -481,6 +487,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 481 | inputHistoryIndex: -1, | 487 | inputHistoryIndex: -1, | ||
| 482 | } | 488 | } | ||
| 483 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | 489 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | ||
| 490 | + | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | |||
| 484 | if cfg.Agent != nil { | 491 | if cfg.Agent != nil { | ||
| 485 | i.agent = cfg.Agent | 492 | i.agent = cfg.Agent | ||
| 486 | i.view.Messages = cfg.Agent.Messages() | 493 | i.view.Messages = cfg.Agent.Messages() | ||
| @@ -2630,6 +2637,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 2630 | } | 2637 | } | ||
| 2631 | 2638 | ||||
| 2632 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | 2639 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | ||
| 2640 | + | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | |||
| 2633 | 2641 | ||||
| 2634 | reasoningOptions := []settingsOption{ | 2642 | reasoningOptions := []settingsOption{ | ||
| 2635 | {value: "", label: "off", desc: "no reasoning"}, | 2643 | {value: "", label: "off", desc: "no reasoning"}, | ||
| @@ -2700,6 +2708,12 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 2700 | desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time", | 2708 | desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time", | ||
| 2701 | value: recursiveFiles, | 2709 | value: recursiveFiles, | ||
| 2702 | }, | 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 | + | }, | |||
| 2703 | { | 2717 | { | ||
| 2704 | key: "reasoning", | 2718 | key: "reasoning", | ||
| 2705 | label: "thinking level", | 2719 | label: "thinking level", | ||
| @@ -2800,6 +2814,22 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 2800 | i.statusOK = "recursive @-file search " + onOff(value) | 2814 | i.statusOK = "recursive @-file search " + onOff(value) | ||
| 2801 | i.statusErr = "" | 2815 | i.statusErr = "" | ||
| 2802 | i.mu.Unlock() | 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() | |||
| 2803 | } | 2833 | } | ||
| 2804 | } | 2834 | } | ||
| 2805 | 2835 | ||||
M packages/agent/settings_store.go +9 -0
| @@ -31,6 +31,15 @@ func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error { | |||
| 31 | 31 | return SaveConfig(cfg) | |
| 32 | 32 | } | |
| 33 | 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 | + | ||
| 34 | 43 | func (configSettingsStore) SetReasoning(level string) error { | |
| 35 | 44 | cfg, err := LoadConfig() | |
| 36 | 45 | if err != nil { | |
| @@ -31,6 +31,15 @@ func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error { | |||||
| 31 | return SaveConfig(cfg) | 31 | return SaveConfig(cfg) | ||
| 32 | } | 32 | } | ||
| 33 | 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 | + | ||||
| 34 | func (configSettingsStore) SetReasoning(level string) error { | 43 | func (configSettingsStore) SetReasoning(level string) error { | ||
| 35 | cfg, err := LoadConfig() | 44 | cfg, err := LoadConfig() | ||
| 36 | if err != nil { | 45 | if err != nil { | ||