// @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as RcMap from "effect/RcMap"; import * as Schema from "effect/Schema"; import type { FilesystemBrowseInput, FilesystemBrowseResult, ProjectListEntriesInput, ProjectListEntriesResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import * as WorkspacePaths from "./Services/WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( "WorkspaceEntriesError", { cwd: Schema.String, operation: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }, ) {} export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass()( "WorkspaceEntriesBrowseError", { cwd: Schema.optional(Schema.String), partialPath: Schema.String, operation: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }, ) {} export class WorkspaceEntries extends Context.Service< WorkspaceEntries, { readonly browse: ( input: FilesystemBrowseInput, ) => Effect.Effect; readonly list: ( input: ProjectListEntriesInput, ) => Effect.Effect; readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; readonly refresh: (cwd: string) => Effect.Effect; } >()("t3/workspace/WorkspaceEntries") {} function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { return path.join(NodeOS.homedir(), input.slice(2)); } return input; } const resolveBrowseTarget = ( input: FilesystemBrowseInput, path: Path.Path, ): Effect.Effect => Effect.gen(function* () { const platform = yield* HostProcessPlatform; if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { return yield* new WorkspaceEntriesBrowseError({ cwd: input.cwd, partialPath: input.partialPath, operation: "workspaceEntries.resolveBrowseTarget", detail: "Windows-style paths are only supported on Windows.", }); } if (!isExplicitRelativePath(input.partialPath)) { return path.resolve(expandHomePath(input.partialPath, path)); } if (!input.cwd) { return yield* new WorkspaceEntriesBrowseError({ cwd: input.cwd, partialPath: input.partialPath, operation: "workspaceEntries.resolveBrowseTarget", detail: "Relative filesystem browse paths require a current project.", }); } return path.resolve(expandHomePath(input.cwd, path), input.partialPath); }); const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( cwd: string, ): Effect.fn.Return { return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => new WorkspaceEntriesError({ cwd, operation: "workspaceEntries.normalizeWorkspaceRoot", detail: cause.message, cause, }), ), ); }); const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")( function* (cwd) { const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( Effect.orElseSucceed(() => cwd), ); if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { return; } yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; yield* searchIndex.refresh(); }).pipe( Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), Effect.catch((cause) => Effect.gen(function* () { yield* Effect.logWarning("Failed to refresh workspace search index", { cwd, cause, }); yield* workspaceSearchIndexes.invalidate(normalizedCwd); }), ), ); }, ); const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( function* (input) { const resolvedInputPath = yield* resolveBrowseTarget(input, path); const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); const dirents = yield* Effect.tryPromise({ try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), catch: (cause) => new WorkspaceEntriesBrowseError({ cwd: input.cwd, partialPath: input.partialPath, operation: "workspaceEntries.browse.readDirectory", detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, cause, }), }).pipe( Effect.catchIf( (error) => { const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; return code === "EACCES" || code === "EPERM"; }, () => Effect.succeed([]), ), ); const showHidden = endsWithSeparator || prefix.startsWith("."); const lowerPrefix = prefix.toLowerCase(); const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; for (const dirent of dirents) { if ( dirent.isDirectory() && dirent.name.toLowerCase().startsWith(lowerPrefix) && (showHidden || !dirent.name.startsWith(".")) ) { entries.push({ name: dirent.name, fullPath: path.join(parentPath, dirent.name), }); } } return { parentPath, entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), }; }, ); const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); const normalizedQuery = input.query .trim() .toLowerCase() .replace(/^[@./]+/, ""); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.search(normalizedQuery, input.limit); }).pipe( Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), Effect.mapError( (cause) => new WorkspaceEntriesError({ cwd: input.cwd, operation: "workspaceEntries.search", detail: cause.message, cause, }), ), ); }, ); const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); }).pipe( Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), Effect.mapError( (cause) => new WorkspaceEntriesError({ cwd: input.cwd, operation: "workspaceEntries.list", detail: cause.message, cause, }), ), ); }, ); return WorkspaceEntries.of({ browse, list, refresh, search }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), );