diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
index 9b2334fd..a8dc682c 100644
--- a/packages/server/src/server.ts
+++ b/packages/server/src/server.ts
@@ -2108,6 +2108,103 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
return new Response((Bun as any).file(absPath), { status: 200, headers });
});
+ addRoute(routes, "GET", "/workspace/:id/files/content", "client", async (ctx) => {
+ const workspace = await resolveWorkspace(config, ctx.params.id);
+ const requested = (ctx.url.searchParams.get("path") ?? "").trim();
+ const relativePath = normalizeWorkspaceRelativePath(requested, { allowSubdirs: true });
+ const lowered = relativePath.toLowerCase();
+ const isMarkdown = lowered.endsWith(".md") || lowered.endsWith(".mdx") || lowered.endsWith(".markdown");
+ if (!isMarkdown) {
+ throw new ApiError(400, "invalid_path", "Only markdown files are supported");
+ }
+
+ const absPath = resolveSafeChildPath(workspace.path, relativePath);
+ if (!(await exists(absPath))) {
+ throw new ApiError(404, "file_not_found", "File not found");
+ }
+ const info = await stat(absPath);
+ if (!info.isFile()) {
+ throw new ApiError(404, "file_not_found", "File not found");
+ }
+
+ const maxBytes = 5_000_000;
+ if (info.size > maxBytes) {
+ throw new ApiError(413, "file_too_large", "File exceeds size limit", { maxBytes, size: info.size });
+ }
+
+ const content = await readFile(absPath, "utf8");
+ return jsonResponse({ path: relativePath, content, bytes: info.size, updatedAt: info.mtimeMs });
+ });
+
+ addRoute(routes, "POST", "/workspace/:id/files/content", "client", async (ctx) => {
+ ensureWritable(config);
+ requireClientScope(ctx, "collaborator");
+ const workspace = await resolveWorkspace(config, ctx.params.id);
+ const body = await readJsonBody(ctx.request);
+
+ const requestedPath = String(body.path ?? "");
+ const relativePath = normalizeWorkspaceRelativePath(requestedPath, { allowSubdirs: true });
+ const lowered = relativePath.toLowerCase();
+ const isMarkdown = lowered.endsWith(".md") || lowered.endsWith(".mdx") || lowered.endsWith(".markdown");
+ if (!isMarkdown) {
+ throw new ApiError(400, "invalid_path", "Only markdown files are supported");
+ }
+
+ if (typeof body.content !== "string") {
+ throw new ApiError(400, "invalid_payload", "content must be a string");
+ }
+ const content = body.content;
+ const bytes = Buffer.byteLength(content, "utf8");
+ const maxBytes = 5_000_000;
+ if (bytes > maxBytes) {
+ throw new ApiError(413, "file_too_large", "File exceeds size limit", { maxBytes, size: bytes });
+ }
+
+ const baseUpdatedAtRaw = body.baseUpdatedAt;
+ const baseUpdatedAt =
+ typeof baseUpdatedAtRaw === "number" && Number.isFinite(baseUpdatedAtRaw) ? baseUpdatedAtRaw : null;
+ const force = body.force === true;
+
+ const absPath = resolveSafeChildPath(workspace.path, relativePath);
+
+ const before = (await exists(absPath)) ? await stat(absPath) : null;
+ if (before && !before.isFile()) {
+ throw new ApiError(400, "invalid_path", "Path must point to a file");
+ }
+ const beforeUpdatedAt = before ? before.mtimeMs : null;
+ if (!force && beforeUpdatedAt !== null && baseUpdatedAt !== null && beforeUpdatedAt !== baseUpdatedAt) {
+ throw new ApiError(409, "conflict", "File changed since it was loaded", {
+ baseUpdatedAt,
+ currentUpdatedAt: beforeUpdatedAt,
+ });
+ }
+
+ await requireApproval(ctx, {
+ workspaceId: workspace.id,
+ action: "workspace.file.write",
+ summary: `Write ${relativePath}`,
+ paths: [absPath],
+ });
+
+ await ensureDir(dirname(absPath));
+ const tmp = `${absPath}.tmp-${shortId()}`;
+ await writeFile(tmp, content, "utf8");
+ await rename(tmp, absPath);
+ const after = await stat(absPath);
+
+ await recordAudit(workspace.path, {
+ id: shortId(),
+ workspaceId: workspace.id,
+ actor: ctx.actor ?? { type: "remote" },
+ action: "workspace.file.write",
+ target: absPath,
+ summary: `Wrote ${relativePath}`,
+ timestamp: Date.now(),
+ });
+
+ return jsonResponse({ ok: true, path: relativePath, bytes, updatedAt: after.mtimeMs });
+ });
+
addRoute(routes, "GET", "/workspace/:id/plugins", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
diff --git a/pr/518-markdown-editor/01-touched-files.png b/pr/518-markdown-editor/01-touched-files.png
new file mode 100644
index 00000000..cb2968dd
Binary files /dev/null and b/pr/518-markdown-editor/01-touched-files.png differ
diff --git a/pr/518-markdown-editor/02-editor-open.png b/pr/518-markdown-editor/02-editor-open.png
new file mode 100644
index 00000000..7ca82c18
Binary files /dev/null and b/pr/518-markdown-editor/02-editor-open.png differ
diff --git a/pr/518-markdown-editor/03-after-save.png b/pr/518-markdown-editor/03-after-save.png
new file mode 100644
index 00000000..60b6ee80
Binary files /dev/null and b/pr/518-markdown-editor/03-after-save.png differ
diff --git a/pr/518-markdown-editor/04-sidebar-touched.png b/pr/518-markdown-editor/04-sidebar-touched.png
new file mode 100644
index 00000000..d65002a8
Binary files /dev/null and b/pr/518-markdown-editor/04-sidebar-touched.png differ
diff --git a/pr/518-markdown-editor/05-sidebar-editor-preview.png b/pr/518-markdown-editor/05-sidebar-editor-preview.png
new file mode 100644
index 00000000..0ef89c89
Binary files /dev/null and b/pr/518-markdown-editor/05-sidebar-editor-preview.png differ
diff --git a/pr/518-markdown-editor/06-sidebar-after-save.png b/pr/518-markdown-editor/06-sidebar-after-save.png
new file mode 100644
index 00000000..47614caf
Binary files /dev/null and b/pr/518-markdown-editor/06-sidebar-after-save.png differ