mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
97
packages/protocol/src/messages.file-editing.test.ts
Normal file
97
packages/protocol/src/messages.file-editing.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user