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 ProjectSearchContentsInput,
18 ProjectSearchContentsResult,
19 ProjectSearchEntriesInput,
20 ProjectSearchEntriesResult,
21} from "@t3tools/contracts";
22import * as HostProcess from "@t3tools/shared/HostProcess";
23import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
24import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";
25
26import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
27import * as VcsProcess from "../vcs/VcsProcess.ts";
28import * as WorkspacePaths from "./WorkspacePaths.ts";
29import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
30
31export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedError<WorkspaceEntriesWindowsPathUnsupportedError>()(
32 "WorkspaceEntriesWindowsPathUnsupportedError",
33 {
34 cwd: Schema.optional(Schema.String),
35 partialPath: Schema.String,
36 platform: Schema.String,
37 },
38) {
39 override get message(): string {
40 const cwd = this.cwd ? ` from '${this.cwd}'` : "";
41 return `Windows-style workspace path '${this.partialPath}' is not supported on '${this.platform}'${cwd}.`;
42 }
43}
44
45export class WorkspaceEntriesCurrentProjectRequiredError extends Schema.TaggedError<WorkspaceEntriesCurrentProjectRequiredError>()(
46 "WorkspaceEntriesCurrentProjectRequiredError",
47 {
48 partialPath: Schema.String,
49 },
50) {
51 override get message(): string {
52 return `A current project is required to browse relative workspace path '${this.partialPath}'.`;
53 }
54}
55
56export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedError<WorkspaceEntriesReadDirectoryError>()(
57 "WorkspaceEntriesReadDirectoryError",
58 {
59 cwd: Schema.optional(Schema.String),
60 partialPath: Schema.String,
61 parentPath: Schema.String,
62 cause: Schema.Defect(),
63 },
64) {
65 override get message(): string {
66 const cwd = this.cwd ? ` from '${this.cwd}'` : "";
67 return `Failed to read workspace directory '${this.parentPath}' while browsing '${this.partialPath}'${cwd}.`;
68 }
69}
70
71export const WorkspaceEntriesBrowseError = Schema.Union([
72 WorkspaceEntriesWindowsPathUnsupportedError,
73 WorkspaceEntriesCurrentProjectRequiredError,
74 WorkspaceEntriesReadDirectoryError,
75]);
76export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type;
77
78export const WorkspaceEntriesError = Schema.Union([
79 WorkspaceEntriesReadDirectoryError,
80 WorkspacePaths.WorkspaceRootNotExistsError,
81 WorkspacePaths.WorkspaceRootCreateFailedError,
82 WorkspacePaths.WorkspaceRootStatFailedError,
83 WorkspacePaths.WorkspaceRootNotDirectoryError,
84 WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed,
85 WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut,
86 WorkspaceSearchIndex.WorkspaceSearchIndexSearchFailed,
87]);
88export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type;
89
90export class WorkspaceEntries extends Context.Service<
91 WorkspaceEntries,
92 {
93 readonly browse: (
94 input: FilesystemBrowseInput,
95 ) => Effect.Effect<FilesystemBrowseResult, WorkspaceEntriesBrowseError>;
96 readonly list: (
97 input: ProjectListEntriesInput,
98 ) => Effect.Effect<ProjectListEntriesResult, WorkspaceEntriesError>;
99 readonly search: (
100 input: ProjectSearchEntriesInput,
101 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;
102 readonly searchContents: (
103 input: ProjectSearchContentsInput,
104 ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceEntriesError>;
105 readonly refresh: (cwd: string) => Effect.Effect<void>;
106 }
107>()("t3/workspace/WorkspaceEntries") {}
108
109const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* (
110 input: FilesystemBrowseInput,
111 path: Path.Path,
112): Effect.fn.Return<string, WorkspaceEntriesBrowseError> {
113 const platform = yield* HostProcess.Platform;
114 if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {
115 return yield* new WorkspaceEntriesWindowsPathUnsupportedError({
116 cwd: input.cwd,
117 partialPath: input.partialPath,
118 platform,
119 });
120 }
121
122 if (!isExplicitRelativePath(input.partialPath)) {
123 return path.resolve(expandHomePath(input.partialPath, yield* HostProcess.HomeDirectory));
124 }
125
126 if (!input.cwd) {
127 return yield* new WorkspaceEntriesCurrentProjectRequiredError({
128 partialPath: input.partialPath,
129 });
130 }
131 return path.resolve(
132 expandHomePath(input.cwd, yield* HostProcess.HomeDirectory),
133 input.partialPath,
134 );
135});
136
137/** @public Service construction is part of the canonical Effect module API. */
138export const make = Effect.gen(function* () {
139 const path = yield* Path.Path;
140 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
141 const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap;
142 const vcsProcess = yield* VcsProcess.VcsProcess;
143
144 const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* (
145 cwd: string,
146 ): Effect.fn.Return<string, WorkspaceEntriesError> {
147 return yield* workspacePaths.normalizeWorkspaceRoot(cwd);
148 });
149
150 const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")(
151 function* (cwd) {
152 const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe(
153 Effect.orElseSucceed(() => cwd),
154 );
155 for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) {
156 const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant);
157 if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) {
158 continue;
159 }
160 const recoverRefreshFailure = (
161 cause:
162 | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed
163 | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut
164 | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed,
165 ) =>
166 Effect.gen(function* () {
167 yield* Effect.logWarning("Failed to refresh workspace search index", {
168 cwd,
169 variant,
170 cause,
171 });
172 yield* workspaceSearchIndexes.invalidate(indexKey);
173 });
174 yield* Effect.gen(function* () {
175 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
176 yield* searchIndex.refresh();
177 }).pipe(
178 Effect.provide(workspaceSearchIndexes.get(indexKey)),
179 Effect.catchTags({
180 WorkspaceSearchIndexCreateFailed: recoverRefreshFailure,
181 WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure,
182 WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure,
183 }),
184 );
185 }
186 },
187 );
188
189 const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")(
190 function* (input) {
191 const resolvedInputPath = yield* resolveBrowseTarget(input, path);
192 const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~";
193 const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath);
194 const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath);
195
196 const dirents = yield* Effect.tryPromise({
197 try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }),
198 catch: (cause) =>
199 new WorkspaceEntriesReadDirectoryError({
200 cwd: input.cwd,
201 partialPath: input.partialPath,
202 parentPath,
203 cause,
204 }),
205 }).pipe(
206 Effect.catchIf(
207 (error) => {
208 const code = (error.cause as NodeJS.ErrnoException | undefined)?.code;
209 return code === "EACCES" || code === "EPERM";
210 },
211 () => Effect.succeed([]),
212 ),
213 );
214
215 const showHidden = endsWithSeparator || prefix.startsWith(".");
216 const lowerPrefix = prefix.toLowerCase();
217 const entries: Array<{ readonly name: string; readonly fullPath: string }> = [];
218 for (const dirent of dirents) {
219 if (
220 dirent.isDirectory() &&
221 dirent.name.toLowerCase().startsWith(lowerPrefix) &&
222 (showHidden || !dirent.name.startsWith("."))
223 ) {
224 entries.push({
225 name: dirent.name,
226 fullPath: path.join(parentPath, dirent.name),
227 });
228 }
229 }
230
231 return {
232 parentPath,
233 entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)),
234 };
235 },
236 );
237
238 const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")(
239 function* (input) {
240 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
241 const normalizedQuery = normalizeSearchQuery(input.query, {
242 trimLeadingPattern: /^[@./]+/,
243 });
244 return yield* Effect.gen(function* () {
245 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
246 return yield* searchIndex.search(normalizedQuery, input.limit, input.kind, input.imageOnly);
247 }).pipe(
248 Effect.provide(
249 workspaceSearchIndexes.get(
250 WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"),
251 ),
252 ),
253 );
254 },
255 );
256
257 const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn(
258 "WorkspaceEntries.searchContents",
259 )(function* (input) {
260 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
261 return yield* Effect.gen(function* () {
262 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
263 return yield* searchIndex.searchContents(input);
264 }).pipe(
265 Effect.provide(
266 workspaceSearchIndexes.get(
267 WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"),
268 ),
269 ),
270 );
271 });
272
273 const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")(
274 function* (input) {
275 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
276 if (input.directoryPath !== undefined) {
277 const directoryPath = input.directoryPath;
278 const toError = (cause: unknown) =>
279 new WorkspaceEntriesReadDirectoryError({
280 cwd: normalizedCwd,
281 partialPath: directoryPath,
282 parentPath: path.resolve(normalizedCwd, directoryPath),
283 cause,
284 });
285 const target =
286 directoryPath === ""
287 ? { absolutePath: normalizedCwd, relativePath: "" }
288 : yield* workspacePaths
289 .resolveRelativePathWithinRoot({
290 workspaceRoot: normalizedCwd,
291 relativePath: directoryPath,
292 })
293 .pipe(Effect.mapError(toError));
294 const entries = yield* Effect.tryPromise({
295 try: async () => {
296 const root = await NodeFSP.realpath(normalizedCwd);
297 const directory = await NodeFSP.realpath(target.absolutePath);
298 const relative = path.relative(root, directory);
299 if (
300 relative === ".." ||
301 relative.startsWith(`..${path.sep}`) ||
302 path.isAbsolute(relative) ||
303 relative.split(path.sep).includes(".git") ||
304 target.relativePath.split("/").includes(".git")
305 ) {
306 throw new Error("Directory must be inside the workspace and outside .git.");
307 }
308 const children = await NodeFSP.readdir(directory, { withFileTypes: true });
309 return children.flatMap((child): ProjectEntry[] => {
310 if (child.name === ".git" || (!child.isDirectory() && !child.isFile())) return [];
311 return [
312 {
313 path: target.relativePath ? `${target.relativePath}/${child.name}` : child.name,
314 kind: child.isDirectory() ? "directory" : "file",
315 },
316 ];
317 });
318 },
319 catch: toError,
320 });
321 // Use stdin so large directories cannot exceed the command-line argument limit.
322 // Ignore classification is optional in non-git workspaces or when git is unavailable.
323 const ignored = new Set<string>();
324 for (let offset = 0; offset < entries.length; offset += 1000) {
325 const chunk = entries.slice(offset, offset + 1000);
326 const result = yield* vcsProcess
327 .run({
328 operation: "WorkspaceEntries.list",
329 command: "git",
330 args: ["-c", "core.fsmonitor=false", "check-ignore", "-z", "--stdin"],
331 cwd: normalizedCwd,
332 stdin: `${chunk.map((entry) => entry.path).join("\0")}\0`,
333 allowNonZeroExit: true,
334 timeoutMs: 10_000,
335 maxOutputBytes: 16 * 1024 * 1024,
336 })
337 .pipe(Effect.orElseSucceed(() => undefined));
338 if (!result || (result.exitCode !== 0 && result.exitCode !== 1)) break;
339 for (const ignoredPath of result.stdout.split("\0")) ignored.add(ignoredPath);
340 }
341 return {
342 entries: entries.map((entry) =>
343 ignored.has(entry.path) ? { ...entry, ignored: true } : entry,
344 ),
345 truncated: false,
346 };
347 }
348 return yield* Effect.gen(function* () {
349 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
350 return yield* searchIndex.list();
351 }).pipe(
352 Effect.provide(
353 workspaceSearchIndexes.get(
354 WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"),
355 ),
356 ),
357 );
358 },
359 );
360
361 return WorkspaceEntries.of({ browse, list, refresh, search, searchContents });
362});
363
364export const layer = Layer.effect(WorkspaceEntries, make).pipe(
365 Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer),
366 Layer.provide(VcsProcess.layer),
367);