287 lines · 10.5 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:offimport * as NodeFSP from "node:fs/promises"; 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,  ProjectSearchContentsInput,  ProjectSearchContentsResult,  ProjectSearchEntriesInput,  ProjectSearchEntriesResult,} from "@t3tools/contracts";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import { expandHomePathWith } from "../pathExpansion.ts";import * as WorkspacePaths from "./WorkspacePaths.ts";import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedError<WorkspaceEntriesWindowsPathUnsupportedError>()(  "WorkspaceEntriesWindowsPathUnsupportedError",  {    cwd: Schema.optional(Schema.String),    partialPath: Schema.String,    platform: Schema.String,  },) {  override get message(): string {    const cwd = this.cwd ? ` from '${this.cwd}'` : "";    return `Windows-style workspace path '${this.partialPath}' is not supported on '${this.platform}'${cwd}.`;  }} export class WorkspaceEntriesCurrentProjectRequiredError extends Schema.TaggedError<WorkspaceEntriesCurrentProjectRequiredError>()(  "WorkspaceEntriesCurrentProjectRequiredError",  {    partialPath: Schema.String,  },) {  override get message(): string {    return `A current project is required to browse relative workspace path '${this.partialPath}'.`;  }} export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedError<WorkspaceEntriesReadDirectoryError>()(  "WorkspaceEntriesReadDirectoryError",  {    cwd: Schema.optional(Schema.String),    partialPath: Schema.String,    parentPath: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    const cwd = this.cwd ? ` from '${this.cwd}'` : "";    return `Failed to read workspace directory '${this.parentPath}' while browsing '${this.partialPath}'${cwd}.`;  }} export const WorkspaceEntriesBrowseError = Schema.Union([  WorkspaceEntriesWindowsPathUnsupportedError,  WorkspaceEntriesCurrentProjectRequiredError,  WorkspaceEntriesReadDirectoryError,]);export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; export const WorkspaceEntriesError = Schema.Union([  WorkspacePaths.WorkspaceRootNotExistsError,  WorkspacePaths.WorkspaceRootCreateFailedError,  WorkspacePaths.WorkspaceRootStatFailedError,  WorkspacePaths.WorkspaceRootNotDirectoryError,  WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed,  WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut,  WorkspaceSearchIndex.WorkspaceSearchIndexSearchFailed,]);export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type; export class WorkspaceEntries extends Context.Service<  WorkspaceEntries,  {    readonly browse: (      input: FilesystemBrowseInput,    ) => Effect.Effect<FilesystemBrowseResult, WorkspaceEntriesBrowseError>;    readonly list: (      input: ProjectListEntriesInput,    ) => Effect.Effect<ProjectListEntriesResult, WorkspaceEntriesError>;    readonly search: (      input: ProjectSearchEntriesInput,    ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;    readonly searchContents: (      input: ProjectSearchContentsInput,    ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceEntriesError>;    readonly refresh: (cwd: string) => Effect.Effect<void>;  }>()("t3/workspace/WorkspaceEntries") {} const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* (  input: FilesystemBrowseInput,  path: Path.Path,): Effect.fn.Return<string, WorkspaceEntriesBrowseError> {  const platform = yield* HostProcessPlatform;  if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {    return yield* new WorkspaceEntriesWindowsPathUnsupportedError({      cwd: input.cwd,      partialPath: input.partialPath,      platform,    });  }   if (!isExplicitRelativePath(input.partialPath)) {    return path.resolve(expandHomePathWith(input.partialPath, path));  }   if (!input.cwd) {    return yield* new WorkspaceEntriesCurrentProjectRequiredError({      partialPath: input.partialPath,    });  }  return path.resolve(expandHomePathWith(input.cwd, path), input.partialPath);}); /** @public Service construction is part of the canonical Effect module API. */export 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<string, WorkspaceEntriesError> {    return yield* workspacePaths.normalizeWorkspaceRoot(cwd);  });   const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")(    function* (cwd) {      const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe(        Effect.orElseSucceed(() => cwd),      );      for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) {        const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant);        if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) {          continue;        }        const recoverRefreshFailure = (          cause:            | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed            | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut            | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed,        ) =>          Effect.gen(function* () {            yield* Effect.logWarning("Failed to refresh workspace search index", {              cwd,              variant,              cause,            });            yield* workspaceSearchIndexes.invalidate(indexKey);          });        yield* Effect.gen(function* () {          const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;          yield* searchIndex.refresh();        }).pipe(          Effect.provide(workspaceSearchIndexes.get(indexKey)),          Effect.catchTags({            WorkspaceSearchIndexCreateFailed: recoverRefreshFailure,            WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure,            WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure,          }),        );      }    },  );   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 WorkspaceEntriesReadDirectoryError({            cwd: input.cwd,            partialPath: input.partialPath,            parentPath,            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 = normalizeSearchQuery(input.query, {        trimLeadingPattern: /^[@./]+/,      });      return yield* Effect.gen(function* () {        const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;        return yield* searchIndex.search(normalizedQuery, input.limit, input.kind, input.imageOnly);      }).pipe(        Effect.provide(          workspaceSearchIndexes.get(            WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"),          ),        ),      );    },  );   const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn(    "WorkspaceEntries.searchContents",  )(function* (input) {    const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);    return yield* Effect.gen(function* () {      const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;      return yield* searchIndex.searchContents(input);    }).pipe(      Effect.provide(        workspaceSearchIndexes.get(          WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"),        ),      ),    );  });   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(            WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"),          ),        ),      );    },  );   return WorkspaceEntries.of({ browse, list, refresh, search, searchContents });}); export const layer = Layer.effect(WorkspaceEntries, make).pipe(  Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer),);