v0.50.1 a637010

file_suggest_test.go RawHistoryFolder

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}

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close