1package modes
2
3import (
4 "os"
5 "path/filepath"
6 "sort"
7 "strings"
8 "testing"
9 "time"
10)
11
12func TestExpandFileChipsAtPickerShape(t *testing.T) {
13 cwd := filepath.Join(string(filepath.Separator), "repo")
14 in := "read [file:README.md] and [dir:docs/]"
15 want := "read " + filepath.Join(cwd, "README.md") + " and " + filepath.Join(cwd, "docs")
16 if got := expandFileChips(in, cwd); got != want {
17 t.Fatalf("expandFileChips() = %q, want %q", got, want)
18 }
19}
20
21func TestExpandFileChipsLeavesEditorPlaceholderShapeAlone(t *testing.T) {
22 cwd := filepath.Join(string(filepath.Separator), "repo")
23 // tui.Editor.SubmitValue should expand [file:N:name] using its
24 // private path map before modes see the text. If such a token leaks
25 // through, modes must not guess that "1:foo.txt" is a relative path.
26 in := "read [file:1:foo.txt]"
27 if got := expandFileChips(in, cwd); got != in {
28 t.Fatalf("editor placeholder was changed: %q", got)
29 }
30}
31
32// TestFileSuggesterPicksUpNewEntries pins the cache-invalidation bug
33// fix: creating a subdirectory after the picker has already scanned
34// the cwd must surface that subdirectory on the next scan, without
35// any explicit invalidation call. The cache is keyed on the dir's
36// mtime, which the OS bumps on every entry add/remove/rename.
37func TestFileSuggesterPicksUpNewEntries(t *testing.T) {
38 tmp := t.TempDir()
39 if err := os.WriteFile(filepath.Join(tmp, "existing.txt"), []byte("x"), 0o644); err != nil {
40 t.Fatal(err)
41 }
42
43 s := newFileSuggester()
44 s.SetCWD(tmp)
45
46 first := s.scan()
47 if !containsEntry(first, "existing.txt", false) {
48 t.Fatalf("first scan missing existing.txt: %#v", first)
49 }
50 if containsEntry(first, "test", true) {
51 t.Fatalf("first scan unexpectedly saw the not-yet-created test/: %#v", first)
52 }
53
54 // Sleep one filesystem tick: HFS+/APFS/ext4 all bump directory
55 // mtime with at-least 1s resolution depending on mount options.
56 // Without this sleep on coarse-resolution filesystems the mtime
57 // after Mkdir can equal the mtime captured during the first scan
58 // and the cache would (correctly) be retained.
59 time.Sleep(1100 * time.Millisecond)
60 if err := os.Mkdir(filepath.Join(tmp, "test"), 0o755); err != nil {
61 t.Fatal(err)
62 }
63
64 second := s.scan()
65 if !containsEntry(second, "test", true) {
66 t.Fatalf("second scan did not pick up the newly created test/: %#v", second)
67 }
68 if !containsEntry(second, "existing.txt", false) {
69 t.Fatalf("second scan dropped existing.txt: %#v", second)
70 }
71
72 // Directories sort before files.
73 sorted := make([]string, 0, len(second))
74 for _, e := range second {
75 sorted = append(sorted, e.name)
76 }
77 if !sort.IsSorted(byDirsFirst(second)) {
78 t.Fatalf("entries are not dirs-first / case-insensitive sorted: %v", sorted)
79 }
80}
81
82// TestFileSuggesterRecursiveHonorsNestedGitignore reproduces the
83// reported bug: a vendored tool directory (.opencode) carries its own
84// .gitignore that excludes node_modules, but the repo root .gitignore
85// says nothing about it. Before nested .gitignore support the
86// recursive walk surfaced thousands of node_modules files and a deeply
87// nested real source file (eda/rjg/enk-1150/pipeline.py) could not be
88// found. The walk must prune the nested-ignored tree and surface the
89// deep file.
90func TestFileSuggesterRecursiveHonorsNestedGitignore(t *testing.T) {
91 tmp := t.TempDir()
92 // Root .gitignore knows nothing about node_modules.
93 if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
94 t.Fatal(err)
95 }
96 // .opencode/.gitignore ignores its own node_modules.
97 opencodeNM := filepath.Join(tmp, ".opencode", "node_modules", "zod", "src")
98 if err := os.MkdirAll(opencodeNM, 0o755); err != nil {
99 t.Fatal(err)
100 }
101 if err := os.WriteFile(filepath.Join(tmp, ".opencode", ".gitignore"), []byte("node_modules\n"), 0o644); err != nil {
102 t.Fatal(err)
103 }
104 if err := os.WriteFile(filepath.Join(opencodeNM, "pipeline.test.ts"), []byte("x"), 0o644); err != nil {
105 t.Fatal(err)
106 }
107 // The deeply nested real file the user is hunting for.
108 deep := filepath.Join(tmp, "eda", "rjg", "enk-1150")
109 if err := os.MkdirAll(deep, 0o755); err != nil {
110 t.Fatal(err)
111 }
112 if err := os.WriteFile(filepath.Join(deep, "pipeline.py"), []byte("x"), 0o644); err != nil {
113 t.Fatal(err)
114 }
115
116 s := newFileSuggester()
117 s.SetCWD(tmp)
118 s.SetRecursive(true)
119
120 all := s.scan()
121 for _, e := range all {
122 if strings.Contains(filepath.ToSlash(e.rel), "node_modules") {
123 t.Fatalf("recursive scan surfaced nested-gitignored node_modules: %#v", e)
124 }
125 }
126 rel := filepath.Join("eda", "rjg", "enk-1150", "pipeline.py")
127 if !containsEntry(all, rel, false) {
128 t.Fatalf("recursive scan missing deep pipeline.py: %#v", all)
129 }
130 // The fuzzy query should now find the real file.
131 got := s.matches("@pipeline.py")
132 if !containsEntry(got, rel, false) {
133 t.Fatalf("@pipeline.py did not match the deep file: %#v", got)
134 }
135}
136
137func containsEntry(entries []fileEntry, name string, isDir bool) bool {
138 for _, e := range entries {
139 if e.name == name && e.isDir == isDir {
140 return true
141 }
142 }
143 return false
144}
145
146type byDirsFirst []fileEntry
147
148func (b byDirsFirst) Len() int { return len(b) }
149func (b byDirsFirst) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
150func (b byDirsFirst) Less(i, j int) bool {
151 if b[i].isDir != b[j].isDir {
152 return b[i].isDir
153 }
154 return b[i].name < b[j].name
155}
156
157// TestFileSuggesterFuzzyMatch verifies the @-query ranks entries with
158// a fuzzy subsequence match rather than a plain substring, so a
159// non-contiguous pattern like "fsg" still finds "file_suggest.go".
160func TestFileSuggesterFuzzyMatch(t *testing.T) {
161 tmp := t.TempDir()
162 for _, name := range []string{"file_suggest.go", "interactive.go", "README.md"} {
163 if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
164 t.Fatal(err)
165 }
166 }
167 s := newFileSuggester()
168 s.SetCWD(tmp)
169
170 got := s.matches("@fsg")
171 if !containsEntry(got, "file_suggest.go", false) {
172 t.Fatalf("fuzzy query @fsg did not match file_suggest.go: %#v", got)
173 }
174 if len(got) == 0 || got[0].name != "file_suggest.go" {
175 t.Fatalf("file_suggest.go not ranked first for @fsg: %#v", got)
176 }
177}
178
179// TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter reproduces the
180// reported bug: in flat (non-recursive) mode, typing "@eda" to find a
181// directory then opening it with Right must show that directory's
182// contents, not re-apply the "eda" filter inside it (which matches
183// nothing). The interactive layer clears the @-query when descending,
184// so here we model that by browsing with Right and then matching an
185// empty query against the new level.
186func TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter(t *testing.T) {
187 tmp := t.TempDir()
188 // eda/rjg/enk-1150 with a file inside, plus a sibling so the filter
189 // is meaningful at the top level.
190 if err := os.MkdirAll(filepath.Join(tmp, "eda", "rjg", "enk-1150"), 0o755); err != nil {
191 t.Fatal(err)
192 }
193 if err := os.WriteFile(filepath.Join(tmp, "eda", "rjg", "enk-1150", "pipeline.py"), []byte("x"), 0o644); err != nil {
194 t.Fatal(err)
195 }
196 if err := os.MkdirAll(filepath.Join(tmp, "unrelated"), 0o755); err != nil {
197 t.Fatal(err)
198 }
199
200 s := newFileSuggester()
201 s.SetCWD(tmp) // flat mode
202
203 // Top level: "@eda" highlights the eda/ directory. Render populates
204 // lastMatches, which Right/Left act on (it runs every frame before
205 // key handling in the live UI).
206 s.lastMatches = s.matches("@eda")
207 if !containsEntry(s.lastMatches, "eda", true) {
208 t.Fatalf("@eda did not match eda/: %#v", s.lastMatches)
209 }
210 s.cursor = 0 // eda/ is the (only) match, selected.
211
212 // Open it. After the interactive layer clears the query, the picker
213 // is browsing eda/ with an empty filter and must show rjg/.
214 if !s.Right() {
215 t.Fatal("Right() did not open eda/")
216 }
217 if got := s.matches("@"); !containsEntry(got, "rjg", true) {
218 t.Fatalf("after opening eda/, empty filter did not show rjg/: %#v", got)
219 }
220 // The stale filter would have shown nothing: confirm that's the
221 // behavior the fix avoids.
222 if got := s.matches("@eda"); len(got) != 0 {
223 t.Fatalf("stale @eda filter inside eda/ unexpectedly matched: %#v", got)
224 }
225}
226
227// TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
228// tree and matches against the cwd-relative path, so a pattern can
229// span directory boundaries.
230func TestFileSuggesterRecursiveMatch(t *testing.T) {
231 tmp := t.TempDir()
232 if err := os.MkdirAll(filepath.Join(tmp, "src", "foo"), 0o755); err != nil {
233 t.Fatal(err)
234 }
235 if err := os.WriteFile(filepath.Join(tmp, "src", "foo", "bar.go"), []byte("x"), 0o644); err != nil {
236 t.Fatal(err)
237 }
238 s := newFileSuggester()
239 s.SetCWD(tmp)
240 s.SetRecursive(true)
241
242 rel := filepath.Join("src", "foo", "bar.go")
243 got := s.matches("@foobar")
244 if !containsEntry(got, rel, false) {
245 t.Fatalf("recursive @foobar did not match %s: %#v", rel, got)
246 }
247}
248
249// TestFileSuggesterRecursiveSkipsHeavyDirs ensures the walk prunes
250// directories like .git that would otherwise dominate the budget.
251func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) {
252 tmp := t.TempDir()
253 if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil {
254 t.Fatal(err)
255 }
256 if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil {
257 t.Fatal(err)
258 }
259 if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
260 t.Fatal(err)
261 }
262 s := newFileSuggester()
263 s.SetCWD(tmp)
264 s.SetRecursive(true)
265
266 all := s.scan()
267 for _, e := range all {
268 if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) {
269 t.Fatalf("recursive scan descended into .git: %#v", e)
270 }
271 }
272 if !containsEntry(all, "main.go", false) {
273 t.Fatalf("recursive scan missing main.go: %#v", all)
274 }
275}
276
277// TestFileSuggesterRecursiveHonorsGitignore ensures the recursive walk
278// prunes anything listed in the project's root .gitignore — build
279// outputs, dependency dirs, and IaC tool caches like
280// .terraform/.terragrunt-cache — while still surfacing tracked files.
281func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) {
282 tmp := t.TempDir()
283 if err := os.WriteFile(filepath.Join(tmp, ".gitignore"),
284 []byte(".terraform/\n.terragrunt-cache/\nnode_modules/\n*.log\n"), 0o644); err != nil {
285 t.Fatal(err)
286 }
287 ignored := []string{".terraform", ".terragrunt-cache", "node_modules"}
288 for _, dir := range ignored {
289 nested := filepath.Join(tmp, dir, "deep")
290 if err := os.MkdirAll(nested, 0o755); err != nil {
291 t.Fatal(err)
292 }
293 if err := os.WriteFile(filepath.Join(nested, "junk"), []byte("x"), 0o644); err != nil {
294 t.Fatal(err)
295 }
296 }
297 if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
298 t.Fatal(err)
299 }
300 if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil {
301 t.Fatal(err)
302 }
303 s := newFileSuggester()
304 s.SetCWD(tmp)
305 s.SetRecursive(true)
306
307 all := s.scan()
308 for _, e := range all {
309 for _, skip := range ignored {
310 if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) {
311 t.Fatalf("recursive scan descended into gitignored %s: %#v", skip, e)
312 }
313 }
314 if e.rel == "debug.log" {
315 t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e)
316 }
317 }
318 if !containsEntry(all, "main.tf", false) {
319 t.Fatalf("recursive scan missing tracked main.tf: %#v", all)
320 }
321}
322
323// TestFileSuggesterFlatModeHonorsGitignore verifies the default
324// directory-by-directory browse also hides gitignored entries and
325// always hides .git.
326func TestFileSuggesterFlatModeHonorsGitignore(t *testing.T) {
327 tmp := t.TempDir()
328 if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("node_modules/\n*.log\n"), 0o644); err != nil {
329 t.Fatal(err)
330 }
331 if err := os.MkdirAll(filepath.Join(tmp, "node_modules"), 0o755); err != nil {
332 t.Fatal(err)
333 }
334 if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil {
335 t.Fatal(err)
336 }
337 if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
338 t.Fatal(err)
339 }
340 if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
341 t.Fatal(err)
342 }
343 s := newFileSuggester()
344 s.SetCWD(tmp) // flat mode, respectGitignore on by default
345
346 all := s.scan()
347 if containsEntry(all, "node_modules", true) {
348 t.Fatal("flat scan surfaced gitignored node_modules/")
349 }
350 if containsEntry(all, ".git", true) {
351 t.Fatal("flat scan surfaced .git/")
352 }
353 if containsEntry(all, "debug.log", false) {
354 t.Fatal("flat scan surfaced gitignored *.log file")
355 }
356 if !containsEntry(all, "main.go", false) {
357 t.Fatalf("flat scan missing tracked main.go: %#v", all)
358 }
359 // .gitignore itself is not ignored, so it should remain visible.
360 if !containsEntry(all, ".gitignore", false) {
361 t.Fatalf("flat scan missing .gitignore: %#v", all)
362 }
363}
364
365// TestFileSuggesterRespectGitignoreToggle verifies disabling the
366// setting surfaces gitignored entries in both modes (while .git stays
367// hidden in recursive mode to protect the entry budget).
368func TestFileSuggesterRespectGitignoreToggle(t *testing.T) {
369 tmp := t.TempDir()
370 if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
371 t.Fatal(err)
372 }
373 if err := os.MkdirAll(filepath.Join(tmp, "dist"), 0o755); err != nil {
374 t.Fatal(err)
375 }
376 s := newFileSuggester()
377 s.SetCWD(tmp)
378
379 // On by default: dist/ hidden.
380 if containsEntry(s.scan(), "dist", true) {
381 t.Fatal("dist/ should be hidden while respectGitignore is on")
382 }
383 // Toggle off: dist/ visible (flat mode).
384 s.SetRespectGitignore(false)
385 if !containsEntry(s.scan(), "dist", true) {
386 t.Fatal("dist/ should be visible after disabling respectGitignore (flat)")
387 }
388 // And in recursive mode.
389 s.SetRecursive(true)
390 if !containsEntry(s.scan(), "dist", true) {
391 t.Fatal("dist/ should be visible after disabling respectGitignore (recursive)")
392 }
393}
394
395// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
396// cached scan so the next matches() reflects the new mode.
397func TestFileSuggesterToggleResetsCache(t *testing.T) {
398 tmp := t.TempDir()
399 if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil {
400 t.Fatal(err)
401 }
402 if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil {
403 t.Fatal(err)
404 }
405 s := newFileSuggester()
406 s.SetCWD(tmp)
407
408 rel := filepath.Join("pkg", "nested.go")
409 if containsEntry(s.matches("@nested"), rel, false) {
410 t.Fatal("flat mode unexpectedly saw nested.go")
411 }
412 s.SetRecursive(true)
413 if !containsEntry(s.matches("@nested"), rel, false) {
414 t.Fatal("recursive mode did not surface nested.go after toggle")
415 }
416}