| import Editor, { type OnMount } from "@monaco-editor/react"; |
| import { useEffect, useMemo, useRef, useState } from "react"; |
| import type { VirtualFile } from "../hooks/useVirtualFS"; |
|
|
| type MonacoEditorInstance = Parameters<OnMount>[0]; |
|
|
| interface CodeEditorProps { |
| files: VirtualFile[]; |
| onChange: (path: string, content: string) => void; |
| |
| |
| |
| |
| |
| |
| |
| |
| streamingFiles?: Map<string, string>; |
| |
| |
| |
| |
| |
| |
| |
| |
| focusedFilePath?: string | null; |
| } |
|
|
| function guessLanguage(path: string): string { |
| const ext = path.split(".").pop()?.toLowerCase() ?? ""; |
| switch (ext) { |
| case "ts": |
| case "tsx": |
| return "typescript"; |
| case "js": |
| case "jsx": |
| return "javascript"; |
| case "html": |
| return "html"; |
| case "css": |
| return "css"; |
| case "json": |
| return "json"; |
| case "md": |
| return "markdown"; |
| case "yml": |
| case "yaml": |
| return "yaml"; |
| case "sh": |
| return "shell"; |
| default: |
| return "plaintext"; |
| } |
| } |
|
|
| interface DisplayFile { |
| path: string; |
| content: string; |
| streaming: boolean; |
| } |
|
|
| export function CodeEditor({ |
| files, |
| onChange, |
| streamingFiles, |
| focusedFilePath, |
| }: CodeEditorProps) { |
| const [activePath, setActivePath] = useState<string | null>(null); |
| |
| |
| |
| |
| |
| const userPickedRef = useRef(false); |
| const lastStreamingKeysRef = useRef<string>(""); |
| const lastFocusedPathRef = useRef<string | null>(null); |
|
|
| |
| |
| |
| const displayFiles: DisplayFile[] = useMemo(() => { |
| const byPath = new Map<string, DisplayFile>(); |
| for (const f of files) { |
| byPath.set(f.path, { path: f.path, content: f.content, streaming: false }); |
| } |
| if (streamingFiles) { |
| for (const [path, content] of streamingFiles) { |
| byPath.set(path, { path, content, streaming: true }); |
| } |
| } |
| return Array.from(byPath.values()).sort((a, b) => |
| a.path.localeCompare(b.path), |
| ); |
| }, [files, streamingFiles]); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| useEffect(() => { |
| const streamingPaths = streamingFiles ? [...streamingFiles.keys()] : []; |
| const key = streamingPaths.sort().join("|"); |
| const streamingStarted = |
| streamingPaths.length > 0 && key !== lastStreamingKeysRef.current; |
| lastStreamingKeysRef.current = key; |
|
|
| if (streamingPaths.length > 0) { |
| const target = streamingPaths[0]; |
| if (activePath !== target && (streamingStarted || !userPickedRef.current)) { |
| setActivePath(target); |
| userPickedRef.current = false; |
| } |
| |
| |
| lastFocusedPathRef.current = focusedFilePath ?? null; |
| return; |
| } |
|
|
| |
| const focusChanged = |
| focusedFilePath != null && focusedFilePath !== lastFocusedPathRef.current; |
| lastFocusedPathRef.current = focusedFilePath ?? null; |
|
|
| if ( |
| focusChanged && |
| focusedFilePath && |
| displayFiles.some((f) => f.path === focusedFilePath) && |
| activePath !== focusedFilePath |
| ) { |
| setActivePath(focusedFilePath); |
| |
| userPickedRef.current = false; |
| return; |
| } |
|
|
| |
| if (!activePath && displayFiles.length > 0) { |
| const preferred = displayFiles.find( |
| (f) => f.path === "index.html" || f.path === "main.js", |
| ); |
| setActivePath(preferred?.path ?? displayFiles[0].path); |
| return; |
| } |
| if (activePath && !displayFiles.some((f) => f.path === activePath)) { |
| setActivePath(displayFiles[0]?.path ?? null); |
| } |
| }, [displayFiles, activePath, streamingFiles, focusedFilePath]); |
|
|
| const activeFile = useMemo( |
| () => displayFiles.find((f) => f.path === activePath) ?? null, |
| [displayFiles, activePath], |
| ); |
|
|
| |
| |
| |
| |
| const monacoRef = useRef<MonacoEditorInstance | null>(null); |
| const handleMount: OnMount = (editor) => { |
| monacoRef.current = editor; |
| }; |
| useEffect(() => { |
| if (!activeFile?.streaming) return; |
| const editor = monacoRef.current; |
| if (!editor) return; |
| const model = editor.getModel(); |
| if (!model) return; |
| const lastLine = model.getLineCount(); |
| |
| |
| editor.revealLine(lastLine); |
| }, [activeFile?.content, activeFile?.streaming]); |
|
|
| if (displayFiles.length === 0) { |
| return ( |
| <div className="code-layout"> |
| <div className="file-tree"> |
| <div className="file-tree-empty">No files yet.</div> |
| </div> |
| <div className="editor-area"> |
| <div className="editor-placeholder"> |
| The agent will write files here. Ask it to create an app. |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="code-layout"> |
| <div className="file-tree"> |
| {displayFiles.map((f) => ( |
| <div |
| key={f.path} |
| className={`file-tree-item ${activePath === f.path ? "active" : ""} ${ |
| f.streaming ? "streaming" : "" |
| }`} |
| onClick={() => { |
| setActivePath(f.path); |
| userPickedRef.current = true; |
| }} |
| > |
| <span className="file-tree-path"> |
| {f.streaming && ( |
| <span className="file-tree-spinner" aria-hidden="true" /> |
| )} |
| {f.path} |
| </span> |
| <span className="file-tree-size"> |
| {f.content.length.toLocaleString()} |
| {f.streaming ? "…" : "B"} |
| </span> |
| </div> |
| ))} |
| </div> |
| <div className="editor-area"> |
| {activeFile ? ( |
| <Editor |
| key={activeFile.path} |
| height="100%" |
| theme="vs-dark" |
| language={guessLanguage(activeFile.path)} |
| value={activeFile.content} |
| onChange={(v) => { |
| if (activeFile.streaming) return; |
| onChange(activeFile.path, v ?? ""); |
| }} |
| onMount={handleMount} |
| options={{ |
| fontSize: 12, |
| fontFamily: |
| '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace', |
| minimap: { enabled: false }, |
| scrollBeyondLastLine: false, |
| tabSize: 2, |
| wordWrap: "on", |
| automaticLayout: true, |
| readOnly: activeFile.streaming, |
| }} |
| /> |
| ) : ( |
| <div className="editor-placeholder">Select a file from the tree.</div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|