"use client"; import { useEffect, useRef, useState } from "react"; import monaco from "monaco-editor"; import Editor, { BeforeMount, OnMount } from "@monaco-editor/react"; import { io } from "socket.io-client"; import { toast } from "sonner"; import { useClerk } from "@clerk/nextjs"; import { createId } from "@paralleldrive/cuid2"; import * as Y from "yjs"; import LiveblocksProvider from "@liveblocks/yjs"; import { MonacoBinding } from "y-monaco"; import { Awareness } from "y-protocols/awareness"; import { TypedLiveblocksProvider, useRoom } from "@/liveblocks.config"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; import { ChevronLeft, ChevronRight, FileJson, Loader2, Plus, RotateCw, Shell, SquareTerminal, TerminalSquare, } from "lucide-react"; import Tab from "../ui/tab"; import Sidebar from "./sidebar"; import EditorTerminal from "./terminal"; import { Button } from "../ui/button"; import GenerateInput from "./generate"; import { Sandbox, User, TFile, TFileData, TFolder, TTab } from "@/lib/types"; import { processFileType, validateName } from "@/lib/utils"; import { Cursors } from "./live/cursors"; import { Terminal } from "@xterm/xterm"; import DisableAccessModal from "./live/disableModal"; import Loading from "./loading"; export default function CodeEditor({ userData, sandboxData, isSharedUser, }: { userData: User; sandboxData: Sandbox; isSharedUser: boolean; }) { const [files, setFiles] = useState<(TFolder | TFile)[]>([]); const [tabs, setTabs] = useState([]); const [editorLanguage, setEditorLanguage] = useState("plaintext"); const [activeFileId, setActiveFileId] = useState(""); const [activeFileContent, setActiveFileContent] = useState(""); const [cursorLine, setCursorLine] = useState(0); const [generate, setGenerate] = useState<{ show: boolean; id: string; line: number; widget: monaco.editor.IContentWidget | undefined; pref: monaco.editor.ContentWidgetPositionPreference[]; width: number; }>({ show: false, line: 0, id: "", widget: undefined, pref: [], width: 0 }); const [decorations, setDecorations] = useState<{ options: monaco.editor.IModelDeltaDecoration[]; instance: monaco.editor.IEditorDecorationsCollection | undefined; }>({ options: [], instance: undefined }); const [terminals, setTerminals] = useState< { id: string; terminal: Terminal | null; }[] >([]); const [activeTerminalId, setActiveTerminalId] = useState(""); const [creatingTerminal, setCreatingTerminal] = useState(false); const [closingTerminal, setClosingTerminal] = useState(""); const [provider, setProvider] = useState(); const [ai, setAi] = useState(false); const [disableAccess, setDisableAccess] = useState({ isDisabled: false, message: "", }); const isOwner = sandboxData.userId === userData.id; const clerk = useClerk(); const room = useRoom(); const activeTerminal = terminals.find((t) => t.id === activeTerminalId); // const editorRef = useRef(null) const [editorRef, setEditorRef] = useState(); const editorContainerRef = useRef(null); const monacoRef = useRef(null); const generateRef = useRef(null); const generateWidgetRef = useRef(null); const handleEditorWillMount: BeforeMount = (monaco) => { monaco.editor.addKeybindingRules([ { keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyG, command: "null", // when: "textInputFocus", }, ]); }; const handleEditorMount: OnMount = (editor, monaco) => { setEditorRef(editor); monacoRef.current = monaco; editor.onDidChangeCursorPosition((e) => { const { column, lineNumber } = e.position; if (lineNumber === cursorLine) return; setCursorLine(lineNumber); const model = editor.getModel(); const endColumn = model?.getLineContent(lineNumber).length || 0; setDecorations((prev) => { return { ...prev, options: [ { range: new monaco.Range( lineNumber, column, lineNumber, endColumn ), options: { afterContentClassName: "inline-decoration", }, }, ], }; }); }); editor.onDidBlurEditorText((e) => { setDecorations((prev) => { return { ...prev, options: [], }; }); }); editor.addAction({ id: "generate", label: "Generate", keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyG], precondition: "editorTextFocus && !suggestWidgetVisible && !renameInputVisible && !inSnippetMode && !quickFixWidgetVisible", run: () => { setGenerate((prev) => { return { ...prev, show: !prev.show, pref: [monaco.editor.ContentWidgetPositionPreference.BELOW], }; }); }, }); }; useEffect(() => { if (!ai) { setGenerate((prev) => { return { ...prev, show: false, }; }); return; } if (generate.show) { editorRef?.changeViewZones(function (changeAccessor) { if (!generateRef.current) return; const id = changeAccessor.addZone({ afterLineNumber: cursorLine, heightInLines: 3, domNode: generateRef.current, }); setGenerate((prev) => { return { ...prev, id, line: cursorLine }; }); }); if (!generateWidgetRef.current) return; const widgetElement = generateWidgetRef.current; const contentWidget = { getDomNode: () => { return widgetElement; }, getId: () => { return "generate.widget"; }, getPosition: () => { return { position: { lineNumber: cursorLine, column: 1, }, preference: generate.pref, }; }, }; setGenerate((prev) => { return { ...prev, widget: contentWidget }; }); editorRef?.addContentWidget(contentWidget); if (generateRef.current && generateWidgetRef.current) { editorRef?.applyFontInfo(generateRef.current); editorRef?.applyFontInfo(generateWidgetRef.current); } } else { editorRef?.changeViewZones(function (changeAccessor) { changeAccessor.removeZone(generate.id); setGenerate((prev) => { return { ...prev, id: "" }; }); }); if (!generate.widget) return; editorRef?.removeContentWidget(generate.widget); setGenerate((prev) => { return { ...prev, widget: undefined, }; }); } }, [generate.show]); useEffect(() => { if (decorations.options.length === 0) { decorations.instance?.clear(); } if (!ai) return; if (decorations.instance) { decorations.instance.set(decorations.options); } else { const instance = editorRef?.createDecorationsCollection(); instance?.set(decorations.options); setDecorations((prev) => { return { ...prev, instance, }; }); } }, [decorations.options]); const socket = io( `http://localhost:4000?userId=${userData.id}&sandboxId=${sandboxData.id}` ); useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "s" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setTabs((prev) => prev.map((tab) => tab.id === activeFileId ? { ...tab, saved: true } : tab ) ); socket.emit("saveFile", activeFileId, editorRef?.getValue()); } }; document.addEventListener("keydown", down); return () => { document.removeEventListener("keydown", down); }; }, [tabs, activeFileId]); const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { width } = entry.contentRect; setGenerate((prev) => { return { ...prev, width }; }); } }); useEffect(() => { const tab = tabs.find((t) => t.id === activeFileId); const model = editorRef?.getModel(); if (!editorRef || !tab || !model) return; const yDoc = new Y.Doc(); const yText = yDoc.getText(tab.id); const yProvider: any = new LiveblocksProvider(room, yDoc); const onSync = (isSynced: boolean) => { if (isSynced) { const text = yText.toString(); if (text === "") { if (activeFileContent) { yText.insert(0, activeFileContent); } else { setTimeout(() => { yText.insert(0, editorRef.getValue()); }, 0); } } } else { // Yjs content is not synchronized } }; yProvider.on("sync", onSync); setProvider(yProvider); const binding = new MonacoBinding( yText, model, new Set([editorRef]), yProvider.awareness as Awareness ); return () => { yDoc.destroy(); yProvider.destroy(); binding.destroy(); yProvider.off("sync", onSync); }; }, [editorRef, room, activeFileContent]); // connection/disconnection effect + resizeobserver useEffect(() => { socket.connect(); if (editorContainerRef.current) { resizeObserver.observe(editorContainerRef.current); } return () => { socket.disconnect(); resizeObserver.disconnect(); // terminals.forEach((term) => { // if (term.terminal) { // term.terminal.dispose(); // } // }); }; }, []); // event listener effect useEffect(() => { const onConnect = () => { console.log("connected"); }; const onDisconnect = () => { console.log("disconnected"); setTerminals([]); }; const onLoadedEvent = (files: (TFolder | TFile)[]) => { setFiles(files); }; const onRateLimit = (message: string) => { toast.error(message); }; const onTerminalResponse = (response: { id: string; data: string }) => { const term = terminals.find((t) => t.id === response.id); if (term && term.terminal) { term.terminal.write(response.data); } }; const onDisableAccess = (message: string) => { setDisableAccess({ isDisabled: true, message, }); }; socket.on("connect", onConnect); socket.on("disconnect", onDisconnect); socket.on("loaded", onLoadedEvent); socket.on("rateLimit", onRateLimit); socket.on("terminalResponse", onTerminalResponse); socket.on("disableAccess", onDisableAccess); return () => { socket.off("connect", onConnect); socket.off("disconnect", onDisconnect); socket.off("loaded", onLoadedEvent); socket.off("rateLimit", onRateLimit); socket.off("terminalResponse", onTerminalResponse); socket.off("disableAccess", onDisableAccess); }; // }, []); }, [terminals]); // Helper functions: const createTerminal = () => { setCreatingTerminal(true); const id = createId(); console.log("creating terminal, id:", id); setTerminals((prev) => [...prev, { id, terminal: null }]); setActiveTerminalId(id); setTimeout(() => { socket.emit("createTerminal", id, () => { setCreatingTerminal(false); }); }, 1000); }; const selectFile = (tab: TTab) => { if (tab.id === activeFileId) return; const exists = tabs.find((t) => t.id === tab.id); setTabs((prev) => { if (exists) { setActiveFileId(exists.id); return prev; } return [...prev, tab]; }); socket.emit("getFile", tab.id, (response: string) => { setActiveFileContent(response); }); setEditorLanguage(processFileType(tab.name)); setActiveFileId(tab.id); }; const closeTab = (tab: TFile) => { const numTabs = tabs.length; const index = tabs.findIndex((t) => t.id === tab.id); if (index === -1) return; const nextId = activeFileId === tab.id ? numTabs === 1 ? null : index < numTabs - 1 ? tabs[index + 1].id : tabs[index - 1].id : activeFileId; setTabs((prev) => prev.filter((t) => t.id !== tab.id)); if (!nextId) { setActiveFileId(""); } else { const nextTab = tabs.find((t) => t.id === nextId); if (nextTab) { selectFile(nextTab); } } }; const closeTerminal = (term: { id: string; terminal: Terminal | null }) => { const numTerminals = terminals.length; const index = terminals.findIndex((t) => t.id === term.id); if (index === -1) return; setClosingTerminal(term.id); socket.emit("closeTerminal", term.id, () => { setClosingTerminal(""); const nextId = activeTerminalId === term.id ? numTerminals === 1 ? null : index < numTerminals - 1 ? terminals[index + 1].id : terminals[index - 1].id : activeTerminalId; // if (activeTerminal && activeTerminal.terminal) // activeTerminal.terminal.dispose(); setTerminals((prev) => prev.filter((t) => t.id !== term.id)); if (!nextId) { setActiveTerminalId(""); } else { const nextTerminal = terminals.find((t) => t.id === nextId); if (nextTerminal) { setActiveTerminalId(nextTerminal.id); } } }); }; const handleRename = ( id: string, newName: string, oldName: string, type: "file" | "folder" ) => { const valid = validateName(newName, oldName, type); if (!valid.status) { if (valid.message) toast.error("Invalid file name."); return false; } socket.emit("renameFile", id, newName); setTabs((prev) => prev.map((tab) => (tab.id === id ? { ...tab, name: newName } : tab)) ); return true; }; const handleDeleteFile = (file: TFile) => { socket.emit("deleteFile", file.id, (response: (TFolder | TFile)[]) => { setFiles(response); }); closeTab(file); }; const handleDeleteFolder = (folder: TFolder) => { // socket.emit("deleteFolder", folder.id, (response: (TFolder | TFile)[]) => { // setFiles(response) // }) }; if (disableAccess.isDisabled) { return ( <> {}} /> ); } return ( <>
{generate.show && ai ? ( t.id === activeFileId)?.name ?? "", code: editorRef?.getValue() ?? "", line: generate.line, }} editor={{ language: editorLanguage, }} onExpand={() => { editorRef?.changeViewZones(function (changeAccessor) { changeAccessor.removeZone(generate.id); if (!generateRef.current) return; const id = changeAccessor.addZone({ afterLineNumber: cursorLine, heightInLines: 12, domNode: generateRef.current, }); setGenerate((prev) => { return { ...prev, id }; }); }); }} onAccept={(code: string) => { const line = generate.line; setGenerate((prev) => { return { ...prev, show: !prev.show, }; }); const file = editorRef?.getValue(); const lines = file?.split("\n") || []; lines.splice(line - 1, 0, code); const updatedFile = lines.join("\n"); editorRef?.setValue(updatedFile); }} /> ) : null}
{ if (type === "file") { setFiles((prev) => [ ...prev, { id: `projects/${sandboxData.id}/${name}`, name, type: "file" }, ]); } else { console.log("adding folder"); // setFiles(prev => [...prev, { id, name, type: "folder", children: [] }]) } }} ai={ai} setAi={setAi} />
{tabs.map((tab) => ( { selectFile(tab); }} onClose={() => closeTab(tab)} > {tab.name} ))}
{!activeFileId ? ( <>
No file selected.
) : clerk.loaded ? ( <> {provider ? : null} { if (value === activeFileContent) { setTabs((prev) => prev.map((tab) => tab.id === activeFileId ? { ...tab, saved: true } : tab ) ); } else { setTabs((prev) => prev.map((tab) => tab.id === activeFileId ? { ...tab, saved: false } : tab ) ); } }} options={{ minimap: { enabled: false, }, padding: { bottom: 4, top: 4, }, scrollBeyondLastLine: false, fixedOverflowWidgets: true, fontFamily: "var(--font-geist-mono)", }} theme="vs-dark" value={activeFileContent} /> ) : (
Waiting for Clerk to load...
)}
Preview
{isOwner ? ( <>
{terminals.map((term) => ( setActiveTerminalId(term.id)} onClose={() => closeTerminal(term)} selected={activeTerminalId === term.id} > Shell ))}
{socket && activeTerminal ? (
{terminals.map((term) => ( { // console.log( // "setting terminal", // activeTerminalId, // t.options // ); setTerminals((prev) => prev.map((term) => term.id === activeTerminalId ? { ...term, terminal: t } : term ) ); }} visible={activeTerminalId === term.id} /> ))}
) : (
No terminals open.
)} ) : (
No terminal access.
)}
); }