v0.2.26 4c2e835

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
82func containsEntry(entries []fileEntry, name string, isDir bool) bool {
83	for _, e := range entries {
84		if e.name == name && e.isDir == isDir {
85			return true
86		}
87	}
88	return false
89}
90
91type byDirsFirst []fileEntry
92
93func (b byDirsFirst) Len() int      { return len(b) }
94func (b byDirsFirst) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
95func (b byDirsFirst) Less(i, j int) bool {
96	if b[i].isDir != b[j].isDir {
97		return b[i].isDir
98	}
99	return b[i].name < b[j].name
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".
105func 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.
127func 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.
148func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) {
149	tmp := t.TempDir()
150	if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil {
151		t.Fatal(err)
152	}
153	if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil {
154		t.Fatal(err)
155	}
156	if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
157		t.Fatal(err)
158	}
159	s := newFileSuggester()
160	s.SetCWD(tmp)
161	s.SetRecursive(true)
162
163	all := s.scan()
164	for _, e := range all {
165		if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) {
166			t.Fatalf("recursive scan descended into .git: %#v", e)
167		}
168	}
169	if !containsEntry(all, "main.go", false) {
170		t.Fatalf("recursive scan missing main.go: %#v", all)
171	}
172}
173
174// TestFileSuggesterRecursiveHonorsGitignore ensures the recursive walk
175// prunes anything listed in the project's root .gitignore — build
176// outputs, dependency dirs, and IaC tool caches like
177// .terraform/.terragrunt-cache — while still surfacing tracked files.
178func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) {
179	tmp := t.TempDir()
180	if err := os.WriteFile(filepath.Join(tmp, ".gitignore"),
181		[]byte(".terraform/\n.terragrunt-cache/\nnode_modules/\n*.log\n"), 0o644); err != nil {
182		t.Fatal(err)
183	}
184	ignored := []string{".terraform", ".terragrunt-cache", "node_modules"}
185	for _, dir := range ignored {
186		nested := filepath.Join(tmp, dir, "deep")
187		if err := os.MkdirAll(nested, 0o755); err != nil {
188			t.Fatal(err)
189		}
190		if err := os.WriteFile(filepath.Join(nested, "junk"), []byte("x"), 0o644); err != nil {
191			t.Fatal(err)
192		}
193	}
194	if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
195		t.Fatal(err)
196	}
197	if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil {
198		t.Fatal(err)
199	}
200	s := newFileSuggester()
201	s.SetCWD(tmp)
202	s.SetRecursive(true)
203
204	all := s.scan()
205	for _, e := range all {
206		for _, skip := range ignored {
207			if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) {
208				t.Fatalf("recursive scan descended into gitignored %s: %#v", skip, e)
209			}
210		}
211		if e.rel == "debug.log" {
212			t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e)
213		}
214	}
215	if !containsEntry(all, "main.tf", false) {
216		t.Fatalf("recursive scan missing tracked main.tf: %#v", all)
217	}
218}
219
220// TestFileSuggesterFlatModeHonorsGitignore verifies the default
221// directory-by-directory browse also hides gitignored entries and
222// always hides .git.
223func 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).
265func TestFileSuggesterRespectGitignoreToggle(t *testing.T) {
266	tmp := t.TempDir()
267	if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
268		t.Fatal(err)
269	}
270	if err := os.MkdirAll(filepath.Join(tmp, "dist"), 0o755); err != nil {
271		t.Fatal(err)
272	}
273	s := newFileSuggester()
274	s.SetCWD(tmp)
275
276	// On by default: dist/ hidden.
277	if containsEntry(s.scan(), "dist", true) {
278		t.Fatal("dist/ should be hidden while respectGitignore is on")
279	}
280	// Toggle off: dist/ visible (flat mode).
281	s.SetRespectGitignore(false)
282	if !containsEntry(s.scan(), "dist", true) {
283		t.Fatal("dist/ should be visible after disabling respectGitignore (flat)")
284	}
285	// And in recursive mode.
286	s.SetRecursive(true)
287	if !containsEntry(s.scan(), "dist", true) {
288		t.Fatal("dist/ should be visible after disabling respectGitignore (recursive)")
289	}
290}
291
292// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
293// cached scan so the next matches() reflects the new mode.
294func TestFileSuggesterToggleResetsCache(t *testing.T) {
295	tmp := t.TempDir()
296	if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil {
297		t.Fatal(err)
298	}
299	if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil {
300		t.Fatal(err)
301	}
302	s := newFileSuggester()
303	s.SetCWD(tmp)
304
305	rel := filepath.Join("pkg", "nested.go")
306	if containsEntry(s.matches("@nested"), rel, false) {
307		t.Fatal("flat mode unexpectedly saw nested.go")
308	}
309	s.SetRecursive(true)
310	if !containsEntry(s.matches("@nested"), rel, false) {
311		t.Fatal("recursive mode did not surface nested.go after toggle")
312	}
313}

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 477

↑↓ moveenter openesc close