diff --git a/packages/app/src/app/components/session/markdown-editor-sidebar.tsx b/packages/app/src/app/components/session/markdown-editor-sidebar.tsx new file mode 100644 index 00000000..f3191282 --- /dev/null +++ b/packages/app/src/app/components/session/markdown-editor-sidebar.tsx @@ -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, ">"); + +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 ` +
+ ${ + language + ? `
${language}
` + : "" + } +
${escapeHtml(text)}
+
+ `; + }; + + renderer.codespan = ({ text }) => { + return `${escapeHtml(text)}`; + }; + + renderer.link = ({ href, title, text }) => { + const safeHref = isSafeUrl(href ?? "") ? escapeHtml(href ?? "#") : "#"; + const safeTitle = title ? escapeHtml(title) : ""; + return ` + + ${text} + + `; + }; + + renderer.image = ({ href, title, text }) => { + const safeHref = isSafeUrl(href ?? "") ? escapeHtml(href ?? "") : ""; + const safeTitle = title ? escapeHtml(title) : ""; + return ` + ${escapeHtml(text || + `; + }; + + 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(null); + const [original, setOriginal] = createSignal(""); + const [draft, setDraft] = createSignal(""); + const [baseUpdatedAt, setBaseUpdatedAt] = createSignal(null); + + const [confirmDiscardClose, setConfirmDiscardClose] = createSignal(false); + const [confirmDiscardReload, setConfirmDiscardReload] = createSignal(false); + const [confirmOverwrite, setConfirmOverwrite] = createSignal(false); + + const [pendingPath, setPendingPath] = createSignal(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 ( + +