diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..410aebb98ca9 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -81,6 +81,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsCreateUploadUrl]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsRenameEntry]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsDeleteEntry]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c3104e7bc420..6bfc94a446fc 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -33,6 +33,11 @@ import { storeAttachmentUpload, validateAttachmentUploadToken, } from "./assets/AttachmentUpload.ts"; +import { + WORKSPACE_UPLOAD_ROUTE_PREFIX, + storeWorkspaceUpload, + validateWorkspaceUploadToken, +} from "./workspace/WorkspaceUpload.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -280,6 +285,55 @@ export const attachmentUploadRouteLayer = HttpRouter.add( }), ); +export const workspaceUploadRouteLayer = HttpRouter.add( + "POST", + `${WORKSPACE_UPLOAD_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + const token = url.value.pathname.slice(`${WORKSPACE_UPLOAD_ROUTE_PREFIX}/`.length); + if (!token) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const claims = yield* validateWorkspaceUploadToken(token); + if (!claims) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const contentLengthHeader = request.headers["content-length"]; + if ( + contentLengthHeader !== undefined && + (!Number.isInteger(Number(contentLengthHeader)) || + Number(contentLengthHeader) !== claims.sizeBytes) + ) { + return HttpServerResponse.text("Content-Length must match the upload size.", { + status: 400, + }); + } + + // NodeStream.toArrayBuffer treats a falsy maxBytes as "no limit", so a + // 0-byte claim (empty files are a valid upload) would otherwise disable + // the body limit entirely. Floor it at 1 byte; an empty body still passes. + const maxBodySize = FileSystem.Size(Math.max(claims.sizeBytes, 1)); + const body = yield* request.arrayBuffer.pipe( + Effect.provideService(HttpServerRequest.MaxBodySize, maxBodySize), + Effect.orElseSucceed(() => null), + ); + if (body === null) { + return HttpServerResponse.text("Failed to read the upload body.", { status: 400 }); + } + + const stored = yield* storeWorkspaceUpload(claims, new Uint8Array(body)); + return stored.ok + ? HttpServerResponse.empty({ status: 204 }) + : HttpServerResponse.text(stored.detail, { status: stored.status }); + }), +); + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5e4f19172eff..c74c45113eb2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -133,6 +133,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; +import { WORKSPACE_UPLOAD_ROUTE_PREFIX } from "./workspace/WorkspaceUpload.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -5127,6 +5128,125 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("uploads workspace file bytes through a signed URL issued by websocket rpc", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-upload-", + }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const issued = yield* client[WS_METHODS.projectsCreateUploadUrl]({ + cwd: workspaceDir, + relativePath: "uploaded/dropped.bin", + sizeBytes: 6, + overwrite: false, + }); + assert.equal(issued.relativePath, "uploaded/dropped.bin"); + + const badContentLength = yield* HttpClient.post(issued.relativeUrl, { + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3]), "application/octet-stream"), + }); + assert.equal(badContentLength.status, 400); + + const response = yield* HttpClient.post(issued.relativeUrl, { + body: HttpBody.uint8Array( + new Uint8Array([1, 2, 3, 4, 5, 6]), + "application/octet-stream", + ), + }); + assert.equal(response.status, 204); + + const persisted = yield* fs.readFile( + path.join(workspaceDir, "uploaded", "dropped.bin"), + ); + assert.deepEqual(Array.from(persisted), [1, 2, 3, 4, 5, 6]); + + const notFoundResponse = yield* HttpClient.post( + `${WORKSPACE_UPLOAD_ROUTE_PREFIX}/not-a-real-token`, + { body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6])) }, + ); + assert.equal(notFoundResponse.status, 404); + + const conflictTarget = yield* client[WS_METHODS.projectsCreateUploadUrl]({ + cwd: workspaceDir, + relativePath: "uploaded/conflict.bin", + sizeBytes: 6, + overwrite: false, + }); + yield* fs.writeFile( + path.join(workspaceDir, "uploaded", "conflict.bin"), + new Uint8Array([9, 9, 9, 9, 9, 9]), + ); + const conflictResponse = yield* HttpClient.post(conflictTarget.relativeUrl, { + body: HttpBody.uint8Array( + new Uint8Array([1, 2, 3, 4, 5, 6]), + "application/octet-stream", + ), + }); + assert.equal(conflictResponse.status, 409); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("floors the workspace upload body limit for zero-byte claims", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-upload-empty-", + }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const emptyTarget = yield* client[WS_METHODS.projectsCreateUploadUrl]({ + cwd: workspaceDir, + relativePath: "empty.bin", + sizeBytes: 0, + overwrite: false, + }); + const emptyResponse = yield* HttpClient.post(emptyTarget.relativeUrl, { + body: HttpBody.uint8Array(new Uint8Array(0), "application/octet-stream"), + }); + assert.equal(emptyResponse.status, 204); + const emptyPath = path.join(workspaceDir, "empty.bin"); + assert.isTrue(yield* fs.exists(emptyPath)); + assert.equal((yield* fs.readFile(emptyPath)).byteLength, 0); + + // Mint a second zero-byte claim and post a chunked body (no + // Content-Length, so the header check above is skipped) that + // exceeds it. NodeStream.toArrayBuffer treats a falsy maxBytes + // as unlimited, so this only fails once the limit is floored at + // 1 byte. + const oversizedTarget = yield* client[WS_METHODS.projectsCreateUploadUrl]({ + cwd: workspaceDir, + relativePath: "empty-oversized.bin", + sizeBytes: 0, + overwrite: false, + }); + const oversizedResponse = yield* HttpClient.post(oversizedTarget.relativeUrl, { + body: HttpBody.stream(Stream.make(new Uint8Array([1, 2, 3, 4, 5, 6]))), + }); + assert.equal(oversizedResponse.status, 400); + assert.isFalse(yield* fs.exists(path.join(workspaceDir, "empty-oversized.bin"))); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("creates a missing workspace root during websocket project.create dispatch", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0a31bf376dae..bf46f74c0908 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -18,6 +18,7 @@ import { staticAndDevRouteLayer, browserApiCorsLayer, httpCompressionLayer, + workspaceUploadRouteLayer, } from "./http.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; @@ -458,6 +459,7 @@ export const makeRoutesLayer = Layer.mergeAll( otlpTracesProxyRouteLayer, assetRouteLayer, attachmentUploadRouteLayer, + workspaceUploadRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993d..8ed790b89abb 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,9 +1,18 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; +import { + ProjectDeleteEntryError, + ProjectRenameEntryError, + ProjectRenameEntryTargetExistsError, +} from "@t3tools/contracts"; 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"; @@ -265,4 +274,747 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); }); + + describe("renameEntry", () => { + it.effect("renames a file within its directory", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/notes.md", "# Notes\n"); + + const result = yield* workspaceFileSystem.renameEntry({ + cwd, + relativePath: "src/notes.md", + newRelativePath: "src/journal.md", + }); + + expect(result).toEqual({ relativePath: "src/journal.md" }); + const renamed = yield* fileSystem + .readFileString(path.join(cwd, "src/journal.md")) + .pipe(Effect.orDie); + expect(renamed).toBe("# Notes\n"); + const sourceExists = yield* fileSystem.exists(path.join(cwd, "src/notes.md")); + expect(sourceExists).toBe(false); + }), + ); + + it.effect("invalidates workspace entry search cache after renames", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "notes.md", "# Notes\n"); + const beforeRename = yield* workspaceEntries.list({ cwd }); + expect(beforeRename.entries.some((entry) => entry.path === "journal.md")).toBe(false); + + yield* workspaceFileSystem.renameEntry({ + cwd, + relativePath: "notes.md", + newRelativePath: "journal.md", + }); + + const afterRename = yield* workspaceEntries.list({ cwd }); + expect(afterRename.entries.some((entry) => entry.path === "journal.md")).toBe(true); + expect(afterRename.entries.some((entry) => entry.path === "notes.md")).toBe(false); + }), + ); + + it.effect("rejects renaming onto an existing entry without overwriting it", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/source.md", "source\n"); + yield* writeTextFile(cwd, "src/taken.md", "taken\n"); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src/source.md", + newRelativePath: "src/taken.md", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryTargetExistsError); + expect(error).toMatchObject({ cwd, relativePath: "src/taken.md" }); + const source = yield* fileSystem + .readFileString(path.join(cwd, "src/source.md")) + .pipe(Effect.orDie); + expect(source).toBe("source\n"); + const taken = yield* fileSystem + .readFileString(path.join(cwd, "src/taken.md")) + .pipe(Effect.orDie); + expect(taken).toBe("taken\n"); + }), + ); + + it.effect("leaves the file untouched when renamed onto its own path", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/notes.md", "# Notes\n"); + + const result = yield* workspaceFileSystem.renameEntry({ + cwd, + relativePath: "src/notes.md", + newRelativePath: "src/notes.md", + }); + + expect(result).toEqual({ relativePath: "src/notes.md" }); + const contents = yield* fileSystem + .readFileString(path.join(cwd, "src/notes.md")) + .pipe(Effect.orDie); + expect(contents).toBe("# Notes\n"); + }), + ); + + // A hard-linked target reaches the same-inode path a case-only rename + // takes on a case-insensitive filesystem, deterministically on Linux. + // Both names are genuine directory entries, so the target counts as + // occupied; only a true case change lists a single name. + it.effect("rejects renaming onto a hard link of the same file", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/notes.md", "# Notes\n"); + yield* fileSystem + .link(path.join(cwd, "src/notes.md"), path.join(cwd, "src/Notes.md")) + .pipe(Effect.orDie); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src/notes.md", + newRelativePath: "src/Notes.md", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryTargetExistsError); + expect(error).toMatchObject({ cwd, relativePath: "src/Notes.md" }); + const sourceExists = yield* fileSystem.exists(path.join(cwd, "src/notes.md")); + expect(sourceExists).toBe(true); + const targetContents = yield* fileSystem + .readFileString(path.join(cwd, "src/Notes.md")) + .pipe(Effect.orDie); + expect(targetContents).toBe("# Notes\n"); + }), + ); + + // A symlink at the target stats to the source's inode but is its own + // entry; treating it as the same file would delete the source and leave + // the symlink dangling. + it.effect("rejects renaming onto a symlink that points at the source", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/notes.md", "# Notes\n"); + yield* fileSystem + .symlink(path.join(cwd, "src/notes.md"), path.join(cwd, "src/link.md")) + .pipe(Effect.orDie); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src/notes.md", + newRelativePath: "src/link.md", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryTargetExistsError); + expect(error).toMatchObject({ cwd, relativePath: "src/link.md" }); + const source = yield* fileSystem + .readFileString(path.join(cwd, "src/notes.md")) + .pipe(Effect.orDie); + expect(source).toBe("# Notes\n"); + }), + ); + + it.effect("rejects renames that leave the source directory", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/notes.md", "# Notes\n"); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src/notes.md", + newRelativePath: "docs/notes.md", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryError); + expect(error).toMatchObject({ + cwd, + relativePath: "src/notes.md", + stage: "cross-directory", + }); + }), + ); + + it.effect("rejects renaming a directory", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fileSystem.makeDirectory(path.join(cwd, "src")); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src", + newRelativePath: "lib", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryError); + expect(error).toMatchObject({ stage: "not-a-file" }); + }), + ); + + it.effect("rejects renames whose directory resolves outside the root through a symlink", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outside = yield* makeTempDir; + yield* writeTextFile(outside, "owned.txt", "outside\n"); + yield* fileSystem.symlink(outside, path.join(cwd, "linked")); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "linked/owned.txt", + newRelativePath: "linked/renamed.txt", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryError); + expect(error).toMatchObject({ stage: "escapes-root" }); + const untouched = yield* fileSystem + .readFileString(path.join(outside, "owned.txt")) + .pipe(Effect.orDie); + expect(untouched).toBe("outside\n"); + const renamedExists = yield* fileSystem.exists(path.join(outside, "renamed.txt")); + expect(renamedExists).toBe(false); + }), + ); + }); + + describe("deleteEntry", () => { + it.effect("deletes a file relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/notes.md", "# Notes\n"); + + yield* workspaceFileSystem.deleteEntry({ cwd, relativePath: "src/notes.md" }); + + const exists = yield* fileSystem.exists(path.join(cwd, "src/notes.md")); + expect(exists).toBe(false); + }), + ); + + it.effect("succeeds when the file is already missing", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + yield* workspaceFileSystem.deleteEntry({ cwd, relativePath: "missing/notes.md" }); + }), + ); + + // stat follows the link and reads NotFound, so without lstat the delete + // would report success while the entry stays on disk. + it.effect("removes a dangling symlink instead of reporting it already gone", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fileSystem.makeDirectory(path.join(cwd, "src")); + yield* fileSystem.symlink( + path.join(cwd, "src/missing.md"), + path.join(cwd, "src/broken.md"), + ); + + yield* workspaceFileSystem.deleteEntry({ cwd, relativePath: "src/broken.md" }); + + const names = yield* fileSystem.readDirectory(path.join(cwd, "src")); + expect(names).not.toContain("broken.md"); + }), + ); + + it.effect("deletes a symlink without touching its target", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/notes.md", "# Notes\n"); + yield* fileSystem.symlink(path.join(cwd, "src/notes.md"), path.join(cwd, "src/alias.md")); + + yield* workspaceFileSystem.deleteEntry({ cwd, relativePath: "src/alias.md" }); + + const aliasExists = yield* fileSystem.exists(path.join(cwd, "src/alias.md")); + expect(aliasExists).toBe(false); + const contents = yield* fileSystem + .readFileString(path.join(cwd, "src/notes.md")) + .pipe(Effect.orDie); + expect(contents).toBe("# Notes\n"); + }), + ); + + it.effect("rejects deleting a directory", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fileSystem.makeDirectory(path.join(cwd, "src")); + yield* writeTextFile(cwd, "src/index.ts", "export {};\n"); + + const error = yield* workspaceFileSystem + .deleteEntry({ cwd, relativePath: "src" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectDeleteEntryError); + expect(error).toMatchObject({ stage: "not-a-file" }); + const stillThere = yield* fileSystem.exists(path.join(cwd, "src/index.ts")); + expect(stillThere).toBe(true); + }), + ); + }); }); + +const linkRejections: Array = []; +const linklessFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + return FileSystem.FileSystem.of({ + ...real, + link: (fromPath, toPath) => { + linkRejections.push(toPath); + return Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "link", + syscall: "link", + pathOrDescriptor: toPath, + description: "EPERM: the volume rejects hard links", + }), + ); + }, + }); + }), +); + +const LinklessTestLayer = Layer.empty.pipe( + Layer.provideMerge(ProjectLayer), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workspace-files-test-", + }), + ), + Layer.provideMerge(linklessFileSystemLayer), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(LinklessTestLayer, { excludeTestServices: true })( + "WorkspaceFileSystemLive without hard links", + (it) => { + it.effect("renames a file when the volume rejects hard links", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/notes.md", "# Notes\n"); + + const result = yield* workspaceFileSystem.renameEntry({ + cwd, + relativePath: "src/notes.md", + newRelativePath: "src/journal.md", + }); + + expect(result).toEqual({ relativePath: "src/journal.md" }); + expect(linkRejections.length).toBeGreaterThan(0); + const renamed = yield* fileSystem + .readFileString(path.join(cwd, "src/journal.md")) + .pipe(Effect.orDie); + expect(renamed).toBe("# Notes\n"); + const sourceExists = yield* fileSystem.exists(path.join(cwd, "src/notes.md")); + expect(sourceExists).toBe(false); + }), + ); + + it.effect("still rejects renaming onto an existing entry without hard links", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/source.md", "source\n"); + yield* writeTextFile(cwd, "src/taken.md", "taken\n"); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src/source.md", + newRelativePath: "src/taken.md", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryTargetExistsError); + expect(error).toMatchObject({ cwd, relativePath: "src/taken.md" }); + const source = yield* fileSystem + .readFileString(path.join(cwd, "src/source.md")) + .pipe(Effect.orDie); + expect(source).toBe("source\n"); + const taken = yield* fileSystem + .readFileString(path.join(cwd, "src/taken.md")) + .pipe(Effect.orDie); + expect(taken).toBe("taken\n"); + }), + ); + + // exists() follows the link and reads false for a dangling one; the + // O_EXCL claim fails on the entry itself, so the symlink survives. + it.effect("rejects renaming onto a dangling symlink without hard links", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/source.md", "source\n"); + yield* fileSystem.symlink( + path.join(cwd, "src/missing.md"), + path.join(cwd, "src/broken.md"), + ); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src/source.md", + newRelativePath: "src/broken.md", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryTargetExistsError); + const names = yield* fileSystem.readDirectory(path.join(cwd, "src")); + expect(names).toContain("broken.md"); + expect(names).toContain("source.md"); + }), + ); + }, +); + +// Simulates a linkless volume whose rename also fails; when rivalBytesOnRename +// holds bytes, the rename first replaces the target with them, standing in for +// a confirmed overwrite landing between the claim and the rename. When +// statErrorPath names a path, the next stat of that exact path fails once, +// standing in for a transient volume fault between the claim and the inode +// capture; when rivalBytesOnStatError holds bytes, the failing stat first +// replaces the path with them, standing in for a confirmed overwrite landing +// in that same window. +const rivalBytesOnRename: { current: Uint8Array | null } = { current: null }; +const statErrorPath: { current: string | null } = { current: null }; +const rivalBytesOnStatError: { current: Uint8Array | null } = { current: null }; +const brokenRenameFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + return FileSystem.FileSystem.of({ + ...real, + stat: (statPath) => + Effect.suspend(() => { + if (statPath !== statErrorPath.current) { + return real.stat(statPath); + } + statErrorPath.current = null; + const rival = rivalBytesOnStatError.current; + if (rival) { + NodeFS.rmSync(statPath, { force: true }); + NodeFS.writeFileSync(statPath, rival); + } + return Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "stat", + syscall: "stat", + pathOrDescriptor: statPath, + description: "EIO: the volume failed the stat", + }), + ); + }), + link: (_fromPath, toPath) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "link", + syscall: "link", + pathOrDescriptor: toPath, + description: "EPERM: the volume rejects hard links", + }), + ), + rename: (_oldPath, newPath) => + Effect.sync(() => { + const rival = rivalBytesOnRename.current; + if (rival !== null) { + const rivalPath = `${newPath}.rival`; + NodeFS.writeFileSync(rivalPath, rival, { flag: "wx" }); + NodeFS.renameSync(rivalPath, newPath); + } + }).pipe( + Effect.andThen( + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + syscall: "rename", + pathOrDescriptor: newPath, + description: "EACCES: the volume failed the rename", + }), + ), + ), + ), + }); + }), +); + +const BrokenRenameTestLayer = Layer.empty.pipe( + Layer.provideMerge(ProjectLayer), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workspace-files-test-", + }), + ), + Layer.provideMerge(brokenRenameFileSystemLayer), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(BrokenRenameTestLayer, { excludeTestServices: true })( + "WorkspaceFileSystemLive when the fallback rename fails", + (it) => { + it.effect("reclaims the target name so a retry does not read it as taken", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/source.md", "source\n"); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src/source.md", + newRelativePath: "src/renamed.md", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryError); + expect(error).toMatchObject({ stage: "rename" }); + const names = yield* fileSystem.readDirectory(path.join(cwd, "src")); + expect(names).toContain("source.md"); + expect(names).not.toContain("renamed.md"); + }), + ); + + // A zero-byte rival is indistinguishable from the claim by size, so this + // pins the reclaim's inode comparison. + it.effect("keeps a rival's zero-byte overwrite when the fallback rename fails", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/source.md", "source\n"); + rivalBytesOnRename.current = new Uint8Array(0); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src/source.md", + newRelativePath: "src/renamed.md", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryError); + expect(error).toMatchObject({ stage: "rename" }); + // The rival replaced the claim before the failed rename; the reclaim + // must not delete it. + const target = path.join(cwd, "src/renamed.md"); + expect(NodeFS.existsSync(target)).toBe(true); + expect(NodeFS.readFileSync(target).byteLength).toBe(0); + expect(NodeFS.existsSync(path.join(cwd, "src/source.md"))).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + rivalBytesOnRename.current = null; + }), + ), + ), + ); + + // A failed claim-inode stat surfaces before any rename runs; without the + // reclaim the empty claim would make every retry read the name as taken. + it.effect("reclaims the target name when the claim-inode stat fails", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/source.md", "source\n"); + statErrorPath.current = path.join(cwd, "src/renamed.md"); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src/source.md", + newRelativePath: "src/renamed.md", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryError); + expect(error).toMatchObject({ stage: "rename" }); + expect(NodeFS.existsSync(path.join(cwd, "src/renamed.md"))).toBe(false); + expect(NodeFS.existsSync(path.join(cwd, "src/source.md"))).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + statErrorPath.current = null; + }), + ), + ), + ); + + // A confirmed overwrite can replace the claim in the same window the stat + // fault covers; the reclaim must not delete the rival's stored file. + it.effect("keeps a rival's overwrite when the claim-inode stat fails", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/source.md", "source\n"); + statErrorPath.current = path.join(cwd, "src/renamed.md"); + rivalBytesOnStatError.current = new TextEncoder().encode("rival\n"); + + const error = yield* workspaceFileSystem + .renameEntry({ + cwd, + relativePath: "src/source.md", + newRelativePath: "src/renamed.md", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProjectRenameEntryError); + expect(error).toMatchObject({ stage: "rename" }); + expect(NodeFS.readFileSync(path.join(cwd, "src/renamed.md"), "utf8")).toBe("rival\n"); + expect(NodeFS.existsSync(path.join(cwd, "src/source.md"))).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + statErrorPath.current = null; + rivalBytesOnStatError.current = null; + }), + ), + ), + ); + }, +); + +// The link lands, then a concurrent writer replaces the source name with a +// new file before the source removal runs. +const linkRivalBytes: { current: Uint8Array | null } = { current: null }; +const linkRivalFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + return FileSystem.FileSystem.of({ + ...real, + link: (fromPath, toPath) => + real.link(fromPath, toPath).pipe( + Effect.andThen( + Effect.sync(() => { + const rival = linkRivalBytes.current; + if (rival) { + // A real writer renames its own staged file onto the source + // name, replacing the inode; remove-then-write reproduces it. + NodeFS.rmSync(fromPath, { force: true }); + NodeFS.writeFileSync(fromPath, rival); + } + }), + ), + ), + }); + }), +); + +const LinkRivalTestLayer = Layer.empty.pipe( + Layer.provideMerge(ProjectLayer), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workspace-files-test-", + }), + ), + Layer.provideMerge(linkRivalFileSystemLayer), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(LinkRivalTestLayer, { excludeTestServices: true })( + "WorkspaceFileSystemLive when a writer replaces the source after the link", + (it) => { + it.effect("keeps the writer's file instead of removing the source name", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/source.md", "source\n"); + linkRivalBytes.current = new TextEncoder().encode("rival\n"); + + const result = yield* workspaceFileSystem.renameEntry({ + cwd, + relativePath: "src/source.md", + newRelativePath: "src/renamed.md", + }); + + expect(result).toEqual({ relativePath: "src/renamed.md" }); + // The rename lands under the new name and the writer's file survives + // under the old one, the shape a plain rename race would also leave. + expect(NodeFS.readFileSync(path.join(cwd, "src/renamed.md"), "utf8")).toBe("source\n"); + expect(NodeFS.readFileSync(path.join(cwd, "src/source.md"), "utf8")).toBe("rival\n"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + linkRivalBytes.current = null; + }), + ), + ), + ); + }, +); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb390..62066cdaeb24 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -9,16 +9,25 @@ */ import * as NodeFSP from "node:fs/promises"; -import type { - ProjectReadFileInput, - ProjectReadFileResult, - ProjectWriteFileInput, - ProjectWriteFileResult, +import { + ProjectDeleteEntryError, + ProjectRenameEntryError, + ProjectRenameEntryTargetExistsError, + type ProjectDeleteEntryInput, + type ProjectDeleteEntryStage, + type ProjectReadFileInput, + type ProjectReadFileResult, + type ProjectRenameEntryInput, + type ProjectRenameEntryResult, + type ProjectRenameEntryStage, + type ProjectWriteFileInput, + type ProjectWriteFileResult, } from "@t3tools/contracts"; 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"; @@ -123,6 +132,26 @@ export class WorkspaceFileSystem extends Context.Service< ProjectWriteFileResult, WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError >; + /** + * Rename a file to a new name in the same directory. + * + * Never overwrites: an existing entry at the new name fails with + * `ProjectRenameEntryTargetExistsError`. Directories are rejected. + */ + readonly renameEntry: ( + input: ProjectRenameEntryInput, + ) => Effect.Effect< + ProjectRenameEntryResult, + ProjectRenameEntryError | ProjectRenameEntryTargetExistsError + >; + /** + * Delete a file relative to the workspace root. + * + * Deleting an already-missing file succeeds. Directories are rejected. + */ + readonly deleteEntry: ( + input: ProjectDeleteEntryInput, + ) => Effect.Effect; } >()("t3/workspace/WorkspaceFileSystem") {} @@ -297,7 +326,329 @@ export const make = Effect.gen(function* () { return { relativePath: target.relativePath }; }); - return WorkspaceFileSystem.of({ readFile, writeFile }); + // The lexical resolve cannot see symlinked directory components, so rename + // and delete canonically re-check the entry's parent directory before + // mutating, the same way storeWorkspaceUpload guards uploads. + const directoryEscapesWorkspaceRoot = Effect.fn(function* ( + workspaceRoot: string, + directory: string, + ) { + const [canonicalRoot, canonicalDir] = yield* Effect.all([ + fileSystem.realPath(workspaceRoot), + fileSystem.realPath(directory), + ]); + const relativeDir = path.relative(canonicalRoot, canonicalDir); + return ( + relativeDir === ".." || + relativeDir.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeDir) + ); + }); + + // A link conflict can be the source itself seen under another name, either + // a case variant on a case-insensitive filesystem or a pre-existing hard + // link; the same device and inode identifies it. lstat, not stat: a symlink + // pointing at the source is a distinct entry, and following it here would + // let the rename delete the source and leave the symlink dangling. Stat + // failures count as a genuine conflict, the safe reading. + const isSameFile = Effect.fn(function* (leftPath: string, rightPath: string) { + const stats = yield* Effect.tryPromise(() => + Promise.all([ + NodeFSP.lstat(leftPath, { bigint: true }), + NodeFSP.lstat(rightPath, { bigint: true }), + ]), + ).pipe(Effect.orElseSucceed(() => null)); + if (stats === null) { + return false; + } + const [left, right] = stats; + return left.dev === right.dev && left.ino === right.ino; + }); + + const renameEntry: WorkspaceFileSystem["Service"]["renameEntry"] = Effect.fn( + "WorkspaceFileSystem.renameEntry", + )(function* (input) { + const renameError = (stage: ProjectRenameEntryStage, cause?: unknown) => + new ProjectRenameEntryError({ + cwd: input.cwd, + relativePath: input.relativePath, + stage, + cause, + }); + + const [source, target] = yield* Effect.all([ + workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }), + workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.newRelativePath, + }), + ]).pipe(Effect.mapError((cause) => renameError("resolve-path", cause))); + if (path.dirname(source.relativePath) !== path.dirname(target.relativePath)) { + return yield* renameError("cross-directory"); + } + + const sourceStat = yield* fileSystem + .stat(source.absolutePath) + .pipe(Effect.mapError((cause) => renameError("resolve-path", cause))); + if (sourceStat.type !== "File") { + return yield* renameError("not-a-file"); + } + + const escapes = yield* directoryEscapesWorkspaceRoot( + input.cwd, + path.dirname(source.absolutePath), + ).pipe(Effect.mapError((cause) => renameError("resolve-path", cause))); + if (escapes) { + return yield* renameError("escapes-root"); + } + + // Renaming a file onto its own exact path is a no-op. Without this guard + // the identical path would read as a hard link pair below and the source + // removal would delete the file's only directory entry. + if (source.relativePath === target.relativePath) { + return { relativePath: target.relativePath }; + } + + // rename would replace an existing target; link fails atomically instead, + // so a rename can never clobber another entry. The link and the source + // removal form one critical section: an interrupt between them would + // strand both names on disk, so the pair runs uninterruptibly. + yield* Effect.uninterruptible( + Effect.gen(function* () { + const claim = yield* fileSystem.link(source.absolutePath, target.absolutePath).pipe( + Effect.as("linked" as const), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed("conflict" as const), + ), + // FAT and exFAT volumes reject hard links; a real failure of any + // other kind resurfaces from the fallback rename below. + Effect.catch(() => Effect.succeed("unsupported" as const)), + ); + if (claim === "unsupported") { + // Without hard links an empty O_EXCL create claims the target name, + // so the rename below can only ever replace this rename's own claim + // and no window exists between the conflict check and the rename. + // The create also fails on entries exists() cannot see, such as a + // dangling symlink. When the name is occupied by the source's own + // inode, the volume resolves names case-insensitively and the + // rename is a case change, which needs no claim: any rival create + // at the target fails against the source until the rename lands. + const fallbackClaim = yield* fileSystem + .writeFile(target.absolutePath, new Uint8Array(0), { flag: "wx" }) + .pipe( + Effect.as("claimed" as const), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed("occupied" as const), + ), + Effect.mapError((cause) => renameError("rename", cause)), + ); + if (fallbackClaim === "occupied") { + const sameFile = yield* isSameFile(source.absolutePath, target.absolutePath); + if (!sameFile) { + return yield* new ProjectRenameEntryTargetExistsError({ + cwd: input.cwd, + relativePath: target.relativePath, + }); + } + return yield* fileSystem + .rename(source.absolutePath, target.absolutePath) + .pipe(Effect.mapError((cause) => renameError("rename", cause))); + } + // A rival's confirmed overwrite can replace the claim before the + // rename and can legitimately be zero bytes, so the failed-rename + // reclaim below requires the inode captured at claim time, with the + // size check alone only where the platform reports no inode. + const claimInode = yield* fileSystem.stat(target.absolutePath).pipe( + Effect.map((info) => Option.getOrNull(info.ino)), + // A stat failure here strands the empty claim, so every later + // attempt reads the name as taken. With no inode to identify the + // claim by, the reclaim re-stats and removes only a zero-byte + // file, so a rival's non-empty overwrite is never deleted; when + // the fault persists the claim stays, trading a retryable + // conflict for zero data loss. + Effect.tapError(() => + fileSystem.stat(target.absolutePath).pipe( + Effect.flatMap((info) => + info.size === FileSystem.Size(0) + ? fileSystem.remove(target.absolutePath, { force: true }) + : Effect.void, + ), + Effect.ignore, + ), + ), + Effect.mapError((cause) => renameError("rename", cause)), + ); + return yield* fileSystem.rename(source.absolutePath, target.absolutePath).pipe( + // A failed rename leaves the empty claim at the target; reclaim + // it so a retry does not read the name as taken. + Effect.tapError(() => + fileSystem.stat(target.absolutePath).pipe( + Effect.flatMap((info) => + info.size === FileSystem.Size(0) && Option.getOrNull(info.ino) === claimInode + ? fileSystem.remove(target.absolutePath, { force: true }) + : Effect.void, + ), + Effect.ignore, + ), + ), + Effect.mapError((cause) => renameError("rename", cause)), + ); + } + if (claim === "conflict") { + const sameFile = yield* isSameFile(source.absolutePath, target.absolutePath); + if (!sameFile) { + return yield* new ProjectRenameEntryTargetExistsError({ + cwd: input.cwd, + relativePath: target.relativePath, + }); + } + // The conflicting target is another name of the source's own inode. + // The directory listing reports exact on-disk names and tells the + // two shapes apart. A case change only ever lists one of the names, + // so both names listed is a pre-existing hard link pair, where the + // target name is occupied like any other conflict. Only the source + // listed is the source under another casing on a case-insensitive + // filesystem, where rename applies the case change. + const siblingNames = yield* fileSystem + .readDirectory(path.dirname(source.absolutePath)) + .pipe(Effect.mapError((cause) => renameError("rename", cause))); + const sourceListed = siblingNames.includes(path.basename(source.absolutePath)); + const targetListed = siblingNames.includes(path.basename(target.absolutePath)); + if (sourceListed && targetListed) { + return yield* new ProjectRenameEntryTargetExistsError({ + cwd: input.cwd, + relativePath: target.relativePath, + }); + } + if (sourceListed) { + return yield* fileSystem + .rename(source.absolutePath, target.absolutePath) + .pipe(Effect.mapError((cause) => renameError("rename", cause))); + } + // The file can also sit on disk under a third casing that matches + // neither typed name, which the listing checks above cannot see. + // Resolve the entry that folds to the source name and rename from + // it so the case change still lands. No such entry means the name + // already carries the target casing or the data moved on, and the + // rename is done. + const sourceFold = path.basename(source.absolutePath).toLowerCase(); + const targetName = path.basename(target.absolutePath); + const onDiskName = siblingNames.find( + (name) => name !== targetName && name.toLowerCase() === sourceFold, + ); + if (onDiskName === undefined) { + return; + } + return yield* fileSystem + .rename(path.join(path.dirname(source.absolutePath), onDiskName), target.absolutePath) + .pipe(Effect.mapError((cause) => renameError("rename", cause))); + } + // A concurrent writer can replace the source name after the link + // lands; removing it then would destroy the newer data. The source is + // only removed while it still names the linked inode. Otherwise the + // writer's file stays under the source name, the same shape a plain + // rename leaves when the source is recreated mid-flight. + const sourceStillLinked = yield* isSameFile(source.absolutePath, target.absolutePath); + if (!sourceStillLinked) { + return; + } + yield* fileSystem.remove(source.absolutePath).pipe( + // A missing source means something else removed it after the link + // landed, leaving the target as the only copy of the data; rolling + // the link back would destroy it. Only real removal failures, where + // the source still exists, undo the link. + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.void, + ), + Effect.catchTags({ + PlatformError: (cause) => + Effect.gen(function* () { + yield* fileSystem.remove(target.absolutePath, { force: true }).pipe(Effect.ignore); + return yield* renameError("rename", cause); + }), + }), + ); + }), + ); + + yield* workspaceEntries.refresh(input.cwd); + return { relativePath: target.relativePath }; + }); + + const deleteEntry: WorkspaceFileSystem["Service"]["deleteEntry"] = Effect.fn( + "WorkspaceFileSystem.deleteEntry", + )(function* (input) { + const deleteError = (stage: ProjectDeleteEntryStage, cause?: unknown) => + new ProjectDeleteEntryError({ + cwd: input.cwd, + relativePath: input.relativePath, + stage, + cause, + }); + + const target = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }) + .pipe(Effect.mapError((cause) => deleteError("resolve-path", cause))); + + // A parent directory that fails to canonicalize as missing means the entry + // is already gone, which counts as a successful delete. + const escapes = yield* directoryEscapesWorkspaceRoot( + input.cwd, + path.dirname(target.absolutePath), + ).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + Effect.mapError((cause) => deleteError("resolve-path", cause)), + ); + if (escapes === null) { + return; + } + if (escapes) { + return yield* deleteError("escapes-root"); + } + + // lstat examines the directory entry itself, so a dangling symlink is + // still found and removed; stat would follow it, read NotFound, and + // report success while the entry stays on disk. + const targetStat = yield* Effect.tryPromise(() => NodeFSP.lstat(target.absolutePath)).pipe( + Effect.catchIf( + (error) => + typeof error.cause === "object" && + error.cause !== null && + "code" in error.cause && + error.cause.code === "ENOENT", + () => Effect.succeed(null), + ), + Effect.mapError((error) => deleteError("resolve-path", error.cause)), + ); + if (targetStat === null) { + return; + } + // remove never follows a symlink, so deleting one drops only the link. + // Everything else must be a regular file: directories, FIFOs, sockets, + // and device nodes stay out of reach, matching renameEntry. + if (!targetStat.isFile() && !targetStat.isSymbolicLink()) { + return yield* deleteError("not-a-file"); + } + + yield* fileSystem + .remove(target.absolutePath, { force: true }) + .pipe(Effect.mapError((cause) => deleteError("remove", cause))); + yield* workspaceEntries.refresh(input.cwd); + }); + + return WorkspaceFileSystem.of({ readFile, writeFile, renameEntry, deleteEntry }); }); export const layer = Layer.effect(WorkspaceFileSystem, make); diff --git a/apps/server/src/workspace/WorkspaceUpload.test.ts b/apps/server/src/workspace/WorkspaceUpload.test.ts new file mode 100644 index 000000000000..8f622d53c09d --- /dev/null +++ b/apps/server/src/workspace/WorkspaceUpload.test.ts @@ -0,0 +1,580 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { PROJECT_UPLOAD_URL_TTL_MS } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import { base64UrlEncode, signPayload } from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; +import { + WORKSPACE_UPLOAD_ROUTE_PREFIX, + issueWorkspaceUploadUrl, + storeWorkspaceUpload, + validateWorkspaceUploadToken, +} from "./WorkspaceUpload.ts"; + +const testLayer = Layer.empty.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsProcess.layer), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-upload-test-" })), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempWorkspaceRoot = Effect.fn("makeTempWorkspaceRoot")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-workspace-upload-", + }); +}); + +function tokenFromRelativeUrl(relativeUrl: string): string { + return relativeUrl.slice(`${WORKSPACE_UPLOAD_ROUTE_PREFIX}/`.length); +} + +const AttachmentUploadClaimsForTest = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment-upload"), + attachmentId: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + expiresAt: Schema.Number, +}); +const encodeAttachmentUploadClaimsForTest = Schema.encodeSync( + Schema.fromJsonString(AttachmentUploadClaimsForTest), +); + +describe("WorkspaceUpload", () => { + it.effect("mints, validates, and stores an upload roundtrip", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const bytes = new Uint8Array([1, 2, 3, 4]); + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "sub/dir/file.bin", + sizeBytes: bytes.byteLength, + }); + expect(issued.relativePath).toBe("sub/dir/file.bin"); + + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: true, relativePath: "sub/dir/file.bin" }); + + const finalPath = NodePath.join(cwd, "sub/dir/file.bin"); + expect(NodeFS.existsSync(finalPath)).toBe(true); + expect(NodeFS.readFileSync(finalPath)).toEqual(Buffer.from(bytes)); + const siblingEntries = NodeFS.readdirSync(NodePath.dirname(finalPath)); + expect(siblingEntries.some((entry) => entry.endsWith(".part"))).toBe(false); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a mint target that escapes the workspace root", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + + const error = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "../outside.txt", + sizeBytes: 3, + }).pipe(Effect.flip); + + expect(error._tag).toBe("ProjectCreateUploadUrlError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects mint on an existing file without overwrite, allows it with overwrite", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(NodePath.join(cwd, "existing.txt"), "old"); + + const rejected = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "existing.txt", + sizeBytes: 3, + }).pipe(Effect.flip); + expect(rejected._tag).toBe("ProjectUploadTargetExistsError"); + + const bytes = new Uint8Array([9, 9, 9]); + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "existing.txt", + sizeBytes: bytes.byteLength, + overwrite: true, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: true, relativePath: "existing.txt" }); + expect(NodeFS.readFileSync(NodePath.join(cwd, "existing.txt"))).toEqual(Buffer.from(bytes)); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a mint whose target is a directory, even with overwrite", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(NodePath.join(cwd, "folder")); + + const error = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "folder", + sizeBytes: 3, + overwrite: true, + }).pipe(Effect.flip); + + expect(error._tag).toBe("ProjectCreateUploadUrlError"); + expect(error).toMatchObject({ stage: "target-not-file" }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a store body whose size does not match the claims", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "file.bin", + sizeBytes: 4, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, new Uint8Array([1, 2, 3])); + expect(result).toMatchObject({ ok: false, status: 400 }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a store when the target appeared after mint without overwrite", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const fileSystem = yield* FileSystem.FileSystem; + const bytes = new Uint8Array([5, 6, 7]); + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "race.txt", + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + yield* fileSystem.writeFileString(NodePath.join(cwd, "race.txt"), "raced"); + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toMatchObject({ ok: false, status: 409 }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a store when a directory appeared at the target after mint", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const fileSystem = yield* FileSystem.FileSystem; + const bytes = new Uint8Array([5, 6, 7]); + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "raced-folder", + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + yield* fileSystem.makeDirectory(NodePath.join(cwd, "raced-folder")); + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toMatchObject({ ok: false, status: 409 }); + expect(NodeFS.readdirSync(NodePath.join(cwd, "raced-folder"))).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a store whose directory resolves outside the root through a symlink", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const outside = yield* makeTempWorkspaceRoot(); + NodeFS.symlinkSync(outside, NodePath.join(cwd, "linked")); + + const bytes = new Uint8Array([1, 2, 3]); + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "linked/nested/owned.txt", + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toMatchObject({ ok: false, status: 400 }); + expect(NodeFS.readdirSync(outside)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("stores a file whose basename approaches the filename length limit", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const bytes = new Uint8Array([1, 2, 3]); + const relativePath = `${"a".repeat(230)}.bin`; + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath, + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: true, relativePath }); + expect(NodeFS.readFileSync(NodePath.join(cwd, relativePath))).toEqual(Buffer.from(bytes)); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("stores into an in-root directory whose name starts with dots", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const bytes = new Uint8Array([4, 5, 6]); + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "..config/file.txt", + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: true, relativePath: "..config/file.txt" }); + expect(NodeFS.readFileSync(NodePath.join(cwd, "..config/file.txt"))).toEqual( + Buffer.from(bytes), + ); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects tampered, malformed, expired, and cross-kind tokens", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "file.bin", + sizeBytes: 3, + }); + const token = tokenFromRelativeUrl(issued.relativeUrl); + const [payload, signature] = token.split("."); + + expect(yield* validateWorkspaceUploadToken(`${payload}x.${signature}`)).toBeNull(); + expect(yield* validateWorkspaceUploadToken("garbage")).toBeNull(); + + yield* TestClock.adjust(PROJECT_UPLOAD_URL_TTL_MS + 1); + expect(yield* validateWorkspaceUploadToken(token)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects an attachment-upload token presented to the workspace validator", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const secret = yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32); + const nowMs = yield* Clock.currentTimeMillis; + const encodedPayload = base64UrlEncode( + encodeAttachmentUploadClaimsForTest({ + version: 1, + kind: "attachment-upload", + attachmentId: "pending-00000000-0000-4000-8000-000000000000", + name: "file.png", + mimeType: "image/png", + sizeBytes: 3, + expiresAt: nowMs + 60_000, + }), + ); + const token = `${encodedPayload}.${signPayload(encodedPayload, secret)}`; + + expect(yield* validateWorkspaceUploadToken(token)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("reclaims the staging part before the entries refresh", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const bytes = new Uint8Array([1, 2, 3]); + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "probe.bin", + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: true, relativePath: "probe.bin" }); + + // The refresh rebuilds the search index from disk, so a part still + // present at refresh time would be indexed as a phantom entry. + expect(refreshSnapshots.length).toBeGreaterThan(0); + const seenAtRefresh = refreshSnapshots.flat(); + expect(seenAtRefresh).toContain("probe.bin"); + expect(seenAtRefresh.some((entry) => entry.endsWith(".part"))).toBe(false); + }).pipe(Effect.provide(refreshProbeTestLayer)), + ); + + it.effect("falls back to an exclusive create when the volume rejects hard links", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const bytes = new Uint8Array([9, 8, 7]); + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "fat.bin", + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: true, relativePath: "fat.bin" }); + expect(linkRejections.length).toBeGreaterThan(0); + expect(NodeFS.readFileSync(NodePath.join(cwd, "fat.bin"))).toEqual(Buffer.from(bytes)); + const siblingEntries = NodeFS.readdirSync(cwd); + expect(siblingEntries.some((entry) => entry.endsWith(".part"))).toBe(false); + + const conflicted = yield* storeWorkspaceUpload(claims, bytes); + expect(conflicted).toEqual({ + ok: false, + status: 409, + detail: "A file already exists at this path.", + }); + }).pipe(Effect.provide(linklessTestLayer)), + ); + + it.effect("reclaims the empty claim when the fallback rename fails", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const bytes = new Uint8Array([1, 2, 3]); + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "held.bin", + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: false, status: 500, detail: "Failed to persist upload." }); + // The reclaim removed the empty claim, so a retry does not read the + // name as taken. + expect(NodeFS.existsSync(NodePath.join(cwd, "held.bin"))).toBe(false); + }).pipe(Effect.provide(brokenRenameTestLayer)), + ); + + it.effect("keeps a rival's content when the fallback rename fails", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const bytes = new Uint8Array([1, 2, 3]); + const rival = new Uint8Array([42, 42]); + rivalBytesOnRename.current = rival; + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "contested.bin", + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: false, status: 500, detail: "Failed to persist upload." }); + // A confirmed overwrite that replaced the claim before the failed + // rename must survive the reclaim. + expect(NodeFS.readFileSync(NodePath.join(cwd, "contested.bin"))).toEqual(Buffer.from(rival)); + }).pipe( + Effect.provide(brokenRenameTestLayer), + Effect.ensuring( + Effect.sync(() => { + rivalBytesOnRename.current = null; + }), + ), + ), + ); + + it.effect("keeps a rival's zero-byte overwrite when the fallback rename fails", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const bytes = new Uint8Array([1, 2, 3]); + rivalBytesOnRename.current = new Uint8Array(0); + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "emptied.bin", + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: false, status: 500, detail: "Failed to persist upload." }); + // The rival's file matches the claim's size but not its inode; the + // reclaim must leave it in place. + expect(NodeFS.existsSync(NodePath.join(cwd, "emptied.bin"))).toBe(true); + expect(NodeFS.readFileSync(NodePath.join(cwd, "emptied.bin")).byteLength).toBe(0); + }).pipe( + Effect.provide(brokenRenameTestLayer), + Effect.ensuring( + Effect.sync(() => { + rivalBytesOnRename.current = null; + }), + ), + ), + ); +}); + +const refreshSnapshots: Array> = []; +const refreshProbeLayer = Layer.effect( + WorkspaceEntries.WorkspaceEntries, + Effect.gen(function* () { + const real = yield* WorkspaceEntries.WorkspaceEntries; + return WorkspaceEntries.WorkspaceEntries.of({ + ...real, + refresh: (cwd) => + Effect.sync(() => { + refreshSnapshots.push(NodeFS.readdirSync(cwd, { recursive: true }) as Array); + }).pipe(Effect.andThen(real.refresh(cwd))), + }); + }), +); + +const refreshProbeTestLayer = refreshProbeLayer.pipe(Layer.provideMerge(testLayer)); + +const linkRejections: Array = []; +const linklessFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + return FileSystem.FileSystem.of({ + ...real, + link: (fromPath, toPath) => { + linkRejections.push(toPath); + return Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "link", + syscall: "link", + pathOrDescriptor: toPath, + description: "EPERM: the volume rejects hard links", + }), + ); + }, + }); + }), +); + +// Simulates a linkless volume whose rename also fails; when rivalBytesOnRename +// holds bytes, the rename first replaces the target with them, standing in for +// a confirmed overwrite landing between the claim and the rename. +const rivalBytesOnRename: { current: Uint8Array | null } = { current: null }; +const brokenRenameFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + return FileSystem.FileSystem.of({ + ...real, + link: (_fromPath, toPath) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "link", + syscall: "link", + pathOrDescriptor: toPath, + description: "EPERM: the volume rejects hard links", + }), + ), + rename: (_fromPath, toPath) => + Effect.sync(() => { + const rival = rivalBytesOnRename.current; + if (rival !== null) { + const rivalPath = `${toPath}.rival`; + NodeFS.writeFileSync(rivalPath, rival, { flag: "wx" }); + NodeFS.renameSync(rivalPath, toPath); + } + }).pipe( + Effect.andThen( + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + syscall: "rename", + pathOrDescriptor: toPath, + description: "EACCES: rename rejected", + }), + ), + ), + ), + }); + }), +); + +const brokenRenameTestLayer = Layer.empty.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsProcess.layer), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-upload-test-" })), + Layer.provideMerge(brokenRenameFileSystemLayer), + Layer.provideMerge(NodeServices.layer), +); + +const linklessTestLayer = Layer.empty.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsProcess.layer), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-upload-test-" })), + Layer.provideMerge(linklessFileSystemLayer), + Layer.provideMerge(NodeServices.layer), +); diff --git a/apps/server/src/workspace/WorkspaceUpload.ts b/apps/server/src/workspace/WorkspaceUpload.ts new file mode 100644 index 000000000000..cf2178487d54 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceUpload.ts @@ -0,0 +1,405 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import { + PROJECT_UPLOAD_URL_TTL_MS, + ProjectCreateUploadUrlError, + ProjectUploadTargetExistsError, + type ProjectCreateUploadUrlInput, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +export const WORKSPACE_UPLOAD_ROUTE_PREFIX = "/api/workspace/upload"; + +// Asset download, attachment upload, and workspace upload tokens share this +// key; the signed claim kind keeps the token spaces separate. +const SIGNING_SECRET_NAME = "asset-access-signing-key"; + +const WorkspaceUploadClaims = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("workspace-upload"), + cwd: Schema.String, + relativePath: Schema.String, + sizeBytes: Schema.Number, + overwrite: Schema.Boolean, + expiresAt: Schema.Number, +}); +export type WorkspaceUploadClaims = typeof WorkspaceUploadClaims.Type; + +const workspaceUploadClaimsJson = Schema.fromJsonString(WorkspaceUploadClaims); +const decodeWorkspaceUploadClaims = Schema.decodeUnknownOption(workspaceUploadClaimsJson); +const encodeWorkspaceUploadClaims = Schema.encodeSync(workspaceUploadClaimsJson); + +function decodeClaims(encodedPayload: string): WorkspaceUploadClaims | null { + try { + return Option.getOrNull(decodeWorkspaceUploadClaims(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +const loadSigningSecret = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); +}); + +export const issueWorkspaceUploadUrl = Effect.fn("WorkspaceUpload.issueUrl")(function* ( + input: ProjectCreateUploadUrlInput, +) { + const secret = yield* loadSigningSecret.pipe( + Effect.mapError( + (cause) => + new ProjectCreateUploadUrlError({ + cwd: input.cwd, + relativePath: input.relativePath, + stage: "signing-key", + cause, + }), + ), + ); + + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const target = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }) + .pipe( + Effect.mapError( + (error) => + new ProjectCreateUploadUrlError({ + cwd: input.cwd, + relativePath: input.relativePath, + stage: "resolve-path", + cause: error, + }), + ), + ); + + const fileSystem = yield* FileSystem.FileSystem; + const targetExists = yield* fileSystem.exists(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new ProjectCreateUploadUrlError({ + cwd: input.cwd, + relativePath: target.relativePath, + stage: "target-check", + cause, + }), + ), + ); + if (targetExists) { + const targetInfo = yield* fileSystem.stat(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new ProjectCreateUploadUrlError({ + cwd: input.cwd, + relativePath: target.relativePath, + stage: "target-check", + cause, + }), + ), + ); + if (targetInfo.type !== "File") { + return yield* new ProjectCreateUploadUrlError({ + cwd: input.cwd, + relativePath: target.relativePath, + stage: "target-not-file", + }); + } + if (input.overwrite !== true) { + return yield* new ProjectUploadTargetExistsError({ + cwd: input.cwd, + relativePath: target.relativePath, + }); + } + } + + const nowMs = yield* Clock.currentTimeMillis; + const expiresAt = nowMs + PROJECT_UPLOAD_URL_TTL_MS; + const encodedPayload = base64UrlEncode( + encodeWorkspaceUploadClaims({ + version: 1, + kind: "workspace-upload", + cwd: input.cwd, + relativePath: target.relativePath, + sizeBytes: input.sizeBytes, + overwrite: input.overwrite === true, + expiresAt, + }), + ); + + return { + relativePath: target.relativePath, + relativeUrl: `${WORKSPACE_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`, + expiresAt, + }; +}); + +export const validateWorkspaceUploadToken = Effect.fn("WorkspaceUpload.validateToken")(function* ( + token: string, +) { + const [encodedPayload, signature, unexpectedSegment] = token.split("."); + if (!encodedPayload || !signature || unexpectedSegment) { + return null; + } + + const secret = yield* loadSigningSecret.pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the workspace upload signing key.", { cause }), + ), + Effect.orElseSucceed(() => null), + ); + if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) { + return null; + } + + const claims = decodeClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) { + return null; + } + return claims; +}); + +export type StoreWorkspaceUploadResult = + | { readonly ok: true; readonly relativePath: string } + | { readonly ok: false; readonly status: number; readonly detail: string }; + +export const storeWorkspaceUpload = Effect.fn("WorkspaceUpload.store")(function* ( + claims: WorkspaceUploadClaims, + bytes: Uint8Array, +) { + if (bytes.byteLength !== claims.sizeBytes) { + return { + ok: false, + status: 400, + detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`, + } satisfies StoreWorkspaceUploadResult; + } + + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const target = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: claims.cwd, + relativePath: claims.relativePath, + }) + .pipe(Effect.catchTags({ WorkspacePathOutsideRootError: () => Effect.succeed(null) })); + if (!target) { + return { + ok: false, + status: 500, + detail: "Failed to resolve the workspace upload target.", + } satisfies StoreWorkspaceUploadResult; + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // The part file lives beside the target under a fixed-length name so a long + // target basename cannot push the temporary filename past the 255-byte + // filesystem component limit. + const partPath = path.join(path.dirname(target.absolutePath), `.${NodeCrypto.randomUUID()}.part`); + const escapesWorkspaceRoot = Effect.fn(function* (directory: string) { + const [canonicalRoot, canonicalDir] = yield* Effect.all([ + fileSystem.realPath(claims.cwd), + fileSystem.realPath(directory), + ]); + const relativeDir = path.relative(canonicalRoot, canonicalDir); + return ( + relativeDir === ".." || + relativeDir.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeDir) + ); + }); + return yield* Effect.gen(function* () { + const targetExists = yield* fileSystem.exists(target.absolutePath); + if (targetExists) { + const targetInfo = yield* fileSystem.stat(target.absolutePath); + if (targetInfo.type !== "File") { + // Overwrite renames the part file onto the target, which must never + // replace a directory that appeared after the URL was minted. + return { + ok: false, + status: 409, + detail: "A folder exists at this path.", + } satisfies StoreWorkspaceUploadResult; + } + if (!claims.overwrite) { + return { + ok: false, + status: 409, + detail: "A file already exists at this path.", + } satisfies StoreWorkspaceUploadResult; + } + } + + // The lexical resolve above cannot see symlinked directory components, and + // recursive mkdir follows them, so canonically re-check the deepest + // existing ancestor before creating directories and the final directory + // before any bytes land, the same way AssetAccess guards signed reads. + const targetDirectory = path.dirname(target.absolutePath); + let existingAncestor = targetDirectory; + while (!(yield* fileSystem.exists(existingAncestor))) { + const parent = path.dirname(existingAncestor); + if (parent === existingAncestor) { + return { + ok: false, + status: 500, + detail: "Failed to resolve the workspace upload target.", + } satisfies StoreWorkspaceUploadResult; + } + existingAncestor = parent; + } + if (yield* escapesWorkspaceRoot(existingAncestor)) { + return { + ok: false, + status: 400, + detail: "Upload path resolves outside the project.", + } satisfies StoreWorkspaceUploadResult; + } + yield* fileSystem.makeDirectory(targetDirectory, { recursive: true }); + if (yield* escapesWorkspaceRoot(targetDirectory)) { + return { + ok: false, + status: 400, + detail: "Upload path resolves outside the project.", + } satisfies StoreWorkspaceUploadResult; + } + if (claims.overwrite) { + yield* fileSystem.writeFile(partPath, bytes); + yield* fileSystem.rename(partPath, target.absolutePath); + } else { + // rename replaces a file created after the exists check above; linking + // the staged part onto the target claims the name atomically instead, so + // concurrent non-overwrite uploads cannot clobber and a failed write + // never strands a partial target. FAT and exFAT volumes reject hard + // links, so those claim the name with an empty O_EXCL create and then + // rename the part onto their own claim. The failed create removes + // nothing, so it can never delete a rival's file; only a failed rename + // reclaims the name, and only while the name still holds the empty + // claim. + yield* fileSystem.writeFile(partPath, bytes); + const claim = yield* fileSystem.link(partPath, target.absolutePath).pipe( + Effect.as("claimed" as const), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed("conflict" as const), + ), + Effect.catch(() => Effect.succeed("unsupported" as const)), + ); + if (claim === "conflict") { + return { + ok: false, + status: 409, + detail: "A file already exists at this path.", + } satisfies StoreWorkspaceUploadResult; + } + if (claim === "unsupported") { + // The claim and the rename onto it form one critical section: an + // interrupt between them would strand a permanent empty file at the + // target, so the pair runs uninterruptibly. The failed-rename reclaim + // checks that the name still holds the empty claim, so a confirmed + // overwrite landing in between can never have its content deleted. + const fallback = yield* Effect.uninterruptible( + Effect.gen(function* () { + const conflict = yield* fileSystem + .writeFile(target.absolutePath, new Uint8Array(0), { flag: "wx" }) + .pipe( + Effect.as(false), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed(true), + ), + ); + if (conflict) { + return "conflict" as const; + } + // A rival's confirmed overwrite could legitimately be zero bytes, + // so size alone cannot identify the claim; the reclaim also + // requires the inode captured at claim time, and falls back to + // the size check only where the platform reports no inode. + const claimInode = yield* fileSystem.stat(target.absolutePath).pipe( + Effect.map((info) => Option.getOrNull(info.ino)), + // A stat failure here would strand the empty claim as a + // permanent conflict. With no inode to identify the claim by, + // the reclaim re-stats and removes only a zero-byte file, so a + // rival's non-empty overwrite is never deleted; when the fault + // persists the claim stays, trading a retryable conflict for + // zero data loss. + Effect.tapError(() => + fileSystem.stat(target.absolutePath).pipe( + Effect.flatMap((info) => + info.size === FileSystem.Size(0) + ? fileSystem.remove(target.absolutePath, { force: true }) + : Effect.void, + ), + Effect.ignore, + ), + ), + ); + yield* fileSystem.rename(partPath, target.absolutePath).pipe( + Effect.tapError(() => + fileSystem.stat(target.absolutePath).pipe( + Effect.flatMap((info) => + info.size === FileSystem.Size(0) && Option.getOrNull(info.ino) === claimInode + ? fileSystem.remove(target.absolutePath, { force: true }) + : Effect.void, + ), + Effect.ignore, + ), + ), + ); + return "stored" as const; + }), + ); + if (fallback === "conflict") { + return { + ok: false, + status: 409, + detail: "A file already exists at this path.", + } satisfies StoreWorkspaceUploadResult; + } + } + } + + // The link path leaves the part behind on purpose; reclaim it before the + // refresh so the rebuilt index never lists a phantom part entry. The + // rename paths already consumed it, making this a no-op there. + yield* fileSystem.remove(partPath, { force: true }).pipe(Effect.ignore); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + yield* workspaceEntries.refresh(claims.cwd); + + return { ok: true, relativePath: target.relativePath } satisfies StoreWorkspaceUploadResult; + }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to persist workspace upload.", { + cwd: claims.cwd, + relativePath: claims.relativePath, + cause, + }).pipe( + Effect.as({ + ok: false, + status: 500, + detail: "Failed to persist upload.", + } satisfies StoreWorkspaceUploadResult), + ), + ), + // Effect.catch does not run on fiber interruption, so the part file is + // reclaimed here on every exit, including a client that drops mid-upload. + Effect.ensuring(fileSystem.remove(partPath, { force: true }).pipe(Effect.ignore)), + ); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 55b0be07c667..14728495be46 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -96,6 +96,7 @@ import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; +import { issueWorkspaceUploadUrl } from "./workspace/WorkspaceUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -1938,6 +1939,18 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsCreateUploadUrl]: (input) => + observeRpcEffect(WS_METHODS.projectsCreateUploadUrl, issueWorkspaceUploadUrl(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.projectsRenameEntry]: (input) => + observeRpcEffect(WS_METHODS.projectsRenameEntry, workspaceFileSystem.renameEntry(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.projectsDeleteEntry]: (input) => + observeRpcEffect(WS_METHODS.projectsDeleteEntry, workspaceFileSystem.deleteEntry(input), { + "rpc.aggregate": "workspace", + }), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb1cf698535a..d650673fd78e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -156,6 +156,7 @@ import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; +import { WorkspaceFileDropOverlay } from "./chat/WorkspaceFileDropOverlay"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, @@ -6669,18 +6670,11 @@ function ChatViewContent(props: ChatViewProps) { onDrop={workspaceFileDropHandlers.onDrop} > {isWorkspaceFileDragActive ? ( - + /> ) : null} {/* Provider status overlays the timeline without changing its content height. */}
diff --git a/apps/web/src/components/chat/WorkspaceFileDropOverlay.tsx b/apps/web/src/components/chat/WorkspaceFileDropOverlay.tsx new file mode 100644 index 000000000000..b6fe8162d988 --- /dev/null +++ b/apps/web/src/components/chat/WorkspaceFileDropOverlay.tsx @@ -0,0 +1,27 @@ +import type { ComponentPropsWithoutRef, ReactNode } from "react"; + +import { cn } from "~/lib/utils"; + +/** Full-surface drop treatment shared by the chat and files-view drop targets. */ +export function WorkspaceFileDropOverlay( + props: { icon: ReactNode; label: string } & ComponentPropsWithoutRef<"div">, +) { + const { icon, label, className, ...rest } = props; + return ( +
+
+ {icon} + {label} +
+
+ ); +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index cbe20f4d3a8d..d95bb2d4adde 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -2,11 +2,15 @@ import type { ContextMenuItem as TreeContextMenuItem, ContextMenuOpenContext as TreeContextMenuOpenContext, } from "@pierre/trees"; -import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; +import type { ContextMenuItem, EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { RotateCw } from "lucide-react"; -import { useEffect, useMemo, useRef } from "react"; +import * as Cause from "effect/Cause"; +import { RotateCcw, RotateCw, Upload, XIcon } from "lucide-react"; +import type { DragEvent as ReactDragEvent, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; @@ -15,12 +19,26 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; +import { formatAttachmentUploadProgress } from "~/lib/attachmentUploadState"; +import { + cancelWorkspaceUpload, + dismissWorkspaceUpload, + retryWorkspaceUpload, + startWorkspaceUploads, + useWorkspaceUploadStore, + type WorkspaceUploadState, +} from "~/lib/workspaceUploadQueue"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { projectEnvironment } from "~/state/projects"; +import { makeWorkspaceFileDropHandlers } from "../chat/workspaceFileDrop"; +import { WorkspaceFileDropOverlay } from "../chat/WorkspaceFileDropOverlay"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; +import { RenameEntryDialog } from "./RenameEntryDialog"; interface FileBrowserPanelProps { environmentId: EnvironmentId; @@ -32,6 +50,28 @@ interface FileBrowserPanelProps { selectedPathRevealId: number; onOpenFile: (relativePath: string) => void; onRefreshSelectedFile?: () => void; + /** + * A rename, delete, or confirmed overwrite upload is about to run. Saves + * and mutations share one serial per-project command queue, so a save + * enqueued during the mutation would land after it and recreate the file, + * and uploads bypass the queue entirely; pending saves for the path must + * be held before the mutation runs. + */ + onEntryMutationStart?: (relativePath: string) => void; + /** + * The mutation failed, or the upload settled, and the file is still in + * place; held saves may run again. + */ + onEntryMutationFailed?: (relativePath: string) => void; + /** A rename succeeded; open surfaces for the old path should follow the file. */ + onEntryRenamed?: (relativePath: string, newRelativePath: string) => void; + /** A delete succeeded; open surfaces for the path should close. */ + onEntryDeleted?: (relativePath: string) => void; + /** + * An upload landed at the path, possibly replacing an open file; stale + * cached contents and pending edits for it should be dropped. + */ + onEntryUploaded?: (relativePath: string) => void; } const TREE_UNSAFE_CSS = ` @@ -71,6 +111,97 @@ function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void } ); } +function UploadFilesButton(props: { onClick: () => void }) { + return ( + + + } + > + + + Upload files + + ); +} + +function UploadRowButton(props: { label: string; icon: ReactNode; onClick: () => void }) { + return ( + + + } + > + {props.icon} + + {props.label} + + ); +} + +function UploadRow(props: { id: string; upload: WorkspaceUploadState }) { + const { id, upload } = props; + return ( +
+ + {upload.name}} + /> + {upload.name} + + {upload.status === "uploading" ? ( + <> + + {formatAttachmentUploadProgress(upload.progress)} + + } + onClick={() => cancelWorkspaceUpload(id)} + /> + + ) : ( + <> + + + {upload.reason} + + } + /> + {upload.reason} + + } + onClick={() => retryWorkspaceUpload(id)} + /> + } + onClick={() => dismissWorkspaceUpload(id)} + /> + + )} +
+ ); +} + function FileSearchField(props: { ariaLabel: string; name: string; @@ -107,16 +238,105 @@ export default function FileBrowserPanel({ selectedPathRevealId, onOpenFile, onRefreshSelectedFile, + onEntryMutationStart, + onEntryMutationFailed, + onEntryRenamed, + onEntryDeleted, + onEntryUploaded, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); const composerRef = useComposerHandleContext(); const entriesQuery = useProjectEntriesQuery(environmentId, cwd); + const [dragActive, setDragActive] = useState(false); + const fileInputRef = useRef(null); + const handleAddFiles = useCallback( + (files: File[]) => { + if (files.length === 0) return; + startWorkspaceUploads({ + environmentId, + cwd, + files, + // An overwrite upload replaces the open file's bytes outside the + // serial save lane, so pending saves hold from conflict discovery, + // before the confirm dialog opens, until the job settles. A successful + // upload re-arms saves through onEntryUploaded's reset, which makes + // the settle release a no-op. + onOverwriteStart: (relativePath) => { + onEntryMutationStart?.(relativePath); + }, + onSettled: (relativePath) => { + onEntryMutationFailed?.(relativePath); + }, + onUploaded: (relativePath) => { + entriesQuery.refresh(); + onEntryUploaded?.(relativePath); + }, + }); + }, + [ + cwd, + entriesQuery, + environmentId, + onEntryMutationFailed, + onEntryMutationStart, + onEntryUploaded, + ], + ); + // The shared drop handlers manage drag-active state, but their onDrop reads + // event.dataTransfer.files directly, which includes an unreadable stand-in + // File for a dropped directory. Filter with dataTransfer.items instead so + // directories never reach the upload queue. + const fileDropHandlers = useMemo( + () => makeWorkspaceFileDropHandlers({ setDragActive, addFiles: handleAddFiles }), + [handleAddFiles], + ); + const handleDrop = useCallback( + (event: ReactDragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + setDragActive(false); + const items = Array.from(event.dataTransfer.items); + const supportsEntries = items.length > 0 && typeof items[0]?.webkitGetAsEntry === "function"; + const files = supportsEntries + ? items.flatMap((item) => { + if (item.kind !== "file") return []; + const entry = item.webkitGetAsEntry(); + if (entry !== null && !entry.isFile) return []; + const file = item.getAsFile(); + return file ? [file] : []; + }) + : Array.from(event.dataTransfer.files); + handleAddFiles(files); + }, + [handleAddFiles], + ); + // Flattened [id, state, id, state, ...] so the shallow compare sees stable + // string ids and per-upload state refs; uploads for other panels never + // re-render this one. + const uploadEntries = useWorkspaceUploadStore( + useShallow((state) => + Object.entries(state.uploadsById) + .filter(([, upload]) => upload.environmentId === environmentId && upload.cwd === cwd) + .flat(), + ), + ); + const uploads = useMemo(() => { + const pairs: Array<[string, WorkspaceUploadState]> = []; + for (let index = 0; index < uploadEntries.length; index += 2) { + pairs.push([ + uploadEntries[index] as string, + uploadEntries[index + 1] as WorkspaceUploadState, + ]); + } + return pairs; + }, [uploadEntries]); const entries = entriesQuery.data?.entries ?? []; const entryKinds = useMemo( () => new Map(entries.map((entry) => [entry.path, entry.kind] as const)), [entries], ); const entryKindsRef = useRef>(entryKinds); + const [renameTarget, setRenameTarget] = useState(null); const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); const syncingSelectionRef = useRef(false); @@ -135,6 +355,34 @@ export default function FileBrowserPanel({ return () => document.removeEventListener("contextmenu", capturePointer, true); }, []); + const confirmAndDeleteEntry = async (relativePath: string) => { + const name = relativePath.split("/").at(-1) ?? relativePath; + const confirmed = await readLocalApi()?.dialogs.confirm( + `Delete ${name}?\nThis permanently deletes the file from the project.`, + { variant: "destructive" }, + ); + if (confirmed !== true) return; + onEntryMutationStart?.(relativePath); + const result = await runAtomCommand( + appAtomRegistry, + projectEnvironment.deleteEntry, + { environmentId, input: { cwd, relativePath } }, + { reportFailure: false }, + ); + if (result._tag === "Success") { + entriesQuery.refresh(); + onEntryDeleted?.(relativePath); + return; + } + onEntryMutationFailed?.(relativePath); + const failure = Cause.squash(result.cause); + toastManager.add({ + type: "error", + title: "Failed to delete file", + description: failure instanceof Error ? failure.message : "An error occurred.", + }); + }; + const showEntryContextMenu = async ( item: TreeContextMenuItem, context: TreeContextMenuOpenContext, @@ -152,14 +400,19 @@ export default function FileBrowserPanel({ const position = pointerIsFresh ? { x: pointer.x, y: pointer.y } : { x: anchorRect.left, y: anchorRect.bottom }; - try { - const clicked = await api.contextMenu.show( - [ - { id: "copy-mention", label: "Copy mention" }, - { id: "add-to-chat", label: "Add to chat" }, - ], - position, + const menuItems: ContextMenuItem<"copy-mention" | "add-to-chat" | "rename" | "delete">[] = [ + { id: "copy-mention", label: "Copy mention" }, + { id: "add-to-chat", label: "Add to chat" }, + ]; + // Rename and delete operate on files only in v1; directories stay read-only. + if (entryKindsRef.current.get(relativePath) === "file") { + menuItems.push( + { id: "rename", label: "Rename", separatorBefore: true }, + { id: "delete", label: "Delete", destructive: true }, ); + } + try { + const clicked = await api.contextMenu.show(menuItems, position); if (clicked === "copy-mention") { try { await writeTextToClipboard(mention); @@ -191,6 +444,14 @@ export default function FileBrowserPanel({ description: "The chat isn't ready to accept input right now.", }); } + return; + } + if (clicked === "rename") { + setRenameTarget(relativePath); + return; + } + if (clicked === "delete") { + await confirmAndDeleteEntry(relativePath); } } finally { context.close(); @@ -353,14 +614,26 @@ export default function FileBrowserPanel({ return (
+ {dragActive ? ( +
); } diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a8c364763c28..1ffb96917eb6 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -27,6 +27,8 @@ import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hoo import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; import { resolvePathLinkTarget } from "~/terminal-links"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Toggle } from "~/components/ui/toggle"; @@ -59,6 +61,7 @@ import { fileBreadcrumbs } from "./filePath"; import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; import { + clearProjectFileQueryData, confirmProjectFileQueryData, getOptimisticProjectFileQueryData, setProjectFileQueryData, @@ -393,6 +396,18 @@ interface EditableFileSurfaceProps { wordWrap: boolean; onPostRender: FilePostRender; onPendingChange: (relativePath: string, pending: boolean) => void; + /** + * Receives the active editor's save controls: suspend before a rename or + * delete, then discard on success or resume on failure. + */ + discardSavesRef?: { current: FileSaveControls | null } | undefined; +} + +interface FileSaveControls { + suspend: () => void; + resume: () => void; + discard: () => void; + reset: () => void; } interface FileSelectionOverride { @@ -405,9 +420,10 @@ function useFileSaveCoordinator({ cwd, relativePath, onPendingChange, + discardSavesRef, }: Pick< EditableFileSurfaceProps, - "environmentId" | "cwd" | "relativePath" | "onPendingChange" + "environmentId" | "cwd" | "relativePath" | "onPendingChange" | "discardSavesRef" >): FileSaveCoordinator { const writeFile = useAtomCommand(projectEnvironment.writeFile); const coordinator = useMemo( @@ -428,6 +444,18 @@ function useFileSaveCoordinator({ ); useEffect(() => () => coordinator.dispose(), [coordinator]); + useEffect(() => { + if (!discardSavesRef) return; + discardSavesRef.current = { + suspend: () => coordinator.suspend(), + resume: () => coordinator.resume(), + discard: () => coordinator.discard(), + reset: () => coordinator.reset(), + }; + return () => { + discardSavesRef.current = null; + }; + }, [coordinator, discardSavesRef]); return coordinator; } @@ -442,6 +470,7 @@ function EditableFileSurface({ wordWrap, onPostRender, onPendingChange, + discardSavesRef, }: EditableFileSurfaceProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); @@ -462,6 +491,7 @@ function EditableFileSurface({ cwd, relativePath, onPendingChange, + discardSavesRef, }); const editor = useMemo( () => @@ -707,6 +737,7 @@ function RenderedMarkdownSurface({ contents, threadRef, onPendingChange, + discardSavesRef, }: Omit< EditableFileSurfaceProps, | "resolvedTheme" @@ -723,6 +754,7 @@ function RenderedMarkdownSurface({ cwd, relativePath, onPendingChange, + discardSavesRef, }); return ( @@ -797,6 +829,21 @@ export default function FilePreviewPanel({ null, ); const breadcrumbRef = useRef(null); + const discardActiveFileSavesRef = useRef(null); + // The delete and rename callbacks below can fire after a thread switch, when + // discardActiveFileSavesRef already points at the new thread's editor. This + // ref tracks what that editor shows now, so a stale callback cannot discard + // an unrelated file's pending edits. + const activeEditorFileRef = useRef({ environmentId, cwd, relativePath }); + useEffect(() => { + activeEditorFileRef.current = { environmentId, cwd, relativePath }; + }); + const editorShowsFile = (path: string) => { + const active = activeEditorFileRef.current; + return ( + active.environmentId === environmentId && active.cwd === cwd && active.relativePath === path + ); + }; const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; // A reveal still wins over the preference: the line only exists in the source. const renderMarkdown = @@ -1019,6 +1066,7 @@ export default function FilePreviewPanel({ threadRef={threadRef} contents={file.data.contents} onPendingChange={onPendingChange} + discardSavesRef={discardActiveFileSavesRef} /> ) : file.data.truncated ? ( ) ) : null} @@ -1081,6 +1130,74 @@ export default function FilePreviewPanel({ selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})} + onEntryMutationStart={(path) => { + if (editorShowsFile(path)) discardActiveFileSavesRef.current?.suspend(); + }} + onEntryUploaded={(path) => { + // An overwrite upload replaced the bytes on disk; drop the + // stale optimistic overlay and pending edits so a later save + // cannot write the pre-upload snapshot over the upload. + clearProjectFileQueryData(environmentId, cwd, path); + if (!editorShowsFile(path)) return; + if (isImage) { + // The image preview renders a signed asset URL whose query + // stays fresh for minutes; re-mint it so the overwritten + // bytes replace the stale bitmap immediately. + if (absolutePath) { + appAtomRegistry.refresh( + assetEnvironment.createUrl({ + environmentId, + input: { + resource: { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: absolutePath, + }, + }, + }), + ); + } + return; + } + discardActiveFileSavesRef.current?.reset(); + file.refresh(); + }} + onEntryMutationFailed={(path) => { + if (editorShowsFile(path)) discardActiveFileSavesRef.current?.resume(); + }} + onEntryDeleted={(path) => { + if (editorShowsFile(path)) discardActiveFileSavesRef.current?.discard(); + clearProjectFileQueryData(environmentId, cwd, path); + useRightPanelStore.getState().closeSurface(threadRef, `file:${path}`); + }} + onEntryRenamed={(from, to) => { + if (editorShowsFile(from)) discardActiveFileSavesRef.current?.discard(); + clearProjectFileQueryData(environmentId, cwd, from); + const store = useRightPanelStore.getState(); + const panel = selectThreadRightPanelState(store.byThreadKey, threadRef); + const fromSurface = panel.surfaces.find((surface) => surface.id === `file:${from}`); + const previousActiveId = panel.activeSurfaceId; + const wasPanelOpen = panel.isOpen; + store.closeSurface(threadRef, `file:${from}`); + if (!fromSurface) return; + // The surface a rename replaces keeps its scroll target; a + // tab opened at a line reopens at that line, not at the top. + const revealLine = + fromSurface.kind === "file" && fromSurface.revealLine !== null + ? fromSurface.revealLine + : undefined; + store.openFile(threadRef, to, revealLine); + // Renaming a background tab must not steal focus from the + // file the user is looking at. + if (previousActiveId !== null && previousActiveId !== `file:${from}`) { + store.activateSurface(threadRef, previousActiveId); + } + // The rename RPC can finish after the user closed the panel; + // swapping the surface must not override that close. + if (!wasPanelOpen) { + store.close(threadRef); + } + }} /> ) : null} diff --git a/apps/web/src/components/files/RenameEntryDialog.tsx b/apps/web/src/components/files/RenameEntryDialog.tsx new file mode 100644 index 000000000000..94af03219b37 --- /dev/null +++ b/apps/web/src/components/files/RenameEntryDialog.tsx @@ -0,0 +1,159 @@ +import { isProjectRenameEntryTargetExistsError, type EnvironmentId } from "@t3tools/contracts"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { useEffect, useId, useRef, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { projectEnvironment } from "~/state/projects"; + +export function RenameEntryDialog({ + environmentId, + cwd, + relativePath, + onClose, + onRenameStart, + onRenameFailed, + onRenamed, +}: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly relativePath: string; + readonly onClose: () => void; + /** The rename is about to run; pending saves for the path must not enqueue behind it. */ + readonly onRenameStart?: () => void; + /** The rename failed and the file kept its name; held saves may run again. */ + readonly onRenameFailed?: () => void; + readonly onRenamed: (newRelativePath: string) => void; +}) { + const lastSlash = relativePath.lastIndexOf("/"); + const directoryPrefix = lastSlash === -1 ? "" : relativePath.slice(0, lastSlash + 1); + const basename = lastSlash === -1 ? relativePath : relativePath.slice(lastSlash + 1); + const [name, setName] = useState(basename); + const [isRenaming, setIsRenaming] = useState(false); + const [renameError, setRenameError] = useState(null); + const inputRef = useRef(null); + const isRenamingRef = useRef(false); + const formId = useId(); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + const input = inputRef.current; + if (!input) return; + input.focus(); + // Select the name without its extension so typing replaces just the stem. + const dotIndex = input.value.lastIndexOf("."); + input.setSelectionRange(0, dotIndex > 0 ? dotIndex : input.value.length); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, []); + + // Renaming onto the unchanged name fails server-side with targetExists, so + // submit stays disabled until the name actually changes. + const candidate = name.trim(); + const submitDisabled = + isRenaming || candidate.length === 0 || candidate === basename || candidate.includes("/"); + + const submitRename = async () => { + if (isRenamingRef.current || submitDisabled) { + return; + } + isRenamingRef.current = true; + setIsRenaming(true); + setRenameError(null); + const newRelativePath = `${directoryPrefix}${candidate}`; + onRenameStart?.(); + const result = await runAtomCommand( + appAtomRegistry, + projectEnvironment.renameEntry, + { + environmentId, + input: { cwd, relativePath, newRelativePath }, + }, + { reportFailure: false }, + ); + isRenamingRef.current = false; + setIsRenaming(false); + if (result._tag === "Success") { + onRenamed(newRelativePath); + onClose(); + return; + } + onRenameFailed?.(); + setRenameError( + isProjectRenameEntryTargetExistsError(Cause.squash(result.cause)) + ? "A file with that name already exists." + : "Rename failed. Try again.", + ); + }; + + const cancelRename = () => { + if (isRenamingRef.current) { + return; + } + onClose(); + }; + + return ( + { + if (!open) { + cancelRename(); + } + }} + > + + + Rename file + + Rename {relativePath}. The file stays in its folder. + + + +
{ + event.preventDefault(); + void submitRename(); + }} + > + setName(event.target.value)} + /> + {renameError ?

{renameError}

: null} +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index 1acbb0c1d205..25d383df5aa4 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -91,4 +91,296 @@ describe("FileSaveCoordinator", () => { expect(onPendingChange).toHaveBeenCalledWith(true); expect(onPendingChange).not.toHaveBeenCalledWith(false); }); + + it("discard drops unsaved edits instead of flushing them on dispose", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const onPendingChange = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange, + onConfirmed: vi.fn(), + }); + + coordinator.change("doomed"); + coordinator.discard(); + coordinator.dispose(); + await vi.runAllTimersAsync(); + + expect(persist).not.toHaveBeenCalled(); + expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); + }); + + it("ignores changes made after discard", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const onPendingChange = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange, + onConfirmed: vi.fn(), + }); + + coordinator.discard(); + coordinator.change("late editor churn"); + await vi.runAllTimersAsync(); + + expect(persist).not.toHaveBeenCalled(); + expect(onPendingChange).not.toHaveBeenCalledWith(true); + }); + + it("reset drops pending edits but later changes still save", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const onPendingChange = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange, + onConfirmed: vi.fn(), + }); + + coordinator.change("stale pre-upload edit"); + coordinator.reset(); + await vi.advanceTimersByTimeAsync(5_000); + expect(persist).not.toHaveBeenCalled(); + expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); + + coordinator.change("fresh edit"); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledWith("fresh edit"); + }); + + it("a save resolving after reset neither confirms nor blocks later edits", async () => { + vi.useFakeTimers(); + const inFlight = deferred(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValueOnce(AsyncResult.success(undefined)); + const onConfirmed = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed, + }); + + coordinator.change("pre-upload snapshot"); + await vi.advanceTimersByTimeAsync(500); + expect(persist).toHaveBeenCalledOnce(); + + coordinator.reset(); + inFlight.resolve(AsyncResult.success(undefined)); + await vi.runAllTimersAsync(); + expect(onConfirmed).not.toHaveBeenCalled(); + + coordinator.change("post-upload edit"); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledTimes(2); + expect(persist).toHaveBeenLastCalledWith("post-upload edit"); + expect(onConfirmed).toHaveBeenCalledWith("post-upload edit"); + }); + + it("an edit made after reset saves once the stale write resolves", async () => { + vi.useFakeTimers(); + const inFlight = deferred(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValueOnce(AsyncResult.success(undefined)); + const onConfirmed = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed, + }); + + coordinator.change("pre-upload snapshot"); + await vi.advanceTimersByTimeAsync(500); + expect(persist).toHaveBeenCalledOnce(); + + coordinator.reset(); + coordinator.change("edited while the stale write was in flight"); + inFlight.resolve(AsyncResult.success(undefined)); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledTimes(2); + expect(persist).toHaveBeenLastCalledWith("edited while the stale write was in flight"); + expect(onConfirmed).toHaveBeenCalledOnce(); + expect(onConfirmed).toHaveBeenCalledWith("edited while the stale write was in flight"); + }); + + it("suspend holds saves and resume persists the held edits", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const onPendingChange = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange, + onConfirmed: vi.fn(), + }); + + coordinator.change("held"); + coordinator.suspend(); + await vi.advanceTimersByTimeAsync(5_000); + expect(persist).not.toHaveBeenCalled(); + + coordinator.resume(); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledWith("held"); + expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); + }); + + it("suspend then discard drops the held edits", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const onPendingChange = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange, + onConfirmed: vi.fn(), + }); + + coordinator.change("doomed"); + coordinator.suspend(); + coordinator.discard(); + coordinator.dispose(); + await vi.runAllTimersAsync(); + + expect(persist).not.toHaveBeenCalled(); + expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); + }); + + it("resume does not re-persist a snapshot that already saved", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("saved before the mutation"); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledOnce(); + + coordinator.suspend(); + coordinator.resume(); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledOnce(); + }); + + it("resume persists an edit made after the last successful save", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("saved"); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledOnce(); + + coordinator.change("edited during the mutation window"); + coordinator.suspend(); + coordinator.resume(); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledTimes(2); + expect(persist).toHaveBeenLastCalledWith("edited during the mutation window"); + }); + + it("overlapping suspends hold saves until the last resume", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("held"); + coordinator.suspend(); + coordinator.suspend(); + coordinator.resume(); + await vi.advanceTimersByTimeAsync(5_000); + expect(persist).not.toHaveBeenCalled(); + + coordinator.resume(); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledWith("held"); + }); + + it("reset releases only the settling mutation's hold", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.suspend(); + coordinator.suspend(); + coordinator.reset(); + coordinator.change("edited while the other mutation still writes"); + await vi.advanceTimersByTimeAsync(5_000); + expect(persist).not.toHaveBeenCalled(); + + coordinator.resume(); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledWith("edited while the other mutation still writes"); + }); + + it("dispose while suspended does not flush behind a pending mutation", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("unflushed"); + coordinator.suspend(); + coordinator.dispose(); + await vi.runAllTimersAsync(); + + expect(persist).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index 138f01d360e3..b7b84c2b5bf0 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -11,13 +11,20 @@ export class FileSaveCoordinator { private timer: ReturnType | null = null; private latestContents = ""; private latestRevision = 0; + private persistedRevision = 0; private lastChangeAt = 0; private saving = false; private disposed = false; + private suspendCount = 0; + private generation = 0; constructor(private readonly options: FileSaveCoordinatorOptions) {} change(contents: string): void { + // After discard() the file is gone from disk; a late editor change (for + // example the cache-key rotation replacing the contents) must not revive + // the revision and recreate the file. + if (this.disposed) return; this.latestContents = contents; this.latestRevision += 1; this.lastChangeAt = Date.now(); @@ -28,7 +35,56 @@ export class FileSaveCoordinator { dispose(): void { this.disposed = true; this.clearTimer(); - if (this.latestRevision > 0) void this.persistLatest(); + if (this.latestRevision > this.persistedRevision) void this.persistLatest(); + } + + /** Drop unsaved edits without persisting; for files removed out from under the surface. */ + discard(): void { + this.disposed = true; + this.reset(); + } + + /** + * Drop unsaved edits but keep saving alive; for files replaced on disk, + * where the surface reloads the new contents and editing continues. + */ + reset(): void { + // A save already on the wire belongs to the replaced file; bumping the + // generation makes its completion drop the result instead of advancing + // the zeroed watermark or confirming stale contents. + this.generation += 1; + // Only the settling mutation's own hold is released; an overlapping + // mutation still writing keeps its hold, so a later edit cannot save + // mid-mutation and overwrite that writer's result. + if (this.suspendCount > 0) this.suspendCount -= 1; + this.clearTimer(); + this.latestRevision = 0; + this.persistedRevision = 0; + this.options.onPendingChange(false); + } + + /** + * Hold pending edits while a rename, delete, or overwrite upload runs, so a + * save cannot land mid-mutation. Holds count: overlapping mutations of the + * same file each take one, and saving stays held until every one has been + * released by discard(), reset(), or resume(). + */ + suspend(): void { + this.suspendCount += 1; + this.clearTimer(); + } + + /** + * Release one hold after a failed mutation left the file in place. Only + * edits newer than the last successful save reschedule; a snapshot that + * already persisted must not overwrite what another writer put on disk + * since. + */ + resume(): void { + if (this.suspendCount === 0) return; + this.suspendCount -= 1; + if (this.suspendCount > 0) return; + if (this.latestRevision > this.persistedRevision) this.schedule(0); } private schedule(delay: number): void { @@ -46,19 +102,30 @@ export class FileSaveCoordinator { } private async persistLatest(): Promise { - if (this.saving || this.latestRevision === 0) return; + if (this.suspendCount > 0 || this.saving || this.latestRevision <= this.persistedRevision) { + return; + } this.saving = true; const contents = this.latestContents; const revision = this.latestRevision; + const generation = this.generation; const result = await this.options.persist(contents); - const succeeded = result._tag === "Success"; + // A reset while the write was on the wire replaced the file under this + // snapshot: the result describes bytes that no longer exist, so it must + // not advance the watermark or confirm the pre-replacement contents. + const stale = generation !== this.generation; + const succeeded = !stale && result._tag === "Success"; if (succeeded) { + this.persistedRevision = revision; this.options.onConfirmed(contents); } this.saving = false; - if (revision === this.latestRevision) { + if (stale && this.latestRevision <= this.persistedRevision) { + return; + } + if (!stale && revision === this.latestRevision) { if (succeeded) this.options.onPendingChange(false); return; } diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 37eb924ca256..26602d8a6919 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -12,6 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { attachmentEnvironment } from "../state/attachments"; import { readPreparedConnection } from "../state/session"; import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState"; +import { uploadXhr } from "./uploadXhr"; const MAX_UPLOADS_PER_ENVIRONMENT = 3; const UPLOAD_TIMEOUT_MS = 5 * 60_000; @@ -69,37 +70,6 @@ function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string) ); } -function uploadBytes(input: { - readonly url: string; - readonly file: File; - readonly onProgress: (progress: number) => void; -}): { readonly done: Promise; readonly abort: () => void } { - const xhr = new XMLHttpRequest(); - const done = new Promise((resolve, reject) => { - xhr.open("POST", input.url, true); - xhr.timeout = UPLOAD_TIMEOUT_MS; - xhr.setRequestHeader("Content-Type", input.file.type); - xhr.upload.addEventListener("progress", (event) => { - if (event.lengthComputable && event.total > 0) { - input.onProgress(event.loaded / event.total); - } - }); - xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject(new Error(`Upload rejected (${xhr.status})`)); - } - }); - xhr.addEventListener("error", () => reject(new Error("Upload failed"))); - xhr.addEventListener("timeout", () => reject(new Error("Upload timed out"))); - xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); - xhr.send(input.file); - }); - - return { done, abort: () => xhr.abort() }; -} - async function runUpload(job: UploadJob): Promise { const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(), @@ -158,9 +128,11 @@ async function runUpload(job: UploadJob): Promise { } let lastStep = -1; - const upload = uploadBytes({ + const upload = uploadXhr({ url, file: job.image.file, + contentType: job.image.file.type, + timeoutMs: UPLOAD_TIMEOUT_MS, onProgress: (progress) => { const step = Math.floor(progress * 20); if (step === lastStep || job.cancelled) { diff --git a/apps/web/src/lib/uploadXhr.ts b/apps/web/src/lib/uploadXhr.ts new file mode 100644 index 000000000000..8e6986660ac8 --- /dev/null +++ b/apps/web/src/lib/uploadXhr.ts @@ -0,0 +1,41 @@ +/** A non-2xx response; carries the status so callers can react to specific codes. */ +export class UploadRejectedError extends Error { + constructor(readonly status: number) { + super(`Upload rejected (${status})`); + } +} + +export function uploadXhr(input: { + readonly url: string; + readonly file: File; + readonly contentType?: string; + readonly timeoutMs: number; + readonly onProgress: (progress: number) => void; +}): { readonly done: Promise; readonly abort: () => void } { + const xhr = new XMLHttpRequest(); + const done = new Promise((resolve, reject) => { + xhr.open("POST", input.url, true); + xhr.timeout = input.timeoutMs; + if (input.contentType !== undefined) { + xhr.setRequestHeader("Content-Type", input.contentType); + } + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) { + input.onProgress(event.loaded / event.total); + } + }); + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new UploadRejectedError(xhr.status)); + } + }); + xhr.addEventListener("error", () => reject(new Error("Upload failed"))); + xhr.addEventListener("timeout", () => reject(new Error("Upload timed out"))); + xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); + xhr.send(input.file); + }); + + return { done, abort: () => xhr.abort() }; +} diff --git a/apps/web/src/lib/workspaceUploadQueue.test.ts b/apps/web/src/lib/workspaceUploadQueue.test.ts new file mode 100644 index 000000000000..13ed09f62e3f --- /dev/null +++ b/apps/web/src/lib/workspaceUploadQueue.test.ts @@ -0,0 +1,556 @@ +import { EnvironmentId, ProjectUploadTargetExistsError } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + createUploadUrl: Symbol("create-upload-url"), + runAtomCommand: vi.fn(), + readPreparedConnection: vi.fn(), + requestConfirmDialog: vi.fn(), +})); + +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + runAtomCommand: mocks.runAtomCommand, +})); + +vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); + +vi.mock("../state/projects", () => ({ + projectEnvironment: { + createUploadUrl: mocks.createUploadUrl, + }, +})); + +vi.mock("../state/session", () => ({ + readPreparedConnection: mocks.readPreparedConnection, +})); + +vi.mock("../localApi", () => ({ + readLocalApi: () => ({ dialogs: { confirm: mocks.requestConfirmDialog } }), +})); + +import { + cancelWorkspaceUpload, + dismissWorkspaceUpload, + retryWorkspaceUpload, + startWorkspaceUploads, + useWorkspaceUploadStore, +} from "./workspaceUploadQueue"; + +type ProgressListener = (event: { + readonly lengthComputable: boolean; + readonly loaded: number; + readonly total: number; +}) => void; + +class TestXmlHttpRequest { + static requests: TestXmlHttpRequest[] = []; + + status = 0; + timeout = 0; + method: string | null = null; + url: string | null = null; + readonly headers = new Map(); + readonly listeners = new Map void>(); + progressListener: ProgressListener | null = null; + + readonly upload = { + addEventListener: (_event: string, listener: ProgressListener) => { + this.progressListener = listener; + }, + }; + + constructor() { + TestXmlHttpRequest.requests.push(this); + } + + open(method: string, url: string): void { + this.method = method; + this.url = url; + } + + setRequestHeader(name: string, value: string): void { + this.headers.set(name, value); + } + + addEventListener(event: string, listener: () => void): void { + this.listeners.set(event, listener); + } + + send(): void {} + + abort(): void { + this.listeners.get("abort")?.(); + } + + progress(loaded: number, total: number): void { + this.progressListener?.({ lengthComputable: true, loaded, total }); + } + + complete(status = 204): void { + this.status = status; + this.listeners.get("load")?.(); + } +} + +const environmentId = EnvironmentId.make("environment-1"); +const cwd = "/workspace/project"; + +function makeFile(name: string): File { + return new File([new Uint8Array([1, 2, 3])], name); +} + +function mintedResult(relativePath: string) { + return { + _tag: "Success" as const, + value: { + relativePath, + relativeUrl: `/api/workspace/upload/token-${relativePath}`, + expiresAt: 1, + }, + }; +} + +function targetExistsFailure(relativePath: string) { + return { + _tag: "Failure" as const, + cause: Cause.fail(new ProjectUploadTargetExistsError({ cwd, relativePath })), + }; +} + +function genericMintFailure() { + return { + _tag: "Failure" as const, + cause: Cause.fail({ + _tag: "ProjectCreateUploadUrlError", + message: "boom", + }), + }; +} + +function uploadsById() { + return useWorkspaceUploadStore.getState().uploadsById; +} + +function findUpload(name: string) { + return Object.entries(uploadsById()).find(([, upload]) => upload.name === name); +} + +describe("workspaceUploadQueue", () => { + beforeEach(() => { + TestXmlHttpRequest.requests = []; + mocks.runAtomCommand.mockReset(); + mocks.readPreparedConnection.mockReset(); + mocks.requestConfirmDialog.mockReset(); + mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" }); + mocks.runAtomCommand.mockImplementation( + async ( + _registry: unknown, + command: unknown, + target: { readonly input: { readonly relativePath: string } }, + ) => { + if (command === mocks.createUploadUrl) { + return mintedResult(target.input.relativePath); + } + throw new Error("unexpected command"); + }, + ); + vi.stubGlobal("XMLHttpRequest", TestXmlHttpRequest); + }); + + afterEach(() => { + useWorkspaceUploadStore.setState({ uploadsById: {} }); + vi.unstubAllGlobals(); + }); + + it("uploads a file, reports progress, then removes the entry and calls onUploaded", async () => { + const onUploaded = vi.fn(); + const file = makeFile("notes.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded }); + await Promise.resolve(); + await Promise.resolve(); + + const request = TestXmlHttpRequest.requests[0]!; + expect(request.method).toBe("POST"); + expect(request.url).toBe("https://environment.test/api/workspace/upload/token-notes.txt"); + expect(request.headers.has("Content-Type")).toBe(false); + + request.progress(1, 2); + const [uploadId, uploading] = findUpload("notes.txt")!; + expect(uploading).toMatchObject({ + status: "uploading", + relativePath: "notes.txt", + environmentId, + cwd, + progress: 0.5, + }); + + request.complete(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(uploadsById()[uploadId]).toBeUndefined(); + expect(onUploaded).toHaveBeenCalledTimes(1); + }); + + it("keeps a completed upload out of the failed state when onUploaded throws", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const file = makeFile("notes.txt"); + startWorkspaceUploads({ + environmentId, + cwd, + files: [file], + onUploaded: () => { + throw new Error("refresh failed"); + }, + }); + await Promise.resolve(); + await Promise.resolve(); + + TestXmlHttpRequest.requests[0]!.complete(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(findUpload("notes.txt")).toBeUndefined(); + expect(consoleError).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); + + it("marks the entry failed with a reason when minting fails", async () => { + mocks.runAtomCommand.mockResolvedValue(genericMintFailure()); + const file = makeFile("broken.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const [, failed] = findUpload("broken.txt")!; + expect(failed).toMatchObject({ status: "failed", reason: "Upload could not start" }); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + }); + + it("re-mints with overwrite and uploads when the user confirms replacing an existing file", async () => { + let call = 0; + mocks.runAtomCommand.mockImplementation( + async ( + _registry: unknown, + command: unknown, + target: { + readonly input: { readonly relativePath: string; readonly overwrite?: boolean }; + }, + ) => { + if (command !== mocks.createUploadUrl) throw new Error("unexpected command"); + call += 1; + if (call === 1) { + expect(target.input.overwrite).toBeUndefined(); + return targetExistsFailure(target.input.relativePath); + } + expect(target.input.overwrite).toBe(true); + return mintedResult(target.input.relativePath); + }, + ); + mocks.requestConfirmDialog.mockResolvedValue(true); + + const file = makeFile("existing.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(mocks.requestConfirmDialog).toHaveBeenCalledWith( + "Replace existing.txt?\nA file named 'existing.txt' already exists in this project.", + { variant: "destructive" }, + ); + expect(TestXmlHttpRequest.requests).toHaveLength(1); + TestXmlHttpRequest.requests[0]!.complete(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(findUpload("existing.txt")).toBeUndefined(); + }); + + it("prompts to replace and retries with overwrite when the commit loses the race with 409", async () => { + const seenOverwrites: Array = []; + mocks.runAtomCommand.mockImplementation( + async ( + _registry: unknown, + command: unknown, + target: { + readonly input: { readonly relativePath: string; readonly overwrite?: boolean }; + }, + ) => { + if (command !== mocks.createUploadUrl) throw new Error("unexpected command"); + seenOverwrites.push(target.input.overwrite); + return mintedResult(target.input.relativePath); + }, + ); + mocks.requestConfirmDialog.mockResolvedValue(true); + + const file = makeFile("raced.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + + TestXmlHttpRequest.requests[0]!.complete(409); + for (let tick = 0; tick < 6; tick += 1) await Promise.resolve(); + + expect(mocks.requestConfirmDialog).toHaveBeenCalledWith( + "Replace raced.txt?\nA file named 'raced.txt' already exists in this project.", + { variant: "destructive" }, + ); + expect(seenOverwrites).toEqual([undefined, true]); + expect(TestXmlHttpRequest.requests).toHaveLength(2); + expect(findUpload("raced.txt")![1]).toMatchObject({ status: "uploading" }); + + TestXmlHttpRequest.requests[1]!.complete(); + for (let tick = 0; tick < 4; tick += 1) await Promise.resolve(); + + expect(findUpload("raced.txt")).toBeUndefined(); + }); + + it("marks the entry failed when the user declines to replace after a commit-time 409", async () => { + mocks.requestConfirmDialog.mockResolvedValue(false); + + const file = makeFile("raced.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + + TestXmlHttpRequest.requests[0]!.complete(409); + for (let tick = 0; tick < 6; tick += 1) await Promise.resolve(); + + expect(mocks.requestConfirmDialog).toHaveBeenCalledTimes(1); + expect(TestXmlHttpRequest.requests).toHaveLength(1); + expect(findUpload("raced.txt")![1]).toMatchObject({ + status: "failed", + reason: "File already exists", + }); + }); + + it("marks the entry failed with 'File already exists' when the user declines to replace", async () => { + mocks.runAtomCommand.mockImplementation( + async ( + _registry: unknown, + command: unknown, + target: { readonly input: { readonly relativePath: string } }, + ) => { + if (command !== mocks.createUploadUrl) throw new Error("unexpected command"); + return targetExistsFailure(target.input.relativePath); + }, + ); + mocks.requestConfirmDialog.mockResolvedValue(false); + + const file = makeFile("existing.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const [, failed] = findUpload("existing.txt")!; + expect(failed).toMatchObject({ status: "failed", reason: "File already exists" }); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + }); + + it("fires onOverwriteStart at conflict discovery, before the confirm dialog opens", async () => { + const events: string[] = []; + mocks.runAtomCommand.mockImplementation( + async ( + _registry: unknown, + command: unknown, + target: { readonly input: { readonly relativePath: string } }, + ) => { + if (command !== mocks.createUploadUrl) throw new Error("unexpected command"); + return targetExistsFailure(target.input.relativePath); + }, + ); + let resolveConfirm!: (value: boolean) => void; + mocks.requestConfirmDialog.mockImplementation(() => { + events.push("dialog"); + return new Promise((resolve) => { + resolveConfirm = resolve; + }); + }); + + startWorkspaceUploads({ + environmentId, + cwd, + files: [makeFile("existing.txt")], + onUploaded: vi.fn(), + onOverwriteStart: () => events.push("overwrite-start"), + onSettled: () => events.push("settled"), + }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(["overwrite-start", "dialog"]); + + resolveConfirm(false); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(["overwrite-start", "dialog", "settled"]); + }); + + it("does not fire onSettled for uploads that never hit a conflict", async () => { + const onSettled = vi.fn(); + startWorkspaceUploads({ + environmentId, + cwd, + files: [makeFile("notes.txt")], + onUploaded: vi.fn(), + onSettled, + }); + await Promise.resolve(); + await Promise.resolve(); + + TestXmlHttpRequest.requests[0]!.complete(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(findUpload("notes.txt")).toBeUndefined(); + expect(onSettled).not.toHaveBeenCalled(); + }); + + it("retry leaves the overwrite phase, so a conflict-free retry does not settle again", async () => { + mocks.runAtomCommand.mockResolvedValueOnce(targetExistsFailure("existing.txt")); + mocks.requestConfirmDialog.mockResolvedValue(false); + const onSettled = vi.fn(); + startWorkspaceUploads({ + environmentId, + cwd, + files: [makeFile("existing.txt")], + onUploaded: vi.fn(), + onSettled, + }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(onSettled).toHaveBeenCalledTimes(1); + + const [uploadId] = findUpload("existing.txt")!; + retryWorkspaceUpload(uploadId); + await Promise.resolve(); + await Promise.resolve(); + + TestXmlHttpRequest.requests[0]!.complete(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(uploadsById()[uploadId]).toBeUndefined(); + expect(onSettled).toHaveBeenCalledTimes(1); + }); + + it("cancelWorkspaceUpload aborts the in-flight XHR and removes the entry", async () => { + const file = makeFile("cancel-me.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + + const request = TestXmlHttpRequest.requests[0]!; + const [uploadId] = findUpload("cancel-me.txt")!; + cancelWorkspaceUpload(uploadId); + + expect(uploadsById()[uploadId]).toBeUndefined(); + await Promise.resolve(); + await Promise.resolve(); + expect(request.listeners.has("abort")).toBe(true); + }); + + it("caps concurrent uploads at 3 per environment", async () => { + const files = ["a.txt", "b.txt", "c.txt", "d.txt"].map(makeFile); + startWorkspaceUploads({ environmentId, cwd, files, onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + + expect(TestXmlHttpRequest.requests).toHaveLength(3); + + for (const request of TestXmlHttpRequest.requests) { + request.complete(); + } + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(TestXmlHttpRequest.requests).toHaveLength(4); + }); + + it("retryWorkspaceUpload restarts a failed entry", async () => { + mocks.runAtomCommand.mockResolvedValueOnce(genericMintFailure()); + const file = makeFile("retry.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const [uploadId, failed] = findUpload("retry.txt")!; + expect(failed).toMatchObject({ status: "failed" }); + + mocks.runAtomCommand.mockImplementation( + async ( + _registry: unknown, + command: unknown, + target: { readonly input: { readonly relativePath: string } }, + ) => { + if (command !== mocks.createUploadUrl) throw new Error("unexpected command"); + return mintedResult(target.input.relativePath); + }, + ); + retryWorkspaceUpload(uploadId); + await Promise.resolve(); + await Promise.resolve(); + + expect(TestXmlHttpRequest.requests).toHaveLength(1); + expect(uploadsById()[uploadId]).toMatchObject({ status: "uploading" }); + + TestXmlHttpRequest.requests[0]!.complete(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(uploadsById()[uploadId]).toBeUndefined(); + }); + + it("retryWorkspaceUpload ignores a second click while the retry is already uploading", async () => { + mocks.runAtomCommand.mockResolvedValueOnce(genericMintFailure()); + const file = makeFile("retry-twice.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const [uploadId] = findUpload("retry-twice.txt")!; + retryWorkspaceUpload(uploadId); + retryWorkspaceUpload(uploadId); + await Promise.resolve(); + await Promise.resolve(); + + expect(TestXmlHttpRequest.requests).toHaveLength(1); + expect(uploadsById()[uploadId]).toMatchObject({ status: "uploading" }); + }); + + it("dismissWorkspaceUpload removes a failed entry", async () => { + mocks.runAtomCommand.mockResolvedValue(genericMintFailure()); + const file = makeFile("dismiss-me.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const [uploadId] = findUpload("dismiss-me.txt")!; + dismissWorkspaceUpload(uploadId); + + expect(uploadsById()[uploadId]).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/workspaceUploadQueue.ts b/apps/web/src/lib/workspaceUploadQueue.ts new file mode 100644 index 000000000000..0dd7f0d0a0af --- /dev/null +++ b/apps/web/src/lib/workspaceUploadQueue.ts @@ -0,0 +1,382 @@ +import { isProjectUploadTargetExistsError, type EnvironmentId } from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { create } from "zustand"; + +import { readLocalApi } from "../localApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { projectEnvironment } from "../state/projects"; +import { readPreparedConnection } from "../state/session"; +import { randomUUID } from "./utils"; +import { UploadRejectedError, uploadXhr } from "./uploadXhr"; + +const MAX_UPLOADS_PER_ENVIRONMENT = 3; +// Matches the upload token TTL (see PROJECT_UPLOAD_URL_TTL_MS), since +// workspace uploads allow files up to 100 MiB. +const UPLOAD_TIMEOUT_MS = 10 * 60_000; + +export type WorkspaceUploadState = + | { + readonly status: "uploading"; + readonly name: string; + readonly relativePath: string; + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly progress: number; + } + | { + readonly status: "failed"; + readonly name: string; + readonly relativePath: string; + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly reason: string; + }; + +interface WorkspaceUploadStore { + readonly uploadsById: Readonly>; +} + +export const useWorkspaceUploadStore = create(() => ({ + uploadsById: {}, +})); + +interface UploadJob { + readonly id: string; + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly relativePath: string; + readonly file: File; + readonly onUploaded: (relativePath: string) => void; + readonly onOverwriteStart: ((relativePath: string) => void) | undefined; + readonly onSettled: ((relativePath: string) => void) | undefined; + overwrite: boolean; + overwriteStarted: boolean; + cancelled: boolean; + abort: (() => void) | null; +} + +// Jobs stay here from queueing through a terminal state (failed) so retry can +// reuse the original File. Success and cancellation remove the entry. +const jobsById = new Map(); +const queue: UploadJob[] = []; +const activeUploadsByEnvironment = new Map(); + +function setUploadState(uploadId: string, upload: WorkspaceUploadState): void { + useWorkspaceUploadStore.setState((state) => ({ + uploadsById: { ...state.uploadsById, [uploadId]: upload }, + })); +} + +function clearUploadState(uploadId: string): void { + useWorkspaceUploadStore.setState((state) => { + if (!(uploadId in state.uploadsById)) { + return state; + } + const uploadsById = { ...state.uploadsById }; + delete uploadsById[uploadId]; + return { uploadsById }; + }); +} + +function failJob(job: UploadJob, reason: string): void { + setUploadState(job.id, { + status: "failed", + name: job.file.name, + relativePath: job.relativePath, + environmentId: job.environmentId, + cwd: job.cwd, + reason, + }); +} + +function mintUploadUrl(job: UploadJob) { + return runAtomCommand( + appAtomRegistry, + projectEnvironment.createUploadUrl, + { + environmentId: job.environmentId, + input: { + cwd: job.cwd, + relativePath: job.relativePath, + sizeBytes: job.file.size, + ...(job.overwrite ? { overwrite: true } : {}), + }, + }, + { reportFailure: false }, + ); +} + +function confirmReplace(job: UploadJob): Promise { + return Promise.resolve( + readLocalApi()?.dialogs.confirm( + `Replace ${job.relativePath}?\nA file named '${job.relativePath}' already exists in this project.`, + { variant: "destructive" }, + ), + ); +} + +// The overwrite phase begins at conflict discovery rather than at +// confirmation: a debounced save could otherwise fire while the confirm +// dialog is open and land after the upload replaces the bytes. +function startOverwritePhase(job: UploadJob): void { + if (job.overwriteStarted) { + return; + } + job.overwriteStarted = true; + job.onOverwriteStart?.(job.relativePath); +} + +// The mint-time existence check is only advisory: a rival upload can land +// between it and the server's atomic commit, which then answers 409. Both +// conflict stages funnel into the same replace confirmation, and the overwrite +// flag bounds the loop to one retry. +async function runUpload(job: UploadJob): Promise { + for (;;) { + let minted = await mintUploadUrl(job); + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + + if (minted._tag !== "Success") { + if (!isProjectUploadTargetExistsError(Cause.squash(minted.cause))) { + failJob(job, "Upload could not start"); + return; + } + + startOverwritePhase(job); + + const confirmed = await confirmReplace(job); + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + if (confirmed !== true) { + failJob(job, "File already exists"); + return; + } + + job.overwrite = true; + minted = await mintUploadUrl(job); + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + if (minted._tag !== "Success") { + failJob(job, "Upload could not start"); + return; + } + } + + const connection = readPreparedConnection(job.environmentId); + const url = connection + ? resolveAssetUrl(connection.httpBaseUrl, minted.value.relativeUrl) + : null; + if (!url) { + failJob(job, "Not connected"); + return; + } + + let lastStep = -1; + const upload = uploadXhr({ + url, + file: job.file, + timeoutMs: UPLOAD_TIMEOUT_MS, + onProgress: (progress) => { + const step = Math.floor(progress * 20); + if (step === lastStep || job.cancelled) { + return; + } + lastStep = step; + setUploadState(job.id, { + status: "uploading", + name: job.file.name, + relativePath: job.relativePath, + environmentId: job.environmentId, + cwd: job.cwd, + progress, + }); + }, + }); + job.abort = upload.abort; + + try { + await upload.done; + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + jobsById.delete(job.id); + clearUploadState(job.id); + try { + job.onUploaded(job.relativePath); + } catch (error) { + // The upload itself succeeded; a throwing refresh callback must not + // resurrect the cleared entry as an unretryable failure. + console.error(error); + } + return; + } catch (error) { + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + if (error instanceof UploadRejectedError && error.status === 409 && !job.overwrite) { + startOverwritePhase(job); + const confirmed = await confirmReplace(job); + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + if (confirmed !== true) { + failJob(job, "File already exists"); + return; + } + job.overwrite = true; + continue; + } + failJob(job, error instanceof Error ? error.message : "Upload failed"); + return; + } finally { + job.abort = null; + } + } +} + +function pumpUploads(): void { + for (let index = 0; index < queue.length; ) { + const job = queue[index]!; + const active = activeUploadsByEnvironment.get(job.environmentId) ?? 0; + if (active >= MAX_UPLOADS_PER_ENVIRONMENT) { + index += 1; + continue; + } + + queue.splice(index, 1); + if (job.cancelled) { + continue; + } + activeUploadsByEnvironment.set(job.environmentId, active + 1); + void runUpload(job) + .catch(() => { + if (!job.cancelled) { + failJob(job, "Upload failed"); + } + }) + .finally(() => { + // Settle pairs with onOverwriteStart: only jobs that entered the + // overwrite phase took a save hold, so only those release one. + if (job.overwriteStarted) { + try { + job.onSettled?.(job.relativePath); + } catch (error) { + // A throwing settle callback must not stall the queue. + console.error(error); + } + } + const remaining = (activeUploadsByEnvironment.get(job.environmentId) ?? 1) - 1; + if (remaining > 0) { + activeUploadsByEnvironment.set(job.environmentId, remaining); + } else { + activeUploadsByEnvironment.delete(job.environmentId); + } + pumpUploads(); + }); + } +} + +export function startWorkspaceUploads(input: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly files: ReadonlyArray; + readonly onUploaded: (relativePath: string) => void; + /** The path collided with an existing file; the confirm dialog is about to open. */ + readonly onOverwriteStart?: (relativePath: string) => void; + /** A job that fired onOverwriteStart reached a terminal state: stored, failed, or cancelled. */ + readonly onSettled?: (relativePath: string) => void; +}): void { + for (const file of input.files) { + const id = randomUUID(); + // Uploads land at the project root in v1. The RPC schema trims the path + // on encode, so an untrimmed name would store the file under a different + // path than the one the job reports. + const relativePath = file.name.trim(); + const job: UploadJob = { + id, + environmentId: input.environmentId, + cwd: input.cwd, + relativePath, + file, + onUploaded: input.onUploaded, + onOverwriteStart: input.onOverwriteStart, + onSettled: input.onSettled, + overwrite: false, + overwriteStarted: false, + cancelled: false, + abort: null, + }; + jobsById.set(id, job); + if (relativePath === "") { + failJob(job, "File name is empty"); + continue; + } + queue.push(job); + setUploadState(id, { + status: "uploading", + name: file.name, + relativePath, + environmentId: input.environmentId, + cwd: input.cwd, + progress: 0, + }); + } + pumpUploads(); +} + +export function cancelWorkspaceUpload(uploadId: string): void { + const job = jobsById.get(uploadId); + if (!job) { + return; + } + job.cancelled = true; + jobsById.delete(uploadId); + const queuedIndex = queue.indexOf(job); + if (queuedIndex !== -1) { + queue.splice(queuedIndex, 1); + } + job.abort?.(); + clearUploadState(uploadId); +} + +export function retryWorkspaceUpload(uploadId: string): void { + const job = jobsById.get(uploadId); + if (!job) { + return; + } + // A second click can land before the row rerenders; only failed jobs restart. + if (useWorkspaceUploadStore.getState().uploadsById[uploadId]?.status !== "failed") { + return; + } + job.cancelled = false; + // The target may have changed since the original confirmation, so a retry + // re-confirms an overwrite instead of carrying the earlier answer over. + job.overwrite = false; + job.overwriteStarted = false; + setUploadState(job.id, { + status: "uploading", + name: job.file.name, + relativePath: job.relativePath, + environmentId: job.environmentId, + cwd: job.cwd, + progress: 0, + }); + queue.push(job); + pumpUploads(); +} + +export function dismissWorkspaceUpload(uploadId: string): void { + jobsById.delete(uploadId); + clearUploadState(uploadId); +} diff --git a/docs/user/files.md b/docs/user/files.md new file mode 100644 index 000000000000..d8930c42aecc --- /dev/null +++ b/docs/user/files.md @@ -0,0 +1,40 @@ +# Files view + +The files view shows your project's folder tree: directories and files in one list you can +expand, collapse, and select. Click a file to open it in the preview pane. + +## Opening and searching + +Type in the search field to filter the tree to matching names. Press Escape to clear the search +and show the full tree again. + +Right-click a file for **Copy mention** and **Add to chat**, so you can reference it in your +message without leaving the tree. + +## Uploading files + +Uploading is available in the web and desktop apps. The mobile files view does not support it. + +Drag files from your computer onto the files view, or select the upload button at the top of the +panel to choose files from a picker. Uploads land in the project's root folder. Dragging a folder +does not upload its contents; drop the files themselves. + +If a file with that name already exists, T3 Code asks before replacing it. Decline and the upload +is cancelled; the existing file is left as it was. + +While a file uploads, its row shows progress and a cancel button. A failed upload shows the reason +and gives you retry and dismiss buttons. + +Uploading works the same way whether you're connected locally, over a remote network, or through a +tunnel. + +## Renaming and deleting files + +Right-click a file for **Rename** and **Delete**. Renaming and deleting work on files only; +folders are not yet supported. + +Rename opens a dialog prefilled with the current name. The file keeps its place: renaming changes +the name, not the folder. If a file with the new name already exists, the rename is refused and +the existing file is left untouched. + +Delete asks for confirmation first, then permanently removes the file from the project. diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc321547..39309ce8ae50 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -92,14 +92,40 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: projectConcurrency, }), + // Saves, renames, and deletes share one serial lane per project. A rename + // and a delete key on different paths when the rename targets the deleted + // path, so per-path lanes would let the delete run concurrently and + // remove the freshly renamed file; one lane orders every pair, including + // a save racing a mutation of its own path. writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, scheduler: fileScheduler, concurrency: { mode: "serial", - key: ({ environmentId, input }) => - JSON.stringify([environmentId, input.cwd, input.relativePath]), + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), + }, + }), + createUploadUrl: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:create-upload-url", + tag: WS_METHODS.projectsCreateUploadUrl, + }), + renameEntry: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:rename-entry", + tag: WS_METHODS.projectsRenameEntry, + scheduler: fileScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), + }, + }), + deleteEntry: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:delete-entry", + tag: WS_METHODS.projectsDeleteEntry, + scheduler: fileScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), }, }), }; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 757c000a065a..5964ed0856ed 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -298,3 +298,213 @@ export class ProjectWriteFileError extends Schema.TaggedErrorClass()( + "ProjectUploadTargetExistsError", + { + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `A file already exists at '${this.relativePath}' in '${this.cwd}'.`; + } +} + +export const isProjectUploadTargetExistsError = Schema.is(ProjectUploadTargetExistsError); + +export const ProjectCreateUploadUrlStage = Schema.Literals([ + "signing-key", + "resolve-path", + "target-not-file", + "target-check", +]); +export type ProjectCreateUploadUrlStage = typeof ProjectCreateUploadUrlStage.Type; + +type ProjectCreateUploadUrlFailureContext = { + readonly cwd: string; + readonly relativePath: string; + readonly stage: ProjectCreateUploadUrlStage; + readonly cause?: unknown; +}; + +function projectCreateUploadUrlStageMessage(props: ProjectCreateUploadUrlFailureContext): string { + switch (props.stage) { + case "signing-key": + return "Failed to load the upload signing key."; + case "resolve-path": + return `Failed to resolve '${props.relativePath}' within '${props.cwd}'.`; + case "target-not-file": + return `'${props.relativePath}' already exists as a folder; uploads can only replace files.`; + case "target-check": + return `Failed to check for an existing file at '${props.relativePath}' in '${props.cwd}'.`; + } +} + +export class ProjectCreateUploadUrlError extends Schema.TaggedErrorClass()( + "ProjectCreateUploadUrlError", + { + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString, + stage: ProjectCreateUploadUrlStage, + message: TrimmedNonEmptyString, + // Validation stages fail without an underlying error, so a cause only + // accompanies real I/O failures. + cause: Schema.optional(Schema.Defect()), + }, +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectCreateUploadUrlFailureContext) { + super({ ...props, message: projectCreateUploadUrlStageMessage(props) } as any); + } +} + +export const ProjectRenameEntryInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), + newRelativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH), + ), +}); +export type ProjectRenameEntryInput = typeof ProjectRenameEntryInput.Type; + +export const ProjectRenameEntryResult = Schema.Struct({ + relativePath: TrimmedNonEmptyString, +}); +export type ProjectRenameEntryResult = typeof ProjectRenameEntryResult.Type; + +export class ProjectRenameEntryTargetExistsError extends Schema.TaggedErrorClass()( + "ProjectRenameEntryTargetExistsError", + { + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `A file already exists at '${this.relativePath}' in '${this.cwd}'.`; + } +} + +export const isProjectRenameEntryTargetExistsError = Schema.is(ProjectRenameEntryTargetExistsError); + +export const ProjectRenameEntryStage = Schema.Literals([ + "resolve-path", + "escapes-root", + "not-a-file", + "cross-directory", + "rename", +]); +export type ProjectRenameEntryStage = typeof ProjectRenameEntryStage.Type; + +type ProjectRenameEntryFailureContext = { + readonly cwd: string; + readonly relativePath: string; + readonly stage: ProjectRenameEntryStage; + readonly cause?: unknown; +}; + +function projectRenameEntryStageMessage(props: ProjectRenameEntryFailureContext): string { + switch (props.stage) { + case "resolve-path": + return `Failed to resolve '${props.relativePath}' within '${props.cwd}'.`; + case "escapes-root": + return `'${props.relativePath}' resolves outside the project.`; + case "not-a-file": + return `'${props.relativePath}' is not a file.`; + case "cross-directory": + return `Cannot rename '${props.relativePath}' into a different directory.`; + case "rename": + return `Failed to rename '${props.relativePath}' in '${props.cwd}'.`; + } +} + +export class ProjectRenameEntryError extends Schema.TaggedErrorClass()( + "ProjectRenameEntryError", + { + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString, + stage: ProjectRenameEntryStage, + message: TrimmedNonEmptyString, + // Validation stages fail without an underlying error, so a cause only + // accompanies real I/O failures. + cause: Schema.optional(Schema.Defect()), + }, +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectRenameEntryFailureContext) { + super({ ...props, message: projectRenameEntryStageMessage(props) } as any); + } +} + +export const ProjectDeleteEntryInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), +}); +export type ProjectDeleteEntryInput = typeof ProjectDeleteEntryInput.Type; + +export const ProjectDeleteEntryStage = Schema.Literals([ + "resolve-path", + "escapes-root", + "not-a-file", + "remove", +]); +export type ProjectDeleteEntryStage = typeof ProjectDeleteEntryStage.Type; + +type ProjectDeleteEntryFailureContext = { + readonly cwd: string; + readonly relativePath: string; + readonly stage: ProjectDeleteEntryStage; + readonly cause?: unknown; +}; + +function projectDeleteEntryStageMessage(props: ProjectDeleteEntryFailureContext): string { + switch (props.stage) { + case "resolve-path": + return `Failed to resolve '${props.relativePath}' within '${props.cwd}'.`; + case "escapes-root": + return `'${props.relativePath}' resolves outside the project.`; + case "not-a-file": + return `'${props.relativePath}' is not a file; only files can be deleted from the files view.`; + case "remove": + return `Failed to delete '${props.relativePath}' in '${props.cwd}'.`; + } +} + +export class ProjectDeleteEntryError extends Schema.TaggedErrorClass()( + "ProjectDeleteEntryError", + { + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString, + stage: ProjectDeleteEntryStage, + message: TrimmedNonEmptyString, + // Validation stages fail without an underlying error, so a cause only + // accompanies real I/O failures. + cause: Schema.optional(Schema.Defect()), + }, +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectDeleteEntryFailureContext) { + super({ ...props, message: projectDeleteEntryStageMessage(props) } as any); + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..ff702ce83f80 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -112,18 +112,28 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectCreateUploadUrlError, + ProjectCreateUploadUrlInput, + ProjectCreateUploadUrlResult, + ProjectDeleteEntryError, + ProjectDeleteEntryInput, ProjectListEntriesError, ProjectListEntriesInput, ProjectListEntriesResult, ProjectReadFileError, ProjectReadFileInput, ProjectReadFileResult, + ProjectRenameEntryError, + ProjectRenameEntryInput, + ProjectRenameEntryResult, + ProjectRenameEntryTargetExistsError, ProjectSearchContentsError, ProjectSearchContentsInput, ProjectSearchContentsResult, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectUploadTargetExistsError, ProjectWriteFileError, ProjectWriteFileInput, ProjectWriteFileResult, @@ -216,6 +226,9 @@ export const WS_METHODS = { projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + projectsCreateUploadUrl: "projects.createUploadUrl", + projectsRenameEntry: "projects.renameEntry", + projectsDeleteEntry: "projects.deleteEntry", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -666,6 +679,31 @@ export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), }); +export const WsProjectsCreateUploadUrlRpc = Rpc.make(WS_METHODS.projectsCreateUploadUrl, { + payload: ProjectCreateUploadUrlInput, + success: ProjectCreateUploadUrlResult, + error: Schema.Union([ + ProjectCreateUploadUrlError, + ProjectUploadTargetExistsError, + EnvironmentAuthorizationError, + ]), +}); + +export const WsProjectsRenameEntryRpc = Rpc.make(WS_METHODS.projectsRenameEntry, { + payload: ProjectRenameEntryInput, + success: ProjectRenameEntryResult, + error: Schema.Union([ + ProjectRenameEntryError, + ProjectRenameEntryTargetExistsError, + EnvironmentAuthorizationError, + ]), +}); + +export const WsProjectsDeleteEntryRpc = Rpc.make(WS_METHODS.projectsDeleteEntry, { + payload: ProjectDeleteEntryInput, + error: Schema.Union([ProjectDeleteEntryError, EnvironmentAuthorizationError]), +}); + export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1066,6 +1104,9 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, + WsProjectsCreateUploadUrlRpc, + WsProjectsRenameEntryRpc, + WsProjectsDeleteEntryRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc,