Edit workspace files directly on web (#2270)

* feat(files): edit workspace files on web

Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge.

* feat(panels): surface and protect modified tabs

Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently.

* fix(tests): use portable fake timeout handle

* fix(files): harden editor conflict handling

Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface.

* fix(files): close editor concurrency gaps

Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads.

* test(files): expect read revision metadata
This commit is contained in:
Mohamed Boudra
2026-07-20 22:41:19 +02:00
committed by GitHub
parent 9292f58896
commit 4bda2dfea9
69 changed files with 3678 additions and 241 deletions

View File

@@ -14,6 +14,7 @@ export const FileBeginMetadataSchema = z.object({
size: z.number().int().nonnegative(),
encoding: z.enum(["utf-8", "binary"]),
modifiedAt: z.string(),
revision: z.string().optional(),
fileName: z.string().optional(),
});

View File

@@ -0,0 +1,97 @@
import { describe, expect, test } from "vitest";
import {
FileSubscribeRequestSchema,
FileSubscribeResponseSchema,
FileUnsubscribeRequestSchema,
FileUpdateSchema,
FileWriteRequestSchema,
FileWriteResponseSchema,
ServerInfoStatusPayloadSchema,
} from "./messages.js";
describe("workspace file editing messages", () => {
test("keeps the capability optional for older server info payloads", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "server-1",
features: {},
}).features?.workspaceFileEditing,
).toBeUndefined();
});
test("parses subscribe, unsubscribe, and version update messages", () => {
expect(
FileSubscribeRequestSchema.parse({
type: "fs.file.subscribe.request",
cwd: "/workspace",
path: "file.ts",
subscriptionId: "subscription-1",
requestId: "request-1",
}).subscriptionId,
).toBe("subscription-1");
expect(
FileSubscribeResponseSchema.parse({
type: "fs.file.subscribe.response",
payload: {
subscriptionId: "subscription-1",
initial: {
status: "ready",
cwd: "/workspace",
path: "file.ts",
size: 12,
modifiedAt: "2026-07-18T00:00:00.000Z",
},
requestId: "request-1",
},
}).payload.initial.status,
).toBe("ready");
expect(
FileUnsubscribeRequestSchema.parse({
type: "fs.file.unsubscribe.request",
subscriptionId: "subscription-1",
requestId: "request-2",
}).subscriptionId,
).toBe("subscription-1");
expect(
FileUpdateSchema.parse({
type: "fs.file.update",
payload: {
subscriptionId: "subscription-1",
version: { status: "missing", cwd: "/workspace", path: "file.ts" },
},
}).payload.version.status,
).toBe("missing");
});
test("parses optimistic write requests and conflict responses", () => {
expect(
FileWriteRequestSchema.parse({
type: "fs.file.write.request",
cwd: "/workspace",
path: "file.ts",
content: "const value = 1;\n",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
requestId: "request-1",
}).content,
).toBe("const value = 1;\n");
expect(
FileWriteResponseSchema.parse({
type: "fs.file.write.response",
payload: {
result: {
status: "conflict",
version: {
status: "ready",
cwd: "/workspace",
path: "file.ts",
size: 20,
modifiedAt: "2026-07-18T00:00:01.000Z",
},
},
requestId: "request-1",
},
}).payload.result.status,
).toBe("conflict");
});
});

View File

@@ -2132,6 +2132,7 @@ const FileExplorerFileSchema = z.object({
mimeType: z.string().optional(),
size: z.number(),
modifiedAt: z.string(),
revision: z.string().optional(),
});
const FileExplorerDirectorySchema = z.object({
@@ -2148,6 +2149,52 @@ export const FileExplorerRequestSchema = z.object({
acceptBinary: z.boolean().optional(),
});
export const FileVersionSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("ready"),
cwd: z.string(),
path: z.string(),
size: z.number().int().nonnegative(),
modifiedAt: z.string(),
revision: z.string().optional(),
}),
z.object({
status: z.literal("missing"),
cwd: z.string(),
path: z.string(),
}),
z.object({
status: z.literal("error"),
cwd: z.string(),
path: z.string(),
error: z.string(),
}),
]);
export const FileSubscribeRequestSchema = z.object({
type: z.literal("fs.file.subscribe.request"),
cwd: z.string(),
path: z.string(),
subscriptionId: z.string(),
requestId: z.string(),
});
export const FileUnsubscribeRequestSchema = z.object({
type: z.literal("fs.file.unsubscribe.request"),
subscriptionId: z.string(),
requestId: z.string(),
});
export const FileWriteRequestSchema = z.object({
type: z.literal("fs.file.write.request"),
cwd: z.string(),
path: z.string(),
content: z.string(),
expectedModifiedAt: z.string(),
expectedRevision: z.string().optional(),
requestId: z.string(),
});
export const ProjectIconRequestSchema = z.object({
type: z.literal("project_icon_request"),
cwd: z.string(),
@@ -2463,6 +2510,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WorkspaceCreateRequestSchema,
WorkspaceClearAttentionRequestSchema,
FileExplorerRequestSchema,
FileSubscribeRequestSchema,
FileUnsubscribeRequestSchema,
FileWriteRequestSchema,
ProjectIconRequestSchema,
FileDownloadTokenRequestSchema,
FileUploadRequestSchema,
@@ -2698,6 +2748,8 @@ export const ServerInfoStatusPayloadSchema = z
worktreeRestore: z.boolean().optional(),
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
workspaceRecovery: z.boolean().optional(),
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: z.boolean().optional(),
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
@@ -4552,6 +4604,50 @@ export const FileExplorerResponseSchema = z.object({
}),
});
export const FileSubscribeResponseSchema = z.object({
type: z.literal("fs.file.subscribe.response"),
payload: z.object({
subscriptionId: z.string(),
initial: FileVersionSchema,
requestId: z.string(),
}),
});
export const FileUnsubscribeResponseSchema = z.object({
type: z.literal("fs.file.unsubscribe.response"),
payload: z.object({
subscriptionId: z.string(),
requestId: z.string(),
}),
});
export const FileWriteResultSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("written"),
modifiedAt: z.string(),
size: z.number(),
revision: z.string().optional(),
}),
z.object({ status: z.literal("conflict"), version: FileVersionSchema }),
z.object({ status: z.literal("error"), error: z.string() }),
]);
export const FileWriteResponseSchema = z.object({
type: z.literal("fs.file.write.response"),
payload: z.object({
result: FileWriteResultSchema,
requestId: z.string(),
}),
});
export const FileUpdateSchema = z.object({
type: z.literal("fs.file.update"),
payload: z.object({
subscriptionId: z.string(),
version: FileVersionSchema,
}),
});
const ProjectIconSchema = z.object({
data: z.string(),
mimeType: z.string(),
@@ -5099,6 +5195,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeArchiveResponseSchema,
CreatePaseoWorktreeResponseSchema,
FileExplorerResponseSchema,
FileSubscribeResponseSchema,
FileUnsubscribeResponseSchema,
FileWriteResponseSchema,
FileUpdateSchema,
ProjectIconResponseSchema,
FileDownloadTokenResponseSchema,
FileUploadResponseSchema,
@@ -5493,6 +5593,15 @@ export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSche
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type FileVersion = z.infer<typeof FileVersionSchema>;
export type FileSubscribeRequest = z.infer<typeof FileSubscribeRequestSchema>;
export type FileSubscribeResponse = z.infer<typeof FileSubscribeResponseSchema>;
export type FileUnsubscribeRequest = z.infer<typeof FileUnsubscribeRequestSchema>;
export type FileUnsubscribeResponse = z.infer<typeof FileUnsubscribeResponseSchema>;
export type FileWriteRequest = z.infer<typeof FileWriteRequestSchema>;
export type FileWriteResponse = z.infer<typeof FileWriteResponseSchema>;
export type FileWriteResult = z.infer<typeof FileWriteResultSchema>;
export type FileUpdate = z.infer<typeof FileUpdateSchema>;
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;