forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | /** |
| 2 | * EditorPane edits a workspace file with Monaco: dirty indicator, Save | |
| 3 | * button and Ctrl/Cmd+S, saving through the backend file API. | |
| 4 | * | |
| 5 | * @example | |
| 6 | * <EditorPane path="src/main.go" /> | |
| 7 | */ | |
| 8 | ||
| 9 | import { lazy, Suspense, useCallback, useEffect, useState } from "react"; | |
| 10 | import { readFile, writeFile } from "../api"; | |
| 11 | import { languageForPath } from "../lang"; | |
| 12 | ||
| 13 | const MonacoViewer = lazy(() => import("./MonacoViewer")); | |
| 14 | ||
| 15 | type SaveStatus = "idle" | "saving" | "saved"; | |
| 16 | ||
| 17 | export function EditorPane({ path }: { path: string | null }) { | |
| 18 | const [original, setOriginal] = useState<string | null>(null); | |
| 19 | const [text, setText] = useState(""); | |
| 20 | const [error, setError] = useState<string | null>(null); | |
| 21 | const [status, setStatus] = useState<SaveStatus>("idle"); | |
| 22 | ||
| 23 | useEffect(() => { | |
| 24 | if (!path) { | |
| 25 | return; | |
| 26 | } | |
| 27 | setOriginal(null); | |
| 28 | setError(null); | |
| 29 | setStatus("idle"); | |
| 30 | readFile(path) | |
| 31 | .then((file) => { | |
| 32 | setOriginal(file.content); | |
| 33 | setText(file.content); | |
| 34 | }) | |
| 35 | .catch((cause: Error) => setError(cause.message)); | |
| 36 | }, [path]); | |
| 37 | ||
| 38 | const dirty = original !== null && text !== original; | |
| 39 | ||
| 40 | const save = useCallback(async () => { | |
| 41 | if (!path || original === null || text === original) { | |
| 42 | return; | |
| 43 | } | |
| 44 | setStatus("saving"); | |
| 45 | setError(null); | |
| 46 | try { | |
| 47 | await writeFile(path, text); | |
| 48 | setOriginal(text); | |
| 49 | setStatus("saved"); | |
| 50 | } catch (cause) { | |
| 51 | setError((cause as Error).message); | |
| 52 | setStatus("idle"); | |
| 53 | } | |
| 54 | }, [path, text, original]); | |
| 55 | ||
| 56 | const placeholder = editorPlaceholder(path, original, error); | |
| 57 | if (placeholder) { | |
| 58 | return placeholder; | |
| 59 | } | |
| 60 | ||
| 61 | return ( | |
| 62 | // The shortcut is handled on the wrapper so it works wherever the | |
| 63 | // focus sits inside the pane, Monaco included. | |
| 64 | <div | |
| 65 | className="editor" | |
| 66 | onKeyDown={(event) => { | |
| 67 | if ((event.ctrlKey || event.metaKey) && event.key === "s") { | |
| 68 | event.preventDefault(); | |
| 69 | void save(); | |
| 70 | } | |
| 71 | }} | |
| 72 | > | |
| 73 | <EditorHeader | |
| 74 | path={path as string} | |
| 75 | dirty={dirty} | |
| 76 | status={status} | |
| 77 | error={error} | |
| 78 | onSave={save} | |
| 79 | /> | |
| 80 | <div className="editor-body"> | |
| 81 | <Suspense fallback={<p className="pane-empty">loading editor…</p>}> | |
| 82 | <MonacoViewer | |
| 83 | content={text} | |
| 84 | language={languageForPath(path as string)} | |
| 85 | onChange={(value) => setText(value ?? "")} | |
| 86 | /> | |
| 87 | </Suspense> | |
| 88 | </div> | |
| 89 | </div> | |
| 90 | ); | |
| 91 | } | |
| 92 | ||
| 93 | /** editorPlaceholder covers the no-file / error / loading states. */ | |
| 94 | function editorPlaceholder( | |
| 95 | path: string | null, | |
| 96 | original: string | null, | |
| 97 | error: string | null, | |
| 98 | ) { | |
| 99 | if (!path) { | |
| 100 | return ( | |
| 101 | <p className="pane-empty">Double-click a file in the tree to edit it.</p> | |
| 102 | ); | |
| 103 | } | |
| 104 | if (error && original === null) { | |
| 105 | return <p className="pane-error">{error}</p>; | |
| 106 | } | |
| 107 | if (original === null) { | |
| 108 | return <p className="pane-empty">loading…</p>; | |
| 109 | } | |
| 110 | return null; | |
| 111 | } | |
| 112 | ||
| 113 | /** EditorHeader shows the file name, dirty marker, save state and button. */ | |
| 114 | function EditorHeader({ | |
| 115 | path, | |
| 116 | dirty, | |
| 117 | status, | |
| 118 | error, | |
| 119 | onSave, | |
| 120 | }: { | |
| 121 | path: string; | |
| 122 | dirty: boolean; | |
| 123 | status: SaveStatus; | |
| 124 | error: string | null; | |
| 125 | onSave: () => Promise<void>; | |
| 126 | }) { | |
| 127 | return ( | |
| 128 | <div className="pane-header"> | |
| 129 | <span className="pane-path" title={path}> | |
| 130 | {path.split("/").pop()} | |
| 131 | {dirty ? " ●" : ""} | |
| 132 | </span> | |
| 133 | {error && <span className="pane-error-inline">{error}</span>} | |
| 134 | {status === "saved" && !dirty && ( | |
| 135 | <span className="pane-saved">saved</span> | |
| 136 | )} | |
| 137 | <button | |
| 138 | type="button" | |
| 139 | className="pane-action" | |
| 140 | disabled={!dirty || status === "saving"} | |
| 141 | onClick={() => void onSave()} | |
| 142 | > | |
| 143 | {status === "saving" ? "Saving…" : "Save"} | |
| 144 | </button> | |
| 145 | </div> | |
| 146 | ); | |
| 147 | } |