mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
BIN
pr/518-markdown-editor/01-touched-files.png
Normal file
BIN
pr/518-markdown-editor/01-touched-files.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
BIN
pr/518-markdown-editor/02-editor-open.png
Normal file
BIN
pr/518-markdown-editor/02-editor-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
BIN
pr/518-markdown-editor/03-after-save.png
Normal file
BIN
pr/518-markdown-editor/03-after-save.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 313 KiB |
BIN
pr/518-markdown-editor/04-sidebar-touched.png
Normal file
BIN
pr/518-markdown-editor/04-sidebar-touched.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
BIN
pr/518-markdown-editor/05-sidebar-editor-preview.png
Normal file
BIN
pr/518-markdown-editor/05-sidebar-editor-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
BIN
pr/518-markdown-editor/06-sidebar-after-save.png
Normal file
BIN
pr/518-markdown-editor/06-sidebar-after-save.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
Reference in New Issue
Block a user