@opengeni/react 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1035 -14
- package/dist/index.js +6867 -1884
- package/dist/index.js.map +1 -1
- package/package.json +65 -2
- package/src/client.ts +21 -0
- package/src/components/code-editor.tsx +398 -0
- package/src/components/desktop-viewer.tsx +647 -0
- package/src/components/diff-view.tsx +230 -0
- package/src/components/file-browser.tsx +838 -0
- package/src/components/message-timeline.tsx +70 -196
- package/src/components/pierre-diff.tsx +140 -0
- package/src/components/pierre-file.tsx +142 -0
- package/src/components/sandbox-files.tsx +509 -0
- package/src/components/sandbox-terminal.tsx +425 -0
- package/src/components/workspace-dock.tsx +247 -0
- package/src/hooks/use-desktop-stream.ts +214 -0
- package/src/hooks/use-sandbox-files.ts +670 -0
- package/src/hooks/use-sandbox-git.ts +105 -0
- package/src/hooks/use-sandbox-terminal.ts +226 -0
- package/src/hooks/use-session-capabilities.ts +415 -0
- package/src/hooks/use-terminal-stream.ts +207 -0
- package/src/index.ts +111 -2
- package/src/lib/cn.ts +20 -1
- package/src/lib/git-patch.ts +37 -0
- package/src/lib/use-theme-type.ts +40 -0
- package/src/lib/xterm-theme.ts +34 -0
- package/src/timeline/activity-rail.tsx +207 -0
- package/src/timeline/disclosure-context.tsx +34 -0
- package/src/timeline/index.ts +85 -0
- package/src/timeline/parsers.ts +248 -0
- package/src/{timeline.ts → timeline/projection.ts} +59 -134
- package/src/timeline/registry.ts +96 -0
- package/src/timeline/screenshot-lightbox.tsx +152 -0
- package/src/timeline/shared.tsx +481 -0
- package/src/timeline/tool-diff.tsx +91 -0
- package/src/timeline/tool-renderers.tsx +882 -0
- package/src/timeline/turn-summary.tsx +125 -0
- package/src/timeline/types.ts +131 -0
- package/src/types/external.d.ts +7 -0
- package/styles/index.css +72 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "React hooks and styled components for OpenGeni: live session streaming, chat composer, message timeline, session status, and fleet views — token-themed (CSS variables), dark-first, built on Tailwind v4 + Radix + Motion.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -40,27 +40,90 @@
|
|
|
40
40
|
"demo:build": "vite build demo"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@opengeni/sdk": "^0.
|
|
43
|
+
"@opengeni/sdk": "^0.4.0",
|
|
44
44
|
"clsx": "^2.1.1",
|
|
45
45
|
"lucide-react": "^1.8.0",
|
|
46
46
|
"motion": "^12.0.0",
|
|
47
47
|
"radix-ui": "^1.4.3",
|
|
48
48
|
"react-markdown": "^10.1.0",
|
|
49
|
+
"react-resizable-panels": "^4.11.2",
|
|
49
50
|
"remark-gfm": "^4.0.1",
|
|
50
51
|
"tailwind-merge": "^3.5.0"
|
|
51
52
|
},
|
|
52
53
|
"peerDependencies": {
|
|
54
|
+
"@codemirror/lang-css": "^6.0.0",
|
|
55
|
+
"@codemirror/lang-html": "^6.0.0",
|
|
56
|
+
"@codemirror/lang-javascript": "^6.0.0",
|
|
57
|
+
"@codemirror/lang-json": "^6.0.0",
|
|
58
|
+
"@codemirror/lang-markdown": "^6.0.0",
|
|
59
|
+
"@codemirror/lang-python": "^6.0.0",
|
|
60
|
+
"@novnc/novnc": "^1.7.0",
|
|
61
|
+
"@pierre/diffs": "^1.2.11",
|
|
62
|
+
"@uiw/react-codemirror": "^4.23.0",
|
|
63
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
64
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
65
|
+
"@xterm/xterm": "^6.0.0",
|
|
53
66
|
"react": "^18.0.0 || ^19.0.0",
|
|
54
67
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
55
68
|
},
|
|
69
|
+
"peerDependenciesMeta": {
|
|
70
|
+
"@codemirror/lang-css": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
73
|
+
"@codemirror/lang-html": {
|
|
74
|
+
"optional": true
|
|
75
|
+
},
|
|
76
|
+
"@codemirror/lang-javascript": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"@codemirror/lang-json": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"@codemirror/lang-markdown": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"@codemirror/lang-python": {
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"@novnc/novnc": {
|
|
89
|
+
"optional": true
|
|
90
|
+
},
|
|
91
|
+
"@pierre/diffs": {
|
|
92
|
+
"optional": true
|
|
93
|
+
},
|
|
94
|
+
"@uiw/react-codemirror": {
|
|
95
|
+
"optional": true
|
|
96
|
+
},
|
|
97
|
+
"@xterm/addon-fit": {
|
|
98
|
+
"optional": true
|
|
99
|
+
},
|
|
100
|
+
"@xterm/addon-web-links": {
|
|
101
|
+
"optional": true
|
|
102
|
+
},
|
|
103
|
+
"@xterm/xterm": {
|
|
104
|
+
"optional": true
|
|
105
|
+
}
|
|
106
|
+
},
|
|
56
107
|
"devDependencies": {
|
|
108
|
+
"@codemirror/lang-css": "^6.3.1",
|
|
109
|
+
"@codemirror/lang-html": "^6.4.11",
|
|
110
|
+
"@codemirror/lang-javascript": "^6.2.5",
|
|
111
|
+
"@codemirror/lang-json": "^6.0.2",
|
|
112
|
+
"@codemirror/lang-markdown": "^6.5.0",
|
|
113
|
+
"@codemirror/lang-python": "^6.2.1",
|
|
57
114
|
"@fontsource-variable/inter": "^5.2.8",
|
|
58
115
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
|
59
116
|
"@happy-dom/global-registrator": "^20.10.2",
|
|
117
|
+
"@novnc/novnc": "^1.7.0",
|
|
118
|
+
"@pierre/diffs": "^1.2.11",
|
|
60
119
|
"@tailwindcss/vite": "^4.2.4",
|
|
120
|
+
"@uiw/react-codemirror": "^4.25.10",
|
|
61
121
|
"@types/react": "^19.2.14",
|
|
62
122
|
"@types/react-dom": "^19.2.3",
|
|
63
123
|
"@vitejs/plugin-react": "^6.0.1",
|
|
124
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
125
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
126
|
+
"@xterm/xterm": "^6.0.0",
|
|
64
127
|
"react": "^19.2.5",
|
|
65
128
|
"react-dom": "^19.2.5",
|
|
66
129
|
"tailwindcss": "^4.2.4",
|
package/src/client.ts
CHANGED
|
@@ -51,4 +51,25 @@ export type SessionClientLike = Pick<
|
|
|
51
51
|
| "createWorkspace"
|
|
52
52
|
| "updateWorkspace"
|
|
53
53
|
| "getBillingUsage"
|
|
54
|
+
// Stream surfacing (Phase 5): capability negotiation + viewer lifecycle
|
|
55
|
+
| "getClientConfig"
|
|
56
|
+
| "getStreamCapabilities"
|
|
57
|
+
| "acknowledgeStream"
|
|
58
|
+
| "attachViewer"
|
|
59
|
+
| "heartbeatViewer"
|
|
60
|
+
| "detachViewer"
|
|
61
|
+
// Channel-A structured services (terminal-as-events feed via fs/git/terminal)
|
|
62
|
+
| "fsList"
|
|
63
|
+
| "fsRead"
|
|
64
|
+
| "fsWrite"
|
|
65
|
+
| "fsDelete"
|
|
66
|
+
| "fsMove"
|
|
67
|
+
| "fsMkdir"
|
|
68
|
+
| "gitStatus"
|
|
69
|
+
| "gitDiff"
|
|
70
|
+
| "terminalExec"
|
|
71
|
+
| "terminalPtyOpen"
|
|
72
|
+
| "terminalPtyWrite"
|
|
73
|
+
| "terminalPtyResize"
|
|
74
|
+
| "terminalPtyClose"
|
|
54
75
|
>;
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { Loader2Icon, SaveIcon } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
type ComponentType,
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
lazy,
|
|
7
|
+
Suspense,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { cn } from "../lib/cn";
|
|
15
|
+
|
|
16
|
+
// --- Lazy CodeMirror surface ----------------------------------------------
|
|
17
|
+
//
|
|
18
|
+
// CodeMirror 6 (via @uiw/react-codemirror) plus the per-language grammars are a
|
|
19
|
+
// heavy bundle (lezer parsers, the view layer). We keep them OFF the critical
|
|
20
|
+
// path and out of any SSR bundle by lazy-importing only when a file is actually
|
|
21
|
+
// EDITED. The read-only viewer stays Pierre's Shiki `File`; this is the
|
|
22
|
+
// editable complement, mounted on demand.
|
|
23
|
+
|
|
24
|
+
/** The subset of `@uiw/react-codemirror`'s props we drive. */
|
|
25
|
+
type ReactCodeMirrorComponent = ComponentType<{
|
|
26
|
+
value?: string;
|
|
27
|
+
height?: string;
|
|
28
|
+
theme?: "light" | "dark" | "none" | unknown;
|
|
29
|
+
editable?: boolean;
|
|
30
|
+
readOnly?: boolean;
|
|
31
|
+
basicSetup?: boolean | Record<string, boolean>;
|
|
32
|
+
extensions?: unknown[];
|
|
33
|
+
onChange?: ((value: string) => void) | undefined;
|
|
34
|
+
className?: string;
|
|
35
|
+
style?: CSSProperties;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
// Resolved lazily on first edit: the editor component + the keymap/Prec helpers
|
|
39
|
+
// (both re-exported from @uiw/react-codemirror, which re-exports @codemirror/view
|
|
40
|
+
// and @codemirror/state) + the chosen language extension.
|
|
41
|
+
type EditorBundle = {
|
|
42
|
+
Editor: ReactCodeMirrorComponent;
|
|
43
|
+
saveKeymapExtension: (onSave: () => void) => unknown;
|
|
44
|
+
languageExtension: unknown | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Language grammar loaders, keyed by the extension class we infer from the path. */
|
|
48
|
+
const LANGUAGE_LOADERS: Record<string, () => Promise<unknown>> = {
|
|
49
|
+
javascript: async () =>
|
|
50
|
+
(await import("@codemirror/lang-javascript")).javascript({ jsx: true, typescript: true }),
|
|
51
|
+
json: async () => (await import("@codemirror/lang-json")).json(),
|
|
52
|
+
python: async () => (await import("@codemirror/lang-python")).python(),
|
|
53
|
+
markdown: async () => (await import("@codemirror/lang-markdown")).markdown(),
|
|
54
|
+
css: async () => (await import("@codemirror/lang-css")).css(),
|
|
55
|
+
html: async () => (await import("@codemirror/lang-html")).html(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Map a filename to a grammar key (or null for plain text — still fully editable). */
|
|
59
|
+
export function languageForPath(path: string): keyof typeof LANGUAGE_LOADERS | null {
|
|
60
|
+
const name = path.split("/").pop() ?? path;
|
|
61
|
+
const ext = name.includes(".") ? (name.split(".").pop() ?? "").toLowerCase() : "";
|
|
62
|
+
switch (ext) {
|
|
63
|
+
case "js":
|
|
64
|
+
case "jsx":
|
|
65
|
+
case "ts":
|
|
66
|
+
case "tsx":
|
|
67
|
+
case "mjs":
|
|
68
|
+
case "cjs":
|
|
69
|
+
return "javascript";
|
|
70
|
+
case "json":
|
|
71
|
+
case "jsonc":
|
|
72
|
+
return "json";
|
|
73
|
+
case "py":
|
|
74
|
+
case "pyi":
|
|
75
|
+
return "python";
|
|
76
|
+
case "md":
|
|
77
|
+
case "markdown":
|
|
78
|
+
case "mdx":
|
|
79
|
+
return "markdown";
|
|
80
|
+
case "css":
|
|
81
|
+
case "scss":
|
|
82
|
+
case "less":
|
|
83
|
+
return "css";
|
|
84
|
+
case "html":
|
|
85
|
+
case "htm":
|
|
86
|
+
case "xml":
|
|
87
|
+
case "svg":
|
|
88
|
+
case "vue":
|
|
89
|
+
return "html";
|
|
90
|
+
default:
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const LazyCodeMirror = lazy(async () => {
|
|
96
|
+
const mod = (await import("@uiw/react-codemirror")) as unknown as {
|
|
97
|
+
default: ReactCodeMirrorComponent;
|
|
98
|
+
};
|
|
99
|
+
return { default: mod.default };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export type CodeEditorProps = {
|
|
103
|
+
/** Workspace-relative path — drives language inference (and the save target upstream). */
|
|
104
|
+
path: string;
|
|
105
|
+
/** The decoded text contents to seed the editor with. */
|
|
106
|
+
initialContents: string;
|
|
107
|
+
/** Persist the current buffer. Resolves when the write lands; rejects to surface an error. */
|
|
108
|
+
onSave: (contents: string) => Promise<unknown>;
|
|
109
|
+
/** Read-only mode (e.g. a truncated/too-large file shown for reference only). */
|
|
110
|
+
readOnly?: boolean | undefined;
|
|
111
|
+
themeType?: "dark" | "light" | undefined;
|
|
112
|
+
/** Rendered while the (lazy) CodeMirror bundle loads. */
|
|
113
|
+
loading?: ReactNode | undefined;
|
|
114
|
+
/** Rendered if `@uiw/react-codemirror` is not installed / fails to import. */
|
|
115
|
+
fallback?: ReactNode | undefined;
|
|
116
|
+
className?: string | undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The EDITABLE single-file pane: CodeMirror 6 with a per-language grammar chosen
|
|
121
|
+
* from the filename, og-* themed, with dirty tracking and a save path wired to
|
|
122
|
+
* `Cmd/Ctrl+S` *and* an explicit Save button. The viewer (Pierre `File`) stays
|
|
123
|
+
* the read-only complement — this is only mounted when the user opts to edit.
|
|
124
|
+
*
|
|
125
|
+
* Save semantics: the buffer is "dirty" the moment it diverges from the last
|
|
126
|
+
* saved baseline; a successful `onSave` clears dirty and re-baselines. A failed
|
|
127
|
+
* save keeps the buffer dirty (nothing is lost) and surfaces the error inline.
|
|
128
|
+
* `readOnly` suppresses every mutation path so a truncated/binary file can never
|
|
129
|
+
* be saved back (which would corrupt it by writing the truncated prefix).
|
|
130
|
+
*/
|
|
131
|
+
export function CodeEditor({
|
|
132
|
+
path,
|
|
133
|
+
initialContents,
|
|
134
|
+
onSave,
|
|
135
|
+
readOnly = false,
|
|
136
|
+
themeType = "dark",
|
|
137
|
+
loading,
|
|
138
|
+
fallback,
|
|
139
|
+
className,
|
|
140
|
+
}: CodeEditorProps) {
|
|
141
|
+
const [failed, setFailed] = useState(false);
|
|
142
|
+
const [bundle, setBundle] = useState<EditorBundle | null>(null);
|
|
143
|
+
|
|
144
|
+
// The live buffer + the baseline it's compared against for dirtiness. The
|
|
145
|
+
// baseline resets whenever a *new* file is loaded (path/initialContents change)
|
|
146
|
+
// or after a successful save.
|
|
147
|
+
const [value, setValue] = useState(initialContents);
|
|
148
|
+
const [baseline, setBaseline] = useState(initialContents);
|
|
149
|
+
const [saving, setSaving] = useState(false);
|
|
150
|
+
const [saveError, setSaveError] = useState<Error | null>(null);
|
|
151
|
+
const [savedTick, setSavedTick] = useState(false);
|
|
152
|
+
|
|
153
|
+
const dirty = value !== baseline;
|
|
154
|
+
|
|
155
|
+
// Reload the buffer when the file identity changes. Guard on initialContents
|
|
156
|
+
// too so an external refresh of the SAME path (e.g. fs.changed re-read) reseeds
|
|
157
|
+
// a clean buffer — but only when the user hasn't got unsaved edits in flight.
|
|
158
|
+
const lastSeed = useRef<{ path: string; contents: string }>({ path, contents: initialContents });
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const seedChanged =
|
|
161
|
+
lastSeed.current.path !== path || lastSeed.current.contents !== initialContents;
|
|
162
|
+
if (!seedChanged) return;
|
|
163
|
+
lastSeed.current = { path, contents: initialContents };
|
|
164
|
+
setValue(initialContents);
|
|
165
|
+
setBaseline(initialContents);
|
|
166
|
+
setSaveError(null);
|
|
167
|
+
setSavedTick(false);
|
|
168
|
+
}, [path, initialContents]);
|
|
169
|
+
|
|
170
|
+
// Resolve the lazy bundle (editor + keymap/Prec helpers + language grammar)
|
|
171
|
+
// once, re-resolving the grammar when the file's language class changes.
|
|
172
|
+
const langKey = useMemo(() => languageForPath(path), [path]);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
let cancelled = false;
|
|
175
|
+
void (async () => {
|
|
176
|
+
try {
|
|
177
|
+
const cmMod = (await import("@uiw/react-codemirror")) as unknown as {
|
|
178
|
+
default: ReactCodeMirrorComponent;
|
|
179
|
+
keymap: { of: (binds: unknown[]) => unknown };
|
|
180
|
+
Prec: { highest: (ext: unknown) => unknown };
|
|
181
|
+
};
|
|
182
|
+
const languageExtension = langKey ? await LANGUAGE_LOADERS[langKey]?.() : null;
|
|
183
|
+
if (cancelled) return;
|
|
184
|
+
// A high-precedence Cmd/Ctrl-S keymap that calls back into the latest
|
|
185
|
+
// save handler (kept fresh via a ref) and swallows the browser's
|
|
186
|
+
// "save page" default.
|
|
187
|
+
const saveKeymapExtension = (run: () => void) =>
|
|
188
|
+
cmMod.Prec.highest(
|
|
189
|
+
cmMod.keymap.of([
|
|
190
|
+
{
|
|
191
|
+
key: "Mod-s",
|
|
192
|
+
preventDefault: true,
|
|
193
|
+
run: () => {
|
|
194
|
+
run();
|
|
195
|
+
return true;
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
]),
|
|
199
|
+
);
|
|
200
|
+
setBundle({
|
|
201
|
+
Editor: cmMod.default,
|
|
202
|
+
saveKeymapExtension,
|
|
203
|
+
languageExtension: languageExtension ?? null,
|
|
204
|
+
});
|
|
205
|
+
} catch {
|
|
206
|
+
if (!cancelled) setFailed(true);
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
return () => {
|
|
210
|
+
cancelled = true;
|
|
211
|
+
};
|
|
212
|
+
}, [langKey]);
|
|
213
|
+
|
|
214
|
+
// Keep the freshest save closure reachable from the (stable) keymap binding.
|
|
215
|
+
const saveRef = useRef<() => void>(() => {});
|
|
216
|
+
|
|
217
|
+
const save = useCallback(async () => {
|
|
218
|
+
if (readOnly) return;
|
|
219
|
+
// Snapshot the buffer at call time so an in-flight edit can't race the write.
|
|
220
|
+
const snapshot = value;
|
|
221
|
+
if (snapshot === baseline) return; // nothing to persist
|
|
222
|
+
setSaving(true);
|
|
223
|
+
setSaveError(null);
|
|
224
|
+
setSavedTick(false);
|
|
225
|
+
try {
|
|
226
|
+
await onSave(snapshot);
|
|
227
|
+
setBaseline(snapshot);
|
|
228
|
+
lastSeed.current = { path, contents: snapshot };
|
|
229
|
+
setSavedTick(true);
|
|
230
|
+
} catch (cause) {
|
|
231
|
+
setSaveError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
232
|
+
} finally {
|
|
233
|
+
setSaving(false);
|
|
234
|
+
}
|
|
235
|
+
}, [readOnly, value, baseline, onSave, path]);
|
|
236
|
+
|
|
237
|
+
saveRef.current = () => {
|
|
238
|
+
void save();
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// A brief "Saved" affordance after a successful write, cleared on the next edit.
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (!savedTick) return;
|
|
244
|
+
const t = setTimeout(() => setSavedTick(false), 1800);
|
|
245
|
+
return () => clearTimeout(t);
|
|
246
|
+
}, [savedTick]);
|
|
247
|
+
|
|
248
|
+
const fileName = path.split("/").filter(Boolean).pop() ?? path;
|
|
249
|
+
|
|
250
|
+
const cmExtensions = useMemo(() => {
|
|
251
|
+
if (!bundle) return [] as unknown[];
|
|
252
|
+
const exts: unknown[] = [bundle.saveKeymapExtension(() => saveRef.current())];
|
|
253
|
+
if (bundle.languageExtension) exts.push(bundle.languageExtension);
|
|
254
|
+
return exts;
|
|
255
|
+
}, [bundle]);
|
|
256
|
+
|
|
257
|
+
if (failed) {
|
|
258
|
+
return (
|
|
259
|
+
<div className={cn("flex h-full min-h-0 flex-col", className)}>
|
|
260
|
+
{fallback ?? (
|
|
261
|
+
<PlainTextarea
|
|
262
|
+
value={value}
|
|
263
|
+
readOnly={readOnly}
|
|
264
|
+
onChange={(next) => {
|
|
265
|
+
setValue(next);
|
|
266
|
+
setSavedTick(false);
|
|
267
|
+
}}
|
|
268
|
+
/>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div
|
|
276
|
+
className={cn("flex h-full min-h-0 flex-col", className)}
|
|
277
|
+
data-opengeni-code-editor
|
|
278
|
+
style={editorVars}
|
|
279
|
+
>
|
|
280
|
+
{/* Save bar: dirty indicator + status + the explicit Save button. */}
|
|
281
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1">
|
|
282
|
+
<span
|
|
283
|
+
className={cn(
|
|
284
|
+
"size-1.5 shrink-0 rounded-full transition-colors",
|
|
285
|
+
readOnly
|
|
286
|
+
? "bg-transparent"
|
|
287
|
+
: dirty
|
|
288
|
+
? "bg-[color:var(--og-color-status-running,var(--color-warning,#d29922))]"
|
|
289
|
+
: "bg-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]",
|
|
290
|
+
)}
|
|
291
|
+
title={readOnly ? "read-only" : dirty ? "unsaved changes" : "saved"}
|
|
292
|
+
/>
|
|
293
|
+
<span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[11px] text-[color:var(--og-color-fg-muted,var(--color-fg-muted,#aaa))]">
|
|
294
|
+
{fileName}
|
|
295
|
+
{dirty && !readOnly ? " •" : ""}
|
|
296
|
+
</span>
|
|
297
|
+
<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
298
|
+
{saveError && (
|
|
299
|
+
<span
|
|
300
|
+
className="max-w-[220px] truncate text-[10px] text-[color:var(--og-color-danger,var(--color-danger,#f85149))]"
|
|
301
|
+
title={saveError.message}
|
|
302
|
+
>
|
|
303
|
+
{saveError.message}
|
|
304
|
+
</span>
|
|
305
|
+
)}
|
|
306
|
+
{!saveError && savedTick && (
|
|
307
|
+
<span className="text-[10px] text-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]">
|
|
308
|
+
Saved
|
|
309
|
+
</span>
|
|
310
|
+
)}
|
|
311
|
+
{readOnly ? (
|
|
312
|
+
<span className="text-[10px] uppercase tracking-wide text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
|
|
313
|
+
Read-only
|
|
314
|
+
</span>
|
|
315
|
+
) : (
|
|
316
|
+
<button
|
|
317
|
+
type="button"
|
|
318
|
+
onClick={() => void save()}
|
|
319
|
+
disabled={saving || !dirty}
|
|
320
|
+
className={cn(
|
|
321
|
+
"flex items-center gap-1 rounded-[var(--og-radius-sm,4px)] border border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] px-1.5 py-0.5 text-[10px]",
|
|
322
|
+
saving || !dirty
|
|
323
|
+
? "cursor-default text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] opacity-60"
|
|
324
|
+
: "text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))] hover:bg-[color:var(--og-color-accent-soft,var(--color-surface-2,#222))]",
|
|
325
|
+
)}
|
|
326
|
+
title="Save (⌘/Ctrl+S)"
|
|
327
|
+
>
|
|
328
|
+
{saving ? (
|
|
329
|
+
<Loader2Icon className="size-3 animate-spin" />
|
|
330
|
+
) : (
|
|
331
|
+
<SaveIcon className="size-3" />
|
|
332
|
+
)}
|
|
333
|
+
Save
|
|
334
|
+
</button>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
{/* The editor surface. */}
|
|
340
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
341
|
+
<Suspense fallback={loading ?? <EditorSkeleton />}>
|
|
342
|
+
<LazyCodeMirror
|
|
343
|
+
value={value}
|
|
344
|
+
theme={themeType === "light" ? "light" : "dark"}
|
|
345
|
+
editable={!readOnly}
|
|
346
|
+
readOnly={readOnly}
|
|
347
|
+
basicSetup={true}
|
|
348
|
+
extensions={cmExtensions}
|
|
349
|
+
height="100%"
|
|
350
|
+
className="og-cm-editor min-h-full text-[12.5px]"
|
|
351
|
+
onChange={
|
|
352
|
+
readOnly
|
|
353
|
+
? undefined
|
|
354
|
+
: (next: string) => {
|
|
355
|
+
setValue(next);
|
|
356
|
+
setSavedTick(false);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/>
|
|
360
|
+
</Suspense>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** og-* themed CSS-var overrides handed to CodeMirror's container. */
|
|
367
|
+
const editorVars = {
|
|
368
|
+
"--og-cm-bg": "var(--og-color-bg, #0d0d0d)",
|
|
369
|
+
fontFamily: "var(--og-font-mono, var(--font-mono, monospace))",
|
|
370
|
+
} as CSSProperties;
|
|
371
|
+
|
|
372
|
+
function PlainTextarea({
|
|
373
|
+
value,
|
|
374
|
+
readOnly,
|
|
375
|
+
onChange,
|
|
376
|
+
}: {
|
|
377
|
+
value: string;
|
|
378
|
+
readOnly: boolean;
|
|
379
|
+
onChange: (value: string) => void;
|
|
380
|
+
}) {
|
|
381
|
+
return (
|
|
382
|
+
<textarea
|
|
383
|
+
value={value}
|
|
384
|
+
readOnly={readOnly}
|
|
385
|
+
spellCheck={false}
|
|
386
|
+
onChange={(e) => onChange(e.target.value)}
|
|
387
|
+
className="h-full w-full resize-none bg-transparent p-2 font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[12px] leading-[18px] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))] outline-none"
|
|
388
|
+
/>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function EditorSkeleton() {
|
|
393
|
+
return (
|
|
394
|
+
<div className="p-3 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
|
|
395
|
+
Loading editor…
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
}
|