feat(app): markdown editor for touched files (#518)

* feat(server): add markdown file read/write endpoints

* feat(app): edit markdown files from sidebar

* fix(app): open sidebar files without workspace prefix

* chore(pr): add markdown editor verification screenshots

* feat(app): dock markdown editor on the right

* refactor(app): dock markdown editor as right sidebar

* chore(pr): add sidebar editor verification screenshots
This commit is contained in:
ben
2026-02-10 10:01:44 -08:00
committed by GitHub
parent 106627d364
commit 017412ca49
10 changed files with 727 additions and 0 deletions

View File

@@ -0,0 +1,533 @@
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { marked } from "marked";
import { FileText, RefreshCcw, Save, X } from "lucide-solid";
import Button from "../button";
import type { OpenworkServerClient, OpenworkWorkspaceFileContent, OpenworkWorkspaceFileWriteResult } from "../../lib/openwork-server";
import { OpenworkServerError } from "../../lib/openwork-server";
export type MarkdownEditorSidebarProps = {
open: boolean;
path: string | null;
workspaceId: string | null;
client: OpenworkServerClient | null;
onClose: () => void;
onToast?: (message: string) => void;
};
const isMarkdown = (value: string) => /\.(md|mdx|markdown)$/i.test(value);
const basename = (value: string) => value.split(/[/\\]/).filter(Boolean).pop() ?? value;
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const isSafeUrl = (url: string) => {
const normalized = (url || "").trim().toLowerCase();
if (!normalized) return false;
if (normalized.startsWith("javascript:")) return false;
if (normalized.startsWith("data:")) return normalized.startsWith("data:image/");
return true;
};
function createMarkdownRenderer() {
const renderer = new marked.Renderer();
renderer.html = ({ text }) => escapeHtml(text);
renderer.code = ({ text, lang }) => {
const language = lang ? escapeHtml(lang) : "";
return `
<div class="rounded-xl border border-dls-border bg-dls-surface px-4 py-3 my-4">
${
language
? `<div class="text-[10px] uppercase tracking-[0.2em] text-dls-secondary mb-2">${language}</div>`
: ""
}
<pre class="overflow-x-auto whitespace-pre text-[13px] leading-relaxed font-mono"><code>${escapeHtml(text)}</code></pre>
</div>
`;
};
renderer.codespan = ({ text }) => {
return `<code class="rounded-md px-1.5 py-0.5 text-[13px] font-mono bg-dls-active text-dls-text">${escapeHtml(text)}</code>`;
};
renderer.link = ({ href, title, text }) => {
const safeHref = isSafeUrl(href ?? "") ? escapeHtml(href ?? "#") : "#";
const safeTitle = title ? escapeHtml(title) : "";
return `
<a
href="${safeHref}"
target="_blank"
rel="noopener noreferrer"
class="underline underline-offset-2 text-dls-accent"
${safeTitle ? `title="${safeTitle}"` : ""}
>
${text}
</a>
`;
};
renderer.image = ({ href, title, text }) => {
const safeHref = isSafeUrl(href ?? "") ? escapeHtml(href ?? "") : "";
const safeTitle = title ? escapeHtml(title) : "";
return `
<img
src="${safeHref}"
alt="${escapeHtml(text || "")}"
${safeTitle ? `title="${safeTitle}"` : ""}
class="max-w-full h-auto rounded-lg my-4"
/>
`;
};
return renderer;
}
function useThrottledValue(value: () => string, delayMs = 120) {
const [state, setState] = createSignal(value());
let timer: number | undefined;
createEffect(() => {
const next = value();
if (!delayMs) {
setState(next);
return;
}
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => {
setState(next);
timer = undefined;
}, delayMs);
});
onCleanup(() => {
if (timer) window.clearTimeout(timer);
});
return state;
}
export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps) {
let textareaRef: HTMLTextAreaElement | undefined;
const [loading, setLoading] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [original, setOriginal] = createSignal("");
const [draft, setDraft] = createSignal("");
const [baseUpdatedAt, setBaseUpdatedAt] = createSignal<number | null>(null);
const [confirmDiscardClose, setConfirmDiscardClose] = createSignal(false);
const [confirmDiscardReload, setConfirmDiscardReload] = createSignal(false);
const [confirmOverwrite, setConfirmOverwrite] = createSignal(false);
const [pendingPath, setPendingPath] = createSignal<string | null>(null);
const [pendingReason, setPendingReason] = createSignal<"switch" | "reload" | null>(null);
const path = createMemo(() => props.path?.trim() ?? "");
const title = createMemo(() => (path() ? basename(path()) : "Markdown"));
const dirty = createMemo(() => draft() !== original());
const canWrite = createMemo(() => Boolean(props.client && props.workspaceId));
const canSave = createMemo(() => dirty() && !saving() && canWrite());
const writeDisabledReason = createMemo(() => {
if (canWrite()) return null;
return "Connect to an OpenWork server workspace to edit files.";
});
const previewSource = useThrottledValue(() => (props.open ? draft() : ""), 120);
const previewHtml = createMemo(() => {
const text = previewSource();
if (!props.open) return "";
if (!text.trim()) return "";
try {
const renderer = createMarkdownRenderer();
const result = marked.parse(text, { breaks: true, gfm: true, renderer, async: false });
return typeof result === "string" ? result : "";
} catch {
return "";
}
});
const resetState = () => {
setLoading(false);
setSaving(false);
setError(null);
setOriginal("");
setDraft("");
setBaseUpdatedAt(null);
setConfirmDiscardClose(false);
setConfirmDiscardReload(false);
setConfirmOverwrite(false);
setPendingPath(null);
setPendingReason(null);
};
const load = async (target: string) => {
const client = props.client;
const workspaceId = props.workspaceId;
if (!client || !workspaceId) {
setError(writeDisabledReason());
return;
}
if (!target) return;
if (!isMarkdown(target)) {
setError("Only markdown files are supported.");
return;
}
setLoading(true);
setError(null);
try {
const result = (await client.readWorkspaceFile(workspaceId, target)) as OpenworkWorkspaceFileContent;
setOriginal(result.content ?? "");
setDraft(result.content ?? "");
setBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null);
requestAnimationFrame(() => textareaRef?.focus());
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load file";
setError(message);
} finally {
setLoading(false);
}
};
const save = async (options?: { force?: boolean }) => {
const client = props.client;
const workspaceId = props.workspaceId;
const target = path();
if (!client || !workspaceId || !target) {
props.onToast?.("Cannot save: OpenWork server not connected");
return;
}
if (!isMarkdown(target)) {
props.onToast?.("Only markdown files are supported");
return;
}
if (!dirty()) return;
setSaving(true);
setError(null);
try {
const result = (await client.writeWorkspaceFile(workspaceId, {
path: target,
content: draft(),
baseUpdatedAt: baseUpdatedAt(),
force: options?.force ?? false,
})) as OpenworkWorkspaceFileWriteResult;
setOriginal(draft());
setBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null);
if (pendingPath() && pendingReason() === "switch") {
const next = pendingPath();
setPendingPath(null);
setPendingReason(null);
if (next) void load(next);
}
} catch (err) {
if (err instanceof OpenworkServerError && err.status === 409) {
setConfirmOverwrite(true);
return;
}
const message = err instanceof Error ? err.message : "Failed to save";
setError(message);
props.onToast?.(message);
} finally {
setSaving(false);
}
};
const requestClose = () => {
if (!dirty()) {
resetState();
props.onClose();
return;
}
setConfirmDiscardClose(true);
};
const requestReload = () => {
const target = path();
if (!target) return;
if (!dirty()) {
void load(target);
return;
}
setConfirmDiscardReload(true);
};
createEffect(() => {
if (!props.open) {
resetState();
return;
}
const target = path();
if (!target) return;
if (pendingReason() === "switch") return;
if (!original() && !draft() && !loading()) {
void load(target);
return;
}
});
createEffect(() => {
if (!props.open) return;
const onKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
if (canSave()) void save();
return;
}
if (event.key === "Escape") {
event.preventDefault();
requestClose();
}
};
window.addEventListener("keydown", onKeyDown);
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
});
createEffect(() => {
if (!props.open) return;
const next = props.path?.trim() ?? "";
if (!next) return;
if (next === path()) return;
if (!dirty()) {
void load(next);
return;
}
setPendingPath(next);
setPendingReason("switch");
});
return (
<Show when={props.open}>
<aside class="fixed inset-y-0 right-0 z-50 w-full md:w-[760px] lg:w-[920px] bg-dls-sidebar border-l border-dls-border flex flex-col">
<div class="shrink-0 px-4 py-3 border-b border-dls-border flex items-center gap-3">
<div class="shrink-0 w-9 h-9 rounded-xl bg-dls-hover border border-dls-border flex items-center justify-center text-dls-secondary">
<FileText size={16} />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<div class="text-sm font-semibold text-dls-text truncate">{title()}</div>
<Show when={dirty()}>
<span class="text-[10px] px-2 py-0.5 rounded-full border border-amber-7/40 bg-amber-2/30 text-amber-11">
Unsaved
</span>
</Show>
</div>
<div class="text-[11px] text-dls-secondary font-mono truncate" title={path()}>
{path()}
</div>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
class="text-xs h-9 py-0 px-3"
onClick={requestReload}
disabled={loading() || saving()}
title="Reload from disk"
>
<RefreshCcw size={14} class={loading() ? "animate-spin" : ""} />
Reload
</Button>
<Button
class="text-xs h-9 py-0 px-3"
onClick={() => void save()}
disabled={!canSave()}
title={writeDisabledReason() ?? "Save (Ctrl/Cmd+S)"}
>
<Save size={14} class={saving() ? "animate-pulse" : ""} />
{saving() ? "Saving..." : "Save"}
</Button>
<Button variant="ghost" class="!p-2 rounded-full" onClick={requestClose}>
<X size={16} />
</Button>
</div>
</div>
<Show when={writeDisabledReason()}>
{(reason) => (
<div class="shrink-0 px-4 py-2 border-b border-dls-border text-[11px] text-dls-secondary">
{reason()}
</div>
)}
</Show>
<Show when={error()}>
{(message) => (
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-red-2/20 text-red-11 text-xs">
{message()}
</div>
)}
</Show>
<Show when={confirmOverwrite()}>
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-amber-2/20 text-amber-11 text-xs flex items-center justify-between gap-3">
<div class="min-w-0">
File changed since load. Overwrite anyway?
</div>
<div class="shrink-0 flex items-center gap-2">
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => setConfirmOverwrite(false)}
>
Cancel
</Button>
<Button
variant="danger"
class="text-xs h-8 py-0 px-3"
onClick={() => {
setConfirmOverwrite(false);
void save({ force: true });
}}
>
Overwrite
</Button>
</div>
</div>
</Show>
<Show when={confirmDiscardReload()}>
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-amber-2/20 text-amber-11 text-xs flex items-center justify-between gap-3">
<div class="min-w-0">Discard unsaved changes and reload from disk?</div>
<div class="shrink-0 flex items-center gap-2">
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => setConfirmDiscardReload(false)}
>
Cancel
</Button>
<Button
variant="secondary"
class="text-xs h-8 py-0 px-3"
onClick={() => {
setConfirmDiscardReload(false);
const target = path();
if (target) void load(target);
}}
>
Reload
</Button>
</div>
</div>
</Show>
<Show when={confirmDiscardClose()}>
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-amber-2/20 text-amber-11 text-xs flex items-center justify-between gap-3">
<div class="min-w-0">Discard unsaved changes and close?</div>
<div class="shrink-0 flex items-center gap-2">
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => setConfirmDiscardClose(false)}
>
Keep
</Button>
<Button
variant="secondary"
class="text-xs h-8 py-0 px-3"
onClick={() => {
setConfirmDiscardClose(false);
resetState();
props.onClose();
}}
>
Discard
</Button>
</div>
</div>
</Show>
<Show when={pendingPath() && pendingReason() === "switch"}>
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-amber-2/20 text-amber-11 text-xs flex items-center justify-between gap-3">
<div class="min-w-0 truncate" title={pendingPath() ?? ""}>
Switch to {pendingPath()}
</div>
<div class="shrink-0 flex items-center gap-2">
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => {
setPendingPath(null);
setPendingReason(null);
}}
>
Cancel
</Button>
<Button
variant="secondary"
class="text-xs h-8 py-0 px-3"
onClick={() => {
const next = pendingPath();
setPendingPath(null);
setPendingReason(null);
setOriginal("");
setDraft("");
if (next) void load(next);
}}
>
Discard & switch
</Button>
<Button
class="text-xs h-8 py-0 px-3"
onClick={() => void save()}
disabled={!canSave()}
>
Save & switch
</Button>
</div>
</div>
</Show>
<div class="flex-1 overflow-hidden">
<div class="h-full grid grid-cols-1 lg:grid-cols-2 gap-3 p-4">
<div class="h-full flex flex-col overflow-hidden">
<div class="text-[11px] uppercase tracking-tight font-bold text-dls-secondary px-1 pb-2">
Markdown
</div>
<textarea
ref={textareaRef}
value={draft()}
onInput={(event) => setDraft(event.currentTarget.value)}
disabled={loading()}
spellcheck={false}
class="flex-1 w-full resize-none rounded-xl border border-dls-border bg-dls-surface px-4 py-3 font-mono text-[13px] leading-relaxed text-dls-text focus:outline-none focus:ring-2 focus:ring-[rgb(var(--dls-accent-rgb)_/_0.25)]"
/>
</div>
<div class="h-full flex flex-col overflow-hidden">
<div class="text-[11px] uppercase tracking-tight font-bold text-dls-secondary px-1 pb-2">
Preview
</div>
<div
class="flex-1 overflow-auto rounded-xl border border-dls-border bg-dls-surface px-5 py-4 text-sm leading-relaxed text-dls-text"
classList={{
"[&_h1]:text-2xl [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2": true,
"[&_h2]:text-xl [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2": true,
"[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2": true,
"[&_p]:my-3": true,
"[&_ul]:my-3 [&_ul]:pl-5 [&_ul]:list-disc": true,
"[&_ol]:my-3 [&_ol]:pl-5 [&_ol]:list-decimal": true,
"[&_li]:my-1": true,
"[&_blockquote]:border-l-2 [&_blockquote]:border-dls-border [&_blockquote]:pl-4 [&_blockquote]:text-dls-secondary [&_blockquote]:my-4": true,
"[&_hr]:my-4 [&_hr]:border-dls-border": true,
}}
innerHTML={previewHtml()}
/>
</div>
</div>
</div>
</aside>
</Show>
);
}

View File

@@ -74,6 +74,20 @@ export type OpenworkSkillContent = {
content: string;
};
export type OpenworkWorkspaceFileContent = {
path: string;
content: string;
bytes: number;
updatedAt: number;
};
export type OpenworkWorkspaceFileWriteResult = {
ok: boolean;
path: string;
bytes: number;
updatedAt: number;
};
export type OpenworkCommandItem = {
name: string;
description?: string;
@@ -992,6 +1006,28 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
return result.text;
},
readWorkspaceFile: (workspaceId: string, path: string) =>
requestJson<OpenworkWorkspaceFileContent>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/files/content?path=${encodeURIComponent(path)}`,
{ token, hostToken },
),
writeWorkspaceFile: (
workspaceId: string,
payload: { path: string; content: string; baseUpdatedAt?: number | null; force?: boolean },
) =>
requestJson<OpenworkWorkspaceFileWriteResult>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/files/content`,
{
token,
hostToken,
method: "POST",
body: payload,
},
),
listArtifacts: (workspaceId: string) =>
requestJson<OpenworkArtifactList>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/artifacts`, {
token,

View File

@@ -62,6 +62,7 @@ import type { SidebarSectionState } from "../components/session/sidebar";
import FlyoutItem from "../components/flyout-item";
import QuestionModal from "../components/question-modal";
import TouchedFilesPanel from "../components/session/touched-files-panel";
import MarkdownEditorSidebar from "../components/session/markdown-editor-sidebar";
export type SessionViewProps = {
selectedSessionId: string | null;
@@ -199,6 +200,9 @@ export default function SessionView(props: SessionViewProps) {
const [autoScrollEnabled, setAutoScrollEnabled] = createSignal(false);
const [scrollOnNextUpdate, setScrollOnNextUpdate] = createSignal(false);
const [markdownEditorOpen, setMarkdownEditorOpen] = createSignal(false);
const [markdownEditorPath, setMarkdownEditorPath] = createSignal<string | null>(null);
// When a session is selected (i.e. we are in SessionView), the right sidebar is
// navigation-only. Avoid showing any tab as "selected" to reduce confusion.
const showRightSidebarSelection = createMemo(() => !props.selectedSessionId);
@@ -247,6 +251,53 @@ export default function SessionView(props: SessionViewProps) {
return out;
});
const normalizeSidebarPath = (value: string) => String(value ?? "").trim().replace(/[\\/]+/g, "/");
const toWorkspaceRelativeForApi = (file: string) => {
const normalized = normalizeSidebarPath(file).replace(/^file:\/\//i, "");
if (!normalized) return "";
const root = normalizeSidebarPath(props.activeWorkspaceRoot).replace(/\/+$/, "");
const rootKey = root.toLowerCase();
const fileKey = normalized.toLowerCase();
if (root && fileKey.startsWith(`${rootKey}/`)) {
return normalized.slice(root.length + 1);
}
if (root && fileKey === rootKey) {
return "";
}
let relative = normalized.replace(/^\.\/+/, "");
if (!relative) return "";
// Some tool outputs include a leading "workspace/" prefix.
if (/^workspace\//i.test(relative)) {
relative = relative.replace(/^workspace\//i, "");
}
if (relative.startsWith("/") || relative.startsWith("~") || /^[a-zA-Z]:\//.test(relative)) return "";
if (relative.split("/").some((part) => part === "." || part === "..")) return "";
return relative;
};
const openMarkdownEditor = (file: string) => {
const relative = toWorkspaceRelativeForApi(file);
if (!relative) {
setToastMessage("Only workspace-relative files can be opened here.");
return;
}
if (!/\.(md|mdx|markdown)$/i.test(relative)) {
setToastMessage("Only markdown files can be edited here right now.");
return;
}
setMarkdownEditorPath(relative);
setMarkdownEditorOpen(true);
};
const closeMarkdownEditor = () => {
setMarkdownEditorOpen(false);
setMarkdownEditorPath(null);
};
const todoLabel = createMemo(() => {
const total = todoCount();
if (!total) return "";
@@ -1988,6 +2039,7 @@ export default function SessionView(props: SessionViewProps) {
id="sidebar-context"
files={touchedFiles()}
workspaceRoot={props.activeWorkspaceRoot}
onFileClick={openMarkdownEditor}
/>
<div class="space-y-1">
@@ -2150,6 +2202,15 @@ export default function SessionView(props: SessionViewProps) {
onOpenBots={openConfig}
/>
<MarkdownEditorSidebar
open={markdownEditorOpen()}
path={markdownEditorPath()}
workspaceId={props.openworkServerWorkspaceId}
client={props.openworkServerClient}
onClose={closeMarkdownEditor}
onToast={(message) => setToastMessage(message)}
/>
<Show when={props.activePermission}>
<div class="absolute inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-gray-2 border border-amber-7/30 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">

View File

@@ -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";

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB