revert to local hosting

This commit is contained in:
Ishaan Dey 2024-05-26 17:28:52 -07:00
parent 640c5c13df
commit fbb98ac3b0
22 changed files with 824 additions and 3890 deletions

View File

@ -1,47 +1,43 @@
export interface Env { export interface Env {
AI: any; AI: any
KEY: string;
} }
export default { export default {
async fetch(request, env): Promise<Response> { async fetch(request, env): Promise<Response> {
if (request.method !== 'GET') { if (request.method !== "GET") {
return new Response('Method Not Allowed', { status: 405 }); return new Response("Method Not Allowed", { status: 405 })
}
if (request.headers.get('Authorization') !== env.KEY) {
return new Response('Unauthorized', { status: 401 });
} }
const url = new URL(request.url); const url = new URL(request.url)
const fileName = url.searchParams.get('fileName'); const fileName = url.searchParams.get("fileName")
const instructions = url.searchParams.get('instructions'); const instructions = url.searchParams.get("instructions")
const line = url.searchParams.get('line'); const line = url.searchParams.get("line")
const code = url.searchParams.get('code'); const code = url.searchParams.get("code")
const response = await env.AI.run('@cf/meta/llama-3-8b-instruct', { const response = await env.AI.run("@cf/meta/llama-3-8b-instruct", {
messages: [ messages: [
{ {
role: 'system', role: "system",
content: content:
'You are an expert coding assistant. You read code from a file, and you suggest new code to add to the file. You may be given instructions on what to generate, which you should follow. You should generate code that is correct, efficient, and follows best practices. You should also generate code that is clear and easy to read. When you generate code, you should only return the code, and nothing else. You should not include backticks in the code you generate.', "You are an expert coding assistant. You read code from a file, and you suggest new code to add to the file. You may be given instructions on what to generate, which you should follow. You should generate code that is correct, efficient, and follows best practices. You should also generate code that is clear and easy to read. When you generate code, you should only return the code, and nothing else. You should not include backticks in the code you generate.",
}, },
{ {
role: 'user', role: "user",
content: `The file is called ${fileName}.`, content: `The file is called ${fileName}.`,
}, },
{ {
role: 'user', role: "user",
content: `Here are my instructions on what to generate: ${instructions}.`, content: `Here are my instructions on what to generate: ${instructions}.`,
}, },
{ {
role: 'user', role: "user",
content: `Suggest me code to insert at line ${line} in my file. Give only the code, and NOTHING else. DO NOT include backticks in your response. My code file content is as follows content: `Suggest me code to insert at line ${line} in my file. Give only the code, and NOTHING else. DO NOT include backticks in your response. My code file content is as follows
${code}`, ${code}`,
}, },
], ],
}); })
return new Response(JSON.stringify(response)); return new Response(JSON.stringify(response))
}, },
} satisfies ExportedHandler<Env>; } satisfies ExportedHandler<Env>

View File

@ -5,6 +5,3 @@ compatibility_flags = ["nodejs_compat"]
[ai] [ai]
binding = "AI" binding = "AI"
[vars]
KEY = ""

View File

@ -0,0 +1 @@
PORT=4000

View File

@ -22,7 +22,7 @@ USER appuser
# todo user namespace mapping # todo user namespace mapping
EXPOSE 3000 EXPOSE 5173
EXPOSE 4000 EXPOSE 4000
CMD [ "node", "dist/index.js" ] CMD [ "node", "dist/index.js" ]

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,6 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@aws-sdk/client-ecs": "^3.577.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",

View File

@ -6,35 +6,10 @@ import {
TFile, TFile,
TFileData, TFileData,
TFolder, TFolder,
User,
} from "./types"; } from "./types";
import {
DeleteServiceCommand,
DescribeServicesCommand,
ECSClient,
} from "@aws-sdk/client-ecs";
dotenv.config(); dotenv.config();
const client = new ECSClient({
region: "us-east-1",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export const testDescribe = async () => {
const command = new DescribeServicesCommand({
cluster: "Sandbox",
services: ["Sandbox"],
});
const response = await client.send(command);
console.log("describing: ", response);
return response;
};
export const getSandboxFiles = async (id: string) => { export const getSandboxFiles = async (id: string) => {
const res = await fetch( const res = await fetch(
`https://storage.ishaan1013.workers.dev/api?sandboxId=${id}` `https://storage.ishaan1013.workers.dev/api?sandboxId=${id}`
@ -176,18 +151,3 @@ export const getProjectSize = async (id: string) => {
); );
return (await res.json()).size; return (await res.json()).size;
}; };
export const stopServer = async (service: string) => {
const command = new DeleteServiceCommand({
cluster: process.env.AWS_ECS_CLUSTER!,
service,
force: true,
});
try {
const response = await client.send(command);
return response;
} catch (error) {
console.error("Error stopping server:", error);
}
};

View File

@ -1,8 +1,8 @@
const startercode = { const startercode = {
node: [ node: [
{ name: 'index.js', body: `console.log("Hello World!")` }, { name: "index.js", body: `console.log("Hello World!")` },
{ {
name: 'package.json', name: "package.json",
body: `{ body: `{
"name": "nodejs", "name": "nodejs",
"version": "1.0.0", "version": "1.0.0",
@ -19,7 +19,7 @@ const startercode = {
], ],
react: [ react: [
{ {
name: 'package.json', name: "package.json",
body: `{ body: `{
"name": "react", "name": "react",
"private": true, "private": true,
@ -48,7 +48,7 @@ const startercode = {
}`, }`,
}, },
{ {
name: 'vite.config.js', name: "vite.config.js",
body: `import { defineConfig } from 'vite' body: `import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
@ -56,14 +56,14 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 3000, port: 5173,
host: "0.0.0.0", host: "0.0.0.0",
} }
}) })
`, `,
}, },
{ {
name: 'index.html', name: "index.html",
body: `<!doctype html> body: `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@ -80,7 +80,7 @@ export default defineConfig({
`, `,
}, },
{ {
name: 'src/App.css', name: "src/App.css",
body: `div { body: `div {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
@ -108,7 +108,7 @@ button {
}`, }`,
}, },
{ {
name: 'src/App.jsx', name: "src/App.jsx",
body: `import './App.css' body: `import './App.css'
import { useState } from 'react' import { useState } from 'react'
@ -133,7 +133,7 @@ export default App
`, `,
}, },
{ {
name: 'src/main.jsx', name: "src/main.jsx",
body: `import React from 'react' body: `import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App.jsx' import App from './App.jsx'
@ -146,6 +146,6 @@ ReactDOM.createRoot(document.getElementById('root')).render(
`, `,
}, },
], ],
}; }
export default startercode; export default startercode

View File

@ -3,13 +3,7 @@ CLERK_SECRET_KEY=
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY= NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
LIVEBLOCKS_SECRET_KEY= LIVEBLOCKS_SECRET_KEY=
VERCEL_ENV=development NEXT_PUBLIC_SERVER_PORT=4000
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
NEXT_PUBLIC_AWS_ECS_CLUSTER=
AWS_ECS_SECURITY_GROUP=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

View File

@ -1,30 +0,0 @@
# Sandbox: Frontend
This is a Next.js 14 project using Typescript, Shadcn UI, Clerk, Socket.IO, and the Monaco Editor.
## Setup
Create a [Clerk](https://clerk.dev/) account and project to get your public + private key.
Then, set up your `.env` file with the required environment variables:
```env
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=<YOUR_KEY>
CLERK_SECRET_KEY=<YOUR_KEY>
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
```
## Running Locally
First, run the development server:
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Deploy on Vercel or any platform of your choice.

View File

@ -4,6 +4,8 @@ import { Sandbox, User, UsersToSandboxes } from "@/lib/types"
import { currentUser } from "@clerk/nextjs" import { currentUser } from "@clerk/nextjs"
import { notFound, redirect } from "next/navigation" import { notFound, redirect } from "next/navigation"
import Editor from "@/components/editor" import Editor from "@/components/editor"
import Loading from "@/components/editor/loading"
import dynamic from "next/dynamic"
export const revalidate = 0 export const revalidate = 0
@ -41,6 +43,11 @@ const getSharedUsers = async (usersToSandboxes: UsersToSandboxes[]) => {
return shared return shared
} }
const CodeEditor = dynamic(() => import("@/components/editor"), {
ssr: false,
loading: () => <Loading />,
})
export default async function CodePage({ params }: { params: { id: string } }) { export default async function CodePage({ params }: { params: { id: string } }) {
const user = await currentUser() const user = await currentUser()
const sandboxId = params.id const sandboxId = params.id
@ -67,7 +74,7 @@ export default async function CodePage({ params }: { params: { id: string } }) {
<Room id={sandboxId}> <Room id={sandboxId}>
<Navbar userData={userData} sandboxData={sandboxData} shared={shared} /> <Navbar userData={userData} sandboxData={sandboxData} shared={shared} />
<div className="w-screen flex grow"> <div className="w-screen flex grow">
<Editor userData={userData} sandboxData={sandboxData} /> <CodeEditor userData={userData} sandboxData={sandboxData} />
</div> </div>
</Room> </Room>
</div> </div>

View File

@ -1,21 +1,21 @@
import type { Metadata } from "next"; import type { Metadata } from "next"
import { GeistSans } from "geist/font/sans"; import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"; import { GeistMono } from "geist/font/mono"
import "./globals.css"; import "./globals.css"
import { ThemeProvider } from "@/components/layout/themeProvider"; import { ThemeProvider } from "@/components/layout/themeProvider"
import { ClerkProvider } from "@clerk/nextjs"; import { ClerkProvider } from "@clerk/nextjs"
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner"
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sandbox", title: "Sandbox",
description: "A collaborative, AI-powered, auto-scaling code sandbox", description: "A collaborative, AI-powered cloud code editing environment",
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode
}>) { }>) {
return ( return (
<ClerkProvider> <ClerkProvider>
@ -34,5 +34,5 @@ export default function RootLayout({
</body> </body>
</html> </html>
</ClerkProvider> </ClerkProvider>
); )
} }

View File

@ -1,4 +1,4 @@
"use client"; "use client"
import { import {
Dialog, Dialog,
@ -7,19 +7,19 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog"
import Image from "next/image"; import Image from "next/image"
import { useState } from "react"; import { useState } from "react"
import { Button } from "../ui/button"; import { Button } from "../ui/button"
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react"
export default function AboutModal({ export default function AboutModal({
open, open,
setOpen, setOpen,
}: { }: {
open: boolean; open: boolean
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void
}) { }) {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@ -29,11 +29,9 @@ export default function AboutModal({
</DialogHeader> </DialogHeader>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Sandbox is an open-source cloud-based code editing environment with Sandbox is an open-source cloud-based code editing environment with
custom AI code autocompletion and real-time collaboration. The custom AI code autocompletion and real-time collaboration.
infrastructure runs on Docker and AWS ECS to scale automatically based
on resource usage.
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); )
} }

View File

@ -1,764 +0,0 @@
"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,
ip,
}: {
userData: User
sandboxData: Sandbox
ip: string
}) {
const socket = io(
`http://${ip}:4000?userId=${userData.id}&sandboxId=${sandboxData.id}`,
{
timeout: 2000,
}
)
const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true)
const [disableAccess, setDisableAccess] = useState({
isDisabled: false,
message: "",
})
// File state
const [files, setFiles] = useState<(TFolder | TFile)[]>([])
const [tabs, setTabs] = useState<TTab[]>([])
const [activeFileId, setActiveFileId] = useState<string>("")
const [activeFileContent, setActiveFileContent] = useState("")
const [deletingFolderId, setDeletingFolderId] = useState("")
// Editor state
const [editorLanguage, setEditorLanguage] = useState("plaintext")
const [cursorLine, setCursorLine] = useState(0)
const [editorRef, setEditorRef] =
useState<monaco.editor.IStandaloneCodeEditor>()
// 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 isOwner = sandboxData.userId === userData.id
const clerk = useClerk()
// Liveblocks hooks
const room = useRoom()
const [provider, setProvider] = useState<TypedLiveblocksProvider>()
// Refs for libraries / features
const editorContainerRef = useRef<HTMLDivElement>(null)
const monacoRef = useRef<typeof monaco | null>(null)
const generateRef = useRef<HTMLDivElement>(null)
const generateWidgetRef = useRef<HTMLDivElement>(null)
const previewPanelRef = useRef<ImperativePanelHandle>(null)
const editorPanelRef = useRef<ImperativePanelHandle>(null)
// 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,
}
},
}
// window width - sidebar width, times the percentage of the editor panel
const width = editorPanelRef.current
? (editorPanelRef.current.getSize() / 100) * (window.innerWidth - 224)
: 400 //fallback
setGenerate((prev) => {
return {
...prev,
widget: contentWidget,
width,
}
})
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
useEffect(() => {
socket.connect()
return () => {
socket.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
setGenerate((prev) => {
return {
...prev,
show: false,
}
})
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("")
})
}
// On disabled access for shared users, show un-interactable loading placeholder + info modal
if (disableAccess.isDisabled)
return (
<>
<DisableAccessModal
message={disableAccess.message}
open={disableAccess.isDisabled}
setOpen={() => {}}
/>
<Loading />
</>
)
return (
<>
{/* Copilot DOM elements */}
<div ref={generateRef} />
<div className="z-50 p-1" ref={generateWidgetRef}>
{generate.show && ai ? (
<GenerateInput
user={userData}
socket={socket}
width={generate.width - 90}
data={{
fileName: tabs.find((t) => 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)
}}
onClose={() => {
setGenerate((prev) => {
return {
...prev,
show: !prev.show,
}
})
}}
/>
) : null}
</div>
{/* Main editor components */}
<Sidebar
sandboxData={sandboxData}
files={files}
selectFile={selectFile}
handleRename={handleRename}
handleDeleteFile={handleDeleteFile}
handleDeleteFolder={handleDeleteFolder}
socket={socket}
setFiles={setFiles}
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
deletingFolderId={deletingFolderId}
// AI Copilot Toggle
ai={ai}
setAi={setAi}
/>
{/* Shadcn resizeable panels: https://ui.shadcn.com/docs/components/resizable */}
<ResizablePanelGroup direction="horizontal">
<ResizablePanel
className="p-2 flex flex-col"
maxSize={80}
minSize={30}
defaultSize={60}
ref={editorPanelRef}
>
<div className="h-10 w-full flex gap-2 overflow-auto tab-scroll">
{/* File tabs */}
{tabs.map((tab) => (
<Tab
key={tab.id}
saved={tab.saved}
selected={activeFileId === tab.id}
onClick={(e) => {
selectFile(tab)
}}
onClose={() => closeTab(tab.id)}
>
{tab.name}
</Tab>
))}
</div>
{/* Monaco editor */}
<div
ref={editorContainerRef}
className="grow w-full overflow-hidden rounded-md relative"
>
{!activeFileId ? (
<>
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<FileJson className="w-6 h-6 mr-3" />
No file selected.
</div>
</>
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
clerk.loaded ? (
<>
{provider ? <Cursors yProvider={provider} /> : null}
<Editor
height="100%"
language={editorLanguage}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
onChange={(value) => {
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={{
tabSize: 2,
minimap: {
enabled: false,
},
padding: {
bottom: 4,
top: 4,
},
scrollBeyondLastLine: false,
fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)",
}}
theme="vs-dark"
value={activeFileContent}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<Loader2 className="animate-spin w-6 h-6 mr-3" />
Waiting for Clerk to load...
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={40}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel
ref={previewPanelRef}
defaultSize={4}
collapsedSize={4}
minSize={25}
collapsible
className="p-2 flex flex-col"
onCollapse={() => setIsPreviewCollapsed(true)}
onExpand={() => setIsPreviewCollapsed(false)}
>
<PreviewWindow
collapsed={isPreviewCollapsed}
open={() => {
previewPanelRef.current?.expand()
setIsPreviewCollapsed(false)
}}
ip={ip}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
defaultSize={50}
minSize={20}
className="p-2 flex flex-col"
>
{isOwner ? (
<Terminals
terminals={terminals}
setTerminals={setTerminals}
socket={socket}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-lg font-medium text-muted-foreground/50 select-none">
<TerminalSquare className="w-4 h-4 mr-2" />
No terminal access.
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</>
)
}

View File

@ -1,66 +1,761 @@
"use client" "use client"
import dynamic from "next/dynamic" import { useEffect, useRef, useState } from "react"
import Loading from "@/components/editor/loading" import monaco from "monaco-editor"
import { Sandbox, User } from "@/lib/types" import Editor, { BeforeMount, OnMount } from "@monaco-editor/react"
import { useEffect, useState } from "react" import { io } from "socket.io-client"
import { toast } from "sonner" import { toast } from "sonner"
import { getTaskIp, startServer } from "@/lib/actions" import { useClerk } from "@clerk/nextjs"
import { checkServiceStatus, setupServer } from "@/lib/utils"
const CodeEditor = dynamic(() => import("@/components/editor/editor"), { import * as Y from "yjs"
ssr: false, import LiveblocksProvider from "@liveblocks/yjs"
loading: () => <Loading />, import { MonacoBinding } from "y-monaco"
}) import { Awareness } from "y-protocols/awareness"
import { TypedLiveblocksProvider, useRoom } from "@/liveblocks.config"
export default function Editor({ 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, userData,
sandboxData, sandboxData,
}: { }: {
userData: User userData: User
sandboxData: Sandbox sandboxData: Sandbox
}) { }) {
const isDev = process.env.VERCEL_ENV === "development" const socket = io(
`http://localhost:${process.env.NEXT_PUBLIC_SERVER_PORT}?userId=${userData.id}&sandboxId=${sandboxData.id}`,
{
timeout: 2000,
}
)
const [isServiceRunning, setIsServiceRunning] = useState(false) const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true)
const [isDeploymentActive, setIsDeploymentActive] = useState(false) const [disableAccess, setDisableAccess] = useState({
const [taskIp, setTaskIp] = useState<string>() isDisabled: false,
const [didFail, setDidFail] = useState(false) message: "",
})
// File state
const [files, setFiles] = useState<(TFolder | TFile)[]>([])
const [tabs, setTabs] = useState<TTab[]>([])
const [activeFileId, setActiveFileId] = useState<string>("")
const [activeFileContent, setActiveFileContent] = useState("")
const [deletingFolderId, setDeletingFolderId] = useState("")
// Editor state
const [editorLanguage, setEditorLanguage] = useState("plaintext")
const [cursorLine, setCursorLine] = useState(0)
const [editorRef, setEditorRef] =
useState<monaco.editor.IStandaloneCodeEditor>()
// 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 isOwner = sandboxData.userId === userData.id
const clerk = useClerk()
// Liveblocks hooks
const room = useRoom()
const [provider, setProvider] = useState<TypedLiveblocksProvider>()
// Refs for libraries / features
const editorContainerRef = useRef<HTMLDivElement>(null)
const monacoRef = useRef<typeof monaco | null>(null)
const generateRef = useRef<HTMLDivElement>(null)
const generateWidgetRef = useRef<HTMLDivElement>(null)
const previewPanelRef = useRef<ImperativePanelHandle>(null)
const editorPanelRef = useRef<ImperativePanelHandle>(null)
// 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(() => { useEffect(() => {
if (isDev) { if (!ai) {
setIsServiceRunning(true) setGenerate((prev) => {
setIsDeploymentActive(true) return {
setTaskIp("localhost") ...prev,
show: false,
}
})
return 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 }
})
})
setupServer({ if (!generateWidgetRef.current) return
sandboxId: sandboxData.id, const widgetElement = generateWidgetRef.current
setIsServiceRunning,
setIsDeploymentActive, const contentWidget = {
setTaskIp, getDomNode: () => {
setDidFail, return widgetElement
toast, },
}) getId: () => {
return "generate.widget"
},
getPosition: () => {
return {
position: {
lineNumber: cursorLine,
column: 1,
},
preference: generate.pref,
}
},
}
// window width - sidebar width, times the percentage of the editor panel
const width = editorPanelRef.current
? (editorPanelRef.current.getSize() / 100) * (window.innerWidth - 224)
: 400 //fallback
setGenerate((prev) => {
return {
...prev,
widget: contentWidget,
width,
}
})
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
useEffect(() => {
socket.connect()
return () => {
socket.disconnect()
}
}, []) }, [])
if (didFail) return <Loading didFail={didFail} /> // Socket event listener effect
if (!isServiceRunning || !isDeploymentActive || !taskIp) 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
setGenerate((prev) => {
return {
...prev,
show: false,
}
})
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("")
})
}
// On disabled access for shared users, show un-interactable loading placeholder + info modal
if (disableAccess.isDisabled)
return ( return (
<Loading <>
text="Creating sandbox resources" <DisableAccessModal
description={ message={disableAccess.message}
isDeploymentActive open={disableAccess.isDisabled}
? "Preparing server networking..." setOpen={() => {}}
: isServiceRunning />
? "Initializing server, this could take a minute..." <Loading />
: "Requesting your server creation..." </>
}
/>
) )
return ( return (
<CodeEditor ip={taskIp} userData={userData} sandboxData={sandboxData} /> <>
{/* Copilot DOM elements */}
<div ref={generateRef} />
<div className="z-50 p-1" ref={generateWidgetRef}>
{generate.show && ai ? (
<GenerateInput
user={userData}
socket={socket}
width={generate.width - 90}
data={{
fileName: tabs.find((t) => 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)
}}
onClose={() => {
setGenerate((prev) => {
return {
...prev,
show: !prev.show,
}
})
}}
/>
) : null}
</div>
{/* Main editor components */}
<Sidebar
sandboxData={sandboxData}
files={files}
selectFile={selectFile}
handleRename={handleRename}
handleDeleteFile={handleDeleteFile}
handleDeleteFolder={handleDeleteFolder}
socket={socket}
setFiles={setFiles}
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
deletingFolderId={deletingFolderId}
// AI Copilot Toggle
ai={ai}
setAi={setAi}
/>
{/* Shadcn resizeable panels: https://ui.shadcn.com/docs/components/resizable */}
<ResizablePanelGroup direction="horizontal">
<ResizablePanel
className="p-2 flex flex-col"
maxSize={80}
minSize={30}
defaultSize={60}
ref={editorPanelRef}
>
<div className="h-10 w-full flex gap-2 overflow-auto tab-scroll">
{/* File tabs */}
{tabs.map((tab) => (
<Tab
key={tab.id}
saved={tab.saved}
selected={activeFileId === tab.id}
onClick={(e) => {
selectFile(tab)
}}
onClose={() => closeTab(tab.id)}
>
{tab.name}
</Tab>
))}
</div>
{/* Monaco editor */}
<div
ref={editorContainerRef}
className="grow w-full overflow-hidden rounded-md relative"
>
{!activeFileId ? (
<>
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<FileJson className="w-6 h-6 mr-3" />
No file selected.
</div>
</>
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
clerk.loaded ? (
<>
{provider ? <Cursors yProvider={provider} /> : null}
<Editor
height="100%"
language={editorLanguage}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
onChange={(value) => {
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={{
tabSize: 2,
minimap: {
enabled: false,
},
padding: {
bottom: 4,
top: 4,
},
scrollBeyondLastLine: false,
fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)",
}}
theme="vs-dark"
value={activeFileContent}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<Loader2 className="animate-spin w-6 h-6 mr-3" />
Waiting for Clerk to load...
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={40}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel
ref={previewPanelRef}
defaultSize={4}
collapsedSize={4}
minSize={25}
collapsible
className="p-2 flex flex-col"
onCollapse={() => setIsPreviewCollapsed(true)}
onExpand={() => setIsPreviewCollapsed(false)}
>
<PreviewWindow
collapsed={isPreviewCollapsed}
open={() => {
previewPanelRef.current?.expand()
setIsPreviewCollapsed(false)
}}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
defaultSize={50}
minSize={20}
className="p-2 flex flex-col"
>
{isOwner ? (
<Terminals
terminals={terminals}
setTerminals={setTerminals}
socket={socket}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-lg font-medium text-muted-foreground/50 select-none">
<TerminalSquare className="w-4 h-4 mr-2" />
No terminal access.
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</>
) )
} }

View File

@ -1,4 +1,4 @@
"use client"; "use client"
import { import {
ChevronLeft, ChevronLeft,
@ -8,21 +8,19 @@ import {
RotateCw, RotateCw,
TerminalSquare, TerminalSquare,
UnfoldVertical, UnfoldVertical,
} from "lucide-react"; } from "lucide-react"
import { useRef, useState } from "react"; import { useRef, useState } from "react"
import { toast } from "sonner"; import { toast } from "sonner"
export default function PreviewWindow({ export default function PreviewWindow({
collapsed, collapsed,
open, open,
ip,
}: { }: {
collapsed: boolean; collapsed: boolean
open: () => void; open: () => void
ip: string;
}) { }) {
const ref = useRef<HTMLIFrameElement>(null); const ref = useRef<HTMLIFrameElement>(null)
const [iframeKey, setIframeKey] = useState(0); const [iframeKey, setIframeKey] = useState(0)
return ( return (
<> <>
@ -32,12 +30,7 @@ export default function PreviewWindow({
} select-none w-full flex gap-2`} } select-none w-full flex gap-2`}
> >
<div className="h-8 rounded-md px-3 bg-secondary flex items-center w-full justify-between"> <div className="h-8 rounded-md px-3 bg-secondary flex items-center w-full justify-between">
<div className="text-xs"> <div className="text-xs">Preview</div>
Preview
{/* <span className="inline-block ml-2 items-center font-mono text-muted-foreground">
localhost:8000
</span> */}
</div>
<div className="flex space-x-1 translate-x-1"> <div className="flex space-x-1 translate-x-1">
{collapsed ? ( {collapsed ? (
<PreviewButton onClick={open}> <PreviewButton onClick={open}>
@ -52,8 +45,8 @@ export default function PreviewWindow({
<PreviewButton <PreviewButton
onClick={() => { onClick={() => {
navigator.clipboard.writeText(`http://${ip}:3000`); navigator.clipboard.writeText(`http://localhost:5173`)
toast.info("Copied preview link to clipboard"); toast.info("Copied preview link to clipboard")
}} }}
> >
<Link className="w-4 h-4" /> <Link className="w-4 h-4" />
@ -63,7 +56,7 @@ export default function PreviewWindow({
// if (ref.current) { // if (ref.current) {
// ref.current.contentWindow?.location.reload(); // ref.current.contentWindow?.location.reload();
// } // }
setIframeKey((prev) => prev + 1); setIframeKey((prev) => prev + 1)
}} }}
> >
<RotateCw className="w-3 h-3" /> <RotateCw className="w-3 h-3" />
@ -80,12 +73,12 @@ export default function PreviewWindow({
ref={ref} ref={ref}
width={"100%"} width={"100%"}
height={"100%"} height={"100%"}
src={`http://${ip}:3000`} src={`http://localhost:5173`}
/> />
</div> </div>
)} )}
</> </>
); )
} }
function PreviewButton({ function PreviewButton({
@ -93,9 +86,9 @@ function PreviewButton({
disabled = false, disabled = false,
onClick, onClick,
}: { }: {
children: React.ReactNode; children: React.ReactNode
disabled?: boolean; disabled?: boolean
onClick: () => void; onClick: () => void
}) { }) {
return ( return (
<div <div
@ -106,5 +99,5 @@ function PreviewButton({
> >
{children} {children}
</div> </div>
); )
} }

View File

@ -1,9 +1,9 @@
import Image from "next/image"; import Image from "next/image"
import Logo from "@/assets/logo.svg"; import Logo from "@/assets/logo.svg"
import XLogo from "@/assets/x.svg"; import XLogo from "@/assets/x.svg"
import Button from "@/components/ui/customButton"; import Button from "@/components/ui/customButton"
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react"
import Link from "next/link"; import Link from "next/link"
export default function Landing() { export default function Landing() {
return ( return (
@ -26,13 +26,11 @@ export default function Landing() {
</div> </div>
</div> </div>
<h1 className="text-2xl font-medium text-center mt-16"> <h1 className="text-2xl font-medium text-center mt-16">
A Collaborative, AI-Powered, Auto-Scaling Code Editor A Collaborative + AI-Powered Code Environment
</h1> </h1>
<div className="text-muted-foreground mt-4 text-center "> <div className="text-muted-foreground mt-4 text-center ">
Sandbox is an open-source cloud-based code editing environment with Sandbox is an open-source cloud-based code editing environment with
custom AI code autocompletion and real-time collaboration. The custom AI code autocompletion and real-time collaboration.
infrastructure runs on Docker and AWS ECS to scale automatically based
on resource usage.
</div> </div>
<div className="mt-8 flex space-x-4"> <div className="mt-8 flex space-x-4">
<Link href="/sign-up"> <Link href="/sign-up">
@ -50,5 +48,5 @@ export default function Landing() {
<div className="aspect-video w-full rounded-lg bg-neutral-800 mt-12"></div> <div className="aspect-video w-full rounded-lg bg-neutral-800 mt-12"></div>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,15 +1,6 @@
"use server" "use server"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import ecsClient, { ec2Client } from "./ecs"
import {
CreateServiceCommand,
DescribeClustersCommand,
DescribeServicesCommand,
DescribeTasksCommand,
ListTasksCommand,
} from "@aws-sdk/client-ecs"
import { DescribeNetworkInterfacesCommand } from "@aws-sdk/client-ec2"
export async function createSandbox(body: { export async function createSandbox(body: {
type: string type: string
@ -87,142 +78,3 @@ export async function unshareSandbox(sandboxId: string, userId: string) {
revalidatePath(`/code/${sandboxId}`) revalidatePath(`/code/${sandboxId}`)
} }
export async function describeService(serviceName: string) {
const command = new DescribeServicesCommand({
cluster: process.env.NEXT_PUBLIC_AWS_ECS_CLUSTER!,
services: [serviceName],
})
return await ecsClient.send(command)
}
export async function getTaskIp(serviceName: string) {
const listCommand = new ListTasksCommand({
cluster: process.env.NEXT_PUBLIC_AWS_ECS_CLUSTER!,
serviceName,
})
const listResponse = await ecsClient.send(listCommand)
const taskArns = listResponse.taskArns
const describeCommand = new DescribeTasksCommand({
cluster: process.env.NEXT_PUBLIC_AWS_ECS_CLUSTER!,
tasks: taskArns,
})
const describeResponse = await ecsClient.send(describeCommand)
const tasks = describeResponse.tasks
const taskAttachment = tasks?.[0].attachments?.[0].details
if (!taskAttachment) {
throw new Error("Task attachment not found")
}
const eni = taskAttachment.find(
(detail) => detail.name === "networkInterfaceId"
)?.value
if (!eni) {
throw new Error("Network interface not found")
}
const describeNetworkInterfacesCommand = new DescribeNetworkInterfacesCommand(
{
NetworkInterfaceIds: [eni],
}
)
const describeNetworkInterfacesResponse = await ec2Client.send(
describeNetworkInterfacesCommand
)
const ip =
describeNetworkInterfacesResponse.NetworkInterfaces?.[0].Association
?.PublicIp
if (!ip) {
throw new Error("Public IP not found")
}
return ip
}
export async function doesServiceExist(serviceName: string) {
const response = await describeService(serviceName)
const activeServices = response.services?.filter(
(service) => service.status === "ACTIVE"
)
console.log("activeServices: ", activeServices)
return activeServices?.length === 1
}
async function countServices() {
const command = new DescribeClustersCommand({
clusters: [process.env.NEXT_PUBLIC_AWS_ECS_CLUSTER!],
})
const response = await ecsClient.send(command)
return response.clusters?.[0].activeServicesCount!
}
export async function startServer(
serviceName: string
): Promise<{ success: boolean; message: string }> {
const serviceExists = await doesServiceExist(serviceName)
if (serviceExists) {
return { success: true, message: "" }
}
const activeServices = await countServices()
if (activeServices >= 100) {
return {
success: false,
message:
"Too many servers are running! Please try again later or contact @ishaandey_ on Twitter/X.",
}
}
const command = new CreateServiceCommand({
cluster: process.env.NEXT_PUBLIC_AWS_ECS_CLUSTER!,
serviceName,
taskDefinition: "Sandbox1",
desiredCount: 1,
launchType: "FARGATE",
networkConfiguration: {
awsvpcConfiguration: {
securityGroups: [process.env.AWS_ECS_SECURITY_GROUP!],
subnets: [
"subnet-06d04f2a6ebb1710c",
"subnet-097c000f157c26a78",
"subnet-00f931ecbabaf87dd",
"subnet-0adcb82d77db9f263",
"subnet-0c6874150d8e63a7c",
"subnet-0b76f9ee3fe20660d",
],
assignPublicIp: "ENABLED",
},
},
})
try {
const response = await ecsClient.send(command)
console.log("started server:", response.service?.serviceName)
return { success: true, message: "" }
// store in workers kv:
// {
// userId: {
// sandboxId,
// serviceName,
// startedAt,
// }
// }
} catch (error: any) {
// console.error("Error starting server:", error.message);
return {
success: false,
message: `Error starting server: ${error.message}. Try again in a minute, or contact @ishaandey_ on Twitter/X if it still doesn't work.`,
}
}
}

View File

@ -1,20 +0,0 @@
import { ECSClient } from "@aws-sdk/client-ecs";
import { EC2Client } from "@aws-sdk/client-ec2";
const ecsClient = new ECSClient({
region: "us-east-1",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export const ec2Client = new EC2Client({
region: "us-east-1",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export default ecsClient;

View File

@ -2,13 +2,6 @@ import { type ClassValue, clsx } from "clsx"
// import { toast } from "sonner" // import { toast } from "sonner"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { Sandbox, TFile, TFolder } from "./types" import { Sandbox, TFile, TFolder } from "./types"
import { Service } from "@aws-sdk/client-ecs"
import {
describeService,
doesServiceExist,
getTaskIp,
startServer,
} from "./actions"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@ -68,89 +61,3 @@ export function addNew(
]) ])
} }
} }
export function checkServiceStatus(serviceName: string): Promise<Service> {
return new Promise((resolve, reject) => {
let tries = 0
const interval = setInterval(async () => {
try {
tries++
if (tries > 40) {
clearInterval(interval)
reject(new Error("Timed out."))
}
const response = await describeService(serviceName)
const activeServices = response.services?.filter(
(service) => service.status === "ACTIVE"
)
console.log("Checking activeServices status", activeServices)
if (activeServices?.length === 1) {
const service = activeServices?.[0]
if (
service.runningCount === service.desiredCount &&
service.deployments?.length === 1
) {
if (service.deployments[0].rolloutState === "COMPLETED") {
clearInterval(interval)
resolve(service)
} else if (service.deployments[0].rolloutState === "FAILED") {
clearInterval(interval)
reject(new Error("Deployment failed."))
}
}
}
} catch (error: any) {
clearInterval(interval)
reject(error)
}
}, 3000)
})
}
export async function setupServer({
sandboxId,
setIsServiceRunning,
setIsDeploymentActive,
setTaskIp,
setDidFail,
toast,
}: {
sandboxId: string
setIsServiceRunning: React.Dispatch<React.SetStateAction<boolean>>
setIsDeploymentActive: React.Dispatch<React.SetStateAction<boolean>>
setTaskIp: React.Dispatch<React.SetStateAction<string | undefined>>
setDidFail: React.Dispatch<React.SetStateAction<boolean>>
toast: any
}) {
const doesExist = await doesServiceExist(sandboxId)
if (!doesExist) {
const response = await startServer(sandboxId)
if (!response.success) {
toast.error(response.message)
setDidFail(true)
return
}
}
setIsServiceRunning(true)
try {
if (!doesExist) {
await checkServiceStatus(sandboxId)
}
setIsDeploymentActive(true)
const taskIp = await getTaskIp(sandboxId)
setTaskIp(taskIp)
} catch (error) {
toast.error("An error occurred while initializing your server.")
setDidFail(true)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.1.7", "@atlaskit/pragmatic-drag-and-drop": "^1.1.7",
"@aws-sdk/client-ec2": "^3.582.0",
"@aws-sdk/client-ecs": "^3.577.0",
"@clerk/nextjs": "^4.29.12", "@clerk/nextjs": "^4.29.12",
"@clerk/themes": "^1.7.12", "@clerk/themes": "^1.7.12",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",