507 lines · 15.7 KB · TypeScript
RawDownload
import * as OS from "node:os";import fsPromises from "node:fs/promises";import type { Dirent } from "node:fs"; import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts";import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";import {  insertRankedSearchResult,  normalizeSearchQuery,  scoreQueryMatch,  type RankedSearchResult,} from "@t3tools/shared/searchRanking"; import { GitCore } from "../../git/Services/GitCore.ts";import {  WorkspaceEntries,  WorkspaceEntriesBrowseError,  WorkspaceEntriesError,  type WorkspaceEntriesShape,} from "../Services/WorkspaceEntries.ts";import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; const WORKSPACE_CACHE_TTL_MS = 15_000;const WORKSPACE_CACHE_MAX_KEYS = 4;const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32;const IGNORED_DIRECTORY_NAMES = new Set([  ".git",  ".convex",  "node_modules",  ".next",  ".turbo",  "dist",  "build",  "out",  ".cache",]); interface WorkspaceIndex {  scannedAt: number;  entries: SearchableWorkspaceEntry[];  truncated: boolean;} interface SearchableWorkspaceEntry extends ProjectEntry {  normalizedPath: string;  normalizedName: string;} type RankedWorkspaceEntry = RankedSearchResult<SearchableWorkspaceEntry>; function toPosixPath(input: string): string {  return input.replaceAll("\\", "/");} function expandHomePath(input: string, path: Path.Path): string {  if (input === "~") {    return OS.homedir();  }  if (input.startsWith("~/") || input.startsWith("~\\")) {    return path.join(OS.homedir(), input.slice(2));  }  return input;} function parentPathOf(input: string): string | undefined {  const separatorIndex = input.lastIndexOf("/");  if (separatorIndex === -1) {    return undefined;  }  return input.slice(0, separatorIndex);} function basenameOf(input: string): string {  const separatorIndex = input.lastIndexOf("/");  if (separatorIndex === -1) {    return input;  }  return input.slice(separatorIndex + 1);} function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry {  const normalizedPath = entry.path.toLowerCase();  return {    ...entry,    normalizedPath,    normalizedName: basenameOf(normalizedPath),  };} function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null {  if (!query) {    return entry.kind === "directory" ? 0 : 1;  }   const { normalizedPath, normalizedName } = entry;   const scores = [    scoreQueryMatch({      value: normalizedName,      query,      exactBase: 0,      prefixBase: 2,      includesBase: 5,      fuzzyBase: 100,    }),    scoreQueryMatch({      value: normalizedPath,      query,      exactBase: 1,      prefixBase: 3,      boundaryBase: 4,      includesBase: 6,      fuzzyBase: 200,      boundaryMarkers: ["/"],    }),  ].filter((score): score is number => score !== null);   if (scores.length === 0) {    return null;  }   return Math.min(...scores);} function isPathInIgnoredDirectory(relativePath: string): boolean {  const firstSegment = relativePath.split("/")[0];  if (!firstSegment) return false;  return IGNORED_DIRECTORY_NAMES.has(firstSegment);} function directoryAncestorsOf(relativePath: string): string[] {  const segments = relativePath.split("/").filter((segment) => segment.length > 0);  if (segments.length <= 1) return [];   const directories: string[] = [];  for (let index = 1; index < segments.length; index += 1) {    directories.push(segments.slice(0, index).join("/"));  }  return directories;} const resolveBrowseTarget = (  input: FilesystemBrowseInput,  pathService: Path.Path,): Effect.Effect<string, WorkspaceEntriesBrowseError> =>  Effect.gen(function* () {    if (process.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 pathService.resolve(expandHomePath(input.partialPath, pathService));    }     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 pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath);  }); export const makeWorkspaceEntries = Effect.gen(function* () {  const path = yield* Path.Path;  const gitOption = yield* Effect.serviceOption(GitCore);  const workspacePaths = yield* WorkspacePaths;   const isInsideGitWorkTree = (cwd: string): Effect.Effect<boolean> =>    Option.match(gitOption, {      onSome: (git) => git.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))),      onNone: () => Effect.succeed(false),    });   const filterGitIgnoredPaths = (    cwd: string,    relativePaths: string[],  ): Effect.Effect<string[], never> =>    Option.match(gitOption, {      onSome: (git) =>        git.filterIgnoredPaths(cwd, relativePaths).pipe(          Effect.map((paths) => [...paths]),          Effect.catch(() => Effect.succeed(relativePaths)),        ),      onNone: () => Effect.succeed(relativePaths),    });   const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")(    function* (cwd: string) {      if (Option.isNone(gitOption)) {        return null;      }      if (!(yield* isInsideGitWorkTree(cwd))) {        return null;      }       const listedFiles = yield* gitOption.value        .listWorkspaceFiles(cwd)        .pipe(Effect.catch(() => Effect.succeed(null)));       if (!listedFiles) {        return null;      }       const listedPaths = [...listedFiles.paths]        .map((entry) => toPosixPath(entry))        .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry));      const filePaths = yield* filterGitIgnoredPaths(cwd, listedPaths);       const directorySet = new Set<string>();      for (const filePath of filePaths) {        for (const directoryPath of directoryAncestorsOf(filePath)) {          if (!isPathInIgnoredDirectory(directoryPath)) {            directorySet.add(directoryPath);          }        }      }       const directoryEntries = [...directorySet]        .toSorted((left, right) => left.localeCompare(right))        .map(          (directoryPath): ProjectEntry => ({            path: directoryPath,            kind: "directory",            parentPath: parentPathOf(directoryPath),          }),        )        .map(toSearchableWorkspaceEntry);      const fileEntries = [...new Set(filePaths)]        .toSorted((left, right) => left.localeCompare(right))        .map(          (filePath): ProjectEntry => ({            path: filePath,            kind: "file",            parentPath: parentPathOf(filePath),          }),        )        .map(toSearchableWorkspaceEntry);       const entries = [...directoryEntries, ...fileEntries];      return {        scannedAt: Date.now(),        entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES),        truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES,      };    },  );   const readDirectoryEntries = Effect.fn("WorkspaceEntries.readDirectoryEntries")(function* (    cwd: string,    relativeDir: string,  ): Effect.fn.Return<    { readonly relativeDir: string; readonly dirents: Dirent[] | null },    WorkspaceEntriesError  > {    return yield* Effect.tryPromise({      try: async () => {        const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd;        const dirents = await fsPromises.readdir(absoluteDir, { withFileTypes: true });        return { relativeDir, dirents };      },      catch: (cause) =>        new WorkspaceEntriesError({          cwd,          operation: "workspaceEntries.readDirectoryEntries",          detail: cause instanceof Error ? cause.message : String(cause),          cause,        }),    }).pipe(      Effect.catchIf(        () => relativeDir.length > 0,        () => Effect.succeed({ relativeDir, dirents: null }),      ),    );  });   const buildWorkspaceIndexFromFilesystem = Effect.fn(    "WorkspaceEntries.buildWorkspaceIndexFromFilesystem",  )(function* (cwd: string): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {    const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd);     let pendingDirectories: string[] = [""];    const entries: SearchableWorkspaceEntry[] = [];    let truncated = false;     while (pendingDirectories.length > 0 && !truncated) {      const currentDirectories = pendingDirectories;      pendingDirectories = [];       const directoryEntries = yield* Effect.forEach(        currentDirectories,        (relativeDir) => readDirectoryEntries(cwd, relativeDir),        { concurrency: WORKSPACE_SCAN_READDIR_CONCURRENCY },      );       const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => {        const { relativeDir, dirents } = directoryEntry;        if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>;         dirents.sort((left, right) => left.name.localeCompare(right.name));        const candidates: Array<{ dirent: Dirent; relativePath: string }> = [];        for (const dirent of dirents) {          if (!dirent.name || dirent.name === "." || dirent.name === "..") {            continue;          }          if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) {            continue;          }          if (!dirent.isDirectory() && !dirent.isFile()) {            continue;          }           const relativePath = toPosixPath(            relativeDir ? path.join(relativeDir, dirent.name) : dirent.name,          );          if (isPathInIgnoredDirectory(relativePath)) {            continue;          }          candidates.push({ dirent, relativePath });        }        return candidates;      });       const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) =>        candidateEntries.map((entry) => entry.relativePath),      );      const allowedPathSet = shouldFilterWithGitIgnore        ? new Set(yield* filterGitIgnoredPaths(cwd, candidatePaths))        : null;       for (const candidateEntries of candidateEntriesByDirectory) {        for (const candidate of candidateEntries) {          if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) {            continue;          }           const entry = toSearchableWorkspaceEntry({            path: candidate.relativePath,            kind: candidate.dirent.isDirectory() ? "directory" : "file",            parentPath: parentPathOf(candidate.relativePath),          });          entries.push(entry);           if (candidate.dirent.isDirectory()) {            pendingDirectories.push(candidate.relativePath);          }           if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) {            truncated = true;            break;          }        }         if (truncated) {          break;        }      }    }     return {      scannedAt: Date.now(),      entries,      truncated,    };  });   const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* (    cwd: string,  ): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {    const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd);    if (gitIndexed) {      return gitIndexed;    }    return yield* buildWorkspaceIndexFromFilesystem(cwd);  });   const workspaceIndexCache = yield* Cache.makeWith<string, WorkspaceIndex, WorkspaceEntriesError>(    buildWorkspaceIndex,    {      capacity: WORKSPACE_CACHE_MAX_KEYS,      timeToLive: (exit) =>        Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero,    },  );   const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* (    cwd: string,  ): Effect.fn.Return<string, WorkspaceEntriesError> {    return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(      Effect.mapError(        (cause) =>          new WorkspaceEntriesError({            cwd,            operation: "workspaceEntries.normalizeWorkspaceRoot",            detail: cause.message,            cause,          }),      ),    );  });   const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")(    function* (cwd) {      const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe(        Effect.catch(() => Effect.succeed(cwd)),      );      yield* Cache.invalidate(workspaceIndexCache, cwd);      if (normalizedCwd !== cwd) {        yield* Cache.invalidate(workspaceIndexCache, normalizedCwd);      }    },  );   const browse: WorkspaceEntriesShape["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: () => fsPromises.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,          }),      });       const showHidden = endsWithSeparator || prefix.startsWith(".");      const lowerPrefix = prefix.toLowerCase();       return {        parentPath,        entries: dirents          .filter(            (dirent) =>              dirent.isDirectory() &&              dirent.name.toLowerCase().startsWith(lowerPrefix) &&              (showHidden || !dirent.name.startsWith(".")),          )          .map((dirent) => ({            name: dirent.name,            fullPath: path.join(parentPath, dirent.name),          }))          .toSorted((left, right) => left.name.localeCompare(right.name)),      };    },  );   const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")(    function* (input) {      const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);      return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe(        Effect.map((index) => {          const normalizedQuery = normalizeSearchQuery(input.query, {            trimLeadingPattern: /^[@./]+/,          });          const limit = Math.max(0, Math.floor(input.limit));          const rankedEntries: RankedWorkspaceEntry[] = [];          let matchedEntryCount = 0;           for (const entry of index.entries) {            const score = scoreEntry(entry, normalizedQuery);            if (score === null) {              continue;            }             matchedEntryCount += 1;            insertRankedSearchResult(              rankedEntries,              { item: entry, score, tieBreaker: entry.path },              limit,            );          }           return {            entries: rankedEntries.map((candidate) => candidate.item),            truncated: index.truncated || matchedEntryCount > limit,          };        }),      );    },  );   return {

Lines 1–500 of 507.