revert to local hosting
This commit is contained in:
parent
640c5c13df
commit
fbb98ac3b0
@ -1,47 +1,43 @@
|
||||
export interface Env {
|
||||
AI: any;
|
||||
KEY: string;
|
||||
AI: any
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env): Promise<Response> {
|
||||
if (request.method !== 'GET') {
|
||||
return new Response('Method Not Allowed', { status: 405 });
|
||||
}
|
||||
if (request.headers.get('Authorization') !== env.KEY) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
if (request.method !== "GET") {
|
||||
return new Response("Method Not Allowed", { status: 405 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const fileName = url.searchParams.get('fileName');
|
||||
const instructions = url.searchParams.get('instructions');
|
||||
const line = url.searchParams.get('line');
|
||||
const code = url.searchParams.get('code');
|
||||
const url = new URL(request.url)
|
||||
const fileName = url.searchParams.get("fileName")
|
||||
const instructions = url.searchParams.get("instructions")
|
||||
const line = url.searchParams.get("line")
|
||||
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: [
|
||||
{
|
||||
role: 'system',
|
||||
role: "system",
|
||||
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}.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
role: "user",
|
||||
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
|
||||
|
||||
${code}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify(response));
|
||||
return new Response(JSON.stringify(response))
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
} satisfies ExportedHandler<Env>
|
||||
|
@ -5,6 +5,3 @@ compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[ai]
|
||||
binding = "AI"
|
||||
|
||||
[vars]
|
||||
KEY = ""
|
||||
|
1
backend/server/.env.example
Normal file
1
backend/server/.env.example
Normal file
@ -0,0 +1 @@
|
||||
PORT=4000
|
@ -22,7 +22,7 @@ USER appuser
|
||||
|
||||
# todo user namespace mapping
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 5173
|
||||
EXPOSE 4000
|
||||
|
||||
CMD [ "node", "dist/index.js" ]
|
1157
backend/server/package-lock.json
generated
1157
backend/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,7 +11,6 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ecs": "^3.577.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
|
@ -6,35 +6,10 @@ import {
|
||||
TFile,
|
||||
TFileData,
|
||||
TFolder,
|
||||
User,
|
||||
} from "./types";
|
||||
|
||||
import {
|
||||
DeleteServiceCommand,
|
||||
DescribeServicesCommand,
|
||||
ECSClient,
|
||||
} from "@aws-sdk/client-ecs";
|
||||
|
||||
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) => {
|
||||
const res = await fetch(
|
||||
`https://storage.ishaan1013.workers.dev/api?sandboxId=${id}`
|
||||
@ -176,18 +151,3 @@ export const getProjectSize = async (id: string) => {
|
||||
);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
@ -1,8 +1,8 @@
|
||||
const startercode = {
|
||||
node: [
|
||||
{ name: 'index.js', body: `console.log("Hello World!")` },
|
||||
{ name: "index.js", body: `console.log("Hello World!")` },
|
||||
{
|
||||
name: 'package.json',
|
||||
name: "package.json",
|
||||
body: `{
|
||||
"name": "nodejs",
|
||||
"version": "1.0.0",
|
||||
@ -19,7 +19,7 @@ const startercode = {
|
||||
],
|
||||
react: [
|
||||
{
|
||||
name: 'package.json',
|
||||
name: "package.json",
|
||||
body: `{
|
||||
"name": "react",
|
||||
"private": true,
|
||||
@ -48,7 +48,7 @@ const startercode = {
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: 'vite.config.js',
|
||||
name: "vite.config.js",
|
||||
body: `import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
@ -56,14 +56,14 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 5173,
|
||||
host: "0.0.0.0",
|
||||
}
|
||||
})
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'index.html',
|
||||
name: "index.html",
|
||||
body: `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -80,7 +80,7 @@ export default defineConfig({
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/App.css',
|
||||
name: "src/App.css",
|
||||
body: `div {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
@ -108,7 +108,7 @@ button {
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: 'src/App.jsx',
|
||||
name: "src/App.jsx",
|
||||
body: `import './App.css'
|
||||
import { useState } from 'react'
|
||||
|
||||
@ -133,7 +133,7 @@ export default App
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/main.jsx',
|
||||
name: "src/main.jsx",
|
||||
body: `import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
@ -146,6 +146,6 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default startercode;
|
||||
export default startercode
|
||||
|
@ -3,13 +3,7 @@ CLERK_SECRET_KEY=
|
||||
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
|
||||
LIVEBLOCKS_SECRET_KEY=
|
||||
|
||||
VERCEL_ENV=development
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
||||
NEXT_PUBLIC_AWS_ECS_CLUSTER=
|
||||
AWS_ECS_SECURITY_GROUP=
|
||||
NEXT_PUBLIC_SERVER_PORT=4000
|
||||
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||
|
@ -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.
|
@ -4,6 +4,8 @@ import { Sandbox, User, UsersToSandboxes } from "@/lib/types"
|
||||
import { currentUser } from "@clerk/nextjs"
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
import Editor from "@/components/editor"
|
||||
import Loading from "@/components/editor/loading"
|
||||
import dynamic from "next/dynamic"
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
@ -41,6 +43,11 @@ const getSharedUsers = async (usersToSandboxes: UsersToSandboxes[]) => {
|
||||
return shared
|
||||
}
|
||||
|
||||
const CodeEditor = dynamic(() => import("@/components/editor"), {
|
||||
ssr: false,
|
||||
loading: () => <Loading />,
|
||||
})
|
||||
|
||||
export default async function CodePage({ params }: { params: { id: string } }) {
|
||||
const user = await currentUser()
|
||||
const sandboxId = params.id
|
||||
@ -67,7 +74,7 @@ export default async function CodePage({ params }: { params: { id: string } }) {
|
||||
<Room id={sandboxId}>
|
||||
<Navbar userData={userData} sandboxData={sandboxData} shared={shared} />
|
||||
<div className="w-screen flex grow">
|
||||
<Editor userData={userData} sandboxData={sandboxData} />
|
||||
<CodeEditor userData={userData} sandboxData={sandboxData} />
|
||||
</div>
|
||||
</Room>
|
||||
</div>
|
||||
|
@ -1,21 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/layout/themeProvider";
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import type { Metadata } from "next"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import "./globals.css"
|
||||
import { ThemeProvider } from "@/components/layout/themeProvider"
|
||||
import { ClerkProvider } from "@clerk/nextjs"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { Analytics } from "@vercel/analytics/react"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sandbox",
|
||||
description: "A collaborative, AI-powered, auto-scaling code sandbox",
|
||||
};
|
||||
description: "A collaborative, AI-powered cloud code editing environment",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<ClerkProvider>
|
||||
@ -34,5 +34,5 @@ export default function RootLayout({
|
||||
</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@ -7,19 +7,19 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
} from "@/components/ui/dialog"
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Button } from "../ui/button"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
|
||||
export default function AboutModal({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@ -29,11 +29,9 @@ export default function AboutModal({
|
||||
</DialogHeader>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Sandbox is an open-source cloud-based code editing environment with
|
||||
custom AI code autocompletion and real-time collaboration. The
|
||||
infrastructure runs on Docker and AWS ECS to scale automatically based
|
||||
on resource usage.
|
||||
custom AI code autocompletion and real-time collaboration.
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,66 +1,761 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import Loading from "@/components/editor/loading"
|
||||
import { Sandbox, User } from "@/lib/types"
|
||||
import { useEffect, useState } from "react"
|
||||
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 { getTaskIp, startServer } from "@/lib/actions"
|
||||
import { checkServiceStatus, setupServer } from "@/lib/utils"
|
||||
import { useClerk } from "@clerk/nextjs"
|
||||
|
||||
const CodeEditor = dynamic(() => import("@/components/editor/editor"), {
|
||||
ssr: false,
|
||||
loading: () => <Loading />,
|
||||
})
|
||||
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"
|
||||
|
||||
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,
|
||||
sandboxData,
|
||||
}: {
|
||||
userData: User
|
||||
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 [isDeploymentActive, setIsDeploymentActive] = useState(false)
|
||||
const [taskIp, setTaskIp] = useState<string>()
|
||||
const [didFail, setDidFail] = useState(false)
|
||||
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 (isDev) {
|
||||
setIsServiceRunning(true)
|
||||
setIsDeploymentActive(true)
|
||||
setTaskIp("localhost")
|
||||
if (!ai) {
|
||||
setGenerate((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
show: false,
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setupServer({
|
||||
sandboxId: sandboxData.id,
|
||||
setIsServiceRunning,
|
||||
setIsDeploymentActive,
|
||||
setTaskIp,
|
||||
setDidFail,
|
||||
toast,
|
||||
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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (didFail) return <Loading didFail={didFail} />
|
||||
if (!isServiceRunning || !isDeploymentActive || !taskIp)
|
||||
return (
|
||||
<Loading
|
||||
text="Creating sandbox resources"
|
||||
description={
|
||||
isDeploymentActive
|
||||
? "Preparing server networking..."
|
||||
: isServiceRunning
|
||||
? "Initializing server, this could take a minute..."
|
||||
: "Requesting your server creation..."
|
||||
// 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 (
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import {
|
||||
ChevronLeft,
|
||||
@ -8,21 +8,19 @@ import {
|
||||
RotateCw,
|
||||
TerminalSquare,
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
} from "lucide-react"
|
||||
import { useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function PreviewWindow({
|
||||
collapsed,
|
||||
open,
|
||||
ip,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
open: () => void;
|
||||
ip: string;
|
||||
collapsed: boolean
|
||||
open: () => void
|
||||
}) {
|
||||
const ref = useRef<HTMLIFrameElement>(null);
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const ref = useRef<HTMLIFrameElement>(null)
|
||||
const [iframeKey, setIframeKey] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -32,12 +30,7 @@ export default function PreviewWindow({
|
||||
} 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="text-xs">
|
||||
Preview
|
||||
{/* <span className="inline-block ml-2 items-center font-mono text-muted-foreground">
|
||||
localhost:8000
|
||||
</span> */}
|
||||
</div>
|
||||
<div className="text-xs">Preview</div>
|
||||
<div className="flex space-x-1 translate-x-1">
|
||||
{collapsed ? (
|
||||
<PreviewButton onClick={open}>
|
||||
@ -52,8 +45,8 @@ export default function PreviewWindow({
|
||||
|
||||
<PreviewButton
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`http://${ip}:3000`);
|
||||
toast.info("Copied preview link to clipboard");
|
||||
navigator.clipboard.writeText(`http://localhost:5173`)
|
||||
toast.info("Copied preview link to clipboard")
|
||||
}}
|
||||
>
|
||||
<Link className="w-4 h-4" />
|
||||
@ -63,7 +56,7 @@ export default function PreviewWindow({
|
||||
// if (ref.current) {
|
||||
// ref.current.contentWindow?.location.reload();
|
||||
// }
|
||||
setIframeKey((prev) => prev + 1);
|
||||
setIframeKey((prev) => prev + 1)
|
||||
}}
|
||||
>
|
||||
<RotateCw className="w-3 h-3" />
|
||||
@ -80,12 +73,12 @@ export default function PreviewWindow({
|
||||
ref={ref}
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
src={`http://${ip}:3000`}
|
||||
src={`http://localhost:5173`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewButton({
|
||||
@ -93,9 +86,9 @@ function PreviewButton({
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@ -106,5 +99,5 @@ function PreviewButton({
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import Image from "next/image";
|
||||
import Logo from "@/assets/logo.svg";
|
||||
import XLogo from "@/assets/x.svg";
|
||||
import Button from "@/components/ui/customButton";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image"
|
||||
import Logo from "@/assets/logo.svg"
|
||||
import XLogo from "@/assets/x.svg"
|
||||
import Button from "@/components/ui/customButton"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function Landing() {
|
||||
return (
|
||||
@ -26,13 +26,11 @@ export default function Landing() {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="text-muted-foreground mt-4 text-center ">
|
||||
Sandbox is an open-source cloud-based code editing environment with
|
||||
custom AI code autocompletion and real-time collaboration. The
|
||||
infrastructure runs on Docker and AWS ECS to scale automatically based
|
||||
on resource usage.
|
||||
custom AI code autocompletion and real-time collaboration.
|
||||
</div>
|
||||
<div className="mt-8 flex space-x-4">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,15 +1,6 @@
|
||||
"use server"
|
||||
|
||||
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: {
|
||||
type: string
|
||||
@ -87,142 +78,3 @@ export async function unshareSandbox(sandboxId: string, userId: string) {
|
||||
|
||||
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.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
@ -2,13 +2,6 @@ import { type ClassValue, clsx } from "clsx"
|
||||
// import { toast } from "sonner"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
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[]) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
1490
frontend/package-lock.json
generated
1490
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,8 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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/themes": "^1.7.12",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
|
Loading…
x
Reference in New Issue
Block a user