diff --git a/backend/orchestrator/src/index.ts b/backend/orchestrator/src/index.ts index 9237271..5739187 100644 --- a/backend/orchestrator/src/index.ts +++ b/backend/orchestrator/src/index.ts @@ -19,8 +19,7 @@ app.use(express.json()) dotenv.config() const corsOptions = { - origin: 'http://localhost:3000', - // origin: 'https://s.ishaand.com', + origin: ['http://localhost:3000', 'https://s.ishaand.com', 'http://localhost:4000'], } const kubeconfig = new KubeConfig() diff --git a/backend/server/src/inactivity.ts b/backend/server/src/inactivity.ts deleted file mode 100644 index 1243ab7..0000000 --- a/backend/server/src/inactivity.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Server } from "socket.io"; -import { DefaultEventsMap } from "socket.io/dist/typed-events"; - -export function checkForInactivity(io: Server) { - io.fetchSockets().then(sockets => { - if (sockets.length === 0) { - console.log("No users have been connected for 15 seconds."); - } - }); -} \ No newline at end of file diff --git a/backend/server/src/index.ts b/backend/server/src/index.ts index a8ddc0a..fa76785 100644 --- a/backend/server/src/index.ts +++ b/backend/server/src/index.ts @@ -18,6 +18,7 @@ import { getSandboxFiles, renameFile, saveFile, + stopServer, } from "./utils" import { IDisposable, IPty, spawn } from "node-pty" import { @@ -437,10 +438,12 @@ io.on("connection", async (socket) => { inactivityTimeout = setTimeout(() => { io.fetchSockets().then(sockets => { if (sockets.length === 0) { - console.log("No users have been connected for 15 seconds."); + // close server + console.log("Closing server due to inactivity."); + stopServer(data.sandboxId, data.userId) } }); - }, 15000); + }, 60000); } }) diff --git a/backend/server/src/utils.ts b/backend/server/src/utils.ts index 1e758a2..e57f58f 100644 --- a/backend/server/src/utils.ts +++ b/backend/server/src/utils.ts @@ -196,3 +196,16 @@ ${code}`, } ) } + +export const stopServer = async (sandboxId: string, userId: string) => { + await fetch("http://localhost:4001/stop", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sandboxId, + userId + }), + }) +} \ No newline at end of file diff --git a/frontend/app/(app)/code/[id]/page.tsx b/frontend/app/(app)/code/[id]/page.tsx index 6d123eb..4c7c705 100644 --- a/frontend/app/(app)/code/[id]/page.tsx +++ b/frontend/app/(app)/code/[id]/page.tsx @@ -2,15 +2,8 @@ import Navbar from "@/components/editor/navbar"; import { Room } from "@/components/editor/live/room"; import { Sandbox, User, UsersToSandboxes } from "@/lib/types"; import { currentUser } from "@clerk/nextjs"; -import dynamic from "next/dynamic"; import { notFound, redirect } from "next/navigation"; -import Loading from "@/components/editor/loading"; -import { Suspense } from "react"; - -const CodeEditor = dynamic(() => import("@/components/editor"), { - ssr: false, - loading: () => , -}); +import Editor from "@/components/editor"; const getUserData = async (id: string) => { const userRes = await fetch( @@ -67,11 +60,7 @@ export default async function CodePage({ params }: { params: { id: string } }) {
- +
{/* */} diff --git a/frontend/components/editor/editor.tsx b/frontend/components/editor/editor.tsx new file mode 100644 index 0000000..08d8b6e --- /dev/null +++ b/frontend/components/editor/editor.tsx @@ -0,0 +1,763 @@ +"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 * 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 { FileJson, Loader2, TerminalSquare } from "lucide-react"; +import Tab from "../ui/tab"; +import Sidebar from "./sidebar"; +import GenerateInput from "./generate"; +import { Sandbox, User, TFile, TFolder, TTab } from "@/lib/types"; +import { addNew, processFileType, validateName } from "@/lib/utils"; +import { Cursors } from "./live/cursors"; +import { Terminal } from "@xterm/xterm"; +import DisableAccessModal from "./live/disableModal"; +import Loading from "./loading"; +import PreviewWindow from "./preview"; +import Terminals from "./terminals"; +import { ImperativePanelHandle } from "react-resizable-panels"; + +export default function CodeEditor({ + userData, + sandboxData, +}: { + userData: User; + sandboxData: Sandbox; +}) { + const socket = io( + `http://localhost:4000?userId=${userData.id}&sandboxId=${sandboxData.id}` + ); + + const [isPreviewCollapsed, setIsPreviewCollapsed] = useState( + sandboxData.type !== "react" + ); + const [disableAccess, setDisableAccess] = useState({ + isDisabled: false, + message: "", + }); + + // File state + const [files, setFiles] = useState<(TFolder | TFile)[]>([]); + const [tabs, setTabs] = useState([]); + const [activeFileId, setActiveFileId] = useState(""); + const [activeFileContent, setActiveFileContent] = useState(""); + const [deletingFolderId, setDeletingFolderId] = useState(""); + + // Editor state + const [editorLanguage, setEditorLanguage] = useState("plaintext"); + const [cursorLine, setCursorLine] = useState(0); + const [editorRef, setEditorRef] = + useState(); + + // AI Copilot state + const [ai, setAi] = useState(false); + 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 }); + + // Terminal state + const [terminals, setTerminals] = useState< + { + id: string; + terminal: Terminal | null; + }[] + >([]); + const [activeTerminalId, setActiveTerminalId] = useState(""); + const [creatingTerminal, setCreatingTerminal] = useState(false); + const [closingTerminal, setClosingTerminal] = useState(""); + const activeTerminal = terminals.find((t) => t.id === activeTerminalId); + + const isOwner = sandboxData.userId === userData.id; + const clerk = useClerk(); + + // Liveblocks hooks + const room = useRoom(); + const [provider, setProvider] = useState(); + + // Refs for libraries / features + const editorContainerRef = useRef(null); + const monacoRef = useRef(null); + const generateRef = useRef(null); + const generateWidgetRef = useRef(null); + const previewPanelRef = useRef(null); + + // Resize observer tracks editor width for generate widget + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width } = entry.contentRect; + setGenerate((prev) => { + return { ...prev, width }; + }); + } + }); + + // Pre-mount editor keybindings + const handleEditorWillMount: BeforeMount = (monaco) => { + monaco.editor.addKeybindingRules([ + { + keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyG, + command: "null", + }, + ]); + }; + + // Post-mount editor keybindings and actions + 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], + }; + }); + }, + }); + }; + + // Generate widget effect + 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]); + + // Decorations effect for generate widget tips + 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]); + + // Save file keybinding logic effect + 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]); + + // Liveblocks live collaboration setup effect + 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); + } + } + } + }; + + 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(); + }; + }, []); + + // Socket event listener effect + useEffect(() => { + const onConnect = () => {}; + + const onDisconnect = () => { + 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) => { + if (!isOwner) + 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 for tabs: + + // Select file and load content + 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); + }; + + // Close tab and remove from tabs + const closeTab = (id: string) => { + const numTabs = tabs.length; + const index = tabs.findIndex((t) => t.id === id); + + console.log("closing tab", id, index); + + if (index === -1) return; + + const nextId = + activeFileId === id + ? numTabs === 1 + ? null + : index < numTabs - 1 + ? tabs[index + 1].id + : tabs[index - 1].id + : activeFileId; + + setTabs((prev) => prev.filter((t) => t.id !== id)); + + if (!nextId) { + setActiveFileId(""); + } else { + const nextTab = tabs.find((t) => t.id === nextId); + if (nextTab) { + selectFile(nextTab); + } + } + }; + + const closeTabs = (ids: string[]) => { + const numTabs = tabs.length; + + if (numTabs === 0) return; + + const allIndexes = ids.map((id) => tabs.findIndex((t) => t.id === id)); + + const indexes = allIndexes.filter((index) => index !== -1); + if (indexes.length === 0) return; + + console.log("closing tabs", ids, indexes); + + const activeIndex = tabs.findIndex((t) => t.id === activeFileId); + + const newTabs = tabs.filter((t) => !ids.includes(t.id)); + setTabs(newTabs); + + if (indexes.length === numTabs) { + setActiveFileId(""); + } else { + const nextTab = + newTabs.length > activeIndex + ? newTabs[activeIndex] + : newTabs[newTabs.length - 1]; + if (nextTab) { + selectFile(nextTab); + } + } + }; + + 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.id); + }; + + const handleDeleteFolder = (folder: TFolder) => { + setDeletingFolderId(folder.id); + console.log("deleting folder", folder.id); + + socket.emit("getFolder", folder.id, (response: string[]) => + closeTabs(response) + ); + + socket.emit("deleteFolder", folder.id, (response: (TFolder | TFile)[]) => { + setFiles(response); + setDeletingFolderId(""); + }); + + setTimeout(() => { + setDeletingFolderId(""); + }, 3000); + }; + + // On disabled access for shared users, show un-interactable loading placeholder + info modal + if (disableAccess.isDisabled) + return ( + <> + {}} + /> + + + ); + + return ( + <> + {/* Copilot DOM elements */} +
+
+ {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} +
+ + {/* Main editor components */} + addNew(name, type, setFiles, sandboxData)} + deletingFolderId={deletingFolderId} + // AI Copilot Toggle + ai={ai} + setAi={setAi} + /> + + {/* Shadcn resizeable panels: https://ui.shadcn.com/docs/components/resizable */} + + +
+ {/* File tabs */} + {tabs.map((tab) => ( + { + selectFile(tab); + }} + onClose={() => closeTab(tab.id)} + > + {tab.name} + + ))} +
+ {/* Monaco editor */} +
+ {!activeFileId ? ( + <> +
+ + No file selected. +
+ + ) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643 + 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... +
+ )} +
+
+ + + + setIsPreviewCollapsed(true)} + onExpand={() => setIsPreviewCollapsed(false)} + > + { + previewPanelRef.current?.expand(); + setIsPreviewCollapsed(false); + }} + /> + + + + {isOwner ? ( + + ) : ( +
+ + No terminal access. +
+ )} +
+
+
+
+ + ); +} diff --git a/frontend/components/editor/index.tsx b/frontend/components/editor/index.tsx index 5197ad7..17d453d 100644 --- a/frontend/components/editor/index.tsx +++ b/frontend/components/editor/index.tsx @@ -1,765 +1,40 @@ "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 dynamic from "next/dynamic"; +import Loading from "@/components/editor/loading"; +import { Sandbox, User } from "@/lib/types"; +import { useEffect, useState } from "react"; +import { startServer } from "@/lib/utils"; import { toast } from "sonner"; -import { useClerk } from "@clerk/nextjs"; -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"; +const CodeEditor = dynamic(() => import("@/components/editor/editor"), { + ssr: false, + loading: () => , +}); -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { FileJson, Loader2, TerminalSquare } from "lucide-react"; -import Tab from "../ui/tab"; -import Sidebar from "./sidebar"; -import GenerateInput from "./generate"; -import { Sandbox, User, TFile, TFolder, TTab } from "@/lib/types"; -import { addNew, processFileType, validateName } from "@/lib/utils"; -import { Cursors } from "./live/cursors"; -import { Terminal } from "@xterm/xterm"; -import DisableAccessModal from "./live/disableModal"; -import Loading from "./loading"; -import PreviewWindow from "./preview"; -import Terminals from "./terminals"; -import { ImperativePanelHandle } from "react-resizable-panels"; - -export default function CodeEditor({ +export default function Editor({ userData, sandboxData, -}: // isSharedUser, -{ +}: { userData: User; sandboxData: Sandbox; - isSharedUser: boolean; }) { - const socket = io( - `http://localhost:4000?userId=${userData.id}&sandboxId=${sandboxData.id}` - ); - - const [isPreviewCollapsed, setIsPreviewCollapsed] = useState( - sandboxData.type !== "react" - ); - const [disableAccess, setDisableAccess] = useState({ - isDisabled: false, - message: "", - }); - - // File state - const [files, setFiles] = useState<(TFolder | TFile)[]>([]); - const [tabs, setTabs] = useState([]); - const [activeFileId, setActiveFileId] = useState(""); - const [activeFileContent, setActiveFileContent] = useState(""); - const [deletingFolderId, setDeletingFolderId] = useState(""); - - // Editor state - const [editorLanguage, setEditorLanguage] = useState("plaintext"); - const [cursorLine, setCursorLine] = useState(0); - const [editorRef, setEditorRef] = - useState(); - - // AI Copilot state - const [ai, setAi] = useState(false); - 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 }); - - // Terminal state - const [terminals, setTerminals] = useState< - { - id: string; - terminal: Terminal | null; - }[] - >([]); - const [activeTerminalId, setActiveTerminalId] = useState(""); - const [creatingTerminal, setCreatingTerminal] = useState(false); - const [closingTerminal, setClosingTerminal] = useState(""); - const activeTerminal = terminals.find((t) => t.id === activeTerminalId); - - const isOwner = sandboxData.userId === userData.id; - const clerk = useClerk(); - - // Liveblocks hooks - const room = useRoom(); - const [provider, setProvider] = useState(); - - // Refs for libraries / features - const editorContainerRef = useRef(null); - const monacoRef = useRef(null); - const generateRef = useRef(null); - const generateWidgetRef = useRef(null); - const previewPanelRef = useRef(null); - - // Resize observer tracks editor width for generate widget - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { width } = entry.contentRect; - setGenerate((prev) => { - return { ...prev, width }; - }); - } - }); - - // Pre-mount editor keybindings - const handleEditorWillMount: BeforeMount = (monaco) => { - monaco.editor.addKeybindingRules([ - { - keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyG, - command: "null", - }, - ]); - }; - - // Post-mount editor keybindings and actions - 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], - }; - }); - }, - }); - }; - - // Generate widget effect - 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]); - - // Decorations effect for generate widget tips - 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]); - - // Save file keybinding logic effect - 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]); - - // Liveblocks live collaboration setup effect - 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); - } - } - } - }; - - 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(); - }; - }, []); - - // Socket event listener effect - useEffect(() => { - const onConnect = () => {}; - - const onDisconnect = () => { - 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) => { - if (!isOwner) - 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 for tabs: - - // Select file and load content - 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); - }; - - // Close tab and remove from tabs - const closeTab = (id: string) => { - const numTabs = tabs.length; - const index = tabs.findIndex((t) => t.id === id); - - console.log("closing tab", id, index); - - if (index === -1) return; - - const nextId = - activeFileId === id - ? numTabs === 1 - ? null - : index < numTabs - 1 - ? tabs[index + 1].id - : tabs[index - 1].id - : activeFileId; - - setTabs((prev) => prev.filter((t) => t.id !== id)); - - if (!nextId) { - setActiveFileId(""); - } else { - const nextTab = tabs.find((t) => t.id === nextId); - if (nextTab) { - selectFile(nextTab); - } - } - }; - - const closeTabs = (ids: string[]) => { - const numTabs = tabs.length; - - if (numTabs === 0) return; - - const allIndexes = ids.map((id) => tabs.findIndex((t) => t.id === id)); - - const indexes = allIndexes.filter((index) => index !== -1); - if (indexes.length === 0) return; - - console.log("closing tabs", ids, indexes); - - const activeIndex = tabs.findIndex((t) => t.id === activeFileId); - - const newTabs = tabs.filter((t) => !ids.includes(t.id)); - setTabs(newTabs); - - if (indexes.length === numTabs) { - setActiveFileId(""); - } else { - const nextTab = - newTabs.length > activeIndex - ? newTabs[activeIndex] - : newTabs[newTabs.length - 1]; - if (nextTab) { - selectFile(nextTab); - } - } - }; - - 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.id); - }; - - const handleDeleteFolder = (folder: TFolder) => { - setDeletingFolderId(folder.id); - console.log("deleting folder", folder.id); - - socket.emit("getFolder", folder.id, (response: string[]) => - closeTabs(response) - ); - - socket.emit("deleteFolder", folder.id, (response: (TFolder | TFile)[]) => { - setFiles(response); - setDeletingFolderId(""); - }); - - setTimeout(() => { - setDeletingFolderId(""); - }, 3000); - }; - - // On disabled access for shared users, show un-interactable loading placeholder + info modal - if (disableAccess.isDisabled) + const [isServerRunning, setIsServerRunning] = useState(false); + + // useEffect(() => { + // startServer(sandboxData.id, userData.id, (success: boolean) => { + // if (!success) { + // toast.error("Failed to start server."); + // return; + // } + // setIsServerRunning(true); + // }); + // }, []); + + if (!isServerRunning) return ( - <> - {}} - /> - - + ); - return ( - <> - {/* Copilot DOM elements */} -
-
- {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} -
- - {/* Main editor components */} - addNew(name, type, setFiles, sandboxData)} - deletingFolderId={deletingFolderId} - // AI Copilot Toggle - ai={ai} - setAi={setAi} - /> - - {/* Shadcn resizeable panels: https://ui.shadcn.com/docs/components/resizable */} - - -
- {/* File tabs */} - {tabs.map((tab) => ( - { - selectFile(tab); - }} - onClose={() => closeTab(tab.id)} - > - {tab.name} - - ))} -
- {/* Monaco editor */} -
- {!activeFileId ? ( - <> -
- - No file selected. -
- - ) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643 - 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... -
- )} -
-
- - - - setIsPreviewCollapsed(true)} - onExpand={() => setIsPreviewCollapsed(false)} - > - { - previewPanelRef.current?.expand(); - setIsPreviewCollapsed(false); - }} - /> - - - - {isOwner ? ( - - ) : ( -
- - No terminal access. -
- )} -
-
-
-
- - ); + return ; } diff --git a/frontend/components/editor/loading.tsx b/frontend/components/editor/loading.tsx index 5ee9ca8..fb0148b 100644 --- a/frontend/components/editor/loading.tsx +++ b/frontend/components/editor/loading.tsx @@ -3,9 +3,22 @@ import Logo from "@/assets/logo.svg"; import { Skeleton } from "../ui/skeleton"; import { Loader, Loader2 } from "lucide-react"; -export default function Loading({ withNav = false }: { withNav?: boolean }) { +export default function Loading({ + withNav = false, + text = "", +}: { + withNav?: boolean; + text?: string; +}) { return ( -
+
+ {text ? ( +
+ + {text} +
+ ) : null} + {withNav ? (
diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index f5f0c65..07f0278 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -47,4 +47,27 @@ export function addNew(name: string, type: "file" | "folder", setFiles: React.Di console.log("adding folder"); setFiles(prev => [...prev, { id: `projects/${sandboxData.id}/${name}`, name, type: "folder", children: [] }]) } +} + +export async function startServer(sandboxId: string, userId: string, callback: (success: boolean) => void) { + try { + await fetch("http://localhost:4001/start", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sandboxId, + userId + }), + }) + + callback(true) + + } catch (error) { + console.error("Failed to start server", error) + + callback(false) + } + } \ No newline at end of file