// @effect-diagnostics nodeBuiltinImport:offimport * 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>()( "WorkspaceEntriesError", { cwd: Schema.String, operation: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), },) {} export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass<WorkspaceEntriesBrowseError>()( "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<FilesystemBrowseResult, WorkspaceEntriesBrowseError>; readonly list: ( input: ProjectListEntriesInput, ) => Effect.Effect<ProjectListEntriesResult, WorkspaceEntriesError>; readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>; readonly refresh: (cwd: string) => Effect.Effect<void>; }>()("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<string, WorkspaceEntriesBrowseError> => 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<string, WorkspaceEntriesError> { 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),);
// @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>()(
"WorkspaceEntriesError",
{
cwd: Schema.String,
operation: Schema.String,
detail: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
) {}
export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass<WorkspaceEntriesBrowseError>()(
"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<FilesystemBrowseResult, WorkspaceEntriesBrowseError>;
readonly list: (
input: ProjectListEntriesInput,
) => Effect.Effect<ProjectListEntriesResult, WorkspaceEntriesError>;
readonly search: (
input: ProjectSearchEntriesInput,
) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;
readonly refresh: (cwd: string) => Effect.Effect<void>;
}
>()("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<string, WorkspaceEntriesBrowseError> =>
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<string, WorkspaceEntriesError> {
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),
);