diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index a08350ed959..bed012ba3a6 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,8 +1,6 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { FileFinder } from "@ff-labs/fff-node"; -import { it, afterEach, describe, expect } from "@effect/vitest"; +import { assert, it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -16,11 +14,6 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; -vi.mock("node:fs/promises", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, readdir: vi.fn(actual.readdir) }; -}); - const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), Layer.provideMerge(WorkspacePaths.layer), @@ -378,16 +371,36 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { it.effect("returns an empty listing when the OS denies directory access", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); - - const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); - vi.mocked(NodeFSP.readdir).mockRejectedValueOnce(denied); + const denied = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readDirectory", + pathOrDescriptor: cwd, + description: "Test PermissionDenied readDirectory failure.", + }); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries.pipe( + Effect.provide( + WorkspaceEntries.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide( + Layer.succeed( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: () => Effect.fail(denied), + }), + ), + ), + ), + ), + ); const result = yield* workspaceEntries.browse({ partialPath: yield* appendSeparator(cwd), }); - expect(result).toEqual({ parentPath: cwd, entries: [] }); + assert.deepEqual(result, { parentPath: cwd, entries: [] }); }), ); }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 7501cbe0eab..5917977b7a1 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -1,10 +1,11 @@ // @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 FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as RcMap from "effect/RcMap"; import * as Schema from "effect/Schema"; @@ -133,6 +134,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu }); export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; @@ -185,37 +187,34 @@ export const make = Effect.gen(function* () { 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 entryNames = yield* fileSystem.readDirectory(parentPath).pipe( + Effect.catchTag("PlatformError", (cause) => + cause.reason._tag === "PermissionDenied" + ? Effect.succeed([]) + : Effect.fail( + new WorkspaceEntriesReadDirectoryError({ + cwd: input.cwd, + partialPath: input.partialPath, + parentPath, + cause, + }), + ), ), ); 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(".")) - ) { + for (const name of entryNames) { + if (name.toLowerCase().startsWith(lowerPrefix) && (showHidden || !name.startsWith("."))) { + const fullPath = path.join(parentPath, name); + const info = yield* fileSystem.stat(fullPath).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "Directory") { + continue; + } entries.push({ - name: dirent.name, - fullPath: path.join(parentPath, dirent.name), + name, + fullPath, }); } } diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993..b3e2bcf8f5a 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,9 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it, describe, expect } from "@effect/vitest"; +import { assert, it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -185,8 +186,8 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i operationPath: resolvedPath, operation: "realpath-target", }); - expect(error.cause).toBeInstanceOf(Error); - expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "NotFound"); }), ); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb39..e9474b9c718 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off /** * WorkspaceFileSystem - Effect service contract for workspace file mutations. * @@ -7,8 +6,6 @@ * * @module WorkspaceFileSystem */ -import * as NodeFSP from "node:fs/promises"; - import type { ProjectReadFileInput, ProjectReadFileResult, @@ -19,6 +16,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -140,30 +138,28 @@ export const make = Effect.gen(function* () { relativePath: input.relativePath, }); - const realWorkspaceRoot = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(input.cwd), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: input.cwd, - operation: "realpath-workspace-root", - cause, - }), - }); - const realTargetPath = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(target.absolutePath), - catch: (cause) => + const toOperationError = + ( + operation: WorkspaceFileSystemOperationError["operation"], + operationPath: string, + resolvedPath = target.absolutePath, + ) => + (cause: unknown) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: target.absolutePath, - operation: "realpath-target", + resolvedPath, + operationPath, + operation, cause, - }), - }); + }); + + const realWorkspaceRoot = yield* fileSystem + .realPath(input.cwd) + .pipe(Effect.mapError(toOperationError("realpath-workspace-root", input.cwd))); + const realTargetPath = yield* fileSystem + .realPath(target.absolutePath) + .pipe(Effect.mapError(toOperationError("realpath-target", target.absolutePath))); const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); if ( relativeRealPath.startsWith(`..${path.sep}`) || @@ -178,84 +174,45 @@ export const make = Effect.gen(function* () { }); } - return yield* Effect.acquireUseRelease( - Effect.tryPromise({ - try: () => NodeFSP.open(realTargetPath, "r"), - catch: (cause) => - new WorkspaceFileSystemOperationError({ + return yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* fileSystem + .open(realTargetPath, { flag: "r" }) + .pipe(Effect.mapError(toOperationError("open", realTargetPath, realTargetPath))); + const stat = yield* handle.stat.pipe( + Effect.mapError(toOperationError("stat", realTargetPath, realTargetPath)), + ); + if (stat.type !== "File") { + return yield* new WorkspacePathNotFileError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: realTargetPath, - operationPath: realTargetPath, - operation: "open", - cause, - }), - }), - (handle) => - Effect.gen(function* () { - const stat = yield* Effect.tryPromise({ - try: () => handle.stat(), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - operationPath: realTargetPath, - operation: "stat", - cause, - }), }); - if (!stat.isFile()) { - return yield* new WorkspacePathNotFileError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - }); - } + } - const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); - const buffer = Buffer.alloc(bytesToRead); - const { bytesRead } = yield* Effect.tryPromise({ - try: () => handle.read(buffer, 0, bytesToRead, 0), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - operationPath: realTargetPath, - operation: "read", - cause, - }), + const byteLength = Number(stat.size); + const bytesToRead = Math.min(byteLength, PROJECT_READ_FILE_MAX_BYTES); + const fileBytes = Option.getOrElse( + yield* handle + .readAlloc(bytesToRead) + .pipe(Effect.mapError(toOperationError("read", realTargetPath, realTargetPath))), + () => new Uint8Array(0), + ); + if (fileBytes.includes(0)) { + return yield* new WorkspaceBinaryFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, }); - const fileBytes = buffer.subarray(0, bytesRead); - if (fileBytes.includes(0)) { - return yield* new WorkspaceBinaryFileError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - }); - } + } - return { - relativePath: target.relativePath, - contents: new TextDecoder("utf-8").decode(fileBytes), - byteLength: stat.size, - truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, - }; - }), - (handle) => - Effect.tryPromise({ - try: () => handle.close(), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - operationPath: realTargetPath, - operation: "close", - cause, - }), - }), + return { + relativePath: target.relativePath, + contents: new TextDecoder("utf-8").decode(fileBytes), + byteLength, + truncated: byteLength > PROJECT_READ_FILE_MAX_BYTES, + }; + }), ); });