1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFSP from "node:fs/promises";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { FileFinder } from "@ff-labs/fff-node";
5import { it, afterEach, describe, expect } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import * as PlatformError from "effect/PlatformError";
11import { vi } from "vite-plus/test";
12
13import * as ServerConfig from "../config.ts";
14import * as HostProcess from "@t3tools/shared/HostProcess";
15import * as VcsProcess from "../vcs/VcsProcess.ts";
16import * as WorkspaceEntries from "./WorkspaceEntries.ts";
17import * as WorkspacePaths from "./WorkspacePaths.ts";
18
19vi.mock("node:fs/promises", async (importOriginal) => {
20 const actual = await importOriginal<typeof import("node:fs/promises")>();
21 return { ...actual, readdir: vi.fn(actual.readdir) };
22});
23
24const layerTest = Layer.empty.pipe(
25 Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))),
26 Layer.provideMerge(WorkspacePaths.layer),
27 Layer.provideMerge(VcsProcess.layer),
28 Layer.provide(
29 ServerConfig.ServerConfig.layerTest(process.cwd(), {
30 prefix: "t3-workspace-entries-test-",
31 }),
32 ),
33 Layer.provideMerge(NodeServices.layer),
34);
35
36const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) {
37 const fileSystem = yield* FileSystem.FileSystem;
38 const dir = yield* fileSystem.makeTempDirectoryScoped({
39 prefix: opts?.prefix ?? "t3code-workspace-entries-",
40 });
41 if (opts?.git) {
42 yield* git(dir, ["init"]);
43 }
44 return dir;
45});
46
47function writeTextFile(
48 cwd: string,
49 relativePath: string,
50 contents = "",
51): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {
52 return Effect.gen(function* () {
53 const fileSystem = yield* FileSystem.FileSystem;
54 const path = yield* Path.Path;
55 const absolutePath = path.join(cwd, relativePath);
56 yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true });
57 yield* fileSystem.writeFileString(absolutePath, contents);
58 });
59}
60
61const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) =>
62 Effect.gen(function* () {
63 const process = yield* VcsProcess.VcsProcess;
64 const result = yield* process.run({
65 operation: "WorkspaceEntries.test.git",
66 command: "git",
67 cwd,
68 args,
69 ...(env ? { env } : {}),
70 timeoutMs: 10_000,
71 });
72 return result.stdout.trim();
73 });
74
75const searchWorkspaceEntries = (input: {
76 cwd: string;
77 query: string;
78 limit: number;
79 kind?: "file" | "directory";
80}) =>
81 Effect.gen(function* () {
82 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
83 return yield* workspaceEntries.search(input);
84 });
85
86const appendSeparator = (input: string) =>
87 Effect.map(HostProcess.Platform, (platform) =>
88 input.endsWith("/") || input.endsWith("\\")
89 ? input
90 : `${input}${platform === "win32" ? "\\" : "/"}`,
91 );
92
93it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
94 afterEach(() => {
95 vi.restoreAllMocks();
96 });
97
98 describe("list", () => {
99 it.effect("lists immediate children including ignored and empty directories", () =>
100 Effect.gen(function* () {
101 const cwd = yield* makeTempDir({ git: true });
102 const fileSystem = yield* FileSystem.FileSystem;
103 const path = yield* Path.Path;
104 yield* writeTextFile(cwd, "tracked.txt");
105 yield* git(cwd, ["add", "tracked.txt"]);
106 yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env\ntracked.txt\n");
107 yield* writeTextFile(cwd, ".env", "secret=value");
108 yield* writeTextFile(cwd, "node_modules/pkg/index.js");
109 yield* writeTextFile(cwd, "src/index.ts");
110 yield* fileSystem.makeDirectory(path.join(cwd, "empty"));
111 yield* git(cwd, ["init", "sub"]);
112 yield* writeTextFile(cwd, "sub/.gitignore", "dist/\n");
113 yield* writeTextFile(cwd, "sub/dist/out.js");
114 const gitlink = yield* git(cwd, ["hash-object", "tracked.txt"]);
115 yield* git(cwd, ["update-index", "--add", "--cacheinfo", `160000,${gitlink},sub`]);
116 // A nested clone that isn't a submodule keeps the outer repository's ignore rules.
117 yield* git(cwd, ["init", "node_modules/pkg"]);
118 yield* git(cwd, ["init", "sub/dist"]);
119
120 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
121 const root = yield* workspaceEntries.list({ cwd, directoryPath: "" });
122 expect(root.entries).toEqual(
123 expect.arrayContaining([
124 { path: ".env", kind: "file", ignored: true },
125 { path: "node_modules", kind: "directory", ignored: true },
126 { path: "src", kind: "directory" },
127 { path: "empty", kind: "directory" },
128 { path: "tracked.txt", kind: "file" },
129 ]),
130 );
131 expect(root.entries.some((entry) => entry.path.includes("/"))).toBe(false);
132 expect(root.entries.some((entry) => entry.path === ".git")).toBe(false);
133 expect(root.truncated).toBe(false);
134 expect(yield* workspaceEntries.list({ cwd, directoryPath: "node_modules/pkg" })).toEqual({
135 entries: [{ path: "node_modules/pkg/index.js", kind: "file", ignored: true }],
136 truncated: false,
137 });
138 // Git refuses pathspecs inside a submodule from the outer repository.
139 expect((yield* workspaceEntries.list({ cwd, directoryPath: "sub" })).entries).toEqual(
140 expect.arrayContaining([
141 { path: "sub/.gitignore", kind: "file" },
142 { path: "sub/dist", kind: "directory", ignored: true },
143 ]),
144 );
145 expect(yield* workspaceEntries.list({ cwd, directoryPath: "sub/dist" })).toEqual({
146 entries: [{ path: "sub/dist/out.js", kind: "file", ignored: true }],
147 truncated: false,
148 });
149 expect(yield* workspaceEntries.list({ cwd, directoryPath: "empty" })).toEqual({
150 entries: [],
151 truncated: false,
152 });
153 }),
154 );
155
156 it.effect("lists symlinks as what they point to, hiding ones that cannot be opened", () =>
157 Effect.gen(function* () {
158 const platform = yield* HostProcess.Platform;
159 if (platform === "win32") return;
160 const cwd = yield* makeTempDir({ git: true });
161 const outside = yield* makeTempDir();
162 const fileSystem = yield* FileSystem.FileSystem;
163 const path = yield* Path.Path;
164 yield* writeTextFile(cwd, "real.txt");
165 yield* writeTextFile(cwd, "realdir/nested.txt");
166 yield* writeTextFile(outside, "outside.txt");
167 yield* fileSystem.symlink("real.txt", path.join(cwd, "link.txt"));
168 yield* fileSystem.symlink("realdir", path.join(cwd, "linkdir"));
169 yield* fileSystem.symlink(outside, path.join(cwd, "outside-link"));
170 yield* fileSystem.symlink(".git", path.join(cwd, "git-link"));
171 yield* fileSystem.symlink("missing", path.join(cwd, "dangling"));
172
173 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
174 const root = yield* workspaceEntries.list({ cwd, directoryPath: "" });
175 expect(root.entries.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
176 { path: "link.txt", kind: "file" },
177 { path: "linkdir", kind: "directory" },
178 { path: "real.txt", kind: "file" },
179 { path: "realdir", kind: "directory" },
180 ]);
181 expect(yield* workspaceEntries.list({ cwd, directoryPath: "linkdir" })).toEqual({
182 entries: [{ path: "linkdir/nested.txt", kind: "file" }],
183 truncated: false,
184 });
185 }),
186 );
187
188 it.effect("hides folder links that lead back to a folder on the listed path", () =>
189 // "Expand all folders" loads every folder it expands, so a link that loops would
190 // keep nesting the same entries without end.
191 Effect.gen(function* () {
192 const platform = yield* HostProcess.Platform;
193 if (platform === "win32") return;
194 const cwd = yield* makeTempDir();
195 const fileSystem = yield* FileSystem.FileSystem;
196 const path = yield* Path.Path;
197 yield* writeTextFile(cwd, "a/file.txt");
198 yield* writeTextFile(cwd, "b/file.txt");
199 yield* fileSystem.symlink(".", path.join(cwd, "self"));
200 yield* fileSystem.symlink("..", path.join(cwd, "a/up"));
201 yield* fileSystem.symlink("../b", path.join(cwd, "a/to-b"));
202 yield* fileSystem.symlink("../a", path.join(cwd, "b/to-a"));
203
204 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
205 const paths = (directoryPath: string) =>
206 workspaceEntries
207 .list({ cwd, directoryPath })
208 .pipe(Effect.map(({ entries }) => entries.map((entry) => entry.path).toSorted()));
209 expect(yield* paths("")).toEqual(["a", "b"]);
210 // A link into a sibling folder is fine on its own...
211 expect(yield* paths("a")).toEqual(["a/file.txt", "a/to-b"]);
212 // ...but once followed, the sibling's link back is hidden.
213 expect(yield* paths("a/to-b")).toEqual(["a/to-b/file.txt"]);
214 }),
215 );
216
217 it.effect("resolves a folder of many symlinks in bounded batches", () =>
218 Effect.gen(function* () {
219 const platform = yield* HostProcess.Platform;
220 if (platform === "win32") return;
221 const cwd = yield* makeTempDir();
222 const fileSystem = yield* FileSystem.FileSystem;
223 const path = yield* Path.Path;
224 // Shaped like a pnpm `node_modules`: one link per package into `.pnpm`.
225 const directoryPath = [...Array.from({ length: 80 }, () => "nested"), "node_modules"].join(
226 "/",
227 );
228 const packages = Array.from({ length: 200 }, (_, index) => `pkg-${index}`);
229 yield* Effect.forEach(
230 packages,
231 (name) =>
232 Effect.gen(function* () {
233 yield* writeTextFile(cwd, `${directoryPath}/.pnpm/${name}/index.js`);
234 yield* fileSystem.symlink(`.pnpm/${name}`, path.join(cwd, directoryPath, name));
235 }),
236 { concurrency: 32, discard: true },
237 );
238 const realpath = NodeFSP.realpath;
239 let inFlight = 0;
240 let maxInFlight = 0;
241 vi.spyOn(NodeFSP, "realpath").mockImplementation((async (target: string) => {
242 maxInFlight = Math.max(maxInFlight, ++inFlight);
243 try {
244 return await realpath(target);
245 } finally {
246 inFlight--;
247 }
248 }) as typeof NodeFSP.realpath);
249
250 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
251 const { entries } = yield* workspaceEntries.list({ cwd, directoryPath });
252 expect(entries.map((entry) => entry.path).toSorted()).toEqual(
253 [
254 `${directoryPath}/.pnpm`,
255 ...packages.map((name) => `${directoryPath}/${name}`),
256 ].toSorted(),
257 );
258 expect(entries.every((entry) => entry.kind === "directory")).toBe(true);
259 expect(maxInFlight).toBeLessThanOrEqual(64);
260 }),
261 );
262
263 it.effect("labels the children of wide folders exactly as git does for each child", () =>
264 Effect.gen(function* () {
265 // Above 1,000 children, listing first asks whether the whole folder is ignored.
266 const cwd = yield* makeTempDir({ git: true });
267 const fileSystem = yield* FileSystem.FileSystem;
268 const path = yield* Path.Path;
269 const platform = yield* HostProcess.Platform;
270 yield* writeTextFile(
271 cwd,
272 ".gitignore",
273 "node_modules/\ndist/\n\\[id\\]/\n*.map\nfoo\n!foo/\n",
274 );
275 yield* Effect.forEach(
276 [
277 "node_modules",
278 "dist",
279 "[id]",
280 "src",
281 ...(platform !== "win32" ? [":foo"] : []),
282 ].flatMap((directory) =>
283 Array.from({ length: 1001 }, (_, index) => `${directory}/pkg-${index}.js`),
284 ),
285 (relativePath) => writeTextFile(cwd, relativePath),
286 { concurrency: 32, discard: true },
287 );
288 for (const relativePath of [
289 "dist/kept.js",
290 "dist/vendored/index.js",
291 "i/page.tsx",
292 "[id]/page.tsx",
293 "src/bundle.map",
294 ]) {
295 yield* writeTextFile(cwd, relativePath);
296 }
297 yield* git(cwd, ["add", "-f", "dist/kept.js", "dist/vendored/index.js", "i/page.tsx"]);
298 if (platform !== "win32") {
299 yield* fileSystem.symlink(path.join(cwd, "src"), path.join(cwd, "node_modules/link"));
300 }
301
302 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
303 for (const { directoryPath, ignored, except } of [
304 { directoryPath: "node_modules", ignored: true, except: [] },
305 { directoryPath: "dist", ignored: true, except: ["dist/kept.js", "dist/vendored"] },
306 // git reads `[id]/page.tsx` as a pathspec, which matches the tracked `i/page.tsx`.
307 { directoryPath: "[id]", ignored: true, except: ["[id]/page.tsx"] },
308 { directoryPath: "src", ignored: false, except: ["src/bundle.map"] },
309 ...(platform !== "win32"
310 ? [
311 // git refuses to classify paths beyond a symlink, so nothing there is labelled.
312 { directoryPath: "node_modules/link", ignored: false, except: [] },
313 // Leading : is pathspec magic; Git sees foo/pkg-0.js, unignored by !foo/.
314 { directoryPath: ":foo", ignored: false, except: [] },
315 ]
316 : []),
317 ]) {
318 const { entries } = yield* workspaceEntries.list({ cwd, directoryPath });
319 expect(entries.length, directoryPath).toBeGreaterThan(1000);
320 expect(
321 entries
322 .filter((entry) => (entry.ignored === true) !== ignored)
323 .map((entry) => entry.path)
324 .toSorted(),
325 directoryPath,
326 ).toEqual(except);
327 }
328 }),
329 );
330
331 it.effect("lists a host folder and its parent, each from its own root", () =>
332 Effect.gen(function* () {
333 const parent = yield* makeTempDir();
334 const path = yield* Path.Path;
335 yield* writeTextFile(parent, "linked/notes.md");
336 yield* writeTextFile(parent, "sibling/readme.md");
337 const linked = path.join(parent, "linked");
338
339 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
340 expect(yield* workspaceEntries.list({ cwd: linked, directoryPath: "" })).toEqual({
341 entries: [{ path: "notes.md", kind: "file" }],
342 truncated: false,
343 });
344 const up = yield* workspaceEntries.list({ cwd: parent, directoryPath: "" });
345 expect(up.entries.map((entry) => entry.path).toSorted()).toEqual(["linked", "sibling"]);
346 const escape = yield* workspaceEntries
347 .list({ cwd: linked, directoryPath: "../sibling" })
348 .pipe(Effect.flip);
349 expect(escape._tag).toBe("WorkspaceEntriesReadDirectoryError");
350 }),
351 );
352
353 it.effect(
354 "rejects directory traversal, git internals, and symlinks outside the workspace",
355 () =>
356 Effect.gen(function* () {
357 const cwd = yield* makeTempDir();
358 const outside = yield* makeTempDir();
359 const fileSystem = yield* FileSystem.FileSystem;
360 const path = yield* Path.Path;
361 yield* writeTextFile(cwd, ".git/HEAD");
362 const platform = yield* HostProcess.Platform;
363 if (platform !== "win32") yield* fileSystem.symlink(outside, path.join(cwd, "external"));
364 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
365 for (const directoryPath of [
366 "../",
367 outside,
368 ".git",
369 "missing",
370 ...(platform !== "win32" ? ["external"] : []),
371 ]) {
372 const error = yield* workspaceEntries.list({ cwd, directoryPath }).pipe(Effect.flip);
373 expect(error._tag).toBe("WorkspaceEntriesReadDirectoryError");
374 }
375 }),
376 );
377
378 it.effect("includes images and other binary files in non-git workspace indexes", () =>
379 Effect.gen(function* () {
380 const cwd = yield* makeTempDir();
381 const imagePaths = ["photo.png", "photo.jpg", "photo.jpeg"];
382 const binaryPaths = [...imagePaths, "document.pdf", "archive.zip", "data.bin"];
383 for (const filePath of binaryPaths) {
384 yield* writeTextFile(cwd, filePath, "\0binary contents\n");
385 }
386 yield* writeTextFile(cwd, "notes.txt", "plain text\n");
387
388 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
389 const expectedPaths = [...binaryPaths, "notes.txt"].toSorted();
390 const listed = yield* workspaceEntries.list({ cwd });
391 expect(listed.entries.map((entry) => entry.path).toSorted()).toEqual(expectedPaths);
392 expect(listed.truncated).toBe(false);
393
394 const searched = yield* workspaceEntries.search({
395 cwd,
396 query: "",
397 limit: 100,
398 kind: "file",
399 });
400 expect(searched.entries.map((entry) => entry.path).toSorted()).toEqual(expectedPaths);
401 expect(searched.truncated).toBe(false);
402
403 const images = yield* workspaceEntries.search({
404 cwd,
405 query: "",
406 limit: 100,
407 imageOnly: true,
408 });
409 expect(images.entries.map((entry) => entry.path).toSorted()).toEqual(imagePaths.toSorted());
410 expect(images.truncated).toBe(false);
411 }),
412 );
413 });
414
415 describe("listIgnoredFiles", () => {
416 it.effect("lists ignored files with sizes and skips wholly ignored folders", () =>
417 Effect.gen(function* () {
418 const cwd = yield* makeTempDir({ git: true });
419 yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env*\n");
420 yield* writeTextFile(cwd, ".env", "A=1");
421 yield* writeTextFile(cwd, "apps/web/index.ts", "y");
422 yield* git(cwd, ["add", "apps/web/index.ts"]);
423 yield* writeTextFile(cwd, "apps/web/.env.local", "B=22");
424 yield* writeTextFile(cwd, "node_modules/pkg/index.js", "x");
425
426 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
427 expect(yield* workspaceEntries.listIgnoredFiles({ cwd })).toEqual({
428 files: [
429 { path: ".env", byteLength: 3 },
430 { path: "apps/web/.env.local", byteLength: 4 },
431 ],
432 truncated: false,
433 });
434 }),
435 );
436 });
437
438 describe("search", () => {
439 it.effect("applies the file filter before limiting search results", () =>
440 Effect.gen(function* () {
441 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" });
442 yield* writeTextFile(cwd, "src/index.ts");
443 yield* writeTextFile(cwd, "src/internal.ts");
444
445 const result = yield* searchWorkspaceEntries({
446 cwd,
447 query: "src",
448 limit: 1,
449 kind: "file",
450 });
451
452 // Native ranking can put either matching file first.
453 expect(result.entries).toHaveLength(1);
454 expect(result.entries[0]?.kind).toBe("file");
455 expect(["src/index.ts", "src/internal.ts"]).toContain(result.entries[0]?.path);
456 expect(result.truncated).toBe(true);
457 }),
458 );
459
460 it.effect("matches glob metacharacters in the query literally", () =>
461 Effect.gen(function* () {
462 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-brackets-" });
463 yield* writeTextFile(cwd, "src/app/game/[id]/index.tsx");
464 yield* writeTextFile(cwd, "src/foo{bar}.ts");
465 yield* writeTextFile(cwd, "src/main.ts");
466 yield* writeTextFile(cwd, "src/notes.md");
467
468 const search = (query: string) =>
469 searchWorkspaceEntries({ cwd, query, limit: 10 }).pipe(
470 Effect.map((result) => result.entries.map((entry) => entry.path)),
471 );
472
473 for (const query of ["game/[i", "game/[id]"]) {
474 expect(yield* search(query)).toContain("src/app/game/[id]/index.tsx");
475 }
476 expect(yield* search("foo{bar}.ts")).toContain("src/foo{bar}.ts");
477 expect((yield* search("*.{ts,tsx}")).toSorted()).toEqual([
478 "src/app/game/[id]/index.tsx",
479 "src/foo{bar}.ts",
480 "src/main.ts",
481 ]);
482 expect((yield* search("*.{ts}")).toSorted()).toEqual(["src/foo{bar}.ts", "src/main.ts"]);
483 expect(yield* search("src/{main}.ts")).toEqual(["src/main.ts"]);
484 expect(yield* search("src/{foo{bar},notes}.md")).toEqual(["src/notes.md"]);
485 }),
486 );
487
488 it.effect("excludes gitignored paths for git repositories", () =>
489 Effect.gen(function* () {
490 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true });
491 yield* writeTextFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n");
492 yield* writeTextFile(cwd, "src/keep.ts", "export {};");
493 yield* writeTextFile(cwd, "ignored.txt", "ignore me");
494 yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}");
495 yield* writeTextFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}");
496 yield* writeTextFile(cwd, ".git/info/exclude", "info-excluded.txt\n");
497 yield* writeTextFile(cwd, "info-excluded.txt", "ignore me");
498 yield* writeTextFile(cwd, ".git/configured-excludes", "configured-excluded.txt\n");
499 yield* git(cwd, ["config", "core.excludesFile", ".git/configured-excludes"]);
500 yield* writeTextFile(cwd, "configured-excluded.txt", "ignore me");
501
502 const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
503 const paths = result.entries.map((entry) => entry.path);
504
505 expect(paths).toContain("src");
506 expect(paths).toContain("src/keep.ts");
507 expect(paths).not.toContain("ignored.txt");
508 expect(paths).not.toContain("info-excluded.txt");
509 expect(paths).not.toContain("configured-excluded.txt");
510 expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
511 expect(paths.some((entryPath) => entryPath.startsWith("convex/"))).toBe(false);
512 }),
513 );
514
515 it.effect("excludes tracked paths that match ignore rules", () =>
516 Effect.gen(function* () {
517 const cwd = yield* makeTempDir({
518 prefix: "t3code-workspace-tracked-gitignore-",
519 git: true,
520 });
521 yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}");
522 yield* writeTextFile(cwd, "src/keep.ts", "export {};");
523 yield* git(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]);
524 yield* writeTextFile(cwd, ".gitignore", ".convex/\n");
525
526 const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
527 const paths = result.entries.map((entry) => entry.path);
528
529 expect(paths).toContain("src");
530 expect(paths).toContain("src/keep.ts");
531 expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
532 }),
533 );
534
535 it.effect("finds a path inside a gitignored nested git worktree only by its exact path", () =>
536 Effect.gen(function* () {
537 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-worktree-", git: true });
538 const env = {
539 ...process.env,
540 GIT_AUTHOR_NAME: "Test",
541 GIT_AUTHOR_EMAIL: "test@example.com",
542 GIT_COMMITTER_NAME: "Test",
543 GIT_COMMITTER_EMAIL: "test@example.com",
544 };
545 yield* writeTextFile(cwd, ".gitignore", ".worktrees/\n");
546 yield* git(cwd, ["add", ".gitignore"]);
547 yield* git(cwd, ["commit", "-m", "init"], env);
548 yield* git(cwd, ["worktree", "add", "-b", "x", ".worktrees/x"]);
549 yield* writeTextFile(cwd, ".worktrees/x/src/foo.py", "print(1)");
550
551 const exact = yield* searchWorkspaceEntries({
552 cwd,
553 query: "src/foo.py",
554 limit: 25,
555 kind: "file",
556 });
557 expect(exact.entries).toEqual([{ path: ".worktrees/x/src/foo.py", kind: "file" }]);
558
559 const partial = yield* searchWorkspaceEntries({ cwd, query: "src/fo", limit: 25 });
560 expect(partial.entries.map((entry) => entry.path)).not.toContain(".worktrees/x/src/foo.py");
561 }),
562 );
563 });
564
565 describe("searchContents", () => {
566 it.effect("returns content matches with file paths, line numbers, and ranges", () =>
567 Effect.gen(function* () {
568 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" });
569 yield* writeTextFile(
570 cwd,
571 "src/shapes.ts",
572 "export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n",
573 );
574 yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n");
575
576 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
577 const result = yield* workspaceEntries.searchContents({
578 cwd,
579 query: "Square",
580 limit: 100,
581 caseSensitive: false,
582 wholeWord: true,
583 useRegex: false,
584 });
585
586 expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([
587 ["src/shapes.ts", 1],
588 ["src/shapes.ts", 2],
589 ]);
590 expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]);
591 expect(result.truncated).toBe(false);
592 }),
593 );
594
595 it.effect("honors case sensitivity and gitignore rules", () =>
596 Effect.gen(function* () {
597 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true });
598 yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n");
599 yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n");
600 yield* writeTextFile(cwd, "ignored.txt", "Square\n");
601 yield* writeTextFile(cwd, ".git/info/exclude", "info-excluded.txt\n");
602 yield* writeTextFile(cwd, "info-excluded.txt", "Square\n");
603
604 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
605 const result = yield* workspaceEntries.searchContents({
606 cwd,
607 query: "Square",
608 limit: 100,
609 caseSensitive: true,
610 wholeWord: false,
611 useRegex: false,
612 });
613
614 expect(result.matches).toHaveLength(1);
615 expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 });
616 }),
617 );
618
619 it.effect("finds later whole-word matches in a file after rejected raw matches", () =>
620 Effect.gen(function* () {
621 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" });
622 yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`);
623
624 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
625 const result = yield* workspaceEntries.searchContents({
626 cwd,
627 query: "foo",
628 limit: 1,
629 caseSensitive: true,
630 wholeWord: true,
631 useRegex: false,
632 });
633
634 expect(result.matches).toEqual([
635 expect.objectContaining({
636 path: "src/words.ts",
637 lineNumber: 11,
638 matchRanges: [{ start: 0, end: 3 }],
639 }),
640 ]);
641 }),
642 );
643
644 it.effect("treats astral-plane letters as whole word characters", () =>
645 Effect.gen(function* () {
646 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" });
647 yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n");
648
649 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
650 const result = yield* workspaceEntries.searchContents({
651 cwd,
652 query: "foo",
653 limit: 100,
654 caseSensitive: true,
655 wholeWord: true,
656 useRegex: false,
657 });
658
659 expect(result.matches).toEqual([
660 expect.objectContaining({
661 path: "src/words.ts",
662 lineNumber: 1,
663 matchRanges: [{ start: 6, end: 9 }],
664 }),
665 ]);
666 }),
667 );
668
669 it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () =>
670 Effect.gen(function* () {
671 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" });
672 yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n");
673
674 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
675 const result = yield* workspaceEntries.searchContents({
676 cwd,
677 query: "-foo-",
678 limit: 100,
679 caseSensitive: true,
680 wholeWord: true,
681 useRegex: false,
682 });
683
684 expect(result.matches).toHaveLength(1);
685 expect(result.matches[0]).toMatchObject({
686 path: "src/words.ts",
687 lineNumber: 1,
688 matchRanges: [
689 { start: 0, end: 5 },
690 { start: 6, end: 11 },
691 { start: 12, end: 17 },
692 ],
693 });
694 }),
695 );
696
697 it.effect("caps matches per file so one dense file cannot fill the page", () =>
698 Effect.gen(function* () {
699 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" });
700 yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300));
701 yield* writeTextFile(cwd, "src/other.ts", "needle\n");
702
703 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
704 const result = yield* workspaceEntries.searchContents({
705 cwd,
706 query: "needle",
707 limit: 500,
708 caseSensitive: true,
709 wholeWord: false,
710 useRegex: false,
711 });
712
713 const byPath = new Map<string, number>();
714 for (const match of result.matches) {
715 byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1);
716 }
717 expect(byPath.get("src/dense.ts")).toBe(100);
718 expect(byPath.get("src/other.ts")).toBe(1);
719 }),
720 );
721
722 it.effect("preserves regex escapes during case-insensitive searches", () =>
723 Effect.gen(function* () {
724 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" });
725 yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n");
726
727 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
728 const result = yield* workspaceEntries.searchContents({
729 cwd,
730 query: "\\SQUARE",
731 limit: 100,
732 caseSensitive: false,
733 wholeWord: false,
734 useRegex: true,
735 });
736
737 expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]);
738 }),
739 );
740
741 it.effect("searches an invalid regex literally without matching case", () =>
742 Effect.gen(function* () {
743 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" });
744 yield* writeTextFile(cwd, "src/calls.ts", "call(Foo(bar));\n");
745
746 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
747 const result = yield* workspaceEntries.searchContents({
748 cwd,
749 query: "foo(",
750 limit: 100,
751 caseSensitive: false,
752 wholeWord: false,
753 useRegex: true,
754 });
755
756 expect(result.regexFallbackError).toBeDefined();
757 expect(result.matches).toEqual([
758 expect.objectContaining({
759 path: "src/calls.ts",
760 lineNumber: 1,
761 matchRanges: [{ start: 5, end: 9 }],
762 }),
763 ]);
764 }),
765 );
766
767 it.effect("maps multi-byte lines to string-indexed ranges", () =>
768 Effect.gen(function* () {
769 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" });
770 yield* writeTextFile(cwd, "src/notes.ts", 'const label = "héllo wörld";\n');
771
772 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
773 const result = yield* workspaceEntries.searchContents({
774 cwd,
775 query: "wörld",
776 limit: 100,
777 caseSensitive: true,
778 wholeWord: false,
779 useRegex: false,
780 });
781
782 expect(result.matches).toHaveLength(1);
783 const match = result.matches[0]!;
784 const range = match.matchRanges[0]!;
785 expect(match.lineContent.slice(range.start, range.end)).toBe("wörld");
786 }),
787 );
788
789 it.effect("keeps matches past the truncated part of a long line", () =>
790 Effect.gen(function* () {
791 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-long-line-" });
792 yield* writeTextFile(cwd, "dist/app.min.js", `${"x".repeat(600)} needleSuffix\n`);
793
794 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
795 const search = (wholeWord: boolean) =>
796 workspaceEntries.searchContents({
797 cwd,
798 query: "needle",
799 limit: 100,
800 caseSensitive: true,
801 wholeWord,
802 useRegex: false,
803 });
804
805 expect((yield* search(false)).matches).toEqual([
806 expect.objectContaining({ path: "dist/app.min.js", lineNumber: 1 }),
807 ]);
808 expect((yield* search(true)).matches).toEqual([]);
809 }),
810 );
811
812 it.effect("keeps answering from the live index when a refresh outlasts the scan wait", () =>
813 Effect.gen(function* () {
814 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-refresh-" });
815 yield* writeTextFile(cwd, "src/notes.ts", "const needle = 1;\n");
816 let scansOutlastWait = false;
817 let finders = 0;
818 let rescans = 0;
819 const createFinder = FileFinder.create.bind(FileFinder);
820 vi.spyOn(FileFinder, "create").mockImplementation((options) => {
821 const created = createFinder(options);
822 if (created.ok) {
823 const finder = created.value;
824 finders += 1;
825 // The synchronous rescan waits on fff's picker lock on the event loop.
826 finder.scanFiles = () => {
827 throw new Error("refresh must not rescan on the JS thread");
828 };
829 const scanFilesAsync = finder.scanFilesAsync.bind(finder);
830 finder.scanFilesAsync = () => {
831 rescans += 1;
832 return scanFilesAsync();
833 };
834 const waitForIndexReady = finder.waitForIndexReady.bind(finder);
835 finder.waitForIndexReady = (timeoutMs) =>
836 scansOutlastWait
837 ? Promise.resolve({ ok: true, value: false })
838 : waitForIndexReady(timeoutMs);
839 }
840 return created;
841 });
842 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
843 const searchNeedle = workspaceEntries.searchContents({
844 cwd,
845 query: "needle",
846 limit: 100,
847 caseSensitive: true,
848 wholeWord: false,
849 useRegex: false,
850 });
851 yield* searchNeedle;
852
853 scansOutlastWait = true;
854 yield* workspaceEntries.refresh(cwd);
855 const result = yield* searchNeedle;
856
857 expect(result.matches.map((match) => match.path)).toEqual(["src/notes.ts"]);
858 // The second search ran on the rescanned live finder, not a rebuild.
859 expect({ finders, rescans }).toEqual({ finders: 1, rescans: 1 });
860 }),
861 );
862 });
863
864 describe("browse", () => {
865 it.effect("shows dot directories in directory mode and hidden-prefix mode", () =>
866 Effect.gen(function* () {
867 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
868 const path = yield* Path.Path;
869 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" });
870 yield* writeTextFile(cwd, ".config/settings.json", "{}");
871 yield* writeTextFile(cwd, "config/settings.json", "{}");
872 const cwdWithSeparator = yield* appendSeparator(cwd);
873
874 const directoryResult = yield* workspaceEntries.browse({
875 partialPath: cwdWithSeparator,
876 });
877 const hiddenPrefixResult = yield* workspaceEntries.browse({
878 partialPath: `${cwdWithSeparator}.c`,
879 });
880
881 expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]);
882 expect(hiddenPrefixResult).toEqual({
883 parentPath: cwd,
884 entries: [{ name: ".config", fullPath: path.join(cwd, ".config") }],
885 });
886 }),
887 );
888
889 it.effect("marks only a missing directory as missing when browsing fails", () =>
890 Effect.gen(function* () {
891 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
892 const path = yield* Path.Path;
893 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-missing-" });
894 yield* writeTextFile(cwd, "file.txt", "");
895
896 const missing = yield* workspaceEntries
897 .browse({ partialPath: `${path.join(cwd, "absent", "nested")}${path.sep}` })
898 .pipe(Effect.flip);
899 const underFile = yield* workspaceEntries
900 .browse({ partialPath: `${path.join(cwd, "file.txt")}${path.sep}` })
901 .pipe(Effect.flip);
902
903 expect(missing).toMatchObject({
904 _tag: "WorkspaceEntriesReadDirectoryError",
905 pathMissing: true,
906 });
907 expect(underFile).toMatchObject({
908 _tag: "WorkspaceEntriesReadDirectoryError",
909 pathMissing: false,
910 });
911 }),
912 );
913 });
914});