Skip to content
Source
Sign inSign up
Open History to see the last commit

WorkspaceEntries.ts

610 lines24.0 KB

1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFSP from "node:fs/promises";
3​
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as RcMap from "effect/RcMap";
9import * as Schema from "effect/Schema";
10​
11import type {
12 FilesystemBrowseInput,
13 FilesystemBrowseResult,
14 ProjectEntry,
15 ProjectListEntriesInput,
16 ProjectListEntriesResult,
17 ProjectListIgnoredFilesInput,
18 ProjectListIgnoredFilesResult,
19 ProjectSearchContentsInput,
20 ProjectSearchContentsResult,
21 ProjectSearchEntriesInput,
22 ProjectSearchEntriesResult,
23} from "@t3tools/contracts";
24import * as HostProcess from "@t3tools/shared/HostProcess";
25import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
26import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";
27​
28import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
29import * as VcsProcess from "../vcs/VcsProcess.ts";
30import * as WorkspacePaths from "./WorkspacePaths.ts";
31import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
32​
33export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedError<WorkspaceEntriesWindowsPathUnsupportedError>()(
34 "WorkspaceEntriesWindowsPathUnsupportedError",
35 {
36 cwd: Schema.optional(Schema.String),
37 partialPath: Schema.String,
38 platform: Schema.String,
39 },
40) {
41 override get message(): string {
42 const cwd = this.cwd ? ` from '${this.cwd}'` : "";
43 return `Windows-style workspace path '${this.partialPath}' is not supported on '${this.platform}'${cwd}.`;
44 }
46​
47export class WorkspaceEntriesCurrentProjectRequiredError extends Schema.TaggedError<WorkspaceEntriesCurrentProjectRequiredError>()(
48 "WorkspaceEntriesCurrentProjectRequiredError",
49 {
50 partialPath: Schema.String,
51 },
52) {
53 override get message(): string {
54 return `A current project is required to browse relative workspace path '${this.partialPath}'.`;
55 }
57​
58export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedError<WorkspaceEntriesReadDirectoryError>()(
59 "WorkspaceEntriesReadDirectoryError",
60 {
61 cwd: Schema.optional(Schema.String),
62 partialPath: Schema.String,
63 parentPath: Schema.String,
64 cause: Schema.Defect(),
65 },
66) {
67 get pathMissing(): boolean {
68 return (this.cause as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
69 }
70​
71 override get message(): string {
72 const cwd = this.cwd ? ` from '${this.cwd}'` : "";
73 return `Failed to read workspace directory '${this.parentPath}' while browsing '${this.partialPath}'${cwd}.`;
74 }
76​
77export const WorkspaceEntriesBrowseError = Schema.Union([
78 WorkspaceEntriesWindowsPathUnsupportedError,
79 WorkspaceEntriesCurrentProjectRequiredError,
80 WorkspaceEntriesReadDirectoryError,
81]);
82export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type;
83​
84export const WorkspaceEntriesError = Schema.Union([
85 WorkspaceEntriesReadDirectoryError,
86 WorkspacePaths.WorkspaceRootNotExistsError,
87 WorkspacePaths.WorkspaceRootCreateFailedError,
88 WorkspacePaths.WorkspaceRootStatFailedError,
89 WorkspacePaths.WorkspaceRootNotDirectoryError,
90 WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed,
91 WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut,
92 WorkspaceSearchIndex.WorkspaceSearchIndexSearchFailed,
93]);
94export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type;
95​
96export class WorkspaceEntries extends Context.Service<
97 WorkspaceEntries,
98 {
99 readonly browse: (
100 input: FilesystemBrowseInput,
101 ) => Effect.Effect<FilesystemBrowseResult, WorkspaceEntriesBrowseError>;
102 readonly list: (
103 input: ProjectListEntriesInput,
104 ) => Effect.Effect<ProjectListEntriesResult, WorkspaceEntriesError>;
105 readonly search: (
106 input: ProjectSearchEntriesInput,
107 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;
108 readonly listIgnoredFiles: (
109 input: ProjectListIgnoredFilesInput,
110 ) => Effect.Effect<ProjectListIgnoredFilesResult, WorkspaceEntriesError>;
111 readonly searchContents: (
112 input: ProjectSearchContentsInput,
113 ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceEntriesError>;
114 readonly refresh: (cwd: string) => Effect.Effect<void>;
115 }
116>()("t3/workspace/WorkspaceEntries") {}
117​
118/** Ignored files offered for copying; more than this is a build folder, not config. */
119const IGNORED_FILES_LIMIT = 200;
120​
121/** Paths sent to one `git check-ignore` call when labelling a folder's children. */
122const IGNORE_CHECK_CHUNK_SIZE = 1000;
123​
124/** Children classified at once when listing a folder; each symlink costs a realpath and a stat. */
125const SYMLINK_RESOLVE_BATCH_SIZE = 64;
126​
127const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* (
128 input: FilesystemBrowseInput,
129 path: Path.Path,
130): Effect.fn.Return<string, WorkspaceEntriesBrowseError> {
131 const platform = yield* HostProcess.Platform;
132 if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {
133 return yield* new WorkspaceEntriesWindowsPathUnsupportedError({
134 cwd: input.cwd,
135 partialPath: input.partialPath,
136 platform,
137 });
138 }
139​
140 if (!isExplicitRelativePath(input.partialPath)) {
141 return path.resolve(expandHomePath(input.partialPath, yield* HostProcess.HomeDirectory));
142 }
143​
144 if (!input.cwd) {
145 return yield* new WorkspaceEntriesCurrentProjectRequiredError({
146 partialPath: input.partialPath,
147 });
148 }
149 return path.resolve(
150 expandHomePath(input.cwd, yield* HostProcess.HomeDirectory),
151 input.partialPath,
152 );
153});
154​
155/** @public */
156export const make = Effect.gen(function* () {
157 const path = yield* Path.Path;
158 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
159 const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap;
160 const vcsProcess = yield* VcsProcess.VcsProcess;
161​
162 const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* (
163 cwd: string,
164 ): Effect.fn.Return<string, WorkspaceEntriesError> {
165 return yield* workspacePaths.normalizeWorkspaceRoot(cwd);
166 });
167​
168 const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")(
169 function* (cwd) {
170 const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe(
171 Effect.orElseSucceed(() => cwd),
172 );
173 for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) {
174 const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant);
175 if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) {
176 continue;
177 }
178 const recoverRefreshFailure = Effect.fn("WorkspaceEntries.recoverRefreshFailure")(
179 function* (
180 cause:
181 | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed
182 | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut
183 | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed,
184 ) {
185 yield* Effect.logWarning("Failed to refresh workspace search index", {
186 cwd,
187 variant,
188 cause,
189 });
190 yield* workspaceSearchIndexes.invalidate(indexKey);
191 },
192 );
193 yield* Effect.gen(function* () {
194 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
195 yield* searchIndex.refresh();
196 }).pipe(
197 Effect.provide(workspaceSearchIndexes.get(indexKey)),
198 Effect.catchTags({
199 WorkspaceSearchIndexCreateFailed: recoverRefreshFailure,
200 WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure,
201 WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure,
202 }),
203 );
204 }
205 },
206 );
207​
208 const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")(
209 function* (input) {
210 const resolvedInputPath = yield* resolveBrowseTarget(input, path);
211 const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~";
212 const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath);
213 const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath);
214​
215 const dirents = yield* Effect.tryPromise({
216 try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }),
217 catch: (cause) =>
218 new WorkspaceEntriesReadDirectoryError({
219 cwd: input.cwd,
220 partialPath: input.partialPath,
221 parentPath,
222 cause,
223 }),
224 }).pipe(
225 Effect.catchIf(
226 (error) => {
227 const code = (error.cause as NodeJS.ErrnoException | undefined)?.code;
228 return code === "EACCES" || code === "EPERM";
229 },
230 () => Effect.succeed([]),
231 ),
232 );
233​
234 const showHidden = endsWithSeparator || prefix.startsWith(".");
235 const lowerPrefix = prefix.toLowerCase();
236 const entries: Array<{ readonly name: string; readonly fullPath: string }> = [];
237 for (const dirent of dirents) {
238 if (
239 dirent.isDirectory() &&
240 dirent.name.toLowerCase().startsWith(lowerPrefix) &&
241 (showHidden || !dirent.name.startsWith("."))
242 ) {
243 entries.push({
244 name: dirent.name,
245 fullPath: path.join(parentPath, dirent.name),
246 });
247 }
248 }
249​
250 return {
251 parentPath,
252 entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)),
253 };
254 },
255 );
256​
257 const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")(
258 function* (input) {
259 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
260 const normalizedQuery = normalizeSearchQuery(input.query, {
261 trimLeadingPattern: /^[@./]+/,
262 });
263 const result = yield* Effect.gen(function* () {
264 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
265 return yield* searchIndex.search(normalizedQuery, input.limit, input.kind, input.imageOnly);
266 }).pipe(
267 Effect.provide(
268 workspaceSearchIndexes.get(
269 WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"),
270 ),
271 ),
272 );
273 if (input.kind === "directory" || input.imageOnly) return result;
274 const worktreeFiles = yield* findNestedWorktreeFiles(
275 normalizedCwd,
276 input.query,
277 result.entries,
278 );
279 if (worktreeFiles.length === 0) return result;
280 const entries = [...worktreeFiles, ...result.entries];
281 return {
282 entries: entries.slice(0, input.limit),
283 truncated: result.truncated || entries.length > input.limit,
284 };
285 },
286 );
287​
288 // The index skips gitignored folders, where agents often put worktrees (`.worktrees/x`).
289 // A path relative to such a worktree is found only when it names an existing file exactly.
290 const findNestedWorktreeFiles = Effect.fn("WorkspaceEntries.findNestedWorktreeFiles")(function* (
291 cwd: string,
292 query: string,
293 indexed: ReadonlyArray<ProjectEntry>,
294 ) {
295 const relativePath = query.trim().replaceAll("\\", "/");
296 if (
297 !relativePath.includes("/") ||
298 relativePath.endsWith("/") ||
299 path.isAbsolute(relativePath) ||
300 isWindowsAbsolutePath(relativePath) ||
301 relativePath.split("/").some((segment) => segment === "..") ||
302 indexed.some(
303 (entry) =>
304 entry.kind === "file" &&
305 (entry.path === relativePath || entry.path.endsWith(`/${relativePath}`)),
306 )
307 ) {
308 return [];
309 }
310 const listed = yield* vcsProcess
311 .run({
312 operation: "WorkspaceEntries.listWorktrees",
313 command: "git",
314 args: ["worktree", "list", "--porcelain"],
315 cwd,
316 allowNonZeroExit: true,
317 timeoutMs: 5_000,
318 })
319 .pipe(Effect.orElseSucceed(() => undefined));
320 if (!listed || listed.exitCode !== 0) return [];
321 const realCwd = yield* Effect.promise(() => NodeFSP.realpath(cwd).catch(() => cwd));
322 const worktreeRoots = listed.stdout.split(/\n\s*\n/).flatMap((record) => {
323 const lines = record.split("\n");
324 const worktree = lines.find((line) => line.startsWith("worktree "));
325 // A `prunable` record's directory is gone; another checkout may now sit there.
326 if (worktree === undefined || lines.some((line) => line.startsWith("prunable"))) return [];
327 const root = worktree.slice("worktree ".length);
328 const relativeRoot = path.relative(realCwd, root);
329 return relativeRoot === "" ||
330 relativeRoot === ".." ||
331 relativeRoot.startsWith(`..${path.sep}`) ||
332 path.isAbsolute(relativeRoot)
333 ? []
334 : [relativeRoot.split(path.sep).join("/")];
335 });
336 const matches = yield* Effect.promise(() =>
337 Promise.all(
338 worktreeRoots.map(async (relativeRoot) => {
339 const candidate = `${relativeRoot}/${relativePath}`;
340 const stat = await NodeFSP.stat(path.join(realCwd, candidate)).catch(() => null);
341 return stat?.isFile() ? candidate : null;
342 }),
343 ),
344 );
345 return matches
346 .filter((candidate) => candidate !== null)
347 .map((candidate): ProjectEntry => ({ path: candidate, kind: "file" }));
348 });
349​
350 const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn(
351 "WorkspaceEntries.searchContents",
352 )(function* (input) {
353 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
354 return yield* Effect.gen(function* () {
355 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
356 return yield* searchIndex.searchContents(input);
357 }).pipe(
358 Effect.provide(
359 workspaceSearchIndexes.get(
360 WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"),
361 ),
362 ),
363 );
364 });
365​
366 const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")(
367 function* (input) {
368 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
369 if (input.directoryPath !== undefined) {
370 const directoryPath = input.directoryPath;
371 const toError = (cause: unknown) =>
372 new WorkspaceEntriesReadDirectoryError({
373 cwd: normalizedCwd,
374 partialPath: directoryPath,
375 parentPath: path.resolve(normalizedCwd, directoryPath),
376 cause,
377 });
378 const target =
379 directoryPath === ""
380 ? { absolutePath: normalizedCwd, relativePath: "" }
381 : yield* workspacePaths
382 .resolveRelativePathWithinRoot({
383 workspaceRoot: normalizedCwd,
384 relativePath: directoryPath,
385 })
386 .pipe(Effect.mapError(toError));
387 const { entries, throughSymlink, nestedGitRoots } = yield* Effect.tryPromise({
388 try: async () => {
389 const root = await NodeFSP.realpath(normalizedCwd);
390 const directory = await NodeFSP.realpath(target.absolutePath);
391 const relative = path.relative(root, directory);
392 if (
393 relative === ".." ||
394 relative.startsWith(`..${path.sep}`) ||
395 path.isAbsolute(relative) ||
396 relative.split(path.sep).includes(".git") ||
397 target.relativePath.split("/").includes(".git")
398 ) {
399 throw new Error("Directory must be inside the workspace and outside .git.");
400 }
401 const children = await NodeFSP.readdir(directory, { withFileTypes: true });
402 const isSameOrInside = (parent: string, child: string) => {
403 const relativeChild = path.relative(parent, child);
404 return (
405 relativeChild !== ".." &&
406 !relativeChild.startsWith(`..${path.sep}`) &&
407 !path.isAbsolute(relativeChild)
408 );
409 };
410 // Reject canonical ancestors so "Expand all folders" cannot follow a cycle.
411 let visitedFolders: Promise<ReadonlyArray<string>> | undefined;
412 const loadVisitedFolders = () =>
413 (visitedFolders ??= (async () => {
414 const folders = [root];
415 let current = normalizedCwd;
416 for (const segment of target.relativePath.split("/").filter(Boolean)) {
417 current = path.join(current, segment);
418 folders.push(await NodeFSP.realpath(current));
419 }
420 return folders;
421 })());
422 const resolveSymlinkKind = async (
423 name: string,
424 ): Promise<ProjectEntry["kind"] | undefined> => {
425 const resolved = await NodeFSP.realpath(path.join(directory, name)).catch(
426 () => undefined,
427 );
428 if (
429 resolved === undefined ||
430 !isSameOrInside(root, resolved) ||
431 path.relative(root, resolved).split(path.sep).includes(".git")
432 ) {
433 return undefined;
434 }
435 const stat = await NodeFSP.stat(resolved).catch(() => undefined);
436 if (stat?.isFile()) return "file";
437 if (!stat?.isDirectory()) return undefined;
438 const folders = await loadVisitedFolders();
439 return folders.some((folder) => isSameOrInside(resolved, folder))
440 ? undefined
441 : "directory";
442 };
443 // Bound filesystem work for directories such as pnpm node_modules.
444 const kinds: Array<ProjectEntry["kind"] | undefined> = [];
445 for (let offset = 0; offset < children.length; offset += SYMLINK_RESOLVE_BATCH_SIZE) {
446 kinds.push(
447 ...(await Promise.all(
448 children
449 .slice(offset, offset + SYMLINK_RESOLVE_BATCH_SIZE)
450 .map(async (child): Promise<ProjectEntry["kind"] | undefined> => {
451 if (child.name === ".git") return undefined;
452 if (child.isDirectory()) return "directory";
453 if (child.isFile()) return "file";
454 return child.isSymbolicLink() ? resolveSymlinkKind(child.name) : undefined;
455 }),
456 )),
457 );
458 }
459 const nestedGitRoots: Array<string> = [];
460 const segments = target.relativePath.split("/").filter(Boolean);
461 for (let index = 1; index <= segments.length; index += 1) {
462 const candidate = segments.slice(0, index).join("/");
463 const dotGit = await NodeFSP.lstat(path.join(normalizedCwd, candidate, ".git")).catch(
464 () => null,
465 );
466 if (dotGit) nestedGitRoots.push(candidate);
467 }
468 return {
469 nestedGitRoots,
470 entries: children.flatMap((child, index): ProjectEntry[] => {
471 const kind = kinds[index];
472 if (kind === undefined) return [];
473 return [
474 {
475 path: target.relativePath ? `${target.relativePath}/${child.name}` : child.name,
476 kind,
477 },
478 ];
479 }),
480 // Git refuses to classify paths beyond a symbolic link.
481 throughSymlink: relative !== target.relativePath.split("/").join(path.sep),
482 };
483 },
484 catch: toError,
485 });
486 let gitRoot = "";
487 let gitPrefix = "";
488 // stdin avoids argument limits; ignore classification is optional without git/repositories.
489 const checkIgnore = (paths: ReadonlyArray<string>) =>
490 vcsProcess
491 .run({
492 operation: "WorkspaceEntries.list",
493 command: "git",
494 args: ["-c", "core.fsmonitor=false", "check-ignore", "-z", "--stdin"],
495 cwd: path.join(normalizedCwd, gitRoot),
496 stdin: `${paths.map((entryPath) => entryPath.slice(gitPrefix.length)).join("\0")}\0`,
497 allowNonZeroExit: true,
498 timeoutMs: 10_000,
499 maxOutputBytes: 16 * 1024 * 1024,
500 })
501 .pipe(Effect.orElseSucceed(() => undefined));
502 // Git refuses paths inside a submodule (128) but still answers for nested clones.
503 for (const nestedGitRoot of nestedGitRoots) {
504 if ((yield* checkIgnore([`${nestedGitRoot}/.git`]))?.exitCode !== 128) continue;
505 gitRoot = nestedGitRoot;
506 gitPrefix = `${gitRoot}/`;
507 }
508 const directoryGitPath = target.relativePath.slice(gitPrefix.length);
509 // Check ignored folders once to avoid child index lookups; exit 0 guarantees no tracked descendants.
510 // Symlinks and pathspec magic still need per-child checks.
511 if (
512 entries.length > IGNORE_CHECK_CHUNK_SIZE &&
513 directoryGitPath !== "" &&
514 !throughSymlink &&
515 !/^:|[*?[\\]/.test(directoryGitPath) &&
516 (yield* checkIgnore([target.relativePath]))?.exitCode === 0
517 ) {
518 return {
519 entries: entries.map((entry) => ({ ...entry, ignored: true })),
520 truncated: false,
521 };
522 }
523 const ignored = new Set<string>();
524 for (let offset = 0; offset < entries.length; offset += IGNORE_CHECK_CHUNK_SIZE) {
525 const result = yield* checkIgnore(
526 entries.slice(offset, offset + IGNORE_CHECK_CHUNK_SIZE).map((entry) => entry.path),
527 );
528 if (!result || (result.exitCode !== 0 && result.exitCode !== 1)) break;
529 for (const ignoredPath of result.stdout.split("\0")) ignored.add(gitPrefix + ignoredPath);
530 }
531 return {
532 entries: entries.map((entry) =>
533 ignored.has(entry.path) ? { ...entry, ignored: true } : entry,
534 ),
535 truncated: false,
536 };
537 }
538 return yield* Effect.gen(function* () {
539 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
540 return yield* searchIndex.list();
541 }).pipe(
542 Effect.provide(
543 workspaceSearchIndexes.get(
544 WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"),
545 ),
546 ),
547 );
548 },
549 );
550​
551 const listIgnoredFiles: WorkspaceEntries["Service"]["listIgnoredFiles"] = Effect.fn(
552 "WorkspaceEntries.listIgnoredFiles",
553 )(function* (input) {
554 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
555 // `--directory` reports a folder holding nothing but ignored files as one
556 // `name/` entry, which is how `node_modules` and build output drop out.
557 const result = yield* vcsProcess
558 .run({
559 operation: "WorkspaceEntries.listIgnoredFiles",
560 command: "git",
561 args: [
562 "-c",
563 "core.fsmonitor=false",
564 "ls-files",
565 "--others",
566 "--ignored",
567 "--exclude-standard",
568 "--directory",
569 "--no-empty-directory",
570 "-z",
571 ],
572 cwd: normalizedCwd,
573 allowNonZeroExit: true,
574 timeoutMs: 10_000,
575 maxOutputBytes: 4 * 1024 * 1024,
576 })
577 .pipe(Effect.orElseSucceed(() => undefined));
578 if (!result || result.exitCode !== 0) return { files: [], truncated: false };
579 // Filter before the cap so files sorting past it stay reachable by search.
580 const query = input.query?.toLowerCase() ?? "";
581 const paths = result.stdout
582 .split("\0")
583 .filter(
584 (entry) => entry.length > 0 && !entry.endsWith("/") && entry.toLowerCase().includes(query),
585 )
586 .toSorted();
587 const files = yield* Effect.promise(() =>
588 Promise.all(
589 paths.slice(0, IGNORED_FILES_LIMIT).map(async (relativePath) => {
590 // lstat: a symlink usually points outside the workspace, where reads refuse to go.
591 const stat = await NodeFSP.lstat(path.join(normalizedCwd, relativePath)).catch(
592 () => null,
593 );
594 return stat?.isFile() ? { path: relativePath, byteLength: stat.size } : null;
595 }),
596 ),
597 );
598 return {
599 files: files.filter((file) => file !== null),
600 truncated: paths.length > IGNORED_FILES_LIMIT,
601 };
602 });
603​
604 return WorkspaceEntries.of({ browse, list, listIgnoredFiles, refresh, search, searchContents });
605});
606​
607export const layer = Layer.effect(WorkspaceEntries, make).pipe(
608 Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer),
609 Layer.provide(VcsProcess.layer),
610);