import { useCallback, useEffect, useMemo, useState } from "react"; import { FileText, RefreshCw, Save } from "lucide-react"; import type { DocumentRefreshOptions, SourceBlock } from "../../../document-model"; import { Button } from "@/openpress/ui/button"; import { Textarea } from "@/openpress/ui/textarea"; import { localMutationHeaders } from "../../localMutationRequest"; import { useEditStatus } from "../../WorkbenchEditStatusContext"; type SourceTreeEditorPanelProps = { sourceBlocksByPath: Record; activeBlockIds?: readonly string[]; pressSlug?: string | null; fetchImpl?: typeof fetch; onDocumentEdited?: (options?: DocumentRefreshOptions) => void | Promise; }; type SourceFileEditResponse = { document?: { renderId?: string; }; }; type SourceFileEntry = { path: string; file: string; blocks: SourceBlock[]; }; const SOURCE_PANEL_CLASS = [ "op-workspace-source-panel grid h-full min-h-0 grid-cols-[260px_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)]", "bg-[var(--op-workspace-main-bg)] text-[var(--op-workspace-text)]", "max-[980px]:grid-cols-[1fr] max-[980px]:grid-rows-[auto_minmax(0,1fr)]", ].join(" "); const SOURCE_TREE_PANE_CLASS = [ "grid min-h-0 grid-rows-[44px_minmax(0,1fr)] overflow-hidden border-r border-[var(--op-workspace-border-muted)] bg-[var(--op-workspace-panel-bg)]", "max-[980px]:max-h-[220px] max-[980px]:border-b max-[980px]:border-r-0", ].join(" "); const SOURCE_EDITOR_PANE_CLASS = [ "grid min-h-0 min-w-0 grid-rows-[36px_minmax(0,1fr)] overflow-hidden", ].join(" "); const SOURCE_HEADER_CLASS = [ "flex min-w-0 items-center justify-between gap-3 border-b border-[var(--op-workspace-border-muted)] bg-[var(--op-workspace-panel-bg)] px-[18px]", ].join(" "); const SOURCE_KICKER_CLASS = [ "font-mono text-[10px] font-semibold uppercase leading-none tracking-normal text-[var(--op-workspace-text-muted)]", ].join(" "); const SOURCE_TITLE_CLASS = [ "m-0 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[15px] font-semibold leading-tight text-[var(--op-workspace-text)]", ].join(" "); const SOURCE_TREE_CLASS = [ "grid min-h-0 content-start gap-1 overflow-y-auto px-[14px] py-4", "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden", ].join(" "); const SOURCE_FILE_BUTTON_CLASS = [ "grid min-h-[50px] w-full min-w-0 cursor-pointer appearance-none grid-cols-[12px_minmax(0,1fr)] items-center gap-3", "rounded-[var(--op-workspace-radius-sm)] border border-transparent bg-transparent px-2.5 py-2 text-left", "text-[var(--op-workspace-text-muted)] transition-[background,border-color,color] duration-150", "hover:bg-[var(--op-workspace-surface-hover)] hover:text-[var(--op-workspace-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--op-workspace-accent-border)]", ].join(" "); const SOURCE_FILE_BUTTON_ACTIVE_CLASS = [ "border-[var(--op-workspace-border-strong)] bg-[var(--op-workspace-surface-hover)] text-[var(--op-workspace-text)]", ].join(" "); const SOURCE_FILE_TEXT_CLASS = "grid min-w-0 gap-1"; const SOURCE_FILE_NAME_CLASS = [ "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[12px] font-semibold leading-none text-[var(--op-workspace-text)]", ].join(" "); const SOURCE_FILE_PATH_CLASS = [ "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[10px] leading-none text-[var(--op-workspace-text-muted)]", ].join(" "); const SOURCE_EDITOR_META_CLASS = [ "op-workspace-source-toolbar flex min-h-9 min-w-0 items-center justify-between gap-3 border-b border-[var(--op-workspace-border-muted)] bg-[var(--op-workspace-surface)] px-4 text-[10px] leading-none text-[var(--op-workspace-text-muted)]", ].join(" "); const SOURCE_EDITOR_META_LEFT_CLASS = "flex min-w-0 items-center gap-2"; const SOURCE_EDITOR_PATH_CLASS = "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono"; const SOURCE_CODE_SHELL_CLASS = [ "grid min-h-0 min-w-0 grid-cols-[48px_minmax(0,1fr)] overflow-hidden bg-[var(--op-workspace-main-bg)]", ].join(" "); const SOURCE_LINE_GUTTER_CLASS = [ "relative min-h-0 overflow-hidden border-r border-[var(--op-workspace-border-muted)] bg-[var(--op-workspace-surface)] py-3 text-right font-mono text-[12px] leading-6 text-[var(--op-workspace-text-muted)]", ].join(" "); const SOURCE_LINE_LIST_CLASS = "m-0 list-none px-2 py-0"; const SOURCE_TEXTAREA_CLASS = [ "h-full min-h-0 resize-none !rounded-none !border-0 !bg-[var(--op-workspace-main-bg)] p-3 font-mono text-[13px] leading-6 !text-[var(--op-workspace-text)]", "shadow-none outline-none field-sizing-fixed caret-[var(--op-workspace-accent)] selection:bg-[var(--op-workspace-accent-surface)]", "placeholder:text-[var(--op-workspace-text-muted)] focus:ring-0 focus-visible:ring-0", ].join(" "); const SOURCE_STATUS_CLASS = "min-w-0 max-w-[34vw] overflow-hidden text-ellipsis whitespace-nowrap text-[10px] text-[var(--op-workspace-text-muted)]"; const SOURCE_SAVE_STATE_CLASS = "shrink-0 font-mono text-[10px] text-[var(--op-workspace-text-muted)]"; const SOURCE_SAVE_STATE_DIRTY_CLASS = "text-[var(--op-workspace-accent)]"; const SOURCE_ACTIONS_CLASS = "inline-flex shrink-0 items-center gap-1.5"; const SOURCE_ACTION_BUTTON_CLASS = [ "op-ui-button min-h-6 cursor-pointer rounded-[var(--op-workspace-radius-sm)] border border-[var(--op-workspace-border)] bg-transparent px-2 text-[11px] text-[var(--op-workspace-text-soft)]", "hover:bg-[var(--op-workspace-surface-hover)] hover:text-[var(--op-workspace-text)]", "disabled:cursor-progress disabled:opacity-55", ].join(" "); const SOURCE_SAVE_BUTTON_CLASS = [ SOURCE_ACTION_BUTTON_CLASS, "op-ui-button-primary border-[var(--op-workspace-accent-border)] bg-[var(--op-workspace-accent)] text-[var(--op-workspace-text-inverse)] hover:bg-[var(--op-workspace-accent-hover)] hover:text-[var(--op-workspace-text-inverse)] disabled:bg-transparent", ].join(" "); const SOURCE_EMPTY_CLASS = [ "m-2 border border-dashed border-[var(--op-workspace-border-muted)] p-3", "text-[11px] leading-snug text-[var(--op-workspace-text-muted)]", ].join(" "); const SOURCE_WARNING_CLASS = [ "col-span-2 grid min-w-0 gap-1 border-b border-amber-500/20 bg-amber-500/10 px-4 py-2", "text-[11px] leading-snug text-amber-100 max-[980px]:col-span-1", ].join(" "); const SOURCE_WARNING_LABEL_CLASS = "font-mono text-[10px] font-bold uppercase leading-none tracking-normal text-amber-300"; export function SourceTreeEditorPanel({ sourceBlocksByPath, activeBlockIds, pressSlug, fetchImpl, onDocumentEdited, }: SourceTreeEditorPanelProps) { const { startSave, completeSave, failSave } = useEditStatus(); const sourceFiles = useMemo(() => createSourceFileEntries(sourceBlocksByPath), [sourceBlocksByPath]); const activeBlockIdsKey = activeBlockIds?.join("\u0000") ?? ""; const preferredPath = useMemo(() => { if (!activeBlockIdsKey) return null; const active = new Set(activeBlockIdsKey.split("\u0000").filter(Boolean)); return sourceFiles.find((file) => file.blocks.some((block) => active.has(block.id)))?.path ?? null; }, [activeBlockIdsKey, sourceFiles]); const [selectedPath, setSelectedPath] = useState(null); const [text, setText] = useState(""); const [savedText, setSavedText] = useState(""); const [status, setStatus] = useState<"idle" | "loading" | "saving" | "failed">("idle"); const [error, setError] = useState(""); const [editorScrollTop, setEditorScrollTop] = useState(0); const selectedFile = sourceFiles.find((file) => file.path === selectedPath) ?? null; const dirty = text !== savedText; const lineCount = useMemo(() => Math.max(1, text.split("\n").length), [text]); useEffect(() => { if (sourceFiles.length === 0) { setSelectedPath(null); return; } if (selectedPath && sourceFiles.some((file) => file.path === selectedPath)) return; setSelectedPath(preferredPath ?? sourceFiles[0]?.path ?? null); }, [preferredPath, selectedPath, sourceFiles]); const loadSelectedFile = useCallback(() => { if (!selectedPath) { setText(""); setSavedText(""); setStatus("idle"); setError(""); return undefined; } const request = fetchImpl ?? globalThis.fetch?.bind(globalThis); if (!request) { setStatus("failed"); setError("Source edit endpoint is unavailable."); return undefined; } let canceled = false; setStatus("loading"); setError(""); void request(sourceFileReadUrl(selectedPath), { method: "GET" }) .then(async (response) => { if (!response.ok) { const message = await response.text().catch(() => ""); throw new Error(message || `Source file read failed with status ${response.status}`); } return response.json() as Promise<{ source?: { text?: string } }>; }) .then((result) => { if (canceled) return; const nextText = result.source?.text ?? ""; setText(nextText); setSavedText(nextText); setStatus("idle"); }) .catch((readError) => { if (canceled) return; const message = readError instanceof Error ? readError.message : String(readError); setStatus("failed"); setError(message); failSave(message); }); return () => { canceled = true; }; }, [failSave, fetchImpl, selectedPath]); useEffect(() => loadSelectedFile(), [loadSelectedFile]); const handleSave = async () => { if (!selectedPath) return; const request = fetchImpl ?? globalThis.fetch?.bind(globalThis); if (!request) { setStatus("failed"); setError("Source edit endpoint is unavailable."); return; } setStatus("saving"); setError(""); startSave(); try { const response = await request("/__openpress/source-edit", { method: "POST", headers: localMutationHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ type: "source-file-edit", path: selectedPath, text, pressSlug, }), }); if (!response.ok) { const message = await response.text().catch(() => ""); throw new Error(message || `Source file edit failed with status ${response.status}`); } const result = await response.json() as SourceFileEditResponse; await onDocumentEdited?.({ expectedRenderId: result.document?.renderId }); setSavedText(text); setStatus("idle"); completeSave(); } catch (saveError) { const message = saveError instanceof Error ? saveError.message : String(saveError); setStatus("failed"); setError(message); failSave(message); } }; return (
Experimental MDX source editing is advanced and may break rendering. Use at your own risk.
{selectedFile ? sourceFileDisplayPath(selectedFile.path, pressSlug) : "No source selected"} {sourceEditorStatusText(status, error)}
{dirty ? "Unsaved" : "Saved"}