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
112 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
113 const root = yield* workspaceEntries.list({ cwd, directoryPath: "" });
114 expect(root.entries).toEqual(
115 expect.arrayContaining([
116 { path: ".env", kind: "file", ignored: true },
117 { path: "node_modules", kind: "directory", ignored: true },
118 { path: "src", kind: "directory" },
119 { path: "empty", kind: "directory" },
120 { path: "tracked.txt", kind: "file" },
121 ]),
122 );
123 expect(root.entries.some((entry) => entry.path.includes("/"))).toBe(false);
124 expect(root.entries.some((entry) => entry.path === ".git")).toBe(false);
125 expect(root.truncated).toBe(false);
126 expect(yield* workspaceEntries.list({ cwd, directoryPath: "node_modules/pkg" })).toEqual({
127 entries: [{ path: "node_modules/pkg/index.js", kind: "file", ignored: true }],
128 truncated: false,
129 });
130 expect(yield* workspaceEntries.list({ cwd, directoryPath: "empty" })).toEqual({
131 entries: [],
132 truncated: false,
133 });
134 }),
135 );
136
137 it.effect(
138 "rejects directory traversal, git internals, and symlinks outside the workspace",
139 () =>
140 Effect.gen(function* () {
141 const cwd = yield* makeTempDir();
142 const outside = yield* makeTempDir();
143 const fileSystem = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145 yield* writeTextFile(cwd, ".git/HEAD");
146 const platform = yield* HostProcess.Platform;
147 if (platform !== "win32") yield* fileSystem.symlink(outside, path.join(cwd, "external"));
148 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
149 for (const directoryPath of [
150 "../",
151 outside,
152 ".git",
153 "missing",
154 ...(platform !== "win32" ? ["external"] : []),
155 ]) {
156 const error = yield* workspaceEntries.list({ cwd, directoryPath }).pipe(Effect.flip);
157 expect(error._tag).toBe("WorkspaceEntriesReadDirectoryError");
158 }
159 }),
160 );
161
162 it.effect(
163 "browses a workspace with more than 25,000 entries without truncation",
164 () =>
165 Effect.gen(function* () {
166 const cwd = yield* makeTempDir();
167 const fileSystem = yield* FileSystem.FileSystem;
168 const path = yield* Path.Path;
169 for (let directory = 0; directory < 26; directory++) {
170 const directoryPath = path.join(cwd, `folder-${directory}`);
171 yield* fileSystem.makeDirectory(directoryPath);
172 yield* Effect.forEach(
173 Array.from({ length: 1000 }, (_, i) => i),
174 (i) => fileSystem.writeFileString(path.join(directoryPath, `file-${i}.txt`), ""),
175 { concurrency: 32, discard: true },
176 );
177 }
178 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
179 const root = yield* workspaceEntries.list({ cwd, directoryPath: "" });
180 expect(root.entries).toHaveLength(26);
181 expect(root.truncated).toBe(false);
182 for (const directory of root.entries) {
183 const result = yield* workspaceEntries.list({ cwd, directoryPath: directory.path });
184 expect(result.entries).toHaveLength(1000);
185 expect(result.truncated).toBe(false);
186 expect(result.entries).toContainEqual({
187 path: `${directory.path}/file-999.txt`,
188 kind: "file",
189 });
190 }
191 }),
192 60_000,
193 );
194
195 it.effect("includes images and other binary files in non-git workspace indexes", () =>
196 Effect.gen(function* () {
197 const cwd = yield* makeTempDir();
198 const imagePaths = ["photo.png", "photo.jpg", "photo.jpeg"];
199 const binaryPaths = [...imagePaths, "document.pdf", "archive.zip", "data.bin"];
200 for (const filePath of binaryPaths) {
201 yield* writeTextFile(cwd, filePath, "\0binary contents\n");
202 }
203 yield* writeTextFile(cwd, "notes.txt", "plain text\n");
204
205 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
206 const expectedPaths = [...binaryPaths, "notes.txt"].toSorted();
207 const listed = yield* workspaceEntries.list({ cwd });
208 expect(listed.entries.map((entry) => entry.path).toSorted()).toEqual(expectedPaths);
209 expect(listed.truncated).toBe(false);
210
211 const searched = yield* workspaceEntries.search({
212 cwd,
213 query: "",
214 limit: 100,
215 kind: "file",
216 });
217 expect(searched.entries.map((entry) => entry.path).toSorted()).toEqual(expectedPaths);
218 expect(searched.truncated).toBe(false);
219
220 const images = yield* workspaceEntries.search({
221 cwd,
222 query: "",
223 limit: 100,
224 imageOnly: true,
225 });
226 expect(images.entries.map((entry) => entry.path).toSorted()).toEqual(imagePaths.toSorted());
227 expect(images.truncated).toBe(false);
228 }),
229 );
230
231 it.effect("returns the complete cached workspace index", () =>
232 Effect.gen(function* () {
233 const cwd = yield* makeTempDir();
234 yield* writeTextFile(cwd, "src/components/Composer.tsx");
235 yield* writeTextFile(cwd, "README.md");
236 yield* writeTextFile(cwd, "node_modules/pkg/index.js");
237
238 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
239 const result = yield* workspaceEntries.list({ cwd });
240
241 expect(result.entries).toEqual(
242 expect.arrayContaining([
243 { path: "src", kind: "directory" },
244 { path: "src/components", kind: "directory" },
245 {
246 path: "src/components/Composer.tsx",
247 kind: "file",
248 },
249 { path: "README.md", kind: "file" },
250 ]),
251 );
252 expect(result.entries.some((entry) => entry.path.startsWith("node_modules"))).toBe(false);
253 expect(result.truncated).toBe(false);
254 }),
255 );
256 });
257
258 describe("search", () => {
259 it.effect("returns files and directories relative to cwd", () =>
260 Effect.gen(function* () {
261 const cwd = yield* makeTempDir();
262 yield* writeTextFile(cwd, "src/components/Composer.tsx");
263 yield* writeTextFile(cwd, "src/index.ts");
264 yield* writeTextFile(cwd, "README.md");
265 yield* writeTextFile(cwd, ".git/HEAD");
266 yield* writeTextFile(cwd, "node_modules/pkg/index.js");
267
268 const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
269 const paths = result.entries.map((entry) => entry.path);
270
271 expect(paths).toContain("src");
272 expect(paths).toContain("src/components");
273 expect(paths).toContain("src/components/Composer.tsx");
274 expect(paths).toContain("README.md");
275 expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false);
276 expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false);
277 expect(result.truncated).toBe(false);
278 }),
279 );
280
281 it.effect("filters and ranks entries by query", () =>
282 Effect.gen(function* () {
283 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-query-" });
284 yield* writeTextFile(cwd, "src/components/Composer.tsx");
285 yield* writeTextFile(cwd, "src/components/composePrompt.ts");
286 yield* writeTextFile(cwd, "docs/composition.md");
287
288 const result = yield* searchWorkspaceEntries({ cwd, query: "compo", limit: 5 });
289
290 expect(result.entries.length).toBeGreaterThan(0);
291 expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true);
292 expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(
293 true,
294 );
295 }),
296 );
297
298 it.effect("supports fuzzy subsequence queries for composer path search", () =>
299 Effect.gen(function* () {
300 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-query-" });
301 yield* writeTextFile(cwd, "src/components/Composer.tsx");
302 yield* writeTextFile(cwd, "src/components/composePrompt.ts");
303 yield* writeTextFile(cwd, "docs/composition.md");
304
305 const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 });
306 const paths = result.entries.map((entry) => entry.path);
307
308 expect(result.entries.length).toBeGreaterThan(0);
309 expect(paths).toContain("src/components");
310 expect(paths).toContain("src/components/Composer.tsx");
311 }),
312 );
313
314 it.effect("prioritizes exact basename matches ahead of broader path matches", () =>
315 Effect.gen(function* () {
316 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-exact-ranking-" });
317 yield* writeTextFile(cwd, "src/components/Composer.tsx");
318 yield* writeTextFile(cwd, "docs/composer.tsx-notes.md");
319
320 const result = yield* searchWorkspaceEntries({ cwd, query: "Composer.tsx", limit: 5 });
321
322 expect(result.entries[0]?.path).toBe("src/components/Composer.tsx");
323 }),
324 );
325
326 it.effect("tracks truncation without sorting every fuzzy match", () =>
327 Effect.gen(function* () {
328 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" });
329 yield* writeTextFile(cwd, "src/components/Composer.tsx");
330 yield* writeTextFile(cwd, "src/components/composePrompt.ts");
331 yield* writeTextFile(cwd, "docs/composition.md");
332
333 const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 });
334
335 expect(result.entries).toHaveLength(1);
336 expect(result.truncated).toBe(true);
337 }),
338 );
339
340 it.effect("applies the file filter before limiting search results", () =>
341 Effect.gen(function* () {
342 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" });
343 yield* writeTextFile(cwd, "src/index.ts");
344 yield* writeTextFile(cwd, "src/internal.ts");
345
346 const result = yield* searchWorkspaceEntries({
347 cwd,
348 query: "src",
349 limit: 1,
350 kind: "file",
351 });
352
353 // Native ranking can put either matching file first.
354 expect(result.entries).toHaveLength(1);
355 expect(result.entries[0]?.kind).toBe("file");
356 expect(["src/index.ts", "src/internal.ts"]).toContain(result.entries[0]?.path);
357 expect(result.truncated).toBe(true);
358 }),
359 );
360
361 it.effect("answers an empty file-filtered query with a bounded file listing", () =>
362 Effect.gen(function* () {
363 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" });
364 yield* writeTextFile(cwd, "src/index.ts");
365 yield* writeTextFile(cwd, "README.md");
366
367 const result = yield* searchWorkspaceEntries({
368 cwd,
369 query: "",
370 limit: 10,
371 kind: "file",
372 });
373
374 const paths = result.entries.map((entry) => entry.path);
375 expect(paths).toHaveLength(2);
376 expect(paths).toContain("src/index.ts");
377 expect(paths).toContain("README.md");
378 expect(result.entries.every((entry) => entry.kind === "file")).toBe(true);
379 }),
380 );
381
382 it.effect("returns only directories for the directory filter", () =>
383 Effect.gen(function* () {
384 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" });
385 yield* writeTextFile(cwd, "src/index.ts");
386
387 const result = yield* searchWorkspaceEntries({
388 cwd,
389 query: "src",
390 limit: 10,
391 kind: "directory",
392 });
393
394 expect(result.entries).toEqual([{ path: "src", kind: "directory" }]);
395 expect(result.truncated).toBe(false);
396 }),
397 );
398
399 it.effect("excludes gitignored paths for git repositories", () =>
400 Effect.gen(function* () {
401 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true });
402 yield* writeTextFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n");
403 yield* writeTextFile(cwd, "src/keep.ts", "export {};");
404 yield* writeTextFile(cwd, "ignored.txt", "ignore me");
405 yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}");
406 yield* writeTextFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}");
407
408 const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
409 const paths = result.entries.map((entry) => entry.path);
410
411 expect(paths).toContain("src");
412 expect(paths).toContain("src/keep.ts");
413 expect(paths).not.toContain("ignored.txt");
414 expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
415 expect(paths.some((entryPath) => entryPath.startsWith("convex/"))).toBe(false);
416 }),
417 );
418
419 it.effect("excludes tracked paths that match ignore rules", () =>
420 Effect.gen(function* () {
421 const cwd = yield* makeTempDir({
422 prefix: "t3code-workspace-tracked-gitignore-",
423 git: true,
424 });
425 yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}");
426 yield* writeTextFile(cwd, "src/keep.ts", "export {};");
427 yield* git(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]);
428 yield* writeTextFile(cwd, ".gitignore", ".convex/\n");
429
430 const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
431 const paths = result.entries.map((entry) => entry.path);
432
433 expect(paths).toContain("src");
434 expect(paths).toContain("src/keep.ts");
435 expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
436 }),
437 );
438
439 it.effect("excludes .convex in non-git workspaces", () =>
440 Effect.gen(function* () {
441 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-non-git-convex-" });
442 yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}");
443 yield* writeTextFile(cwd, "src/keep.ts", "export {};");
444
445 const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
446 const paths = result.entries.map((entry) => entry.path);
447
448 expect(paths).toContain("src");
449 expect(paths).toContain("src/keep.ts");
450 expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
451 }),
452 );
453
454 it.effect("supports typo-resistant file search through fff", () =>
455 Effect.gen(function* () {
456 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fff-typo-" });
457 yield* writeTextFile(cwd, "src/components/Composer.tsx");
458
459 const result = yield* searchWorkspaceEntries({ cwd, query: "compoesr", limit: 10 });
460
461 expect(result.entries).toEqual(
462 expect.arrayContaining([
463 expect.objectContaining({ path: "src/components/Composer.tsx" }),
464 ]),
465 );
466 }),
467 );
468
469 it.effect("rebuilds the cached index after refresh fails", () =>
470 Effect.gen(function* () {
471 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-refresh-failure-" });
472 yield* writeTextFile(cwd, "src/index.ts", "export {};\n");
473
474 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
475 const createSpy = vi.spyOn(FileFinder, "create");
476 yield* workspaceEntries.list({ cwd });
477 expect(createSpy).toHaveBeenCalledTimes(1);
478
479 vi.spyOn(FileFinder.prototype, "scanFiles").mockReturnValueOnce({
480 ok: false,
481 error: "scan failed",
482 });
483 yield* workspaceEntries.refresh(cwd);
484
485 yield* workspaceEntries.list({ cwd });
486 expect(createSpy).toHaveBeenCalledTimes(2);
487 }),
488 );
489 });
490
491 describe("searchContents", () => {
492 it.effect("returns content matches with file paths, line numbers, and ranges", () =>
493 Effect.gen(function* () {
494 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" });
495 yield* writeTextFile(
496 cwd,
497 "src/shapes.ts",
498 "export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n",
499 );
500 yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n");
501
502 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
503 const result = yield* workspaceEntries.searchContents({
504 cwd,
505 query: "Square",
506 limit: 100,
507 caseSensitive: false,
508 wholeWord: true,
509 useRegex: false,
510 });
511
512 expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([
513 ["src/shapes.ts", 1],
514 ["src/shapes.ts", 2],
515 ]);
516 expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]);
517 expect(result.truncated).toBe(false);
518 }),
519 );
520
521 it.effect("honors case sensitivity and gitignore rules", () =>
522 Effect.gen(function* () {
523 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true });
524 yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n");
525 yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n");
526 yield* writeTextFile(cwd, "ignored.txt", "Square\n");
527
528 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
529 const result = yield* workspaceEntries.searchContents({
530 cwd,
531 query: "Square",
532 limit: 100,
533 caseSensitive: true,
534 wholeWord: false,
535 useRegex: false,
536 });
537
538 expect(result.matches).toHaveLength(1);
539 expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 });
540 }),
541 );
542
543 it.effect("filters whole-word matches by word boundaries without widening ranges", () =>
544 Effect.gen(function* () {
545 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" });
546 yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n");
547
548 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
549 const result = yield* workspaceEntries.searchContents({
550 cwd,
551 query: "note",
552 limit: 100,
553 caseSensitive: true,
554 wholeWord: true,
555 useRegex: false,
556 });
557
558 // "notes", "denote", and "footnote" are word-adjacent and excluded;
559 // ranges cover exactly the query, never boundary characters.
560 expect(result.matches).toEqual([
561 expect.objectContaining({
562 path: "src/words.ts",
563 lineNumber: 1,
564 matchRanges: [{ start: 0, end: 4 }],
565 }),
566 expect.objectContaining({
567 path: "src/words.ts",
568 lineNumber: 2,
569 matchRanges: [{ start: 9, end: 13 }],
570 }),
571 ]);
572 }),
573 );
574
575 it.effect("finds later whole-word matches in a file after rejected raw matches", () =>
576 Effect.gen(function* () {
577 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" });
578 yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`);
579
580 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
581 const result = yield* workspaceEntries.searchContents({
582 cwd,
583 query: "foo",
584 limit: 1,
585 caseSensitive: true,
586 wholeWord: true,
587 useRegex: false,
588 });
589
590 expect(result.matches).toEqual([
591 expect.objectContaining({
592 path: "src/words.ts",
593 lineNumber: 11,
594 matchRanges: [{ start: 0, end: 3 }],
595 }),
596 ]);
597 }),
598 );
599
600 it.effect("treats astral-plane letters as whole word characters", () =>
601 Effect.gen(function* () {
602 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" });
603 yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n");
604
605 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
606 const result = yield* workspaceEntries.searchContents({
607 cwd,
608 query: "foo",
609 limit: 100,
610 caseSensitive: true,
611 wholeWord: true,
612 useRegex: false,
613 });
614
615 expect(result.matches).toEqual([
616 expect.objectContaining({
617 path: "src/words.ts",
618 lineNumber: 1,
619 matchRanges: [{ start: 6, end: 9 }],
620 }),
621 ]);
622 }),
623 );
624
625 it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () =>
626 Effect.gen(function* () {
627 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" });
628 yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n");
629
630 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
631 const result = yield* workspaceEntries.searchContents({
632 cwd,
633 query: "-foo-",
634 limit: 100,
635 caseSensitive: true,
636 wholeWord: true,
637 useRegex: false,
638 });
639
640 // Consuming-boundary regex would swallow the separating spaces and
641 // drop the middle occurrence; boundary post-filtering keeps all three.
642 expect(result.matches).toHaveLength(1);
643 expect(result.matches[0]).toMatchObject({
644 path: "src/words.ts",
645 lineNumber: 1,
646 matchRanges: [
647 { start: 0, end: 5 },
648 { start: 6, end: 11 },
649 { start: 12, end: 17 },
650 ],
651 });
652 }),
653 );
654
655 it.effect("matches punctuation-edged regex queries as whole words", () =>
656 Effect.gen(function* () {
657 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" });
658 yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n");
659
660 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
661 const result = yield* workspaceEntries.searchContents({
662 cwd,
663 query: "foo-",
664 limit: 100,
665 caseSensitive: true,
666 wholeWord: true,
667 useRegex: true,
668 });
669
670 // wholeWord + useRegex must not silently drop non-word-edged patterns
671 // like "foo-", and "afoo-" is excluded because 'a'/'f' are both word
672 // characters at the match's left edge.
673 expect(result.matches).toHaveLength(1);
674 expect(result.matches[0]).toMatchObject({
675 path: "src/words.ts",
676 lineNumber: 1,
677 matchRanges: [
678 { start: 0, end: 4 },
679 { start: 5, end: 9 },
680 ],
681 });
682 }),
683 );
684
685 it.effect("caps matches per file so one dense file cannot fill the page", () =>
686 Effect.gen(function* () {
687 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" });
688 yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300));
689 yield* writeTextFile(cwd, "src/other.ts", "needle\n");
690
691 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
692 const result = yield* workspaceEntries.searchContents({
693 cwd,
694 query: "needle",
695 limit: 500,
696 caseSensitive: true,
697 wholeWord: false,
698 useRegex: false,
699 });
700
701 const byPath = new Map<string, number>();
702 for (const match of result.matches) {
703 byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1);
704 }
705 expect(byPath.get("src/dense.ts")).toBe(100);
706 expect(byPath.get("src/other.ts")).toBe(1);
707 }),
708 );
709
710 it.effect("preserves regex escapes during case-insensitive searches", () =>
711 Effect.gen(function* () {
712 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" });
713 yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n");
714
715 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
716 const result = yield* workspaceEntries.searchContents({
717 cwd,
718 query: "\\SQUARE",
719 limit: 100,
720 caseSensitive: false,
721 wholeWord: false,
722 useRegex: true,
723 });
724
725 expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]);
726 }),
727 );
728
729 it.effect("preserves invalid regex errors during case-insensitive searches", () =>
730 Effect.gen(function* () {
731 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" });
732 yield* writeTextFile(cwd, "src/shapes.ts", "foobar\n");
733
734 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
735 const result = yield* workspaceEntries.searchContents({
736 cwd,
737 query: "foo)bar(",
738 limit: 100,
739 caseSensitive: false,
740 wholeWord: false,
741 useRegex: true,
742 });
743
744 expect(result.regexFallbackError).toBeDefined();
745 expect(result.matches).toEqual([]);
746 }),
747 );
748
749 it.effect("maps multi-byte lines to string-indexed ranges", () =>
750 Effect.gen(function* () {
751 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" });
752 yield* writeTextFile(cwd, "src/notes.ts", 'const label = "héllo wörld";\n');
753
754 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
755 const result = yield* workspaceEntries.searchContents({
756 cwd,
757 query: "wörld",
758 limit: 100,
759 caseSensitive: true,
760 wholeWord: false,
761 useRegex: false,
762 });
763
764 expect(result.matches).toHaveLength(1);
765 const match = result.matches[0]!;
766 const range = match.matchRanges[0]!;
767 expect(match.lineContent.slice(range.start, range.end)).toBe("wörld");
768 }),
769 );
770 });
771
772 describe("browse", () => {
773 it.effect("returns matching directories and excludes files", () =>
774 Effect.gen(function* () {
775 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
776 const path = yield* Path.Path;
777 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" });
778 yield* writeTextFile(cwd, "alphabet.txt", "ignore me");
779 yield* writeTextFile(cwd, "alpha/index.ts", "export {};\n");
780 yield* writeTextFile(cwd, "alpine/index.ts", "export {};\n");
781
782 const result = yield* workspaceEntries.browse({
783 partialPath: path.join(cwd, "alp"),
784 });
785
786 expect(result).toEqual({
787 parentPath: cwd,
788 entries: [
789 { name: "alpha", fullPath: path.join(cwd, "alpha") },
790 { name: "alpine", fullPath: path.join(cwd, "alpine") },
791 ],
792 });
793 }),
794 );
795
796 it.effect("shows dot directories in directory mode and hidden-prefix mode", () =>
797 Effect.gen(function* () {
798 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
799 const path = yield* Path.Path;
800 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" });
801 yield* writeTextFile(cwd, ".config/settings.json", "{}");
802 yield* writeTextFile(cwd, "config/settings.json", "{}");
803 const cwdWithSeparator = yield* appendSeparator(cwd);
804
805 const directoryResult = yield* workspaceEntries.browse({
806 partialPath: cwdWithSeparator,
807 });
808 const hiddenPrefixResult = yield* workspaceEntries.browse({
809 partialPath: `${cwdWithSeparator}.c`,
810 });
811
812 expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]);
813 expect(hiddenPrefixResult).toEqual({
814 parentPath: cwd,
815 entries: [{ name: ".config", fullPath: path.join(cwd, ".config") }],
816 });
817 }),
818 );
819
820 it.effect("supports relative paths when cwd is provided", () =>
821 Effect.gen(function* () {
822 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
823 const path = yield* Path.Path;
824 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" });
825 yield* writeTextFile(cwd, "packages/pkg.json", "{}");
826
827 const result = yield* workspaceEntries.browse({
828 cwd,
829 partialPath: "./pack",
830 });
831
832 expect(result).toEqual({
833 parentPath: cwd,
834 entries: [{ name: "packages", fullPath: path.join(cwd, "packages") }],
835 });
836 }),
837 );
838
839 it.effect("rejects relative paths without cwd", () =>
840 Effect.gen(function* () {
841 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
842
843 const error = yield* workspaceEntries
844 .browse({
845 partialPath: "./src",
846 })
847 .pipe(Effect.flip);
848
849 expect(error._tag).toBe("WorkspaceEntriesCurrentProjectRequiredError");
850 expect(error.message).toBe(
851 "A current project is required to browse relative workspace path './src'.",
852 );
853 }),
854 );
855
856 it.effect("returns an empty listing when the OS denies directory access", () =>
857 Effect.gen(function* () {
858 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
859 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" });
860
861 const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
862 vi.mocked(NodeFSP.readdir).mockRejectedValueOnce(denied);
863
864 const result = yield* workspaceEntries.browse({
865 partialPath: yield* appendSeparator(cwd),
866 });
867 expect(result).toEqual({ parentPath: cwd, entries: [] });
868 }),
869 );
870 });
871});