| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeFSP from "node:fs/promises"; |
| 3 | |
| 4 | import * as Context from "effect/Context"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as RcMap from "effect/RcMap"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | |
| 11 | import type { |
| 12 | FilesystemBrowseInput, |
| 13 | FilesystemBrowseResult, |
| 14 | ProjectEntry, |
| 15 | ProjectListEntriesInput, |
| 16 | ProjectListEntriesResult, |
| 17 | ProjectSearchContentsInput, |
| 18 | ProjectSearchContentsResult, |
| 19 | ProjectSearchEntriesInput, |
| 20 | ProjectSearchEntriesResult, |
| 21 | } from "@t3tools/contracts"; |
| 22 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 23 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 24 | import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; |
| 25 | |
| 26 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 27 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 28 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 29 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; |
| 30 | |
| 31 | export 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 | |
| 45 | export 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 | |
| 56 | export 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 | |
| 71 | export const WorkspaceEntriesBrowseError = Schema.Union([ |
| 72 | WorkspaceEntriesWindowsPathUnsupportedError, |
| 73 | WorkspaceEntriesCurrentProjectRequiredError, |
| 74 | WorkspaceEntriesReadDirectoryError, |
| 75 | ]); |
| 76 | export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; |
| 77 | |
| 78 | export 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 | ]); |
| 88 | export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type; |
| 89 | |
| 90 | export 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 | |
| 109 | const 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. */ |
| 138 | export 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 | |
| 364 | export const layer = Layer.effect(WorkspaceEntries, make).pipe( |
| 365 | Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), |
| 366 | Layer.provide(VcsProcess.layer), |
| 367 | ); |