create-interview-cockpit 0.4.0 → 0.6.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/package.json +1 -1
- package/template/client/package-lock.json +753 -1
- package/template/client/package.json +4 -0
- package/template/client/src/App.tsx +20 -0
- package/template/client/src/api.ts +455 -3
- package/template/client/src/components/AiSettingsModal.tsx +855 -248
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +132 -27
- package/template/client/src/components/ChatView.tsx +365 -123
- package/template/client/src/components/CodeContextPanel.tsx +714 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
- package/template/client/src/components/DocRefModal.tsx +551 -0
- package/template/client/src/components/FileAttachments.tsx +128 -12
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +219 -2
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +397 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +412 -25
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +477 -0
- package/template/client/src/store.ts +416 -2
- package/template/client/src/types.ts +41 -1
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +144 -2
- package/template/server/src/index.ts +1890 -188
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +274 -3
|
@@ -0,0 +1,3030 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
} from "react";
|
|
8
|
+
import {
|
|
9
|
+
X,
|
|
10
|
+
GripVertical,
|
|
11
|
+
Maximize2,
|
|
12
|
+
Minimize2,
|
|
13
|
+
Play,
|
|
14
|
+
Trash2,
|
|
15
|
+
Loader2,
|
|
16
|
+
Terminal,
|
|
17
|
+
Clock,
|
|
18
|
+
Save,
|
|
19
|
+
Check,
|
|
20
|
+
Server,
|
|
21
|
+
StopCircle,
|
|
22
|
+
Globe,
|
|
23
|
+
Copy,
|
|
24
|
+
ChevronLeft,
|
|
25
|
+
ChevronRight,
|
|
26
|
+
ChevronUp,
|
|
27
|
+
ChevronDown,
|
|
28
|
+
Eye,
|
|
29
|
+
Code2,
|
|
30
|
+
FilePlus,
|
|
31
|
+
MessageSquare,
|
|
32
|
+
Send,
|
|
33
|
+
Clipboard,
|
|
34
|
+
ClipboardCheck,
|
|
35
|
+
} from "lucide-react";
|
|
36
|
+
import { useStore } from "../store";
|
|
37
|
+
import Editor from "react-simple-code-editor";
|
|
38
|
+
import Prism from "prismjs";
|
|
39
|
+
import "prismjs/components/prism-clike";
|
|
40
|
+
import "prismjs/components/prism-javascript";
|
|
41
|
+
import "prismjs/components/prism-typescript";
|
|
42
|
+
import {
|
|
43
|
+
generatePreviewHTML,
|
|
44
|
+
defaultForType,
|
|
45
|
+
resolveNextjsEntry,
|
|
46
|
+
} from "../reactLab";
|
|
47
|
+
import {
|
|
48
|
+
startNextjsSandbox,
|
|
49
|
+
updateNextjsFiles,
|
|
50
|
+
stopNextjsSandbox,
|
|
51
|
+
} from "../api";
|
|
52
|
+
import ReactMarkdown from "react-markdown";
|
|
53
|
+
import remarkGfm from "remark-gfm";
|
|
54
|
+
|
|
55
|
+
const MIN_W = 420;
|
|
56
|
+
const MIN_H = 300;
|
|
57
|
+
const DEFAULT_W = 760;
|
|
58
|
+
const DEFAULT_H = 560;
|
|
59
|
+
|
|
60
|
+
type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
|
|
61
|
+
|
|
62
|
+
interface OutputLine {
|
|
63
|
+
kind: "stdout" | "stderr" | "info" | "warn";
|
|
64
|
+
text: string;
|
|
65
|
+
source?: "server" | "client";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const LANG_OPTIONS = ["typescript", "javascript"] as const;
|
|
69
|
+
type Lang = (typeof LANG_OPTIONS)[number];
|
|
70
|
+
|
|
71
|
+
// ── Sandbox default snippets ─────────────────────────────────────────
|
|
72
|
+
const DEFAULT_SERVER_CODE = `import express from 'express';
|
|
73
|
+
import { Readable } from 'stream';
|
|
74
|
+
const app = express();
|
|
75
|
+
app.use(express.json());
|
|
76
|
+
|
|
77
|
+
app.get('/ping', (_req, res) => {
|
|
78
|
+
res.json({ message: 'pong', time: Date.now() });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const port = Number(process.env.PORT);
|
|
82
|
+
app.listen(port, () => console.log('Server on :' + port));
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
const DEFAULT_CLIENT_CODE = `// SANDBOX_URL is pre-injected \u2014 points at your running server
|
|
86
|
+
|
|
87
|
+
// \u2500\u2500 REST \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
88
|
+
console.log('--- REST ---');
|
|
89
|
+
const rest = await fetch(SANDBOX_URL + '/ping');
|
|
90
|
+
console.log(await rest.json());
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
// VS Code Dark+ token colours injected once for prismjs.
|
|
94
|
+
// react-simple-code-editor uses Prism.highlight() which emits class-based spans;
|
|
95
|
+
// these inline styles map those classes to the same palette used in the old theme.
|
|
96
|
+
function ensurePrismStyles() {
|
|
97
|
+
const ID = "__code_runner_prism_vsc";
|
|
98
|
+
if (typeof document === "undefined" || document.getElementById(ID)) return;
|
|
99
|
+
const el = document.createElement("style");
|
|
100
|
+
el.id = ID;
|
|
101
|
+
el.textContent = [
|
|
102
|
+
`.token.comment,.token.prolog,.token.doctype,.token.cdata{color:#6A9955}`,
|
|
103
|
+
`.token.punctuation{color:#D4D4D4}`,
|
|
104
|
+
`.token.keyword{color:#569CD6}`,
|
|
105
|
+
`.token.builtin{color:#4EC9B0}`,
|
|
106
|
+
`.token.boolean,.token.constant{color:#569CD6}`,
|
|
107
|
+
`.token.number{color:#B5CEA8}`,
|
|
108
|
+
`.token.string,.token.attr-value,.token.template-string{color:#CE9178}`,
|
|
109
|
+
`.token.char,.token.regex{color:#D16969}`,
|
|
110
|
+
`.token.operator,.token.entity,.token.url{color:#D4D4D4}`,
|
|
111
|
+
`.token.variable{color:#9CDCFE}`,
|
|
112
|
+
`.token.atrule,.token.attr-name{color:#9CDCFE}`,
|
|
113
|
+
`.token.function{color:#DCDCAA}`,
|
|
114
|
+
`.token.class-name{color:#4EC9B0}`,
|
|
115
|
+
`.token.property{color:#9CDCFE}`,
|
|
116
|
+
`.token.selector{color:#D7BA7D}`,
|
|
117
|
+
`.token.tag{color:#4EC9B0}`,
|
|
118
|
+
`.token.important,.token.bold{font-weight:bold}`,
|
|
119
|
+
`.token.italic{font-style:italic}`,
|
|
120
|
+
].join("\n");
|
|
121
|
+
document.head.appendChild(el);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const EDITOR_FONT =
|
|
125
|
+
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
|
126
|
+
|
|
127
|
+
// Syntax-highlighted code editor using react-simple-code-editor.
|
|
128
|
+
// Cursor alignment is exact because the library manages both the textarea and
|
|
129
|
+
// the highlight <pre> with identical computed styles internally.
|
|
130
|
+
function SyntaxEditor({
|
|
131
|
+
value,
|
|
132
|
+
onChange,
|
|
133
|
+
onCtrlEnter,
|
|
134
|
+
language,
|
|
135
|
+
placeholder,
|
|
136
|
+
autoFocus = false,
|
|
137
|
+
fontSize = "12px",
|
|
138
|
+
focusRingClass = "ring-violet-500/40",
|
|
139
|
+
}: {
|
|
140
|
+
value: string;
|
|
141
|
+
onChange: (val: string) => void;
|
|
142
|
+
onCtrlEnter?: () => void;
|
|
143
|
+
language: string;
|
|
144
|
+
placeholder?: string;
|
|
145
|
+
autoFocus?: boolean;
|
|
146
|
+
fontSize?: string;
|
|
147
|
+
focusRingClass?: string;
|
|
148
|
+
}) {
|
|
149
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
ensurePrismStyles();
|
|
153
|
+
if (autoFocus) {
|
|
154
|
+
setTimeout(
|
|
155
|
+
() =>
|
|
156
|
+
containerRef.current
|
|
157
|
+
?.querySelector<HTMLTextAreaElement>("textarea")
|
|
158
|
+
?.focus(),
|
|
159
|
+
50,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
const grammar =
|
|
166
|
+
language === "typescript"
|
|
167
|
+
? Prism.languages.typescript
|
|
168
|
+
: Prism.languages.javascript;
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div
|
|
172
|
+
ref={containerRef}
|
|
173
|
+
className="absolute inset-0 overflow-auto bg-slate-950"
|
|
174
|
+
>
|
|
175
|
+
<Editor
|
|
176
|
+
value={value}
|
|
177
|
+
onValueChange={onChange}
|
|
178
|
+
highlight={(src) =>
|
|
179
|
+
grammar ? Prism.highlight(src, grammar, language) : src
|
|
180
|
+
}
|
|
181
|
+
padding={12}
|
|
182
|
+
insertSpaces
|
|
183
|
+
tabSize={2}
|
|
184
|
+
placeholder={placeholder}
|
|
185
|
+
onKeyDown={(e: React.KeyboardEvent) => {
|
|
186
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
onCtrlEnter?.();
|
|
189
|
+
}
|
|
190
|
+
}}
|
|
191
|
+
style={{
|
|
192
|
+
fontFamily: EDITOR_FONT,
|
|
193
|
+
fontSize,
|
|
194
|
+
lineHeight: 1.625,
|
|
195
|
+
color: "#D4D4D4",
|
|
196
|
+
caretColor: "white",
|
|
197
|
+
minHeight: "100%",
|
|
198
|
+
}}
|
|
199
|
+
textareaClassName={`outline-none focus:ring-1 ${focusRingClass} placeholder:text-slate-600`}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export default function CodeRunnerModal() {
|
|
206
|
+
const {
|
|
207
|
+
closeCodeRunner,
|
|
208
|
+
runnerInitialCode,
|
|
209
|
+
runnerInitialLanguage,
|
|
210
|
+
runnerInitialSandbox,
|
|
211
|
+
runnerInitialFileId,
|
|
212
|
+
currentQuestion,
|
|
213
|
+
saveCodeSnippetToQuestion,
|
|
214
|
+
overwriteContextFileContent,
|
|
215
|
+
} = useStore();
|
|
216
|
+
|
|
217
|
+
const [code, setCode] = useState(runnerInitialCode);
|
|
218
|
+
const [lang, setLang] = useState<Lang>(
|
|
219
|
+
(runnerInitialLanguage as Lang) ?? "typescript",
|
|
220
|
+
);
|
|
221
|
+
const [running, setRunning] = useState(false);
|
|
222
|
+
const [output, setOutput] = useState<OutputLine[]>([]);
|
|
223
|
+
const [durationMs, setDurationMs] = useState<number | null>(null);
|
|
224
|
+
const [timedOut, setTimedOut] = useState(false);
|
|
225
|
+
const [saved, setSaved] = useState(false);
|
|
226
|
+
const [saving, setSaving] = useState(false);
|
|
227
|
+
const [naming, setNaming] = useState(false);
|
|
228
|
+
const [snippetName, setSnippetName] = useState("");
|
|
229
|
+
/** Non-null when editing a previously saved script — Save overwrites, Save As creates new */
|
|
230
|
+
const [activeScriptId, setActiveScriptId] = useState<string | null>(
|
|
231
|
+
runnerInitialFileId ?? null,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// ── Sandbox state ─────────────────────────────────────────
|
|
235
|
+
const [mode, setMode] = useState<"script" | "sandbox">("script");
|
|
236
|
+
const [serverCode, setServerCode] = useState(DEFAULT_SERVER_CODE);
|
|
237
|
+
const [serverLang, setServerLang] = useState<Lang>("typescript");
|
|
238
|
+
const [clientCode, setClientCode] = useState(DEFAULT_CLIENT_CODE);
|
|
239
|
+
const [clientLang, setClientLang] = useState<Lang>("javascript");
|
|
240
|
+
const [sandboxId, setSandboxId] = useState<string | null>(null);
|
|
241
|
+
const [sandboxPort, setSandboxPort] = useState<number | null>(null);
|
|
242
|
+
const [sandboxUrl, setSandboxUrl] = useState<string | null>(null);
|
|
243
|
+
const [serverStarting, setServerStarting] = useState(false);
|
|
244
|
+
const [serverRunning, setServerRunning] = useState(false);
|
|
245
|
+
const [sandboxOutput, setSandboxOutput] = useState<OutputLine[]>([]);
|
|
246
|
+
const [clientRunning, setClientRunning] = useState(false);
|
|
247
|
+
|
|
248
|
+
// ── React/Next.js client state ──────────────────────────────
|
|
249
|
+
const [clientType, setClientType] = useState<"script" | "react" | "nextjs">(
|
|
250
|
+
"script",
|
|
251
|
+
);
|
|
252
|
+
const [reactFiles, setReactFiles] = useState<Record<string, string>>({});
|
|
253
|
+
const [reactActiveFile, setReactActiveFile] = useState<string>("");
|
|
254
|
+
const [reactClientTab, setReactClientTab] = useState<"edit" | "preview">(
|
|
255
|
+
"edit",
|
|
256
|
+
);
|
|
257
|
+
const [reactPreviewSrc, setReactPreviewSrc] = useState<string | null>(null);
|
|
258
|
+
const [reactAddingFile, setReactAddingFile] = useState(false);
|
|
259
|
+
const [reactNewFileName, setReactNewFileName] = useState("");
|
|
260
|
+
// Folders that are collapsed in the Next.js file tree sidebar
|
|
261
|
+
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
|
|
262
|
+
new Set(),
|
|
263
|
+
);
|
|
264
|
+
// Real Next.js dev-server state
|
|
265
|
+
const [nxSandboxId, setNxSandboxId] = useState<string | null>(null);
|
|
266
|
+
const [nxSandboxUrl, setNxSandboxUrl] = useState<string | null>(null);
|
|
267
|
+
const [nxStarting, setNxStarting] = useState(false);
|
|
268
|
+
const [nxError, setNxError] = useState<string | null>(null);
|
|
269
|
+
const nxIframeRef = useRef<HTMLIFrameElement>(null);
|
|
270
|
+
// Simulated URL bar state for Next.js mode
|
|
271
|
+
const [reactPreviewPath, setReactPreviewPath] = useState("/");
|
|
272
|
+
const [reactNavInput, setReactNavInput] = useState("/");
|
|
273
|
+
const [reactNavHistory, setReactNavHistory] = useState<string[]>(["/"]);
|
|
274
|
+
const [reactNavIndex, setReactNavIndex] = useState(0);
|
|
275
|
+
|
|
276
|
+
// ── Sandbox output tab ("output" | "chat") ──────────────────
|
|
277
|
+
const [sbxBottomTab, setSbxBottomTab] = useState<"output" | "chat">("output");
|
|
278
|
+
|
|
279
|
+
// ── Sandbox panel sizes ─────────────────────────────────────────
|
|
280
|
+
// sbxSplit: server pane width as % of the editor row (0–100)
|
|
281
|
+
const [sbxSplit, setSbxSplit] = useState(50);
|
|
282
|
+
// sbxOutputH: output panel height in px
|
|
283
|
+
const [sbxOutputH, setSbxOutputH] = useState(176);
|
|
284
|
+
const [serverCollapsed, setServerCollapsed] = useState(false);
|
|
285
|
+
const [clientCollapsed, setClientCollapsed] = useState(false);
|
|
286
|
+
const [outputCollapsed, setOutputCollapsed] = useState(false);
|
|
287
|
+
const sbxDividerDrag = useRef<{
|
|
288
|
+
startX: number;
|
|
289
|
+
startPct: number;
|
|
290
|
+
containerW: number;
|
|
291
|
+
} | null>(null);
|
|
292
|
+
const sbxOutputDrag = useRef<{ startY: number; startH: number } | null>(null);
|
|
293
|
+
const sbxEditorRowRef = useRef<HTMLDivElement>(null);
|
|
294
|
+
|
|
295
|
+
// ── Sandbox save state (single combined save) ──────────────────
|
|
296
|
+
const [sbxNaming, setSbxNaming] = useState(false);
|
|
297
|
+
const [sbxName, setSbxName] = useState("My Sandbox");
|
|
298
|
+
const [sbxSaved, setSbxSaved] = useState(false);
|
|
299
|
+
const [sbxSaving, setSbxSaving] = useState(false);
|
|
300
|
+
/** When non-null we are editing a previously saved sandbox — Save overwrites, Save As still creates new */
|
|
301
|
+
const [activeSandboxId, setActiveSandboxId] = useState<string | null>(null);
|
|
302
|
+
const sbxNameInputRef = useRef<HTMLInputElement>(null);
|
|
303
|
+
|
|
304
|
+
// ── Sandbox chat state (declared after activeSandboxId) ──────────────
|
|
305
|
+
type SbxChatMessage = {
|
|
306
|
+
id: string;
|
|
307
|
+
role: "user" | "assistant";
|
|
308
|
+
content: string;
|
|
309
|
+
};
|
|
310
|
+
const sbxChatKey = `sbx-chat:${activeSandboxId ?? `q:${currentQuestion?.id ?? "_"}`}`;
|
|
311
|
+
const sbxChatKeyRef = useRef(sbxChatKey);
|
|
312
|
+
const [sbxChatMessages, setSbxChatMessages] = useState<SbxChatMessage[]>(
|
|
313
|
+
() => {
|
|
314
|
+
try {
|
|
315
|
+
const s = localStorage.getItem(sbxChatKey);
|
|
316
|
+
return s ? (JSON.parse(s) as SbxChatMessage[]) : [];
|
|
317
|
+
} catch {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
const [sbxChatInput, setSbxChatInput] = useState("");
|
|
323
|
+
const [sbxChatLoading, setSbxChatLoading] = useState(false);
|
|
324
|
+
const sbxChatScrollRef = useRef<HTMLDivElement>(null);
|
|
325
|
+
const sbxChatInputRef = useRef<HTMLTextAreaElement>(null);
|
|
326
|
+
const sbxChatAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
|
|
327
|
+
const [sbxChatCopiedId, setSbxChatCopiedId] = useState<string | null>(null);
|
|
328
|
+
|
|
329
|
+
// Keep key ref fresh
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
sbxChatKeyRef.current = sbxChatKey;
|
|
332
|
+
}, [sbxChatKey]);
|
|
333
|
+
// Reload chat when artifact changes
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
try {
|
|
336
|
+
const s = localStorage.getItem(sbxChatKey);
|
|
337
|
+
setSbxChatMessages(s ? (JSON.parse(s) as SbxChatMessage[]) : []);
|
|
338
|
+
} catch {
|
|
339
|
+
setSbxChatMessages([]);
|
|
340
|
+
}
|
|
341
|
+
}, [sbxChatKey]);
|
|
342
|
+
// Persist chat
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (sbxChatMessages.length === 0) {
|
|
345
|
+
localStorage.removeItem(sbxChatKeyRef.current);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
localStorage.setItem(
|
|
349
|
+
sbxChatKeyRef.current,
|
|
350
|
+
JSON.stringify(sbxChatMessages),
|
|
351
|
+
);
|
|
352
|
+
}, [sbxChatMessages]);
|
|
353
|
+
// Auto-scroll chat
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (sbxBottomTab === "chat" && sbxChatScrollRef.current)
|
|
356
|
+
sbxChatScrollRef.current.scrollTop =
|
|
357
|
+
sbxChatScrollRef.current.scrollHeight;
|
|
358
|
+
}, [sbxChatMessages, sbxChatLoading, sbxBottomTab]);
|
|
359
|
+
|
|
360
|
+
// Save server+client together as one JSON blob
|
|
361
|
+
const saveSandboxSnippet = async (label: string) => {
|
|
362
|
+
if (!currentQuestion) return;
|
|
363
|
+
setSbxSaving(true);
|
|
364
|
+
try {
|
|
365
|
+
const origin =
|
|
366
|
+
clientType === "react"
|
|
367
|
+
? "react"
|
|
368
|
+
: clientType === "nextjs"
|
|
369
|
+
? "nextjs"
|
|
370
|
+
: "sandbox";
|
|
371
|
+
const payload = JSON.stringify(
|
|
372
|
+
clientType === "script"
|
|
373
|
+
? { serverCode, serverLang, clientCode, clientLang }
|
|
374
|
+
: {
|
|
375
|
+
serverCode,
|
|
376
|
+
serverLang,
|
|
377
|
+
clientCode: "",
|
|
378
|
+
clientLang: "javascript",
|
|
379
|
+
clientType,
|
|
380
|
+
reactFiles,
|
|
381
|
+
reactActiveFile,
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
const cf = await saveCodeSnippetToQuestion(
|
|
385
|
+
currentQuestion.id,
|
|
386
|
+
payload,
|
|
387
|
+
origin,
|
|
388
|
+
label || "My Sandbox",
|
|
389
|
+
origin,
|
|
390
|
+
);
|
|
391
|
+
setActiveSandboxId(cf.id);
|
|
392
|
+
setSbxSaved(true);
|
|
393
|
+
setTimeout(() => setSbxSaved(false), 2000);
|
|
394
|
+
} finally {
|
|
395
|
+
setSbxSaving(false);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// Overwrite the existing sandbox blob in-place
|
|
400
|
+
const overwriteSandboxSnippet = async () => {
|
|
401
|
+
if (!currentQuestion || !activeSandboxId) return;
|
|
402
|
+
setSbxSaving(true);
|
|
403
|
+
try {
|
|
404
|
+
const payload = JSON.stringify(
|
|
405
|
+
clientType === "script"
|
|
406
|
+
? { serverCode, serverLang, clientCode, clientLang }
|
|
407
|
+
: {
|
|
408
|
+
serverCode,
|
|
409
|
+
serverLang,
|
|
410
|
+
clientCode: "",
|
|
411
|
+
clientLang: "javascript",
|
|
412
|
+
clientType,
|
|
413
|
+
reactFiles,
|
|
414
|
+
reactActiveFile,
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
await overwriteContextFileContent(
|
|
418
|
+
currentQuestion.id,
|
|
419
|
+
activeSandboxId,
|
|
420
|
+
payload,
|
|
421
|
+
);
|
|
422
|
+
setSbxSaved(true);
|
|
423
|
+
setTimeout(() => setSbxSaved(false), 2000);
|
|
424
|
+
} finally {
|
|
425
|
+
setSbxSaving(false);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// Overwrite the existing script snippet in-place
|
|
430
|
+
const overwriteScriptSnippet = async () => {
|
|
431
|
+
if (!currentQuestion || !activeScriptId) return;
|
|
432
|
+
setSaving(true);
|
|
433
|
+
try {
|
|
434
|
+
await overwriteContextFileContent(
|
|
435
|
+
currentQuestion.id,
|
|
436
|
+
activeScriptId,
|
|
437
|
+
code,
|
|
438
|
+
);
|
|
439
|
+
setSaved(true);
|
|
440
|
+
setTimeout(() => setSaved(false), 2000);
|
|
441
|
+
} finally {
|
|
442
|
+
setSaving(false);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const outputEndRef = useRef<HTMLDivElement>(null);
|
|
447
|
+
const nameInputRef = useRef<HTMLInputElement>(null);
|
|
448
|
+
// Tracks how many server log lines have already been flushed to sandboxOutput
|
|
449
|
+
const sandboxLogOffsetRef = useRef(0);
|
|
450
|
+
// Stable ref so unmount cleanup can stop sandbox without stale closure
|
|
451
|
+
const sandboxIdRef = useRef<string | null>(null);
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
sandboxIdRef.current = sandboxId;
|
|
454
|
+
}, [sandboxId]);
|
|
455
|
+
|
|
456
|
+
// Sync initial code when the modal is (re)opened with new content
|
|
457
|
+
useLayoutEffect(() => {
|
|
458
|
+
setCode(runnerInitialCode);
|
|
459
|
+
setLang((runnerInitialLanguage as Lang) ?? "typescript");
|
|
460
|
+
setOutput([]);
|
|
461
|
+
setDurationMs(null);
|
|
462
|
+
setTimedOut(false);
|
|
463
|
+
}, [runnerInitialCode, runnerInitialLanguage]);
|
|
464
|
+
|
|
465
|
+
// Restore a saved sandbox when opened via openSandbox()
|
|
466
|
+
useLayoutEffect(() => {
|
|
467
|
+
if (!runnerInitialSandbox) return;
|
|
468
|
+
setMode("sandbox");
|
|
469
|
+
setServerCode(runnerInitialSandbox.serverCode);
|
|
470
|
+
setServerLang((runnerInitialSandbox.serverLang as Lang) ?? "typescript");
|
|
471
|
+
setClientCode(runnerInitialSandbox.clientCode);
|
|
472
|
+
setClientLang((runnerInitialSandbox.clientLang as Lang) ?? "javascript");
|
|
473
|
+
setSandboxOutput([]);
|
|
474
|
+
setActiveSandboxId(runnerInitialSandbox.fileId ?? null);
|
|
475
|
+
// Restore client type and React/Next.js files
|
|
476
|
+
const ct =
|
|
477
|
+
(runnerInitialSandbox.clientType as "script" | "react" | "nextjs") ??
|
|
478
|
+
"script";
|
|
479
|
+
setClientType(ct);
|
|
480
|
+
if (ct === "react" || ct === "nextjs") {
|
|
481
|
+
if (
|
|
482
|
+
runnerInitialSandbox.reactFiles &&
|
|
483
|
+
Object.keys(runnerInitialSandbox.reactFiles).length > 0
|
|
484
|
+
) {
|
|
485
|
+
setReactFiles(runnerInitialSandbox.reactFiles);
|
|
486
|
+
setReactActiveFile(
|
|
487
|
+
runnerInitialSandbox.reactActiveFile ??
|
|
488
|
+
Object.keys(runnerInitialSandbox.reactFiles)[0] ??
|
|
489
|
+
"",
|
|
490
|
+
);
|
|
491
|
+
} else {
|
|
492
|
+
const defs = defaultForType(ct);
|
|
493
|
+
setReactFiles(defs.files);
|
|
494
|
+
setReactActiveFile(defs.activeFile);
|
|
495
|
+
}
|
|
496
|
+
setReactPreviewSrc(null);
|
|
497
|
+
setReactClientTab("edit");
|
|
498
|
+
}
|
|
499
|
+
}, [runnerInitialSandbox]);
|
|
500
|
+
|
|
501
|
+
// Auto-focus is handled inside SyntaxEditor via autoFocus prop.
|
|
502
|
+
|
|
503
|
+
// Scroll output to bottom whenever it grows
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
outputEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
506
|
+
}, [output]);
|
|
507
|
+
|
|
508
|
+
// ── Sandbox divider drag handlers ────────────────────────────────
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
const onMove = (e: MouseEvent) => {
|
|
511
|
+
if (sbxDividerDrag.current) {
|
|
512
|
+
const { startX, startPct, containerW } = sbxDividerDrag.current;
|
|
513
|
+
const dx = e.clientX - startX;
|
|
514
|
+
const pct = Math.min(
|
|
515
|
+
95,
|
|
516
|
+
Math.max(5, startPct + (dx / containerW) * 100),
|
|
517
|
+
);
|
|
518
|
+
setSbxSplit(pct);
|
|
519
|
+
}
|
|
520
|
+
if (sbxOutputDrag.current) {
|
|
521
|
+
const { startY, startH } = sbxOutputDrag.current;
|
|
522
|
+
const dy = startY - e.clientY; // drag up = increase height
|
|
523
|
+
setSbxOutputH(Math.max(60, Math.min(600, startH + dy)));
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
const onUp = () => {
|
|
527
|
+
sbxDividerDrag.current = null;
|
|
528
|
+
sbxOutputDrag.current = null;
|
|
529
|
+
};
|
|
530
|
+
document.addEventListener("mousemove", onMove);
|
|
531
|
+
document.addEventListener("mouseup", onUp);
|
|
532
|
+
return () => {
|
|
533
|
+
document.removeEventListener("mousemove", onMove);
|
|
534
|
+
document.removeEventListener("mouseup", onUp);
|
|
535
|
+
};
|
|
536
|
+
}, []);
|
|
537
|
+
|
|
538
|
+
// ── Position / Size ──────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
const [pos, setPos] = useState(() => ({
|
|
541
|
+
x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
|
|
542
|
+
y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
|
|
543
|
+
}));
|
|
544
|
+
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
|
|
545
|
+
const [maximized, setMaximized] = useState(false);
|
|
546
|
+
const savedPos = useRef(pos);
|
|
547
|
+
const savedSize = useRef(size);
|
|
548
|
+
|
|
549
|
+
const dragStart = useRef<{
|
|
550
|
+
mx: number;
|
|
551
|
+
my: number;
|
|
552
|
+
ox: number;
|
|
553
|
+
oy: number;
|
|
554
|
+
} | null>(null);
|
|
555
|
+
const resizeDir = useRef<ResizeDir>(null);
|
|
556
|
+
const resizeStart = useRef<{
|
|
557
|
+
mx: number;
|
|
558
|
+
my: number;
|
|
559
|
+
ox: number;
|
|
560
|
+
oy: number;
|
|
561
|
+
ow: number;
|
|
562
|
+
oh: number;
|
|
563
|
+
} | null>(null);
|
|
564
|
+
|
|
565
|
+
useEffect(() => {
|
|
566
|
+
const onMove = (e: MouseEvent) => {
|
|
567
|
+
const drag = dragStart.current;
|
|
568
|
+
const resize = resizeStart.current;
|
|
569
|
+
const dir = resizeDir.current;
|
|
570
|
+
if (drag) {
|
|
571
|
+
setPos({
|
|
572
|
+
x: Math.max(0, drag.ox + (e.clientX - drag.mx)),
|
|
573
|
+
y: Math.max(0, drag.oy + (e.clientY - drag.my)),
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
if (resize && dir) {
|
|
577
|
+
const dx = e.clientX - resize.mx;
|
|
578
|
+
const dy = e.clientY - resize.my;
|
|
579
|
+
setSize((prev) => {
|
|
580
|
+
let w = prev.w;
|
|
581
|
+
let h = prev.h;
|
|
582
|
+
if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
|
|
583
|
+
if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
|
|
584
|
+
if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
|
|
585
|
+
if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
|
|
586
|
+
return { w, h };
|
|
587
|
+
});
|
|
588
|
+
if (dir.includes("w"))
|
|
589
|
+
setPos((p) => ({
|
|
590
|
+
...p,
|
|
591
|
+
x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
|
|
592
|
+
}));
|
|
593
|
+
if (dir.includes("n"))
|
|
594
|
+
setPos((p) => ({
|
|
595
|
+
...p,
|
|
596
|
+
y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
|
|
597
|
+
}));
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
const onUp = () => {
|
|
601
|
+
dragStart.current = null;
|
|
602
|
+
resizeStart.current = null;
|
|
603
|
+
resizeDir.current = null;
|
|
604
|
+
};
|
|
605
|
+
document.addEventListener("mousemove", onMove);
|
|
606
|
+
document.addEventListener("mouseup", onUp);
|
|
607
|
+
return () => {
|
|
608
|
+
document.removeEventListener("mousemove", onMove);
|
|
609
|
+
document.removeEventListener("mouseup", onUp);
|
|
610
|
+
};
|
|
611
|
+
}, []);
|
|
612
|
+
|
|
613
|
+
const onTitleMouseDown = useCallback(
|
|
614
|
+
(e: React.MouseEvent) => {
|
|
615
|
+
if (maximized) return;
|
|
616
|
+
e.preventDefault();
|
|
617
|
+
dragStart.current = {
|
|
618
|
+
mx: e.clientX,
|
|
619
|
+
my: e.clientY,
|
|
620
|
+
ox: pos.x,
|
|
621
|
+
oy: pos.y,
|
|
622
|
+
};
|
|
623
|
+
},
|
|
624
|
+
[maximized, pos],
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const startResize = useCallback(
|
|
628
|
+
(dir: ResizeDir) => (e: React.MouseEvent) => {
|
|
629
|
+
if (maximized) return;
|
|
630
|
+
e.preventDefault();
|
|
631
|
+
e.stopPropagation();
|
|
632
|
+
resizeDir.current = dir;
|
|
633
|
+
resizeStart.current = {
|
|
634
|
+
mx: e.clientX,
|
|
635
|
+
my: e.clientY,
|
|
636
|
+
ox: pos.x,
|
|
637
|
+
oy: pos.y,
|
|
638
|
+
ow: size.w,
|
|
639
|
+
oh: size.h,
|
|
640
|
+
};
|
|
641
|
+
},
|
|
642
|
+
[maximized, pos, size],
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
const toggleMax = useCallback(() => {
|
|
646
|
+
if (!maximized) {
|
|
647
|
+
savedPos.current = pos;
|
|
648
|
+
savedSize.current = size;
|
|
649
|
+
setMaximized(true);
|
|
650
|
+
} else {
|
|
651
|
+
setPos(savedPos.current);
|
|
652
|
+
setSize(savedSize.current);
|
|
653
|
+
setMaximized(false);
|
|
654
|
+
}
|
|
655
|
+
}, [maximized, pos, size]);
|
|
656
|
+
|
|
657
|
+
useEffect(() => {
|
|
658
|
+
const handler = (e: KeyboardEvent) => {
|
|
659
|
+
if (e.key === "Escape") closeCodeRunner();
|
|
660
|
+
};
|
|
661
|
+
document.addEventListener("keydown", handler);
|
|
662
|
+
return () => document.removeEventListener("keydown", handler);
|
|
663
|
+
}, [closeCodeRunner]);
|
|
664
|
+
|
|
665
|
+
// ── Run ──────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
const runCode = useCallback(async () => {
|
|
668
|
+
if (running) return;
|
|
669
|
+
setRunning(true);
|
|
670
|
+
setOutput([]);
|
|
671
|
+
setDurationMs(null);
|
|
672
|
+
setTimedOut(false);
|
|
673
|
+
|
|
674
|
+
try {
|
|
675
|
+
const res = await fetch("/api/run-code", {
|
|
676
|
+
method: "POST",
|
|
677
|
+
headers: { "Content-Type": "application/json" },
|
|
678
|
+
body: JSON.stringify({ code, language: lang }),
|
|
679
|
+
});
|
|
680
|
+
const data: {
|
|
681
|
+
stdout: string;
|
|
682
|
+
stderr: string;
|
|
683
|
+
durationMs: number;
|
|
684
|
+
timedOut: boolean;
|
|
685
|
+
} = await res.json();
|
|
686
|
+
|
|
687
|
+
const lines: OutputLine[] = [];
|
|
688
|
+
|
|
689
|
+
if (data.stdout.trim()) {
|
|
690
|
+
data.stdout.split("\n").forEach((l) => {
|
|
691
|
+
if (l !== "") lines.push({ kind: "stdout", text: l });
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
if (data.stderr.trim()) {
|
|
695
|
+
data.stderr.split("\n").forEach((l) => {
|
|
696
|
+
if (l !== "") lines.push({ kind: "stderr", text: l });
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
if (lines.length === 0) {
|
|
700
|
+
lines.push({ kind: "info", text: "(no output)" });
|
|
701
|
+
}
|
|
702
|
+
if (data.timedOut) {
|
|
703
|
+
lines.push({ kind: "warn", text: "⏱ Timed out after 10 seconds" });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
setOutput(lines);
|
|
707
|
+
setDurationMs(data.durationMs);
|
|
708
|
+
setTimedOut(data.timedOut);
|
|
709
|
+
} catch (err) {
|
|
710
|
+
setOutput([{ kind: "stderr", text: String(err) }]);
|
|
711
|
+
} finally {
|
|
712
|
+
setRunning(false);
|
|
713
|
+
}
|
|
714
|
+
}, [code, lang, running]);
|
|
715
|
+
|
|
716
|
+
// ── Sandbox: stop on unmount ──────────────────────────────
|
|
717
|
+
|
|
718
|
+
useEffect(() => {
|
|
719
|
+
return () => {
|
|
720
|
+
if (sandboxIdRef.current) {
|
|
721
|
+
fetch(`/api/sandbox/${sandboxIdRef.current}`, {
|
|
722
|
+
method: "DELETE",
|
|
723
|
+
}).catch(() => {});
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
}, []);
|
|
727
|
+
|
|
728
|
+
// ── Sandbox: poll server logs while running ────────────────
|
|
729
|
+
|
|
730
|
+
useEffect(() => {
|
|
731
|
+
if (!sandboxId) return;
|
|
732
|
+
const interval = setInterval(async () => {
|
|
733
|
+
try {
|
|
734
|
+
const r = await fetch(`/api/sandbox/${sandboxId}/status`);
|
|
735
|
+
const data = await r.json();
|
|
736
|
+
if (!data.running) {
|
|
737
|
+
setSandboxId(null);
|
|
738
|
+
setSandboxPort(null);
|
|
739
|
+
setSandboxUrl(null);
|
|
740
|
+
setServerRunning(false);
|
|
741
|
+
sandboxLogOffsetRef.current = 0;
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const newLogs: string[] = (data.logs as string[]).slice(
|
|
745
|
+
sandboxLogOffsetRef.current,
|
|
746
|
+
);
|
|
747
|
+
if (newLogs.length > 0) {
|
|
748
|
+
sandboxLogOffsetRef.current = (data.logs as string[]).length;
|
|
749
|
+
setSandboxOutput((prev) => [
|
|
750
|
+
...prev,
|
|
751
|
+
...newLogs.flatMap((chunk) =>
|
|
752
|
+
chunk
|
|
753
|
+
.split("\n")
|
|
754
|
+
.filter(Boolean)
|
|
755
|
+
.map((text) => ({
|
|
756
|
+
kind: "stdout" as const,
|
|
757
|
+
text,
|
|
758
|
+
source: "server" as const,
|
|
759
|
+
})),
|
|
760
|
+
),
|
|
761
|
+
]);
|
|
762
|
+
}
|
|
763
|
+
} catch {
|
|
764
|
+
/* ignore transient network errors */
|
|
765
|
+
}
|
|
766
|
+
}, 1000);
|
|
767
|
+
return () => clearInterval(interval);
|
|
768
|
+
}, [sandboxId]);
|
|
769
|
+
|
|
770
|
+
// ── React/Next.js helpers ─────────────────────────────────────
|
|
771
|
+
|
|
772
|
+
/** Seed sensible default content for a freshly created file. */
|
|
773
|
+
const newFileContent = (name: string): string => {
|
|
774
|
+
const base = name.split("/").pop() ?? name;
|
|
775
|
+
// Next.js special files
|
|
776
|
+
if (base === "page.tsx" || base === "page.ts") {
|
|
777
|
+
const routeSegments = name
|
|
778
|
+
.replace(/^app\//, "")
|
|
779
|
+
.replace(/\/page\.tsx?$/, "")
|
|
780
|
+
.split("/")
|
|
781
|
+
.filter(Boolean);
|
|
782
|
+
const routeName =
|
|
783
|
+
routeSegments.length === 0
|
|
784
|
+
? "Home"
|
|
785
|
+
: routeSegments
|
|
786
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
787
|
+
.join("");
|
|
788
|
+
const urlPath =
|
|
789
|
+
routeSegments.length === 0 ? "/" : "/" + routeSegments.join("/");
|
|
790
|
+
return [
|
|
791
|
+
`// ${urlPath} → ${name}`,
|
|
792
|
+
`// Server Component by default — no "use client" needed unless you use hooks`,
|
|
793
|
+
``,
|
|
794
|
+
`export default function ${routeName}Page() {`,
|
|
795
|
+
` return (`,
|
|
796
|
+
` <div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>`,
|
|
797
|
+
` <h1 style={{ fontSize: "1.5rem", fontWeight: "bold" }}>${routeName}</h1>`,
|
|
798
|
+
` <p style={{ color: "#64748b", marginTop: "0.5rem" }}>`,
|
|
799
|
+
` You are on <code>${urlPath}</code>`,
|
|
800
|
+
` </p>`,
|
|
801
|
+
` <button`,
|
|
802
|
+
` onClick={() => (window as any).__nxNavigate("/")}`,
|
|
803
|
+
` style={{ marginTop: "1rem", padding: "0.5rem 1rem", cursor: "pointer",`,
|
|
804
|
+
` borderRadius: "0.375rem", border: "1px solid #cbd5e1", background: "#f8fafc" }}`,
|
|
805
|
+
` >`,
|
|
806
|
+
` ← Back to Home`,
|
|
807
|
+
` </button>`,
|
|
808
|
+
` </div>`,
|
|
809
|
+
` );`,
|
|
810
|
+
`}`,
|
|
811
|
+
``,
|
|
812
|
+
].join("\n");
|
|
813
|
+
}
|
|
814
|
+
if (base === "layout.tsx" || base === "layout.ts") {
|
|
815
|
+
return [
|
|
816
|
+
`// Root Layout — wraps ALL pages, persists across navigations`,
|
|
817
|
+
``,
|
|
818
|
+
`export default function Layout({ children }: { children: React.ReactNode }) {`,
|
|
819
|
+
` return (`,
|
|
820
|
+
` <html lang="en">`,
|
|
821
|
+
` <body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>`,
|
|
822
|
+
` {children}`,
|
|
823
|
+
` </body>`,
|
|
824
|
+
` </html>`,
|
|
825
|
+
` );`,
|
|
826
|
+
`}`,
|
|
827
|
+
``,
|
|
828
|
+
].join("\n");
|
|
829
|
+
}
|
|
830
|
+
if (base === "loading.tsx" || base === "loading.ts") {
|
|
831
|
+
return [
|
|
832
|
+
`// Shown automatically while the page is loading (Suspense boundary)`,
|
|
833
|
+
``,
|
|
834
|
+
`export default function Loading() {`,
|
|
835
|
+
` return <p style={{ padding: "2rem", color: "#64748b" }}>Loading…</p>;`,
|
|
836
|
+
`}`,
|
|
837
|
+
``,
|
|
838
|
+
].join("\n");
|
|
839
|
+
}
|
|
840
|
+
if (base === "error.tsx" || base === "error.ts") {
|
|
841
|
+
return [
|
|
842
|
+
`"use client"; // error boundaries must be Client Components`,
|
|
843
|
+
``,
|
|
844
|
+
`export default function Error({ error, reset }: { error: Error; reset: () => void }) {`,
|
|
845
|
+
` return (`,
|
|
846
|
+
` <div style={{ padding: "2rem" }}>`,
|
|
847
|
+
` <h2>Something went wrong</h2>`,
|
|
848
|
+
` <pre style={{ color: "#dc2626", fontSize: "0.8rem" }}>{error.message}</pre>`,
|
|
849
|
+
` <button onClick={reset}>Try again</button>`,
|
|
850
|
+
` </div>`,
|
|
851
|
+
` );`,
|
|
852
|
+
`}`,
|
|
853
|
+
``,
|
|
854
|
+
].join("\n");
|
|
855
|
+
}
|
|
856
|
+
return `// ${name}\n`;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const getReactEntry = (
|
|
860
|
+
files: Record<string, string>,
|
|
861
|
+
type: "react" | "nextjs",
|
|
862
|
+
): string => {
|
|
863
|
+
if (type === "nextjs")
|
|
864
|
+
return files["app/page.tsx"]
|
|
865
|
+
? "app/page.tsx"
|
|
866
|
+
: (Object.keys(files)[0] ?? "");
|
|
867
|
+
return files["App.tsx"] ? "App.tsx" : (Object.keys(files)[0] ?? "");
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const refreshPreview = useCallback(
|
|
871
|
+
(overridePath?: string) => {
|
|
872
|
+
const type = clientType as "react" | "nextjs";
|
|
873
|
+
let entry: string;
|
|
874
|
+
if (type === "nextjs") {
|
|
875
|
+
const path = overridePath ?? reactPreviewPath;
|
|
876
|
+
const resolved = resolveNextjsEntry(reactFiles, path);
|
|
877
|
+
if (!resolved) {
|
|
878
|
+
// Show a proper 404 — don't fall back to the home page
|
|
879
|
+
const notFoundHTML = generatePreviewHTML(
|
|
880
|
+
{
|
|
881
|
+
"__404__.tsx": [
|
|
882
|
+
`export default function NotFound() {`,
|
|
883
|
+
` const path = ${JSON.stringify(path)};`,
|
|
884
|
+
` return (`,
|
|
885
|
+
` <div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>`,
|
|
886
|
+
` <h1 style={{ fontSize: "1.5rem", fontWeight: "bold", color: "#dc2626" }}>404 — Page Not Found</h1>`,
|
|
887
|
+
` <p style={{ color: "#64748b", marginTop: "0.5rem" }}>`,
|
|
888
|
+
` No file matches <code style={{ background: "#f1f5f9", padding: "0.1em 0.4em", borderRadius: "0.25rem" }}>{path}</code>`,
|
|
889
|
+
` </p>`,
|
|
890
|
+
` <p style={{ color: "#94a3b8", fontSize: "0.875rem", marginTop: "1rem" }}>`,
|
|
891
|
+
` Create <code style={{ background: "#f1f5f9", padding: "0.1em 0.4em", borderRadius: "0.25rem" }}>app${path === "/" ? "" : path}/page.tsx</code> to make this route work.`,
|
|
892
|
+
` </p>`,
|
|
893
|
+
` <button onClick={() => (window as any).__nxNavigate("/")}`,
|
|
894
|
+
` style={{ marginTop: "1.5rem", padding: "0.5rem 1rem", cursor: "pointer",`,
|
|
895
|
+
` borderRadius: "0.375rem", border: "1px solid #cbd5e1", background: "#f8fafc" }}>`,
|
|
896
|
+
` ← Back to Home`,
|
|
897
|
+
` </button>`,
|
|
898
|
+
` </div>`,
|
|
899
|
+
` );`,
|
|
900
|
+
`}`,
|
|
901
|
+
].join("\n"),
|
|
902
|
+
},
|
|
903
|
+
"__404__.tsx",
|
|
904
|
+
undefined,
|
|
905
|
+
true,
|
|
906
|
+
);
|
|
907
|
+
setReactPreviewSrc(notFoundHTML);
|
|
908
|
+
setReactClientTab("preview");
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
entry = resolved;
|
|
912
|
+
} else {
|
|
913
|
+
entry = getReactEntry(reactFiles, type);
|
|
914
|
+
}
|
|
915
|
+
if (!entry) return;
|
|
916
|
+
const html = generatePreviewHTML(
|
|
917
|
+
reactFiles,
|
|
918
|
+
entry,
|
|
919
|
+
sandboxUrl ?? undefined,
|
|
920
|
+
type === "nextjs",
|
|
921
|
+
);
|
|
922
|
+
setReactPreviewSrc(html);
|
|
923
|
+
setReactClientTab("preview");
|
|
924
|
+
},
|
|
925
|
+
[clientType, reactFiles, sandboxUrl, reactPreviewPath],
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
// Auto-refresh preview when files change while the preview tab is visible
|
|
929
|
+
const reactFilesRef = useRef(reactFiles);
|
|
930
|
+
useEffect(() => {
|
|
931
|
+
if (reactFiles === reactFilesRef.current) return;
|
|
932
|
+
reactFilesRef.current = reactFiles;
|
|
933
|
+
if (reactClientTab === "preview" && reactPreviewSrc) {
|
|
934
|
+
refreshPreview();
|
|
935
|
+
}
|
|
936
|
+
}, [reactFiles, reactClientTab, reactPreviewSrc, refreshPreview]);
|
|
937
|
+
|
|
938
|
+
/** Navigate to a new path (updates URL bar + history + re-renders preview). */
|
|
939
|
+
const navigatePreview = useCallback(
|
|
940
|
+
(to: string) => {
|
|
941
|
+
const path = to.startsWith("/") ? to : "/" + to;
|
|
942
|
+
setReactPreviewPath(path);
|
|
943
|
+
setReactNavInput(path);
|
|
944
|
+
setReactNavHistory((prev) => {
|
|
945
|
+
const trimmed = prev.slice(0, reactNavIndex + 1);
|
|
946
|
+
return [...trimmed, path];
|
|
947
|
+
});
|
|
948
|
+
setReactNavIndex((i) => i + 1);
|
|
949
|
+
refreshPreview(path);
|
|
950
|
+
},
|
|
951
|
+
[reactNavIndex, refreshPreview],
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
// Listen for rlab-nav messages from the preview iframe
|
|
955
|
+
useEffect(() => {
|
|
956
|
+
const handler = (e: MessageEvent) => {
|
|
957
|
+
if (e.data?.type === "rlab-nav" && typeof e.data.to === "string") {
|
|
958
|
+
navigatePreview(e.data.to);
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
window.addEventListener("message", handler);
|
|
962
|
+
return () => window.removeEventListener("message", handler);
|
|
963
|
+
}, [navigatePreview]);
|
|
964
|
+
|
|
965
|
+
/** Start a real Next.js dev-server for the lab and point the iframe at it. */
|
|
966
|
+
const startNextjsServer = useCallback(async () => {
|
|
967
|
+
if (nxStarting) return;
|
|
968
|
+
setNxStarting(true);
|
|
969
|
+
setNxError(null);
|
|
970
|
+
try {
|
|
971
|
+
const info = await startNextjsSandbox(reactFiles);
|
|
972
|
+
setNxSandboxId(info.id);
|
|
973
|
+
setNxSandboxUrl(info.url);
|
|
974
|
+
setReactClientTab("preview");
|
|
975
|
+
} catch (err: any) {
|
|
976
|
+
setNxError(err?.message ?? String(err));
|
|
977
|
+
} finally {
|
|
978
|
+
setNxStarting(false);
|
|
979
|
+
}
|
|
980
|
+
}, [nxStarting, reactFiles]);
|
|
981
|
+
|
|
982
|
+
/** Push updated files to the running Next.js server (HMR picks them up). */
|
|
983
|
+
const pushNextjsFiles = useCallback(
|
|
984
|
+
async (files: Record<string, string>) => {
|
|
985
|
+
if (!nxSandboxId) return;
|
|
986
|
+
try {
|
|
987
|
+
await updateNextjsFiles(nxSandboxId, files);
|
|
988
|
+
} catch {
|
|
989
|
+
// non-fatal; HMR may already have picked up the change
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
[nxSandboxId],
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
// Auto-push file changes to the running Next.js server
|
|
996
|
+
const nxFilesRef = useRef(reactFiles);
|
|
997
|
+
useEffect(() => {
|
|
998
|
+
if (!nxSandboxId || reactFiles === nxFilesRef.current) return;
|
|
999
|
+
nxFilesRef.current = reactFiles;
|
|
1000
|
+
void pushNextjsFiles(reactFiles);
|
|
1001
|
+
}, [reactFiles, nxSandboxId, pushNextjsFiles]);
|
|
1002
|
+
|
|
1003
|
+
// Clean up Next.js server when the modal is closed or mode changes away from nextjs
|
|
1004
|
+
const prevClientTypeRef = useRef(clientType);
|
|
1005
|
+
useEffect(() => {
|
|
1006
|
+
const prev = prevClientTypeRef.current;
|
|
1007
|
+
prevClientTypeRef.current = clientType;
|
|
1008
|
+
if (prev === "nextjs" && clientType !== "nextjs" && nxSandboxId) {
|
|
1009
|
+
void stopNextjsSandbox(nxSandboxId);
|
|
1010
|
+
setNxSandboxId(null);
|
|
1011
|
+
setNxSandboxUrl(null);
|
|
1012
|
+
}
|
|
1013
|
+
}, [clientType, nxSandboxId]);
|
|
1014
|
+
|
|
1015
|
+
// Clean up on unmount
|
|
1016
|
+
useEffect(() => {
|
|
1017
|
+
return () => {
|
|
1018
|
+
if (nxSandboxId) void stopNextjsSandbox(nxSandboxId);
|
|
1019
|
+
};
|
|
1020
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1021
|
+
}, [nxSandboxId]);
|
|
1022
|
+
|
|
1023
|
+
const handleClientTypeChange = useCallback(
|
|
1024
|
+
(ct: "script" | "react" | "nextjs") => {
|
|
1025
|
+
if (ct === clientType) return;
|
|
1026
|
+
setClientType(ct);
|
|
1027
|
+
if (ct === "react" || ct === "nextjs") {
|
|
1028
|
+
const defs = defaultForType(ct);
|
|
1029
|
+
setReactFiles(defs.files);
|
|
1030
|
+
setReactActiveFile(defs.activeFile);
|
|
1031
|
+
setReactPreviewSrc(null);
|
|
1032
|
+
setReactClientTab("edit");
|
|
1033
|
+
if (ct === "nextjs") {
|
|
1034
|
+
setReactPreviewPath("/");
|
|
1035
|
+
setReactNavInput("/");
|
|
1036
|
+
setReactNavHistory(["/"]);
|
|
1037
|
+
setReactNavIndex(0);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
[clientType],
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
// ── Sandbox chat handler ──────────────────────────────────
|
|
1045
|
+
|
|
1046
|
+
const handleSbxChatSend = useCallback(async () => {
|
|
1047
|
+
const text = sbxChatInput.trim();
|
|
1048
|
+
if (!text || sbxChatLoading) return;
|
|
1049
|
+
setSbxChatInput("");
|
|
1050
|
+
const userMsg: SbxChatMessage = {
|
|
1051
|
+
id: crypto.randomUUID(),
|
|
1052
|
+
role: "user",
|
|
1053
|
+
content: text,
|
|
1054
|
+
};
|
|
1055
|
+
setSbxChatMessages((prev) => [...prev, userMsg]);
|
|
1056
|
+
setSbxChatLoading(true);
|
|
1057
|
+
const abort = { aborted: false };
|
|
1058
|
+
sbxChatAbortRef.current = abort;
|
|
1059
|
+
const aId = crypto.randomUUID();
|
|
1060
|
+
setSbxChatMessages((prev) => [
|
|
1061
|
+
...prev,
|
|
1062
|
+
{ id: aId, role: "assistant", content: "" },
|
|
1063
|
+
]);
|
|
1064
|
+
const isReactMode = clientType === "react" || clientType === "nextjs";
|
|
1065
|
+
const workspaceFiles = isReactMode
|
|
1066
|
+
? reactFiles
|
|
1067
|
+
: { "client.js": clientCode, "server.ts": serverCode };
|
|
1068
|
+
const labType: "react" | "nextjs" =
|
|
1069
|
+
clientType === "nextjs" ? "nextjs" : "react";
|
|
1070
|
+
try {
|
|
1071
|
+
const history = [...sbxChatMessages, userMsg].map((m) => ({
|
|
1072
|
+
role: m.role,
|
|
1073
|
+
content: m.content,
|
|
1074
|
+
}));
|
|
1075
|
+
const { streamFrontendLabAsk } = await import("../api");
|
|
1076
|
+
await streamFrontendLabAsk(
|
|
1077
|
+
{
|
|
1078
|
+
messages: history,
|
|
1079
|
+
workspace: workspaceFiles,
|
|
1080
|
+
labType,
|
|
1081
|
+
questionId: currentQuestion?.id,
|
|
1082
|
+
},
|
|
1083
|
+
(delta) => {
|
|
1084
|
+
if (abort.aborted) return;
|
|
1085
|
+
setSbxChatMessages((prev) =>
|
|
1086
|
+
prev.map((m) =>
|
|
1087
|
+
m.id === aId ? { ...m, content: m.content + delta } : m,
|
|
1088
|
+
),
|
|
1089
|
+
);
|
|
1090
|
+
},
|
|
1091
|
+
);
|
|
1092
|
+
} catch (err: unknown) {
|
|
1093
|
+
if (!abort.aborted)
|
|
1094
|
+
setSbxChatMessages((prev) =>
|
|
1095
|
+
prev.map((m) =>
|
|
1096
|
+
m.id === aId
|
|
1097
|
+
? { ...m, content: (err as Error)?.message ?? "Request failed" }
|
|
1098
|
+
: m,
|
|
1099
|
+
),
|
|
1100
|
+
);
|
|
1101
|
+
} finally {
|
|
1102
|
+
if (!abort.aborted) setSbxChatLoading(false);
|
|
1103
|
+
}
|
|
1104
|
+
}, [
|
|
1105
|
+
sbxChatInput,
|
|
1106
|
+
sbxChatLoading,
|
|
1107
|
+
sbxChatMessages,
|
|
1108
|
+
clientType,
|
|
1109
|
+
reactFiles,
|
|
1110
|
+
clientCode,
|
|
1111
|
+
serverCode,
|
|
1112
|
+
]);
|
|
1113
|
+
|
|
1114
|
+
// ── Sandbox handlers ─────────────────────────────────────────────
|
|
1115
|
+
|
|
1116
|
+
const startServer = async () => {
|
|
1117
|
+
if (serverStarting) return;
|
|
1118
|
+
// Tear down any existing sandbox first
|
|
1119
|
+
if (sandboxId) {
|
|
1120
|
+
await fetch(`/api/sandbox/${sandboxId}`, { method: "DELETE" }).catch(
|
|
1121
|
+
() => {},
|
|
1122
|
+
);
|
|
1123
|
+
setSandboxId(null);
|
|
1124
|
+
setSandboxPort(null);
|
|
1125
|
+
setSandboxUrl(null);
|
|
1126
|
+
setServerRunning(false);
|
|
1127
|
+
}
|
|
1128
|
+
setServerStarting(true);
|
|
1129
|
+
sandboxLogOffsetRef.current = 0;
|
|
1130
|
+
setSandboxOutput([{ kind: "info", text: "Starting server…" }]);
|
|
1131
|
+
try {
|
|
1132
|
+
const res = await fetch("/api/sandbox/start", {
|
|
1133
|
+
method: "POST",
|
|
1134
|
+
headers: { "Content-Type": "application/json" },
|
|
1135
|
+
body: JSON.stringify({ serverCode, language: serverLang }),
|
|
1136
|
+
});
|
|
1137
|
+
const data = await res.json();
|
|
1138
|
+
if (!res.ok) {
|
|
1139
|
+
setSandboxOutput((prev) => [
|
|
1140
|
+
...prev,
|
|
1141
|
+
{ kind: "stderr", text: data.error || "Failed to start" },
|
|
1142
|
+
]);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
setSandboxId(data.sandboxId);
|
|
1146
|
+
setSandboxPort(data.port);
|
|
1147
|
+
setSandboxUrl(data.sandboxUrl);
|
|
1148
|
+
setServerRunning(true);
|
|
1149
|
+
setSandboxOutput((prev) => [
|
|
1150
|
+
...prev,
|
|
1151
|
+
{
|
|
1152
|
+
kind: "info",
|
|
1153
|
+
text: `✓ Server started at ${data.sandboxUrl}`,
|
|
1154
|
+
source: "server",
|
|
1155
|
+
},
|
|
1156
|
+
]);
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
setSandboxOutput((prev) => [
|
|
1159
|
+
...prev,
|
|
1160
|
+
{ kind: "stderr", text: String(err) },
|
|
1161
|
+
]);
|
|
1162
|
+
} finally {
|
|
1163
|
+
setServerStarting(false);
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
const stopServer = async () => {
|
|
1168
|
+
if (!sandboxId) return;
|
|
1169
|
+
await fetch(`/api/sandbox/${sandboxId}`, { method: "DELETE" }).catch(
|
|
1170
|
+
() => {},
|
|
1171
|
+
);
|
|
1172
|
+
setSandboxId(null);
|
|
1173
|
+
setSandboxPort(null);
|
|
1174
|
+
setSandboxUrl(null);
|
|
1175
|
+
setServerRunning(false);
|
|
1176
|
+
setSandboxOutput((prev) => [
|
|
1177
|
+
...prev,
|
|
1178
|
+
{ kind: "info", text: "Server stopped." },
|
|
1179
|
+
]);
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
const runClient = async () => {
|
|
1183
|
+
if (clientRunning || !sandboxUrl) return;
|
|
1184
|
+
setClientRunning(true);
|
|
1185
|
+
setSandboxOutput((prev) => [
|
|
1186
|
+
...prev,
|
|
1187
|
+
{ kind: "info", text: "── client run ──", source: "client" },
|
|
1188
|
+
]);
|
|
1189
|
+
let hadOutput = false;
|
|
1190
|
+
try {
|
|
1191
|
+
const res = await fetch("/api/sandbox/run-client", {
|
|
1192
|
+
method: "POST",
|
|
1193
|
+
headers: { "Content-Type": "application/json" },
|
|
1194
|
+
body: JSON.stringify({
|
|
1195
|
+
code: clientCode,
|
|
1196
|
+
language: clientLang,
|
|
1197
|
+
sandboxUrl,
|
|
1198
|
+
}),
|
|
1199
|
+
});
|
|
1200
|
+
if (!res.body) throw new Error("No response body");
|
|
1201
|
+
const reader = res.body.getReader();
|
|
1202
|
+
const decoder = new TextDecoder();
|
|
1203
|
+
let buf = "";
|
|
1204
|
+
// Read the SSE stream and push each line to sandboxOutput immediately
|
|
1205
|
+
while (true) {
|
|
1206
|
+
const { done, value } = await reader.read();
|
|
1207
|
+
if (done) break;
|
|
1208
|
+
buf += decoder.decode(value, { stream: true });
|
|
1209
|
+
// SSE messages are separated by double newlines
|
|
1210
|
+
const parts = buf.split("\n\n");
|
|
1211
|
+
buf = parts.pop() ?? "";
|
|
1212
|
+
for (const part of parts) {
|
|
1213
|
+
const dataLine = part.split("\n").find((l) => l.startsWith("data:"));
|
|
1214
|
+
if (!dataLine) continue;
|
|
1215
|
+
const payload = JSON.parse(dataLine.slice(5).trim()) as {
|
|
1216
|
+
kind: string;
|
|
1217
|
+
text: string;
|
|
1218
|
+
};
|
|
1219
|
+
if (payload.kind === "done") {
|
|
1220
|
+
const meta = JSON.parse(payload.text) as {
|
|
1221
|
+
timedOut: boolean;
|
|
1222
|
+
durationMs: number;
|
|
1223
|
+
};
|
|
1224
|
+
if (!hadOutput)
|
|
1225
|
+
setSandboxOutput((prev) => [
|
|
1226
|
+
...prev,
|
|
1227
|
+
{ kind: "info", text: "(no output)", source: "client" },
|
|
1228
|
+
]);
|
|
1229
|
+
if (meta.timedOut)
|
|
1230
|
+
setSandboxOutput((prev) => [
|
|
1231
|
+
...prev,
|
|
1232
|
+
{
|
|
1233
|
+
kind: "warn",
|
|
1234
|
+
text: "⏱ Timed out after 10 seconds",
|
|
1235
|
+
source: "client",
|
|
1236
|
+
},
|
|
1237
|
+
]);
|
|
1238
|
+
} else {
|
|
1239
|
+
hadOutput = true;
|
|
1240
|
+
setSandboxOutput((prev) => [
|
|
1241
|
+
...prev,
|
|
1242
|
+
{
|
|
1243
|
+
kind: payload.kind as OutputLine["kind"],
|
|
1244
|
+
text: payload.text,
|
|
1245
|
+
source: "client",
|
|
1246
|
+
},
|
|
1247
|
+
]);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
setSandboxOutput((prev) => [
|
|
1253
|
+
...prev,
|
|
1254
|
+
{ kind: "stderr", text: String(err), source: "client" },
|
|
1255
|
+
]);
|
|
1256
|
+
} finally {
|
|
1257
|
+
setClientRunning(false);
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
// ── Styles ───────────────────────────────────────────────────
|
|
1262
|
+
|
|
1263
|
+
const modalStyle: React.CSSProperties = maximized
|
|
1264
|
+
? {
|
|
1265
|
+
position: "fixed",
|
|
1266
|
+
inset: 0,
|
|
1267
|
+
width: "100vw",
|
|
1268
|
+
height: "100vh",
|
|
1269
|
+
borderRadius: 0,
|
|
1270
|
+
}
|
|
1271
|
+
: {
|
|
1272
|
+
position: "fixed",
|
|
1273
|
+
left: pos.x,
|
|
1274
|
+
top: pos.y,
|
|
1275
|
+
width: size.w,
|
|
1276
|
+
height: size.h,
|
|
1277
|
+
minWidth: MIN_W,
|
|
1278
|
+
minHeight: MIN_H,
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
return (
|
|
1282
|
+
<div
|
|
1283
|
+
className="fixed z-[60] flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
|
|
1284
|
+
style={modalStyle}
|
|
1285
|
+
>
|
|
1286
|
+
{/* ── Resize handles ── */}
|
|
1287
|
+
{!maximized && (
|
|
1288
|
+
<>
|
|
1289
|
+
<div
|
|
1290
|
+
className="absolute inset-x-0 top-0 h-1 cursor-n-resize"
|
|
1291
|
+
onMouseDown={startResize("n")}
|
|
1292
|
+
/>
|
|
1293
|
+
<div
|
|
1294
|
+
className="absolute inset-x-0 bottom-0 h-1 cursor-s-resize"
|
|
1295
|
+
onMouseDown={startResize("s")}
|
|
1296
|
+
/>
|
|
1297
|
+
<div
|
|
1298
|
+
className="absolute inset-y-0 left-0 w-1 cursor-w-resize"
|
|
1299
|
+
onMouseDown={startResize("w")}
|
|
1300
|
+
/>
|
|
1301
|
+
<div
|
|
1302
|
+
className="absolute inset-y-0 right-0 w-1 cursor-e-resize"
|
|
1303
|
+
onMouseDown={startResize("e")}
|
|
1304
|
+
/>
|
|
1305
|
+
<div
|
|
1306
|
+
className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize"
|
|
1307
|
+
onMouseDown={startResize("se")}
|
|
1308
|
+
/>
|
|
1309
|
+
<div
|
|
1310
|
+
className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize"
|
|
1311
|
+
onMouseDown={startResize("sw")}
|
|
1312
|
+
/>
|
|
1313
|
+
</>
|
|
1314
|
+
)}
|
|
1315
|
+
|
|
1316
|
+
{/* ── Title bar ── */}
|
|
1317
|
+
<div
|
|
1318
|
+
className="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700 shrink-0"
|
|
1319
|
+
onMouseDown={onTitleMouseDown}
|
|
1320
|
+
style={{ cursor: maximized ? "default" : "grab" }}
|
|
1321
|
+
>
|
|
1322
|
+
<GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
|
1323
|
+
<Terminal className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
|
1324
|
+
<span className="text-xs font-mono text-slate-300">Code Runner</span>
|
|
1325
|
+
|
|
1326
|
+
{/* Mode toggle */}
|
|
1327
|
+
<div
|
|
1328
|
+
className="flex items-center rounded overflow-hidden border border-slate-700 text-[10px] ml-1 mr-auto shrink-0"
|
|
1329
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1330
|
+
>
|
|
1331
|
+
{(["script", "sandbox"] as const).map((m) => (
|
|
1332
|
+
<button
|
|
1333
|
+
key={m}
|
|
1334
|
+
type="button"
|
|
1335
|
+
onClick={() => setMode(m)}
|
|
1336
|
+
className={`px-2 py-0.5 capitalize transition-colors ${
|
|
1337
|
+
mode === m
|
|
1338
|
+
? "bg-slate-600 text-slate-200"
|
|
1339
|
+
: "text-slate-500 hover:text-slate-400"
|
|
1340
|
+
}`}
|
|
1341
|
+
>
|
|
1342
|
+
{m}
|
|
1343
|
+
</button>
|
|
1344
|
+
))}
|
|
1345
|
+
</div>
|
|
1346
|
+
|
|
1347
|
+
{/* Script-mode controls */}
|
|
1348
|
+
{mode === "script" && (
|
|
1349
|
+
<>
|
|
1350
|
+
{/* Language toggle */}
|
|
1351
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
1352
|
+
{LANG_OPTIONS.map((l) => (
|
|
1353
|
+
<button
|
|
1354
|
+
key={l}
|
|
1355
|
+
type="button"
|
|
1356
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1357
|
+
onClick={() => setLang(l)}
|
|
1358
|
+
className={`px-2 py-0.5 rounded text-[10px] uppercase tracking-wider font-mono transition-colors ${
|
|
1359
|
+
lang === l
|
|
1360
|
+
? "bg-violet-600/30 text-violet-300"
|
|
1361
|
+
: "text-slate-500 hover:text-slate-300"
|
|
1362
|
+
}`}
|
|
1363
|
+
>
|
|
1364
|
+
{l === "typescript" ? "TS" : "JS"}
|
|
1365
|
+
</button>
|
|
1366
|
+
))}
|
|
1367
|
+
</div>
|
|
1368
|
+
|
|
1369
|
+
{/* Save to Context */}
|
|
1370
|
+
{currentQuestion && !naming && (
|
|
1371
|
+
<button
|
|
1372
|
+
type="button"
|
|
1373
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1374
|
+
onClick={() => {
|
|
1375
|
+
if (activeScriptId) {
|
|
1376
|
+
void overwriteScriptSnippet();
|
|
1377
|
+
} else {
|
|
1378
|
+
if (!code.trim()) return;
|
|
1379
|
+
setSnippetName("Runner snippet");
|
|
1380
|
+
setNaming(true);
|
|
1381
|
+
setTimeout(() => {
|
|
1382
|
+
nameInputRef.current?.focus();
|
|
1383
|
+
nameInputRef.current?.select();
|
|
1384
|
+
}, 30);
|
|
1385
|
+
}
|
|
1386
|
+
}}
|
|
1387
|
+
disabled={saving || (!activeScriptId && !code.trim())}
|
|
1388
|
+
className={`flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium transition-colors shrink-0 ${
|
|
1389
|
+
saved
|
|
1390
|
+
? "bg-cyan-600/30 text-cyan-300"
|
|
1391
|
+
: "bg-slate-700/60 hover:bg-slate-600/60 text-slate-400 hover:text-slate-200"
|
|
1392
|
+
} disabled:opacity-40`}
|
|
1393
|
+
title={
|
|
1394
|
+
activeScriptId
|
|
1395
|
+
? "Overwrite saved script"
|
|
1396
|
+
: "Save to question context"
|
|
1397
|
+
}
|
|
1398
|
+
>
|
|
1399
|
+
{saved ? (
|
|
1400
|
+
<>
|
|
1401
|
+
<Check className="w-3 h-3" />
|
|
1402
|
+
Saved
|
|
1403
|
+
</>
|
|
1404
|
+
) : saving ? (
|
|
1405
|
+
<>
|
|
1406
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1407
|
+
Saving…
|
|
1408
|
+
</>
|
|
1409
|
+
) : (
|
|
1410
|
+
<>
|
|
1411
|
+
<Save className="w-3 h-3" />
|
|
1412
|
+
Save
|
|
1413
|
+
</>
|
|
1414
|
+
)}
|
|
1415
|
+
</button>
|
|
1416
|
+
)}
|
|
1417
|
+
|
|
1418
|
+
{/* Save As — only shown when a script is already saved */}
|
|
1419
|
+
{currentQuestion && !naming && activeScriptId && (
|
|
1420
|
+
<button
|
|
1421
|
+
type="button"
|
|
1422
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1423
|
+
onClick={() => {
|
|
1424
|
+
if (!code.trim()) return;
|
|
1425
|
+
setSnippetName("Runner snippet");
|
|
1426
|
+
setNaming(true);
|
|
1427
|
+
setTimeout(() => {
|
|
1428
|
+
nameInputRef.current?.focus();
|
|
1429
|
+
nameInputRef.current?.select();
|
|
1430
|
+
}, 30);
|
|
1431
|
+
}}
|
|
1432
|
+
disabled={!code.trim() || saving}
|
|
1433
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium text-slate-400 hover:text-cyan-300 hover:bg-cyan-600/10 transition-colors shrink-0 disabled:opacity-40"
|
|
1434
|
+
title="Save as a new script"
|
|
1435
|
+
>
|
|
1436
|
+
<Save className="w-3 h-3" /> Save As
|
|
1437
|
+
</button>
|
|
1438
|
+
)}
|
|
1439
|
+
|
|
1440
|
+
{/* Inline name input */}
|
|
1441
|
+
{currentQuestion && naming && (
|
|
1442
|
+
<div
|
|
1443
|
+
className="flex items-center gap-1 shrink-0"
|
|
1444
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1445
|
+
>
|
|
1446
|
+
<input
|
|
1447
|
+
ref={nameInputRef}
|
|
1448
|
+
value={snippetName}
|
|
1449
|
+
onChange={(e) => setSnippetName(e.target.value)}
|
|
1450
|
+
onKeyDown={async (e) => {
|
|
1451
|
+
if (e.key === "Enter") {
|
|
1452
|
+
e.preventDefault();
|
|
1453
|
+
const label = snippetName.trim() || "Runner snippet";
|
|
1454
|
+
setSaving(true);
|
|
1455
|
+
setNaming(false);
|
|
1456
|
+
try {
|
|
1457
|
+
const cf = await saveCodeSnippetToQuestion(
|
|
1458
|
+
currentQuestion.id,
|
|
1459
|
+
code,
|
|
1460
|
+
lang,
|
|
1461
|
+
label,
|
|
1462
|
+
"user",
|
|
1463
|
+
);
|
|
1464
|
+
setActiveScriptId(cf.id);
|
|
1465
|
+
setSaved(true);
|
|
1466
|
+
setTimeout(() => setSaved(false), 2000);
|
|
1467
|
+
} finally {
|
|
1468
|
+
setSaving(false);
|
|
1469
|
+
}
|
|
1470
|
+
} else if (e.key === "Escape") {
|
|
1471
|
+
setNaming(false);
|
|
1472
|
+
}
|
|
1473
|
+
}}
|
|
1474
|
+
placeholder="Snippet name…"
|
|
1475
|
+
className="w-32 bg-slate-900 border border-cyan-600/50 rounded px-2 py-0.5 text-[11px] text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500"
|
|
1476
|
+
/>
|
|
1477
|
+
<button
|
|
1478
|
+
type="button"
|
|
1479
|
+
onClick={async () => {
|
|
1480
|
+
const label = snippetName.trim() || "Runner snippet";
|
|
1481
|
+
setSaving(true);
|
|
1482
|
+
setNaming(false);
|
|
1483
|
+
try {
|
|
1484
|
+
const cf = await saveCodeSnippetToQuestion(
|
|
1485
|
+
currentQuestion.id,
|
|
1486
|
+
code,
|
|
1487
|
+
lang,
|
|
1488
|
+
label,
|
|
1489
|
+
"user",
|
|
1490
|
+
);
|
|
1491
|
+
setActiveScriptId(cf.id);
|
|
1492
|
+
setSaved(true);
|
|
1493
|
+
setTimeout(() => setSaved(false), 2000);
|
|
1494
|
+
} finally {
|
|
1495
|
+
setSaving(false);
|
|
1496
|
+
}
|
|
1497
|
+
}}
|
|
1498
|
+
className="p-0.5 rounded bg-cyan-600/20 hover:bg-cyan-600/40 text-cyan-400 transition-colors"
|
|
1499
|
+
title="Confirm save (Enter)"
|
|
1500
|
+
>
|
|
1501
|
+
{saving ? (
|
|
1502
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
1503
|
+
) : (
|
|
1504
|
+
<Check className="w-3.5 h-3.5" />
|
|
1505
|
+
)}
|
|
1506
|
+
</button>
|
|
1507
|
+
<button
|
|
1508
|
+
type="button"
|
|
1509
|
+
onClick={() => setNaming(false)}
|
|
1510
|
+
className="p-0.5 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors"
|
|
1511
|
+
title="Cancel (Esc)"
|
|
1512
|
+
>
|
|
1513
|
+
<X className="w-3.5 h-3.5" />
|
|
1514
|
+
</button>
|
|
1515
|
+
</div>
|
|
1516
|
+
)}
|
|
1517
|
+
|
|
1518
|
+
{/* Run */}
|
|
1519
|
+
<button
|
|
1520
|
+
type="button"
|
|
1521
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1522
|
+
onClick={() => void runCode()}
|
|
1523
|
+
disabled={running}
|
|
1524
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-emerald-600/20 hover:bg-emerald-600/40 text-emerald-400 hover:text-emerald-300 disabled:opacity-40 transition-colors shrink-0"
|
|
1525
|
+
title="Run (Ctrl+Enter)"
|
|
1526
|
+
>
|
|
1527
|
+
{running ? (
|
|
1528
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1529
|
+
) : (
|
|
1530
|
+
<Play className="w-3 h-3" />
|
|
1531
|
+
)}
|
|
1532
|
+
Run
|
|
1533
|
+
</button>
|
|
1534
|
+
|
|
1535
|
+
{/* Clear output */}
|
|
1536
|
+
<button
|
|
1537
|
+
type="button"
|
|
1538
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1539
|
+
onClick={() =>
|
|
1540
|
+
navigator.clipboard.writeText(
|
|
1541
|
+
output.map((l) => l.text).join("\n"),
|
|
1542
|
+
)
|
|
1543
|
+
}
|
|
1544
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
|
1545
|
+
title="Copy output"
|
|
1546
|
+
>
|
|
1547
|
+
<Copy className="w-3.5 h-3.5" />
|
|
1548
|
+
</button>
|
|
1549
|
+
<button
|
|
1550
|
+
type="button"
|
|
1551
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1552
|
+
onClick={() => {
|
|
1553
|
+
setOutput([]);
|
|
1554
|
+
setDurationMs(null);
|
|
1555
|
+
setTimedOut(false);
|
|
1556
|
+
}}
|
|
1557
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
|
1558
|
+
title="Clear output"
|
|
1559
|
+
>
|
|
1560
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
1561
|
+
</button>
|
|
1562
|
+
</>
|
|
1563
|
+
)}
|
|
1564
|
+
|
|
1565
|
+
{/* Sandbox: save + clear */}
|
|
1566
|
+
{mode === "sandbox" && (
|
|
1567
|
+
<>
|
|
1568
|
+
{currentQuestion && !sbxNaming && (
|
|
1569
|
+
<>
|
|
1570
|
+
{/* Save — overwrites when a sandbox is already active, otherwise shows naming flow */}
|
|
1571
|
+
<button
|
|
1572
|
+
type="button"
|
|
1573
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1574
|
+
onClick={async () => {
|
|
1575
|
+
if (activeSandboxId) {
|
|
1576
|
+
await overwriteSandboxSnippet();
|
|
1577
|
+
} else {
|
|
1578
|
+
setSbxName("My Sandbox");
|
|
1579
|
+
setSbxNaming(true);
|
|
1580
|
+
setTimeout(() => sbxNameInputRef.current?.focus(), 30);
|
|
1581
|
+
}
|
|
1582
|
+
}}
|
|
1583
|
+
disabled={sbxSaving}
|
|
1584
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium text-slate-400 hover:text-violet-300 hover:bg-violet-600/10 transition-colors shrink-0"
|
|
1585
|
+
title={
|
|
1586
|
+
activeSandboxId
|
|
1587
|
+
? "Overwrite saved sandbox"
|
|
1588
|
+
: "Save both server and client code as a sandbox"
|
|
1589
|
+
}
|
|
1590
|
+
>
|
|
1591
|
+
{sbxSaved ? (
|
|
1592
|
+
<>
|
|
1593
|
+
<Check className="w-3 h-3 text-violet-400" /> Saved
|
|
1594
|
+
</>
|
|
1595
|
+
) : sbxSaving ? (
|
|
1596
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1597
|
+
) : (
|
|
1598
|
+
<>
|
|
1599
|
+
<Save className="w-3 h-3" /> Save
|
|
1600
|
+
</>
|
|
1601
|
+
)}
|
|
1602
|
+
</button>
|
|
1603
|
+
{/* Save As — always creates a new sandbox */}
|
|
1604
|
+
{activeSandboxId && (
|
|
1605
|
+
<button
|
|
1606
|
+
type="button"
|
|
1607
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1608
|
+
onClick={() => {
|
|
1609
|
+
setSbxName("My Sandbox");
|
|
1610
|
+
setSbxNaming(true);
|
|
1611
|
+
setTimeout(() => sbxNameInputRef.current?.focus(), 30);
|
|
1612
|
+
}}
|
|
1613
|
+
disabled={sbxSaving}
|
|
1614
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium text-slate-400 hover:text-violet-300 hover:bg-violet-600/10 transition-colors shrink-0"
|
|
1615
|
+
title="Save as a new sandbox"
|
|
1616
|
+
>
|
|
1617
|
+
<Save className="w-3 h-3" /> Save As
|
|
1618
|
+
</button>
|
|
1619
|
+
)}
|
|
1620
|
+
</>
|
|
1621
|
+
)}
|
|
1622
|
+
{sbxNaming && (
|
|
1623
|
+
<div
|
|
1624
|
+
className="flex items-center gap-1 shrink-0"
|
|
1625
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1626
|
+
>
|
|
1627
|
+
<input
|
|
1628
|
+
ref={sbxNameInputRef}
|
|
1629
|
+
value={sbxName}
|
|
1630
|
+
onChange={(e) => setSbxName(e.target.value)}
|
|
1631
|
+
onKeyDown={async (e) => {
|
|
1632
|
+
if (e.key === "Enter") {
|
|
1633
|
+
e.preventDefault();
|
|
1634
|
+
setSbxNaming(false);
|
|
1635
|
+
await saveSandboxSnippet(sbxName.trim());
|
|
1636
|
+
} else if (e.key === "Escape") {
|
|
1637
|
+
setSbxNaming(false);
|
|
1638
|
+
}
|
|
1639
|
+
}}
|
|
1640
|
+
placeholder="Sandbox name…"
|
|
1641
|
+
className="w-32 bg-slate-900 border border-violet-600/50 rounded px-2 py-0.5 text-[11px] text-slate-200 placeholder-slate-600 outline-none focus:border-violet-500"
|
|
1642
|
+
/>
|
|
1643
|
+
<button
|
|
1644
|
+
type="button"
|
|
1645
|
+
onClick={async () => {
|
|
1646
|
+
setSbxNaming(false);
|
|
1647
|
+
await saveSandboxSnippet(sbxName.trim());
|
|
1648
|
+
}}
|
|
1649
|
+
className="p-0.5 rounded bg-violet-600/20 hover:bg-violet-600/40 text-violet-400 transition-colors"
|
|
1650
|
+
title="Confirm (Enter)"
|
|
1651
|
+
>
|
|
1652
|
+
{sbxSaving ? (
|
|
1653
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
1654
|
+
) : (
|
|
1655
|
+
<Check className="w-3.5 h-3.5" />
|
|
1656
|
+
)}
|
|
1657
|
+
</button>
|
|
1658
|
+
<button
|
|
1659
|
+
type="button"
|
|
1660
|
+
onClick={() => setSbxNaming(false)}
|
|
1661
|
+
className="p-0.5 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors"
|
|
1662
|
+
title="Cancel (Esc)"
|
|
1663
|
+
>
|
|
1664
|
+
<X className="w-3.5 h-3.5" />
|
|
1665
|
+
</button>
|
|
1666
|
+
</div>
|
|
1667
|
+
)}
|
|
1668
|
+
<button
|
|
1669
|
+
type="button"
|
|
1670
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1671
|
+
onClick={() =>
|
|
1672
|
+
navigator.clipboard.writeText(
|
|
1673
|
+
sandboxOutput.map((l) => l.text).join("\n"),
|
|
1674
|
+
)
|
|
1675
|
+
}
|
|
1676
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
|
1677
|
+
title="Copy output"
|
|
1678
|
+
>
|
|
1679
|
+
<Copy className="w-3.5 h-3.5" />
|
|
1680
|
+
</button>
|
|
1681
|
+
<button
|
|
1682
|
+
type="button"
|
|
1683
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1684
|
+
onClick={() => setSandboxOutput([])}
|
|
1685
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
|
1686
|
+
title="Clear output"
|
|
1687
|
+
>
|
|
1688
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
1689
|
+
</button>
|
|
1690
|
+
</>
|
|
1691
|
+
)}
|
|
1692
|
+
|
|
1693
|
+
<button
|
|
1694
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1695
|
+
onClick={toggleMax}
|
|
1696
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
|
1697
|
+
title={maximized ? "Restore" : "Maximise"}
|
|
1698
|
+
>
|
|
1699
|
+
{maximized ? (
|
|
1700
|
+
<Minimize2 className="w-3.5 h-3.5" />
|
|
1701
|
+
) : (
|
|
1702
|
+
<Maximize2 className="w-3.5 h-3.5" />
|
|
1703
|
+
)}
|
|
1704
|
+
</button>
|
|
1705
|
+
<button
|
|
1706
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1707
|
+
onClick={closeCodeRunner}
|
|
1708
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
|
|
1709
|
+
title="Close (Esc)"
|
|
1710
|
+
>
|
|
1711
|
+
<X className="w-3.5 h-3.5" />
|
|
1712
|
+
</button>
|
|
1713
|
+
</div>
|
|
1714
|
+
|
|
1715
|
+
{/* ── Body ── */}
|
|
1716
|
+
{mode === "script" ? (
|
|
1717
|
+
<div className="flex flex-col flex-1 overflow-hidden">
|
|
1718
|
+
{/* Editor pane */}
|
|
1719
|
+
<div className="flex-1 min-h-0 relative">
|
|
1720
|
+
<SyntaxEditor
|
|
1721
|
+
value={code}
|
|
1722
|
+
onChange={setCode}
|
|
1723
|
+
onCtrlEnter={() => void runCode()}
|
|
1724
|
+
language={lang}
|
|
1725
|
+
autoFocus
|
|
1726
|
+
fontSize="13px"
|
|
1727
|
+
focusRingClass="ring-violet-500/40"
|
|
1728
|
+
placeholder={`// TypeScript / JavaScript\n// Ctrl+Enter to run\n\nconst res = await fetch('https://jsonplaceholder.typicode.com/todos/1');\nconst data = await res.json();\nconsole.log(data);`}
|
|
1729
|
+
/>
|
|
1730
|
+
</div>
|
|
1731
|
+
|
|
1732
|
+
{/* Divider */}
|
|
1733
|
+
<div
|
|
1734
|
+
className="h-px bg-slate-700 shrink-0"
|
|
1735
|
+
onMouseDown={startResize("s")}
|
|
1736
|
+
/>
|
|
1737
|
+
|
|
1738
|
+
{/* Output pane */}
|
|
1739
|
+
<div className="h-44 bg-slate-950 flex flex-col overflow-hidden shrink-0">
|
|
1740
|
+
<div className="flex items-center gap-2 px-3 py-1 bg-slate-900 border-b border-slate-800 shrink-0">
|
|
1741
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-medium">
|
|
1742
|
+
Output
|
|
1743
|
+
</span>
|
|
1744
|
+
{running && (
|
|
1745
|
+
<Loader2 className="w-3 h-3 text-emerald-400 animate-spin ml-auto" />
|
|
1746
|
+
)}
|
|
1747
|
+
{!running && durationMs !== null && (
|
|
1748
|
+
<span className="ml-auto flex items-center gap-1 text-[10px] text-slate-600">
|
|
1749
|
+
<Clock className="w-3 h-3" />
|
|
1750
|
+
{durationMs}ms{timedOut ? " · timed out" : ""}
|
|
1751
|
+
</span>
|
|
1752
|
+
)}
|
|
1753
|
+
</div>
|
|
1754
|
+
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[12px] leading-relaxed">
|
|
1755
|
+
{output.length === 0 && !running && (
|
|
1756
|
+
<span className="text-slate-600">
|
|
1757
|
+
Press Run (Ctrl+Enter) to execute
|
|
1758
|
+
</span>
|
|
1759
|
+
)}
|
|
1760
|
+
{output.map((line, i) => (
|
|
1761
|
+
<div
|
|
1762
|
+
key={i}
|
|
1763
|
+
className={
|
|
1764
|
+
line.kind === "stderr"
|
|
1765
|
+
? "text-red-400 whitespace-pre-wrap"
|
|
1766
|
+
: line.kind === "warn"
|
|
1767
|
+
? "text-amber-400 whitespace-pre-wrap"
|
|
1768
|
+
: line.kind === "info"
|
|
1769
|
+
? "text-slate-500 italic whitespace-pre-wrap"
|
|
1770
|
+
: "text-slate-200 whitespace-pre-wrap"
|
|
1771
|
+
}
|
|
1772
|
+
>
|
|
1773
|
+
{line.text}
|
|
1774
|
+
</div>
|
|
1775
|
+
))}
|
|
1776
|
+
<div ref={outputEndRef} />
|
|
1777
|
+
</div>
|
|
1778
|
+
</div>
|
|
1779
|
+
</div>
|
|
1780
|
+
) : (
|
|
1781
|
+
/* ── Sandbox mode ── */
|
|
1782
|
+
<div className="flex flex-col flex-1 overflow-hidden">
|
|
1783
|
+
{/* Two-pane editor row */}
|
|
1784
|
+
<div
|
|
1785
|
+
ref={sbxEditorRowRef}
|
|
1786
|
+
className="flex flex-1 min-h-0 overflow-hidden"
|
|
1787
|
+
>
|
|
1788
|
+
{/* ── Server pane ── */}
|
|
1789
|
+
{serverCollapsed ? (
|
|
1790
|
+
/* Collapsed: slim vertical tab always visible so user can re-open */
|
|
1791
|
+
<button
|
|
1792
|
+
type="button"
|
|
1793
|
+
onClick={() => setServerCollapsed(false)}
|
|
1794
|
+
className="flex flex-col items-center justify-center w-7 shrink-0 bg-slate-800/60 border-r border-slate-700 text-slate-500 hover:text-emerald-400 hover:bg-slate-800 transition-colors gap-1.5 py-3"
|
|
1795
|
+
title="Expand server"
|
|
1796
|
+
>
|
|
1797
|
+
<Server className="w-3 h-3" />
|
|
1798
|
+
<span
|
|
1799
|
+
className="text-[8px] uppercase tracking-widest font-medium"
|
|
1800
|
+
style={{
|
|
1801
|
+
writingMode: "vertical-rl",
|
|
1802
|
+
transform: "rotate(180deg)",
|
|
1803
|
+
}}
|
|
1804
|
+
>
|
|
1805
|
+
Server
|
|
1806
|
+
</span>
|
|
1807
|
+
<ChevronRight className="w-3 h-3" />
|
|
1808
|
+
</button>
|
|
1809
|
+
) : (
|
|
1810
|
+
<div
|
|
1811
|
+
className="flex flex-col min-w-0 overflow-hidden"
|
|
1812
|
+
style={
|
|
1813
|
+
clientCollapsed
|
|
1814
|
+
? { flex: "1 1 0" }
|
|
1815
|
+
: { width: `${sbxSplit}%`, minWidth: 0 }
|
|
1816
|
+
}
|
|
1817
|
+
>
|
|
1818
|
+
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800/60 border-b border-slate-700 shrink-0">
|
|
1819
|
+
<Server className="w-3 h-3 text-emerald-500/70 shrink-0" />
|
|
1820
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-medium flex-1">
|
|
1821
|
+
Server
|
|
1822
|
+
</span>
|
|
1823
|
+
{serverRunning && (
|
|
1824
|
+
<span className="flex items-center gap-1 text-[9px] font-mono text-emerald-400 shrink-0">
|
|
1825
|
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse inline-block" />
|
|
1826
|
+
:{sandboxPort}
|
|
1827
|
+
</span>
|
|
1828
|
+
)}
|
|
1829
|
+
<div className="flex items-center gap-0.5">
|
|
1830
|
+
{LANG_OPTIONS.map((l) => (
|
|
1831
|
+
<button
|
|
1832
|
+
key={l}
|
|
1833
|
+
type="button"
|
|
1834
|
+
onClick={() => setServerLang(l)}
|
|
1835
|
+
className={`px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wider font-mono transition-colors ${
|
|
1836
|
+
serverLang === l
|
|
1837
|
+
? "bg-violet-600/30 text-violet-300"
|
|
1838
|
+
: "text-slate-600 hover:text-slate-300"
|
|
1839
|
+
}`}
|
|
1840
|
+
>
|
|
1841
|
+
{l === "typescript" ? "TS" : "JS"}
|
|
1842
|
+
</button>
|
|
1843
|
+
))}
|
|
1844
|
+
</div>
|
|
1845
|
+
<button
|
|
1846
|
+
type="button"
|
|
1847
|
+
onClick={() => {
|
|
1848
|
+
if (!clientCollapsed) setServerCollapsed((v) => !v);
|
|
1849
|
+
}}
|
|
1850
|
+
disabled={clientCollapsed}
|
|
1851
|
+
className="p-0.5 rounded text-slate-600 hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shrink-0"
|
|
1852
|
+
title="Collapse server"
|
|
1853
|
+
>
|
|
1854
|
+
<ChevronLeft className="w-3 h-3" />
|
|
1855
|
+
</button>
|
|
1856
|
+
{!serverRunning ? (
|
|
1857
|
+
<button
|
|
1858
|
+
type="button"
|
|
1859
|
+
onClick={() => void startServer()}
|
|
1860
|
+
disabled={serverStarting}
|
|
1861
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-emerald-600/20 hover:bg-emerald-600/40 text-emerald-400 disabled:opacity-40 transition-colors shrink-0"
|
|
1862
|
+
title="Start server (Ctrl+Enter in editor)"
|
|
1863
|
+
>
|
|
1864
|
+
{serverStarting ? (
|
|
1865
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1866
|
+
) : (
|
|
1867
|
+
<Play className="w-3 h-3" />
|
|
1868
|
+
)}
|
|
1869
|
+
Start
|
|
1870
|
+
</button>
|
|
1871
|
+
) : (
|
|
1872
|
+
<button
|
|
1873
|
+
type="button"
|
|
1874
|
+
onClick={() => void stopServer()}
|
|
1875
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-red-600/20 hover:bg-red-600/40 text-red-400 transition-colors shrink-0"
|
|
1876
|
+
title="Stop server"
|
|
1877
|
+
>
|
|
1878
|
+
<StopCircle className="w-3 h-3" />
|
|
1879
|
+
Stop
|
|
1880
|
+
</button>
|
|
1881
|
+
)}
|
|
1882
|
+
</div>
|
|
1883
|
+
<div className="flex-1 min-h-0 relative">
|
|
1884
|
+
<SyntaxEditor
|
|
1885
|
+
value={serverCode}
|
|
1886
|
+
onChange={setServerCode}
|
|
1887
|
+
onCtrlEnter={() => void startServer()}
|
|
1888
|
+
language={serverLang}
|
|
1889
|
+
fontSize="12px"
|
|
1890
|
+
focusRingClass="ring-emerald-500/30"
|
|
1891
|
+
placeholder={
|
|
1892
|
+
"import express from 'express';\n// process.env.PORT is auto-injected\n// Ctrl+Enter to start"
|
|
1893
|
+
}
|
|
1894
|
+
/>
|
|
1895
|
+
</div>
|
|
1896
|
+
</div>
|
|
1897
|
+
)}
|
|
1898
|
+
{!serverCollapsed && !clientCollapsed && (
|
|
1899
|
+
<div
|
|
1900
|
+
className="w-1.5 bg-slate-700 hover:bg-violet-500/60 cursor-col-resize shrink-0 transition-colors"
|
|
1901
|
+
onMouseDown={(e) => {
|
|
1902
|
+
e.preventDefault();
|
|
1903
|
+
const containerW = sbxEditorRowRef.current?.offsetWidth ?? 1;
|
|
1904
|
+
sbxDividerDrag.current = {
|
|
1905
|
+
startX: e.clientX,
|
|
1906
|
+
startPct: sbxSplit,
|
|
1907
|
+
containerW,
|
|
1908
|
+
};
|
|
1909
|
+
}}
|
|
1910
|
+
/>
|
|
1911
|
+
)}
|
|
1912
|
+
|
|
1913
|
+
{/* ── Client pane ── */}
|
|
1914
|
+
{clientCollapsed ? (
|
|
1915
|
+
/* Collapsed: slim vertical tab always visible so user can re-open */
|
|
1916
|
+
<button
|
|
1917
|
+
type="button"
|
|
1918
|
+
onClick={() => setClientCollapsed(false)}
|
|
1919
|
+
className="flex flex-col items-center justify-center w-7 shrink-0 bg-slate-800/60 border-l border-slate-700 text-slate-500 hover:text-cyan-400 hover:bg-slate-800 transition-colors gap-1.5 py-3"
|
|
1920
|
+
title="Expand client"
|
|
1921
|
+
>
|
|
1922
|
+
<Globe className="w-3 h-3" />
|
|
1923
|
+
<span
|
|
1924
|
+
className="text-[8px] uppercase tracking-widest font-medium"
|
|
1925
|
+
style={{
|
|
1926
|
+
writingMode: "vertical-rl",
|
|
1927
|
+
transform: "rotate(180deg)",
|
|
1928
|
+
}}
|
|
1929
|
+
>
|
|
1930
|
+
Client
|
|
1931
|
+
</span>
|
|
1932
|
+
<ChevronLeft className="w-3 h-3" />
|
|
1933
|
+
</button>
|
|
1934
|
+
) : (
|
|
1935
|
+
<div
|
|
1936
|
+
className="flex flex-col min-w-0 overflow-hidden"
|
|
1937
|
+
style={{ flex: "1 1 0" }}
|
|
1938
|
+
>
|
|
1939
|
+
{/* Client panel header */}
|
|
1940
|
+
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800/60 border-b border-slate-700 shrink-0">
|
|
1941
|
+
<Globe className="w-3 h-3 text-cyan-500/70 shrink-0" />
|
|
1942
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-medium">
|
|
1943
|
+
Client
|
|
1944
|
+
</span>
|
|
1945
|
+
{/* Client type selector: JS / React / Next */}
|
|
1946
|
+
<div className="flex items-center rounded overflow-hidden border border-slate-700 text-[9px] ml-1 shrink-0">
|
|
1947
|
+
{(["script", "react", "nextjs"] as const).map((ct) => (
|
|
1948
|
+
<button
|
|
1949
|
+
key={ct}
|
|
1950
|
+
type="button"
|
|
1951
|
+
onClick={() => handleClientTypeChange(ct)}
|
|
1952
|
+
className={`px-1.5 py-0.5 transition-colors ${
|
|
1953
|
+
clientType === ct
|
|
1954
|
+
? "bg-slate-600 text-slate-200"
|
|
1955
|
+
: "text-slate-500 hover:text-slate-400"
|
|
1956
|
+
}`}
|
|
1957
|
+
>
|
|
1958
|
+
{ct === "script"
|
|
1959
|
+
? "JS"
|
|
1960
|
+
: ct === "react"
|
|
1961
|
+
? "React"
|
|
1962
|
+
: "Next"}
|
|
1963
|
+
</button>
|
|
1964
|
+
))}
|
|
1965
|
+
</div>
|
|
1966
|
+
<div className="flex-1" />
|
|
1967
|
+
<button
|
|
1968
|
+
type="button"
|
|
1969
|
+
onClick={() => {
|
|
1970
|
+
if (!serverCollapsed) setClientCollapsed((v) => !v);
|
|
1971
|
+
}}
|
|
1972
|
+
disabled={serverCollapsed}
|
|
1973
|
+
className="p-0.5 rounded text-slate-600 hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shrink-0"
|
|
1974
|
+
title="Collapse client"
|
|
1975
|
+
>
|
|
1976
|
+
<ChevronRight className="w-3 h-3" />
|
|
1977
|
+
</button>
|
|
1978
|
+
{/* Script mode: URL + lang toggle + Run */}
|
|
1979
|
+
{clientType === "script" && (
|
|
1980
|
+
<>
|
|
1981
|
+
{sandboxUrl && (
|
|
1982
|
+
<span
|
|
1983
|
+
className="text-[9px] font-mono text-slate-600 truncate max-w-[90px]"
|
|
1984
|
+
title={sandboxUrl}
|
|
1985
|
+
>
|
|
1986
|
+
{sandboxUrl}
|
|
1987
|
+
</span>
|
|
1988
|
+
)}
|
|
1989
|
+
<div className="flex items-center gap-0.5">
|
|
1990
|
+
{LANG_OPTIONS.map((l) => (
|
|
1991
|
+
<button
|
|
1992
|
+
key={l}
|
|
1993
|
+
type="button"
|
|
1994
|
+
onClick={() => setClientLang(l)}
|
|
1995
|
+
className={`px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wider font-mono transition-colors ${
|
|
1996
|
+
clientLang === l
|
|
1997
|
+
? "bg-violet-600/30 text-violet-300"
|
|
1998
|
+
: "text-slate-600 hover:text-slate-300"
|
|
1999
|
+
}`}
|
|
2000
|
+
>
|
|
2001
|
+
{l === "typescript" ? "TS" : "JS"}
|
|
2002
|
+
</button>
|
|
2003
|
+
))}
|
|
2004
|
+
</div>
|
|
2005
|
+
<button
|
|
2006
|
+
type="button"
|
|
2007
|
+
onClick={() => void runClient()}
|
|
2008
|
+
disabled={clientRunning || !serverRunning}
|
|
2009
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-cyan-600/20 hover:bg-cyan-600/40 text-cyan-400 disabled:opacity-40 transition-colors shrink-0"
|
|
2010
|
+
title={
|
|
2011
|
+
serverRunning
|
|
2012
|
+
? "Run client (Ctrl+Enter in editor)"
|
|
2013
|
+
: "Start the server first"
|
|
2014
|
+
}
|
|
2015
|
+
>
|
|
2016
|
+
{clientRunning ? (
|
|
2017
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
2018
|
+
) : (
|
|
2019
|
+
<Play className="w-3 h-3" />
|
|
2020
|
+
)}
|
|
2021
|
+
Run
|
|
2022
|
+
</button>
|
|
2023
|
+
</>
|
|
2024
|
+
)}
|
|
2025
|
+
{/* React/Next mode: optional URL + Preview button + edit/preview toggle for Next */}
|
|
2026
|
+
{(clientType === "react" || clientType === "nextjs") && (
|
|
2027
|
+
<>
|
|
2028
|
+
{sandboxUrl && (
|
|
2029
|
+
<span
|
|
2030
|
+
className="text-[9px] font-mono text-slate-600 truncate max-w-[80px]"
|
|
2031
|
+
title={sandboxUrl}
|
|
2032
|
+
>
|
|
2033
|
+
{sandboxUrl}
|
|
2034
|
+
</span>
|
|
2035
|
+
)}
|
|
2036
|
+
{/* React mode: simple preview button */}
|
|
2037
|
+
{clientType === "react" && (
|
|
2038
|
+
<button
|
|
2039
|
+
type="button"
|
|
2040
|
+
onClick={refreshPreview}
|
|
2041
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-cyan-600/20 hover:bg-cyan-600/40 text-cyan-400 transition-colors shrink-0"
|
|
2042
|
+
title="Render preview"
|
|
2043
|
+
>
|
|
2044
|
+
<Eye className="w-3 h-3" />
|
|
2045
|
+
Preview
|
|
2046
|
+
</button>
|
|
2047
|
+
)}
|
|
2048
|
+
{/* Next.js mode: start real server OR edit/preview toggle */}
|
|
2049
|
+
{clientType === "nextjs" && (
|
|
2050
|
+
<>
|
|
2051
|
+
{!nxSandboxUrl ? (
|
|
2052
|
+
<button
|
|
2053
|
+
type="button"
|
|
2054
|
+
onClick={() => void startNextjsServer()}
|
|
2055
|
+
disabled={nxStarting}
|
|
2056
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-cyan-600/20 hover:bg-cyan-600/40 text-cyan-400 disabled:opacity-50 transition-colors shrink-0"
|
|
2057
|
+
title="Start real Next.js dev server"
|
|
2058
|
+
>
|
|
2059
|
+
{nxStarting ? (
|
|
2060
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
2061
|
+
) : (
|
|
2062
|
+
<Play className="w-3 h-3" />
|
|
2063
|
+
)}
|
|
2064
|
+
{nxStarting ? "Starting…" : "Run Next.js"}
|
|
2065
|
+
</button>
|
|
2066
|
+
) : (
|
|
2067
|
+
<div className="flex items-center rounded overflow-hidden border border-slate-700/50 text-[9px] shrink-0">
|
|
2068
|
+
<button
|
|
2069
|
+
type="button"
|
|
2070
|
+
onClick={() => setReactClientTab("edit")}
|
|
2071
|
+
className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
|
|
2072
|
+
reactClientTab === "edit"
|
|
2073
|
+
? "bg-slate-700 text-slate-200"
|
|
2074
|
+
: "text-slate-500 hover:text-slate-400"
|
|
2075
|
+
}`}
|
|
2076
|
+
title="Edit code"
|
|
2077
|
+
>
|
|
2078
|
+
<Code2 className="w-2.5 h-2.5" />
|
|
2079
|
+
</button>
|
|
2080
|
+
<button
|
|
2081
|
+
type="button"
|
|
2082
|
+
onClick={() => setReactClientTab("preview")}
|
|
2083
|
+
className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
|
|
2084
|
+
reactClientTab === "preview"
|
|
2085
|
+
? "bg-slate-700 text-slate-200"
|
|
2086
|
+
: "text-slate-500 hover:text-slate-400"
|
|
2087
|
+
}`}
|
|
2088
|
+
title="Live preview"
|
|
2089
|
+
>
|
|
2090
|
+
<Eye className="w-2.5 h-2.5" />
|
|
2091
|
+
</button>
|
|
2092
|
+
</div>
|
|
2093
|
+
)}
|
|
2094
|
+
</>
|
|
2095
|
+
)}
|
|
2096
|
+
</>
|
|
2097
|
+
)}
|
|
2098
|
+
</div>
|
|
2099
|
+
|
|
2100
|
+
{/* File tabs row (React only — Next.js uses the tree sidebar) */}
|
|
2101
|
+
{clientType === "react" && (
|
|
2102
|
+
<div className="flex items-center gap-0.5 px-2 py-1 bg-slate-800/40 border-b border-slate-700 shrink-0 overflow-x-auto">
|
|
2103
|
+
{Object.keys(reactFiles).map((fname) => (
|
|
2104
|
+
<button
|
|
2105
|
+
key={fname}
|
|
2106
|
+
type="button"
|
|
2107
|
+
onClick={() => {
|
|
2108
|
+
setReactActiveFile(fname);
|
|
2109
|
+
setReactClientTab("edit");
|
|
2110
|
+
}}
|
|
2111
|
+
className={`flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-mono whitespace-nowrap transition-colors ${
|
|
2112
|
+
fname === reactActiveFile && reactClientTab === "edit"
|
|
2113
|
+
? "bg-slate-900 text-slate-200 border border-slate-600"
|
|
2114
|
+
: "text-slate-500 hover:text-slate-300 hover:bg-slate-800/50"
|
|
2115
|
+
}`}
|
|
2116
|
+
>
|
|
2117
|
+
{fname.includes("/") ? fname.split("/").pop() : fname}
|
|
2118
|
+
<span
|
|
2119
|
+
role="button"
|
|
2120
|
+
onClick={(e) => {
|
|
2121
|
+
e.stopPropagation();
|
|
2122
|
+
if (Object.keys(reactFiles).length <= 1) return;
|
|
2123
|
+
const remaining = Object.keys(reactFiles).filter(
|
|
2124
|
+
(f) => f !== fname,
|
|
2125
|
+
);
|
|
2126
|
+
setReactFiles((prev) => {
|
|
2127
|
+
const next = { ...prev };
|
|
2128
|
+
delete next[fname];
|
|
2129
|
+
return next;
|
|
2130
|
+
});
|
|
2131
|
+
if (reactActiveFile === fname)
|
|
2132
|
+
setReactActiveFile(remaining[0] ?? "");
|
|
2133
|
+
}}
|
|
2134
|
+
className="w-3 h-3 flex items-center justify-center text-slate-600 hover:text-red-400 rounded transition-colors"
|
|
2135
|
+
title="Delete file"
|
|
2136
|
+
>
|
|
2137
|
+
<X className="w-2.5 h-2.5" />
|
|
2138
|
+
</span>
|
|
2139
|
+
</button>
|
|
2140
|
+
))}
|
|
2141
|
+
{/* Add new file */}
|
|
2142
|
+
{reactAddingFile ? (
|
|
2143
|
+
<input
|
|
2144
|
+
autoFocus
|
|
2145
|
+
value={reactNewFileName}
|
|
2146
|
+
onChange={(e) => setReactNewFileName(e.target.value)}
|
|
2147
|
+
onBlur={() => {
|
|
2148
|
+
setReactAddingFile(false);
|
|
2149
|
+
setReactNewFileName("");
|
|
2150
|
+
}}
|
|
2151
|
+
onKeyDown={(e) => {
|
|
2152
|
+
if (e.key === "Enter") {
|
|
2153
|
+
e.preventDefault();
|
|
2154
|
+
const name = reactNewFileName.trim();
|
|
2155
|
+
if (name && !reactFiles[name]) {
|
|
2156
|
+
setReactFiles((prev) => ({
|
|
2157
|
+
...prev,
|
|
2158
|
+
[name]: newFileContent(name),
|
|
2159
|
+
}));
|
|
2160
|
+
setReactActiveFile(name);
|
|
2161
|
+
setReactClientTab("edit");
|
|
2162
|
+
}
|
|
2163
|
+
setReactAddingFile(false);
|
|
2164
|
+
setReactNewFileName("");
|
|
2165
|
+
} else if (e.key === "Escape") {
|
|
2166
|
+
setReactAddingFile(false);
|
|
2167
|
+
setReactNewFileName("");
|
|
2168
|
+
}
|
|
2169
|
+
}}
|
|
2170
|
+
placeholder="filename.tsx"
|
|
2171
|
+
className="w-28 bg-slate-900 border border-cyan-600/50 rounded px-1.5 py-0.5 text-[10px] font-mono text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500"
|
|
2172
|
+
/>
|
|
2173
|
+
) : (
|
|
2174
|
+
<button
|
|
2175
|
+
type="button"
|
|
2176
|
+
onClick={() => setReactAddingFile(true)}
|
|
2177
|
+
className="p-0.5 rounded text-slate-600 hover:text-cyan-400 transition-colors shrink-0"
|
|
2178
|
+
title="New file"
|
|
2179
|
+
>
|
|
2180
|
+
<FilePlus className="w-3 h-3" />
|
|
2181
|
+
</button>
|
|
2182
|
+
)}
|
|
2183
|
+
{/* Edit / Preview tab toggle */}
|
|
2184
|
+
<div className="ml-auto flex items-center rounded overflow-hidden border border-slate-700/50 text-[9px] shrink-0">
|
|
2185
|
+
<button
|
|
2186
|
+
type="button"
|
|
2187
|
+
onClick={() => setReactClientTab("edit")}
|
|
2188
|
+
className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
|
|
2189
|
+
reactClientTab === "edit"
|
|
2190
|
+
? "bg-slate-700 text-slate-200"
|
|
2191
|
+
: "text-slate-500 hover:text-slate-400"
|
|
2192
|
+
}`}
|
|
2193
|
+
title="Edit code"
|
|
2194
|
+
>
|
|
2195
|
+
<Code2 className="w-2.5 h-2.5" />
|
|
2196
|
+
</button>
|
|
2197
|
+
<button
|
|
2198
|
+
type="button"
|
|
2199
|
+
onClick={() => {
|
|
2200
|
+
if (!reactPreviewSrc) refreshPreview();
|
|
2201
|
+
else setReactClientTab("preview");
|
|
2202
|
+
}}
|
|
2203
|
+
className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
|
|
2204
|
+
reactClientTab === "preview"
|
|
2205
|
+
? "bg-slate-700 text-slate-200"
|
|
2206
|
+
: "text-slate-500 hover:text-slate-400"
|
|
2207
|
+
}`}
|
|
2208
|
+
title="Live preview"
|
|
2209
|
+
>
|
|
2210
|
+
<Eye className="w-2.5 h-2.5" />
|
|
2211
|
+
</button>
|
|
2212
|
+
</div>
|
|
2213
|
+
</div>
|
|
2214
|
+
)}
|
|
2215
|
+
|
|
2216
|
+
{/* Client body */}
|
|
2217
|
+
<div
|
|
2218
|
+
className={`flex-1 min-h-0 ${clientType === "nextjs" ? "flex flex-row" : "relative"}`}
|
|
2219
|
+
>
|
|
2220
|
+
{/* ── Next.js VS Code-style file tree sidebar ── */}
|
|
2221
|
+
{clientType === "nextjs" && (
|
|
2222
|
+
<div className="w-36 shrink-0 flex flex-col border-r border-slate-700 bg-slate-900/60 overflow-y-auto">
|
|
2223
|
+
{/* Sidebar header */}
|
|
2224
|
+
<div className="flex items-center justify-between px-2 py-1.5 border-b border-slate-700/60">
|
|
2225
|
+
<span className="text-[9px] uppercase tracking-widest text-slate-500 font-semibold select-none">
|
|
2226
|
+
Explorer
|
|
2227
|
+
</span>
|
|
2228
|
+
{/* Add file button */}
|
|
2229
|
+
{reactAddingFile ? (
|
|
2230
|
+
<input
|
|
2231
|
+
autoFocus
|
|
2232
|
+
value={reactNewFileName}
|
|
2233
|
+
onChange={(e) =>
|
|
2234
|
+
setReactNewFileName(e.target.value)
|
|
2235
|
+
}
|
|
2236
|
+
onBlur={() => {
|
|
2237
|
+
setReactAddingFile(false);
|
|
2238
|
+
setReactNewFileName("");
|
|
2239
|
+
}}
|
|
2240
|
+
onKeyDown={(e) => {
|
|
2241
|
+
if (e.key === "Enter") {
|
|
2242
|
+
e.preventDefault();
|
|
2243
|
+
const name = reactNewFileName.trim();
|
|
2244
|
+
if (name && !reactFiles[name]) {
|
|
2245
|
+
setReactFiles((prev) => ({
|
|
2246
|
+
...prev,
|
|
2247
|
+
[name]: newFileContent(name),
|
|
2248
|
+
}));
|
|
2249
|
+
setReactActiveFile(name);
|
|
2250
|
+
setReactClientTab("edit");
|
|
2251
|
+
}
|
|
2252
|
+
setReactAddingFile(false);
|
|
2253
|
+
setReactNewFileName("");
|
|
2254
|
+
} else if (e.key === "Escape") {
|
|
2255
|
+
setReactAddingFile(false);
|
|
2256
|
+
setReactNewFileName("");
|
|
2257
|
+
}
|
|
2258
|
+
}}
|
|
2259
|
+
placeholder="app/new.tsx"
|
|
2260
|
+
className="w-full bg-slate-800 border border-cyan-600/50 rounded px-1 py-0.5 text-[9px] font-mono text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500"
|
|
2261
|
+
/>
|
|
2262
|
+
) : (
|
|
2263
|
+
<button
|
|
2264
|
+
type="button"
|
|
2265
|
+
onClick={() => setReactAddingFile(true)}
|
|
2266
|
+
className="p-0.5 rounded text-slate-600 hover:text-cyan-400 transition-colors"
|
|
2267
|
+
title="New file (use paths like app/dashboard/page.tsx)"
|
|
2268
|
+
>
|
|
2269
|
+
<FilePlus className="w-3 h-3" />
|
|
2270
|
+
</button>
|
|
2271
|
+
)}
|
|
2272
|
+
</div>
|
|
2273
|
+
{/* Tree nodes */}
|
|
2274
|
+
<div className="flex-1 py-1">
|
|
2275
|
+
{(() => {
|
|
2276
|
+
// Build a folder → file[] map, plus root-level files
|
|
2277
|
+
const allFiles = Object.keys(reactFiles).sort(
|
|
2278
|
+
(a, b) => {
|
|
2279
|
+
const ad = a.split("/").length;
|
|
2280
|
+
const bd = b.split("/").length;
|
|
2281
|
+
return ad !== bd ? ad - bd : a.localeCompare(b);
|
|
2282
|
+
},
|
|
2283
|
+
);
|
|
2284
|
+
// Collect unique top-level folders (first path segment for nested files)
|
|
2285
|
+
const folders = Array.from(
|
|
2286
|
+
new Set(
|
|
2287
|
+
allFiles
|
|
2288
|
+
.filter((f) => f.includes("/"))
|
|
2289
|
+
.map((f) => f.split("/")[0]),
|
|
2290
|
+
),
|
|
2291
|
+
).sort();
|
|
2292
|
+
const rootFiles = allFiles.filter(
|
|
2293
|
+
(f) => !f.includes("/"),
|
|
2294
|
+
);
|
|
2295
|
+
|
|
2296
|
+
const fileIcon = (name: string) => {
|
|
2297
|
+
if (name.endsWith(".tsx") || name.endsWith(".jsx"))
|
|
2298
|
+
return (
|
|
2299
|
+
<span className="text-cyan-400 mr-1 text-[9px]">
|
|
2300
|
+
⚛
|
|
2301
|
+
</span>
|
|
2302
|
+
);
|
|
2303
|
+
if (name.endsWith(".ts") || name.endsWith(".js"))
|
|
2304
|
+
return (
|
|
2305
|
+
<span className="text-yellow-400 mr-1 text-[9px]">
|
|
2306
|
+
JS
|
|
2307
|
+
</span>
|
|
2308
|
+
);
|
|
2309
|
+
return (
|
|
2310
|
+
<span className="text-slate-500 mr-1 text-[9px]">
|
|
2311
|
+
f
|
|
2312
|
+
</span>
|
|
2313
|
+
);
|
|
2314
|
+
};
|
|
2315
|
+
|
|
2316
|
+
const renderFile = (path: string, indent = 0) => (
|
|
2317
|
+
<div key={path} className="group flex items-center">
|
|
2318
|
+
<button
|
|
2319
|
+
type="button"
|
|
2320
|
+
onClick={() => {
|
|
2321
|
+
setReactActiveFile(path);
|
|
2322
|
+
setReactClientTab("edit");
|
|
2323
|
+
}}
|
|
2324
|
+
style={{ paddingLeft: `${8 + indent * 10}px` }}
|
|
2325
|
+
className={`flex-1 flex items-center gap-0.5 py-0.5 pr-1 text-left text-[10px] font-mono truncate transition-colors ${
|
|
2326
|
+
path === reactActiveFile &&
|
|
2327
|
+
reactClientTab === "edit"
|
|
2328
|
+
? "bg-slate-700 text-slate-100"
|
|
2329
|
+
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
2330
|
+
}`}
|
|
2331
|
+
title={path}
|
|
2332
|
+
>
|
|
2333
|
+
{fileIcon(path.split("/").pop() ?? path)}
|
|
2334
|
+
<span className="truncate">
|
|
2335
|
+
{path.split("/").pop()}
|
|
2336
|
+
</span>
|
|
2337
|
+
</button>
|
|
2338
|
+
<button
|
|
2339
|
+
type="button"
|
|
2340
|
+
onClick={() => {
|
|
2341
|
+
if (Object.keys(reactFiles).length <= 1)
|
|
2342
|
+
return;
|
|
2343
|
+
const remaining = Object.keys(
|
|
2344
|
+
reactFiles,
|
|
2345
|
+
).filter((f) => f !== path);
|
|
2346
|
+
setReactFiles((prev) => {
|
|
2347
|
+
const next = { ...prev };
|
|
2348
|
+
delete next[path];
|
|
2349
|
+
return next;
|
|
2350
|
+
});
|
|
2351
|
+
if (reactActiveFile === path)
|
|
2352
|
+
setReactActiveFile(remaining[0] ?? "");
|
|
2353
|
+
}}
|
|
2354
|
+
className="opacity-0 group-hover:opacity-100 p-0.5 mr-1 rounded text-slate-600 hover:text-red-400 transition-all shrink-0"
|
|
2355
|
+
title="Delete file"
|
|
2356
|
+
>
|
|
2357
|
+
<X className="w-2.5 h-2.5" />
|
|
2358
|
+
</button>
|
|
2359
|
+
</div>
|
|
2360
|
+
);
|
|
2361
|
+
|
|
2362
|
+
const renderFolder = (folder: string) => {
|
|
2363
|
+
const isOpen = !collapsedFolders.has(folder);
|
|
2364
|
+
const children = allFiles.filter((f) => {
|
|
2365
|
+
const parts = f.split("/");
|
|
2366
|
+
return parts[0] === folder && parts.length >= 2;
|
|
2367
|
+
});
|
|
2368
|
+
// Build sub-folder groups within this folder
|
|
2369
|
+
const subFolders = Array.from(
|
|
2370
|
+
new Set(
|
|
2371
|
+
children
|
|
2372
|
+
.filter((f) => f.split("/").length > 2)
|
|
2373
|
+
.map((f) =>
|
|
2374
|
+
f.split("/").slice(0, 2).join("/"),
|
|
2375
|
+
),
|
|
2376
|
+
),
|
|
2377
|
+
).sort();
|
|
2378
|
+
const directFiles = children.filter(
|
|
2379
|
+
(f) => f.split("/").length === 2,
|
|
2380
|
+
);
|
|
2381
|
+
|
|
2382
|
+
return (
|
|
2383
|
+
<div key={folder}>
|
|
2384
|
+
{/* Folder row */}
|
|
2385
|
+
<button
|
|
2386
|
+
type="button"
|
|
2387
|
+
onClick={() =>
|
|
2388
|
+
setCollapsedFolders((prev) => {
|
|
2389
|
+
const next = new Set(prev);
|
|
2390
|
+
if (next.has(folder)) next.delete(folder);
|
|
2391
|
+
else next.add(folder);
|
|
2392
|
+
return next;
|
|
2393
|
+
})
|
|
2394
|
+
}
|
|
2395
|
+
className="w-full flex items-center gap-0.5 px-2 py-0.5 text-left text-[10px] font-mono text-slate-300 hover:bg-slate-800 transition-colors select-none"
|
|
2396
|
+
>
|
|
2397
|
+
{isOpen ? (
|
|
2398
|
+
<ChevronDown className="w-2.5 h-2.5 shrink-0 text-slate-500" />
|
|
2399
|
+
) : (
|
|
2400
|
+
<ChevronRight className="w-2.5 h-2.5 shrink-0 text-slate-500" />
|
|
2401
|
+
)}
|
|
2402
|
+
<span className="text-yellow-300/80 mr-0.5">
|
|
2403
|
+
📁
|
|
2404
|
+
</span>
|
|
2405
|
+
<span className="truncate">{folder}/</span>
|
|
2406
|
+
</button>
|
|
2407
|
+
{/* Children */}
|
|
2408
|
+
{isOpen && (
|
|
2409
|
+
<div>
|
|
2410
|
+
{subFolders.map((sf) => {
|
|
2411
|
+
const sfIsOpen =
|
|
2412
|
+
!collapsedFolders.has(sf);
|
|
2413
|
+
const sfChildren = allFiles.filter(
|
|
2414
|
+
(f) =>
|
|
2415
|
+
f.startsWith(sf + "/") &&
|
|
2416
|
+
f.split("/").length ===
|
|
2417
|
+
sf.split("/").length + 1,
|
|
2418
|
+
);
|
|
2419
|
+
const sfKey = sf.split("/").pop() ?? sf;
|
|
2420
|
+
return (
|
|
2421
|
+
<div key={sf}>
|
|
2422
|
+
<button
|
|
2423
|
+
type="button"
|
|
2424
|
+
onClick={() =>
|
|
2425
|
+
setCollapsedFolders((prev) => {
|
|
2426
|
+
const next = new Set(prev);
|
|
2427
|
+
if (next.has(sf))
|
|
2428
|
+
next.delete(sf);
|
|
2429
|
+
else next.add(sf);
|
|
2430
|
+
return next;
|
|
2431
|
+
})
|
|
2432
|
+
}
|
|
2433
|
+
className="w-full flex items-center gap-0.5 pl-[18px] pr-2 py-0.5 text-left text-[10px] font-mono text-slate-300 hover:bg-slate-800 transition-colors select-none"
|
|
2434
|
+
>
|
|
2435
|
+
{sfIsOpen ? (
|
|
2436
|
+
<ChevronDown className="w-2.5 h-2.5 shrink-0 text-slate-500" />
|
|
2437
|
+
) : (
|
|
2438
|
+
<ChevronRight className="w-2.5 h-2.5 shrink-0 text-slate-500" />
|
|
2439
|
+
)}
|
|
2440
|
+
<span className="text-yellow-300/80 mr-0.5">
|
|
2441
|
+
📁
|
|
2442
|
+
</span>
|
|
2443
|
+
<span className="truncate">
|
|
2444
|
+
{sfKey}/
|
|
2445
|
+
</span>
|
|
2446
|
+
</button>
|
|
2447
|
+
{sfIsOpen &&
|
|
2448
|
+
sfChildren.map((f) =>
|
|
2449
|
+
renderFile(f, 3),
|
|
2450
|
+
)}
|
|
2451
|
+
</div>
|
|
2452
|
+
);
|
|
2453
|
+
})}
|
|
2454
|
+
{directFiles.map((f) => renderFile(f, 1))}
|
|
2455
|
+
</div>
|
|
2456
|
+
)}
|
|
2457
|
+
</div>
|
|
2458
|
+
);
|
|
2459
|
+
};
|
|
2460
|
+
|
|
2461
|
+
return (
|
|
2462
|
+
<>
|
|
2463
|
+
{folders.map(renderFolder)}
|
|
2464
|
+
{rootFiles.map((f) => renderFile(f, 0))}
|
|
2465
|
+
</>
|
|
2466
|
+
);
|
|
2467
|
+
})()}
|
|
2468
|
+
</div>
|
|
2469
|
+
</div>
|
|
2470
|
+
)}
|
|
2471
|
+
|
|
2472
|
+
{/* ── Editor / Preview area ── */}
|
|
2473
|
+
<div
|
|
2474
|
+
className={`${clientType === "nextjs" ? "flex-1 min-w-0 relative" : "absolute inset-0"}`}
|
|
2475
|
+
>
|
|
2476
|
+
{clientType === "script" ? (
|
|
2477
|
+
<SyntaxEditor
|
|
2478
|
+
value={clientCode}
|
|
2479
|
+
onChange={setClientCode}
|
|
2480
|
+
onCtrlEnter={() => {
|
|
2481
|
+
if (serverRunning) void runClient();
|
|
2482
|
+
}}
|
|
2483
|
+
language={clientLang}
|
|
2484
|
+
fontSize="12px"
|
|
2485
|
+
focusRingClass="ring-cyan-500/30"
|
|
2486
|
+
placeholder={
|
|
2487
|
+
"// SANDBOX_URL is injected automatically\n// Start the server first, then Ctrl+Enter to run"
|
|
2488
|
+
}
|
|
2489
|
+
/>
|
|
2490
|
+
) : reactClientTab === "edit" ? (
|
|
2491
|
+
<SyntaxEditor
|
|
2492
|
+
key={reactActiveFile}
|
|
2493
|
+
value={reactFiles[reactActiveFile] ?? ""}
|
|
2494
|
+
onChange={(val) =>
|
|
2495
|
+
setReactFiles((prev) => ({
|
|
2496
|
+
...prev,
|
|
2497
|
+
[reactActiveFile]: val,
|
|
2498
|
+
}))
|
|
2499
|
+
}
|
|
2500
|
+
language={
|
|
2501
|
+
reactActiveFile.endsWith(".ts") ||
|
|
2502
|
+
reactActiveFile.endsWith(".tsx")
|
|
2503
|
+
? "typescript"
|
|
2504
|
+
: "javascript"
|
|
2505
|
+
}
|
|
2506
|
+
fontSize="12px"
|
|
2507
|
+
focusRingClass="ring-cyan-500/30"
|
|
2508
|
+
placeholder={`// ${reactActiveFile}\n`}
|
|
2509
|
+
/>
|
|
2510
|
+
) : (
|
|
2511
|
+
// Preview area — URL bar for Next.js, plain iframe for React
|
|
2512
|
+
<div className="w-full h-full flex flex-col">
|
|
2513
|
+
{clientType === "nextjs" && (
|
|
2514
|
+
<div className="flex items-center gap-1 px-2 py-1 bg-slate-800 border-b border-slate-700 shrink-0">
|
|
2515
|
+
{/* Back */}
|
|
2516
|
+
<button
|
|
2517
|
+
type="button"
|
|
2518
|
+
disabled={reactNavIndex <= 0}
|
|
2519
|
+
onClick={() => {
|
|
2520
|
+
const idx = reactNavIndex - 1;
|
|
2521
|
+
const path = reactNavHistory[idx] ?? "/";
|
|
2522
|
+
setReactNavIndex(idx);
|
|
2523
|
+
setReactPreviewPath(path);
|
|
2524
|
+
setReactNavInput(path);
|
|
2525
|
+
if (nxSandboxUrl) {
|
|
2526
|
+
if (nxIframeRef.current)
|
|
2527
|
+
nxIframeRef.current.src =
|
|
2528
|
+
nxSandboxUrl + path;
|
|
2529
|
+
} else {
|
|
2530
|
+
refreshPreview(path);
|
|
2531
|
+
}
|
|
2532
|
+
}}
|
|
2533
|
+
className="p-0.5 rounded text-slate-500 hover:text-slate-200 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shrink-0"
|
|
2534
|
+
title="Back"
|
|
2535
|
+
>
|
|
2536
|
+
<ChevronLeft className="w-3.5 h-3.5" />
|
|
2537
|
+
</button>
|
|
2538
|
+
{/* Forward */}
|
|
2539
|
+
<button
|
|
2540
|
+
type="button"
|
|
2541
|
+
disabled={
|
|
2542
|
+
reactNavIndex >= reactNavHistory.length - 1
|
|
2543
|
+
}
|
|
2544
|
+
onClick={() => {
|
|
2545
|
+
const idx = reactNavIndex + 1;
|
|
2546
|
+
const path = reactNavHistory[idx] ?? "/";
|
|
2547
|
+
setReactNavIndex(idx);
|
|
2548
|
+
setReactPreviewPath(path);
|
|
2549
|
+
setReactNavInput(path);
|
|
2550
|
+
if (nxSandboxUrl) {
|
|
2551
|
+
if (nxIframeRef.current)
|
|
2552
|
+
nxIframeRef.current.src =
|
|
2553
|
+
nxSandboxUrl + path;
|
|
2554
|
+
} else {
|
|
2555
|
+
refreshPreview(path);
|
|
2556
|
+
}
|
|
2557
|
+
}}
|
|
2558
|
+
className="p-0.5 rounded text-slate-500 hover:text-slate-200 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shrink-0"
|
|
2559
|
+
title="Forward"
|
|
2560
|
+
>
|
|
2561
|
+
<ChevronRight className="w-3.5 h-3.5" />
|
|
2562
|
+
</button>
|
|
2563
|
+
{/* Refresh */}
|
|
2564
|
+
<button
|
|
2565
|
+
type="button"
|
|
2566
|
+
onClick={() => {
|
|
2567
|
+
if (nxSandboxUrl && nxIframeRef.current) {
|
|
2568
|
+
nxIframeRef.current.src =
|
|
2569
|
+
nxIframeRef.current.src;
|
|
2570
|
+
} else {
|
|
2571
|
+
refreshPreview();
|
|
2572
|
+
}
|
|
2573
|
+
}}
|
|
2574
|
+
className="p-0.5 rounded text-slate-500 hover:text-slate-200 transition-colors shrink-0"
|
|
2575
|
+
title="Refresh"
|
|
2576
|
+
>
|
|
2577
|
+
<svg
|
|
2578
|
+
className="w-3 h-3"
|
|
2579
|
+
viewBox="0 0 16 16"
|
|
2580
|
+
fill="currentColor"
|
|
2581
|
+
>
|
|
2582
|
+
<path d="M13.65 2.35A8 8 0 1 0 15 8h-2a6 6 0 1 1-1.1-3.48L10 6h5V1l-1.35 1.35z" />
|
|
2583
|
+
</svg>
|
|
2584
|
+
</button>
|
|
2585
|
+
{/* URL bar */}
|
|
2586
|
+
<form
|
|
2587
|
+
className="flex-1 flex items-center gap-1 bg-slate-900 border border-slate-600 rounded px-2 py-0.5 focus-within:border-blue-500/60 transition-colors"
|
|
2588
|
+
onSubmit={(e) => {
|
|
2589
|
+
e.preventDefault();
|
|
2590
|
+
const path = reactNavInput.startsWith("/")
|
|
2591
|
+
? reactNavInput
|
|
2592
|
+
: "/" + reactNavInput;
|
|
2593
|
+
setReactPreviewPath(path);
|
|
2594
|
+
setReactNavHistory((prev) => [
|
|
2595
|
+
...prev.slice(0, reactNavIndex + 1),
|
|
2596
|
+
path,
|
|
2597
|
+
]);
|
|
2598
|
+
setReactNavIndex((i) => i + 1);
|
|
2599
|
+
if (nxSandboxUrl) {
|
|
2600
|
+
if (nxIframeRef.current)
|
|
2601
|
+
nxIframeRef.current.src =
|
|
2602
|
+
nxSandboxUrl + path;
|
|
2603
|
+
} else {
|
|
2604
|
+
navigatePreview(path);
|
|
2605
|
+
}
|
|
2606
|
+
}}
|
|
2607
|
+
>
|
|
2608
|
+
<span className="text-slate-600 text-[9px] font-mono select-none shrink-0">
|
|
2609
|
+
{nxSandboxUrl
|
|
2610
|
+
? `localhost:${nxSandboxUrl.split(":").pop()}`
|
|
2611
|
+
: "localhost:3000"}
|
|
2612
|
+
</span>
|
|
2613
|
+
<input
|
|
2614
|
+
value={reactNavInput}
|
|
2615
|
+
onChange={(e) =>
|
|
2616
|
+
setReactNavInput(e.target.value)
|
|
2617
|
+
}
|
|
2618
|
+
onFocus={(e) => e.target.select()}
|
|
2619
|
+
className="flex-1 bg-transparent text-[11px] font-mono text-slate-200 outline-none placeholder-slate-600 min-w-0"
|
|
2620
|
+
placeholder="/"
|
|
2621
|
+
spellCheck={false}
|
|
2622
|
+
/>
|
|
2623
|
+
</form>
|
|
2624
|
+
{/* Status indicator */}
|
|
2625
|
+
{nxSandboxUrl ? (
|
|
2626
|
+
<span className="text-[9px] font-mono text-green-400 shrink-0">
|
|
2627
|
+
● live
|
|
2628
|
+
</span>
|
|
2629
|
+
) : (
|
|
2630
|
+
(() => {
|
|
2631
|
+
const resolved = resolveNextjsEntry(
|
|
2632
|
+
reactFiles,
|
|
2633
|
+
reactPreviewPath,
|
|
2634
|
+
);
|
|
2635
|
+
return resolved ? (
|
|
2636
|
+
<span
|
|
2637
|
+
className="text-[9px] font-mono text-slate-600 truncate max-w-[100px] shrink-0"
|
|
2638
|
+
title={resolved}
|
|
2639
|
+
>
|
|
2640
|
+
→ {resolved}
|
|
2641
|
+
</span>
|
|
2642
|
+
) : (
|
|
2643
|
+
<span className="text-[9px] font-mono text-red-400 shrink-0">
|
|
2644
|
+
404
|
|
2645
|
+
</span>
|
|
2646
|
+
);
|
|
2647
|
+
})()
|
|
2648
|
+
)}
|
|
2649
|
+
</div>
|
|
2650
|
+
)}
|
|
2651
|
+
{/* Error banner */}
|
|
2652
|
+
{nxError && (
|
|
2653
|
+
<div className="text-[10px] text-red-400 bg-red-950/40 border-b border-red-800 px-3 py-1.5 shrink-0 font-mono">
|
|
2654
|
+
{nxError}
|
|
2655
|
+
</div>
|
|
2656
|
+
)}
|
|
2657
|
+
{/* Starting overlay */}
|
|
2658
|
+
{nxStarting && (
|
|
2659
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-400 text-sm bg-slate-950">
|
|
2660
|
+
<Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
|
|
2661
|
+
<p className="text-[12px]">
|
|
2662
|
+
Starting Next.js dev server…
|
|
2663
|
+
</p>
|
|
2664
|
+
<p className="text-[10px] text-slate-600">
|
|
2665
|
+
This takes ~10 seconds on the first run
|
|
2666
|
+
</p>
|
|
2667
|
+
</div>
|
|
2668
|
+
)}
|
|
2669
|
+
{/* Real Next.js iframe */}
|
|
2670
|
+
{!nxStarting && nxSandboxUrl && (
|
|
2671
|
+
<iframe
|
|
2672
|
+
ref={nxIframeRef}
|
|
2673
|
+
src={nxSandboxUrl + reactPreviewPath}
|
|
2674
|
+
className="flex-1 min-h-0 w-full border-0 bg-white"
|
|
2675
|
+
title="Next.js Preview"
|
|
2676
|
+
onLoad={() => {
|
|
2677
|
+
// Try to read the iframe path (may be blocked cross-origin)
|
|
2678
|
+
try {
|
|
2679
|
+
const p =
|
|
2680
|
+
nxIframeRef.current?.contentWindow?.location
|
|
2681
|
+
.pathname;
|
|
2682
|
+
if (p) {
|
|
2683
|
+
setReactPreviewPath(p);
|
|
2684
|
+
setReactNavInput(p);
|
|
2685
|
+
}
|
|
2686
|
+
} catch {
|
|
2687
|
+
// cross-origin — ignore
|
|
2688
|
+
}
|
|
2689
|
+
}}
|
|
2690
|
+
/>
|
|
2691
|
+
)}
|
|
2692
|
+
{/* Simulation iframe (when no real server) */}
|
|
2693
|
+
{!nxStarting && !nxSandboxUrl && (
|
|
2694
|
+
<iframe
|
|
2695
|
+
srcDoc={reactPreviewSrc ?? ""}
|
|
2696
|
+
sandbox="allow-scripts"
|
|
2697
|
+
className="flex-1 min-h-0 w-full border-0 bg-white"
|
|
2698
|
+
title="React Preview"
|
|
2699
|
+
/>
|
|
2700
|
+
)}
|
|
2701
|
+
</div>
|
|
2702
|
+
)}
|
|
2703
|
+
</div>
|
|
2704
|
+
</div>
|
|
2705
|
+
</div>
|
|
2706
|
+
)}
|
|
2707
|
+
</div>
|
|
2708
|
+
|
|
2709
|
+
{/* Horizontal resizer (output) */}
|
|
2710
|
+
<div
|
|
2711
|
+
className="h-1.5 bg-slate-700 hover:bg-violet-500/60 cursor-row-resize shrink-0 transition-colors"
|
|
2712
|
+
onMouseDown={(e) => {
|
|
2713
|
+
e.preventDefault();
|
|
2714
|
+
sbxOutputDrag.current = {
|
|
2715
|
+
startY: e.clientY,
|
|
2716
|
+
startH: outputCollapsed ? 0 : sbxOutputH,
|
|
2717
|
+
};
|
|
2718
|
+
if (outputCollapsed) setOutputCollapsed(false);
|
|
2719
|
+
}}
|
|
2720
|
+
/>
|
|
2721
|
+
|
|
2722
|
+
{/* Sandbox output pane */}
|
|
2723
|
+
<div
|
|
2724
|
+
className="bg-slate-950 flex flex-col overflow-hidden shrink-0 transition-[height]"
|
|
2725
|
+
style={{ height: outputCollapsed ? 0 : sbxOutputH }}
|
|
2726
|
+
>
|
|
2727
|
+
{/* Tab bar */}
|
|
2728
|
+
<div className="flex items-center gap-0 px-1 bg-slate-900 border-b border-slate-800 shrink-0">
|
|
2729
|
+
<button
|
|
2730
|
+
type="button"
|
|
2731
|
+
onClick={() => setSbxBottomTab("output")}
|
|
2732
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] uppercase tracking-wider font-medium border-b-2 transition-colors ${
|
|
2733
|
+
sbxBottomTab === "output"
|
|
2734
|
+
? "border-emerald-500 text-emerald-300"
|
|
2735
|
+
: "border-transparent text-slate-500 hover:text-slate-300"
|
|
2736
|
+
}`}
|
|
2737
|
+
>
|
|
2738
|
+
{(serverStarting || clientRunning) &&
|
|
2739
|
+
sbxBottomTab !== "output" ? (
|
|
2740
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
2741
|
+
) : null}
|
|
2742
|
+
Output
|
|
2743
|
+
</button>
|
|
2744
|
+
<button
|
|
2745
|
+
type="button"
|
|
2746
|
+
onClick={() => {
|
|
2747
|
+
setSbxBottomTab("chat");
|
|
2748
|
+
setTimeout(() => sbxChatInputRef.current?.focus(), 30);
|
|
2749
|
+
}}
|
|
2750
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] uppercase tracking-wider font-medium border-b-2 transition-colors ${
|
|
2751
|
+
sbxBottomTab === "chat"
|
|
2752
|
+
? "border-violet-500 text-violet-300"
|
|
2753
|
+
: "border-transparent text-slate-500 hover:text-slate-300"
|
|
2754
|
+
}`}
|
|
2755
|
+
>
|
|
2756
|
+
<MessageSquare className="w-3 h-3" />
|
|
2757
|
+
Chat
|
|
2758
|
+
</button>
|
|
2759
|
+
<div className="flex-1" />
|
|
2760
|
+
{sbxBottomTab === "output" &&
|
|
2761
|
+
(serverStarting || clientRunning) && (
|
|
2762
|
+
<Loader2 className="w-3 h-3 text-emerald-400 animate-spin mr-1" />
|
|
2763
|
+
)}
|
|
2764
|
+
{sbxBottomTab === "output" && sandboxOutput.length > 0 && (
|
|
2765
|
+
<div className="flex items-center gap-1 mr-1">
|
|
2766
|
+
<button
|
|
2767
|
+
type="button"
|
|
2768
|
+
onClick={() =>
|
|
2769
|
+
navigator.clipboard.writeText(
|
|
2770
|
+
sandboxOutput.map((l) => l.text).join("\n"),
|
|
2771
|
+
)
|
|
2772
|
+
}
|
|
2773
|
+
className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
|
|
2774
|
+
title="Copy output"
|
|
2775
|
+
>
|
|
2776
|
+
<Copy className="w-3 h-3" />
|
|
2777
|
+
</button>
|
|
2778
|
+
<button
|
|
2779
|
+
type="button"
|
|
2780
|
+
onClick={() => setSandboxOutput([])}
|
|
2781
|
+
className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
|
|
2782
|
+
title="Clear output"
|
|
2783
|
+
>
|
|
2784
|
+
<Trash2 className="w-3 h-3" />
|
|
2785
|
+
</button>
|
|
2786
|
+
</div>
|
|
2787
|
+
)}
|
|
2788
|
+
{sbxBottomTab === "chat" && sbxChatMessages.length > 0 && (
|
|
2789
|
+
<button
|
|
2790
|
+
type="button"
|
|
2791
|
+
onClick={() => setSbxChatMessages([])}
|
|
2792
|
+
className="mr-2 text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
|
|
2793
|
+
>
|
|
2794
|
+
clear
|
|
2795
|
+
</button>
|
|
2796
|
+
)}
|
|
2797
|
+
<button
|
|
2798
|
+
type="button"
|
|
2799
|
+
className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors mr-1"
|
|
2800
|
+
onClick={() => setOutputCollapsed((v) => !v)}
|
|
2801
|
+
title={outputCollapsed ? "Expand" : "Collapse"}
|
|
2802
|
+
>
|
|
2803
|
+
{outputCollapsed ? (
|
|
2804
|
+
<ChevronUp className="w-3 h-3" />
|
|
2805
|
+
) : (
|
|
2806
|
+
<ChevronDown className="w-3 h-3" />
|
|
2807
|
+
)}
|
|
2808
|
+
</button>
|
|
2809
|
+
</div>
|
|
2810
|
+
|
|
2811
|
+
{/* Output tab */}
|
|
2812
|
+
{sbxBottomTab === "output" && (
|
|
2813
|
+
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[12px] leading-relaxed">
|
|
2814
|
+
{sandboxOutput.length === 0 &&
|
|
2815
|
+
!serverStarting &&
|
|
2816
|
+
!clientRunning && (
|
|
2817
|
+
<span className="text-slate-600">
|
|
2818
|
+
Start the server, then run the client
|
|
2819
|
+
</span>
|
|
2820
|
+
)}
|
|
2821
|
+
{sandboxOutput.map((line, i) => (
|
|
2822
|
+
<div key={i} className="flex items-start gap-2">
|
|
2823
|
+
<span
|
|
2824
|
+
className={`shrink-0 text-[9px] font-bold mt-0.5 w-7 text-right ${
|
|
2825
|
+
line.source === "server"
|
|
2826
|
+
? "text-emerald-600"
|
|
2827
|
+
: line.source === "client"
|
|
2828
|
+
? "text-cyan-600"
|
|
2829
|
+
: "text-slate-700"
|
|
2830
|
+
}`}
|
|
2831
|
+
>
|
|
2832
|
+
{line.source === "server"
|
|
2833
|
+
? "srv"
|
|
2834
|
+
: line.source === "client"
|
|
2835
|
+
? "cli"
|
|
2836
|
+
: "···"}
|
|
2837
|
+
</span>
|
|
2838
|
+
<span
|
|
2839
|
+
className={
|
|
2840
|
+
line.kind === "stderr"
|
|
2841
|
+
? "text-red-400 whitespace-pre-wrap"
|
|
2842
|
+
: line.kind === "warn"
|
|
2843
|
+
? "text-amber-400 whitespace-pre-wrap"
|
|
2844
|
+
: line.kind === "info"
|
|
2845
|
+
? "text-slate-500 italic whitespace-pre-wrap"
|
|
2846
|
+
: "text-slate-200 whitespace-pre-wrap"
|
|
2847
|
+
}
|
|
2848
|
+
>
|
|
2849
|
+
{line.text}
|
|
2850
|
+
</span>
|
|
2851
|
+
</div>
|
|
2852
|
+
))}
|
|
2853
|
+
<div ref={outputEndRef} />
|
|
2854
|
+
</div>
|
|
2855
|
+
)}
|
|
2856
|
+
|
|
2857
|
+
{/* Chat tab */}
|
|
2858
|
+
{sbxBottomTab === "chat" && (
|
|
2859
|
+
<>
|
|
2860
|
+
<div
|
|
2861
|
+
ref={sbxChatScrollRef}
|
|
2862
|
+
className="flex-1 overflow-y-auto px-3 py-2 space-y-3"
|
|
2863
|
+
>
|
|
2864
|
+
{sbxChatMessages.length === 0 && (
|
|
2865
|
+
<p className="text-xs text-slate-600 pt-1">
|
|
2866
|
+
Ask anything about your code —{" "}
|
|
2867
|
+
{clientType === "react" || clientType === "nextjs" ? (
|
|
2868
|
+
<span className="text-slate-500">
|
|
2869
|
+
"Why does my useEffect run twice?"
|
|
2870
|
+
</span>
|
|
2871
|
+
) : (
|
|
2872
|
+
<span className="text-slate-500">
|
|
2873
|
+
"Why is fetch failing?"
|
|
2874
|
+
</span>
|
|
2875
|
+
)}
|
|
2876
|
+
</p>
|
|
2877
|
+
)}
|
|
2878
|
+
{sbxChatMessages.map((msg) => (
|
|
2879
|
+
<div
|
|
2880
|
+
key={msg.id}
|
|
2881
|
+
className={`flex flex-col gap-0.5 ${msg.role === "user" ? "items-end" : "items-start"}`}
|
|
2882
|
+
>
|
|
2883
|
+
<div
|
|
2884
|
+
className={`max-w-[85%] rounded-xl px-3 py-2 text-xs leading-5 ${
|
|
2885
|
+
msg.role === "user"
|
|
2886
|
+
? "bg-violet-600/30 text-violet-100 whitespace-pre-wrap"
|
|
2887
|
+
: "bg-slate-800 text-slate-200 prose prose-invert prose-xs max-w-none"
|
|
2888
|
+
}`}
|
|
2889
|
+
>
|
|
2890
|
+
{msg.role === "user" ? (
|
|
2891
|
+
msg.content
|
|
2892
|
+
) : msg.content ? (
|
|
2893
|
+
<ReactMarkdown
|
|
2894
|
+
remarkPlugins={[remarkGfm]}
|
|
2895
|
+
components={{
|
|
2896
|
+
code({ className, children, ...props }) {
|
|
2897
|
+
const isBlock =
|
|
2898
|
+
className?.startsWith("language-");
|
|
2899
|
+
return isBlock ? (
|
|
2900
|
+
<pre className="bg-slate-900/80 rounded p-2 overflow-x-auto my-1">
|
|
2901
|
+
<code
|
|
2902
|
+
className={`${className ?? ""} text-[11px]`}
|
|
2903
|
+
{...props}
|
|
2904
|
+
>
|
|
2905
|
+
{children}
|
|
2906
|
+
</code>
|
|
2907
|
+
</pre>
|
|
2908
|
+
) : (
|
|
2909
|
+
<code
|
|
2910
|
+
className="bg-slate-900/60 px-1 rounded text-violet-300 text-[11px]"
|
|
2911
|
+
{...props}
|
|
2912
|
+
>
|
|
2913
|
+
{children}
|
|
2914
|
+
</code>
|
|
2915
|
+
);
|
|
2916
|
+
},
|
|
2917
|
+
p({ children }) {
|
|
2918
|
+
return (
|
|
2919
|
+
<p className="mb-1 last:mb-0">{children}</p>
|
|
2920
|
+
);
|
|
2921
|
+
},
|
|
2922
|
+
ul({ children }) {
|
|
2923
|
+
return (
|
|
2924
|
+
<ul className="list-disc list-inside mb-1 space-y-0.5">
|
|
2925
|
+
{children}
|
|
2926
|
+
</ul>
|
|
2927
|
+
);
|
|
2928
|
+
},
|
|
2929
|
+
ol({ children }) {
|
|
2930
|
+
return (
|
|
2931
|
+
<ol className="list-decimal list-inside mb-1 space-y-0.5">
|
|
2932
|
+
{children}
|
|
2933
|
+
</ol>
|
|
2934
|
+
);
|
|
2935
|
+
},
|
|
2936
|
+
h2({ children }) {
|
|
2937
|
+
return (
|
|
2938
|
+
<h2 className="text-xs font-semibold text-slate-200 mt-2 mb-0.5">
|
|
2939
|
+
{children}
|
|
2940
|
+
</h2>
|
|
2941
|
+
);
|
|
2942
|
+
},
|
|
2943
|
+
h3({ children }) {
|
|
2944
|
+
return (
|
|
2945
|
+
<h3 className="text-xs font-semibold text-slate-300 mt-1.5 mb-0.5">
|
|
2946
|
+
{children}
|
|
2947
|
+
</h3>
|
|
2948
|
+
);
|
|
2949
|
+
},
|
|
2950
|
+
}}
|
|
2951
|
+
>
|
|
2952
|
+
{msg.content}
|
|
2953
|
+
</ReactMarkdown>
|
|
2954
|
+
) : (
|
|
2955
|
+
<span className="flex items-center gap-1.5 text-slate-500">
|
|
2956
|
+
<Loader2 className="w-3 h-3 animate-spin" />{" "}
|
|
2957
|
+
thinking…
|
|
2958
|
+
</span>
|
|
2959
|
+
)}
|
|
2960
|
+
</div>
|
|
2961
|
+
{msg.role === "assistant" && msg.content && (
|
|
2962
|
+
<button
|
|
2963
|
+
onClick={() => {
|
|
2964
|
+
void navigator.clipboard
|
|
2965
|
+
.writeText(msg.content)
|
|
2966
|
+
.then(() => {
|
|
2967
|
+
setSbxChatCopiedId(msg.id);
|
|
2968
|
+
setTimeout(
|
|
2969
|
+
() => setSbxChatCopiedId(null),
|
|
2970
|
+
1800,
|
|
2971
|
+
);
|
|
2972
|
+
});
|
|
2973
|
+
}}
|
|
2974
|
+
className="flex items-center gap-1 text-[10px] text-slate-700 hover:text-slate-400 transition-colors px-1"
|
|
2975
|
+
title="Copy response"
|
|
2976
|
+
>
|
|
2977
|
+
{sbxChatCopiedId === msg.id ? (
|
|
2978
|
+
<>
|
|
2979
|
+
<ClipboardCheck className="w-3 h-3 text-emerald-400" />
|
|
2980
|
+
<span className="text-emerald-400">Copied</span>
|
|
2981
|
+
</>
|
|
2982
|
+
) : (
|
|
2983
|
+
<>
|
|
2984
|
+
<Clipboard className="w-3 h-3" />
|
|
2985
|
+
<span>Copy</span>
|
|
2986
|
+
</>
|
|
2987
|
+
)}
|
|
2988
|
+
</button>
|
|
2989
|
+
)}
|
|
2990
|
+
</div>
|
|
2991
|
+
))}
|
|
2992
|
+
</div>
|
|
2993
|
+
<div className="flex items-end gap-1.5 px-3 py-2 border-t border-slate-800 bg-slate-900/60 shrink-0">
|
|
2994
|
+
<textarea
|
|
2995
|
+
ref={sbxChatInputRef}
|
|
2996
|
+
rows={1}
|
|
2997
|
+
value={sbxChatInput}
|
|
2998
|
+
onChange={(e) => setSbxChatInput(e.target.value)}
|
|
2999
|
+
onKeyDown={(e) => {
|
|
3000
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
3001
|
+
e.preventDefault();
|
|
3002
|
+
void handleSbxChatSend();
|
|
3003
|
+
}
|
|
3004
|
+
}}
|
|
3005
|
+
placeholder={`Ask about your ${clientType === "react" ? "React" : clientType === "nextjs" ? "Next.js" : "sandbox"} code…`}
|
|
3006
|
+
disabled={sbxChatLoading}
|
|
3007
|
+
className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-600 outline-none resize-none disabled:opacity-50 max-h-20"
|
|
3008
|
+
/>
|
|
3009
|
+
<button
|
|
3010
|
+
type="button"
|
|
3011
|
+
onClick={() => void handleSbxChatSend()}
|
|
3012
|
+
disabled={sbxChatLoading || !sbxChatInput.trim()}
|
|
3013
|
+
className="p-1 rounded text-slate-600 hover:text-violet-400 hover:bg-violet-500/10 disabled:opacity-40 transition-colors shrink-0"
|
|
3014
|
+
title="Send (Enter)"
|
|
3015
|
+
>
|
|
3016
|
+
{sbxChatLoading ? (
|
|
3017
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
3018
|
+
) : (
|
|
3019
|
+
<Send className="w-3.5 h-3.5" />
|
|
3020
|
+
)}
|
|
3021
|
+
</button>
|
|
3022
|
+
</div>
|
|
3023
|
+
</>
|
|
3024
|
+
)}
|
|
3025
|
+
</div>
|
|
3026
|
+
</div>
|
|
3027
|
+
)}
|
|
3028
|
+
</div>
|
|
3029
|
+
);
|
|
3030
|
+
}
|