Compare commits
11 Commits
file-save
...
feature/ap
Author | SHA1 | Date | |
---|---|---|---|
f6fee20955 | |||
74b0c6b9e4 | |||
d840aad3e9 | |||
89804c5906 | |||
aa33ad3031 | |||
f79115974c | |||
1bb518b1af | |||
721ea5cad2 | |||
ebfde291ad | |||
07ca7dee46 | |||
5af35f6920 |
22
README.md
22
README.md
@ -2,7 +2,7 @@
|
||||
|
||||

|
||||
|
||||
Sandbox is an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration, and AI chat.
|
||||
Sandbox is an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration and AI chat.
|
||||
|
||||
For the latest updates, join our Discord server: [discord.gitwit.dev](https://discord.gitwit.dev/).
|
||||
|
||||
@ -10,11 +10,11 @@ For the latest updates, join our Discord server: [discord.gitwit.dev](https://di
|
||||
|
||||
Notes:
|
||||
|
||||
- Double-check that whatever you change "SUPERDUPERSECRET" to, it's the same in all config files.
|
||||
- Double check that whatever you change "SUPERDUPERSECRET" to, it's the same in all config files.
|
||||
|
||||
### 0. Requirements
|
||||
|
||||
The application uses NodeJS for the backend, NextJS for the frontend, and Cloudflare workers for additional backend tasks.
|
||||
The application uses NodeJS for the backend, NextJS for the frontend and Cloudflare workers for additional backend tasks.
|
||||
|
||||
Needed accounts to set up:
|
||||
|
||||
@ -22,7 +22,6 @@ Needed accounts to set up:
|
||||
- [Liveblocks](https://liveblocks.io/): Used for collaborative editing.
|
||||
- [E2B](https://e2b.dev/): Used for the terminals and live preview.
|
||||
- [Cloudflare](https://www.cloudflare.com/): Used for relational data storage (D2) and file storage (R2).
|
||||
- [Anthropic](https://anthropic.com/) and [OpenAI](https://openai.com/): API keys for code generation.
|
||||
|
||||
A quick overview of the tech before we start: The deployment uses a **NextJS** app for the frontend and an **ExpressJS** server on the backend. Presumably that's because NextJS integrates well with Clerk middleware but not with Socket.io.
|
||||
|
||||
@ -42,6 +41,7 @@ Run `npm install` in:
|
||||
/backend/database
|
||||
/backend/storage
|
||||
/backend/server
|
||||
/backend/ai
|
||||
```
|
||||
|
||||
### 2. Adding Clerk
|
||||
@ -152,7 +152,6 @@ NEXT_PUBLIC_DATABASE_WORKER_URL='https://database.🍎.workers.dev'
|
||||
NEXT_PUBLIC_STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
|
||||
NEXT_PUBLIC_WORKERS_KEY='SUPERDUPERSECRET'
|
||||
ANTHROPIC_API_KEY='🔑'
|
||||
OPENAI_API_KEY='🔑'
|
||||
```
|
||||
|
||||
### 10. Running the IDE
|
||||
@ -174,15 +173,6 @@ Setting up deployments first requires a separate domain (such as gitwit.app, whi
|
||||
|
||||
We then deploy Dokku on a separate server, according to this guide: https://dev.to/jamesmurdza/host-your-own-paas-platform-as-a-service-on-amazon-web-services-3f0d
|
||||
|
||||
And we install [dokku-daemon](https://github.com/dokku/dokku-daemon) with the following commands:
|
||||
|
||||
```
|
||||
git clone https://github.com/dokku/dokku-daemon
|
||||
cd dokku-daemon
|
||||
sudo make install
|
||||
systemctl start dokku-daemon
|
||||
```
|
||||
|
||||
The Sandbox platform connects to the Dokku server via SSH, using SSH keys specifically generated for this connection. The SSH key is stored on the Sandbox server, and the following environment variables are set in /backend/server/.env:
|
||||
|
||||
```bash
|
||||
@ -256,7 +246,8 @@ backend/
|
||||
├── database/
|
||||
│ ├── src
|
||||
│ └── drizzle
|
||||
└── storage
|
||||
├── storage
|
||||
└── ai
|
||||
```
|
||||
|
||||
| Path | Description |
|
||||
@ -265,6 +256,7 @@ backend/
|
||||
| `backend/server` | The Express websocket server. |
|
||||
| `backend/database` | API for interfacing with the D1 database (SQLite). |
|
||||
| `backend/storage` | API for interfacing with R2 storage. Service-bound to `/backend/database`. |
|
||||
| `backend/ai` | API for making requests to Workers AI . |
|
||||
|
||||
### Development
|
||||
|
||||
|
@ -31,30 +31,9 @@ export class DokkuClient extends SSHSocketClient {
|
||||
|
||||
// List all deployed Dokku apps
|
||||
async listApps(): Promise<string[]> {
|
||||
const response = await this.sendCommand("--quiet apps:list")
|
||||
return response.output.split("\n")
|
||||
}
|
||||
|
||||
// Get the creation timestamp of an app
|
||||
async getAppCreatedAt(appName: string): Promise<number> {
|
||||
const response = await this.sendCommand(
|
||||
`apps:report --app-created-at ${appName}`
|
||||
)
|
||||
const createdAt = parseInt(response.output.trim(), 10)
|
||||
|
||||
if (isNaN(createdAt)) {
|
||||
throw new Error(
|
||||
`Failed to retrieve creation timestamp for app ${appName}`
|
||||
)
|
||||
}
|
||||
|
||||
return createdAt
|
||||
}
|
||||
|
||||
// Check if an app exists
|
||||
async appExists(appName: string): Promise<boolean> {
|
||||
const response = await this.sendCommand(`apps:exists ${appName}`)
|
||||
return response.output.includes("App") === false
|
||||
const response = await this.sendCommand("apps:list")
|
||||
// Split the output by newline and remove the header
|
||||
return response.output.split("\n").slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,11 +92,7 @@ export class FileManager {
|
||||
|
||||
// Convert local file path to remote path
|
||||
private getRemoteFileId(localId: string): string {
|
||||
return [
|
||||
"projects",
|
||||
this.sandboxId,
|
||||
localId.startsWith("/") ? localId : localId,
|
||||
].join("/")
|
||||
return `projects/${this.sandboxId}${localId}`
|
||||
}
|
||||
|
||||
// Convert remote file path to local file path
|
||||
@ -330,15 +326,13 @@ export class FileManager {
|
||||
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
|
||||
throw new Error("File size too large. Please reduce the file size.")
|
||||
}
|
||||
|
||||
// Save to remote storage
|
||||
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
|
||||
|
||||
// Update local file data cache
|
||||
let file = this.fileData.find((f) => f.id === fileId)
|
||||
if (file) {
|
||||
file.data = body
|
||||
} else {
|
||||
// If the file wasn't in our cache, add it
|
||||
file = {
|
||||
id: fileId,
|
||||
data: body,
|
||||
@ -346,49 +340,7 @@ export class FileManager {
|
||||
this.fileData.push(file)
|
||||
}
|
||||
|
||||
// Save to sandbox filesystem
|
||||
const filePath = path.posix.join(this.dirName, fileId)
|
||||
await this.sandbox.files.write(filePath, body)
|
||||
|
||||
// Instead of updating the entire file structure, just ensure this file exists in it
|
||||
const parts = fileId.split('/').filter(Boolean)
|
||||
let current = this.files
|
||||
let currentPath = ''
|
||||
|
||||
// Navigate/create the path to the file
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath += '/' + parts[i]
|
||||
let folder = current.find(
|
||||
(f) => f.type === 'folder' && f.name === parts[i]
|
||||
) as TFolder
|
||||
|
||||
if (!folder) {
|
||||
folder = {
|
||||
id: currentPath,
|
||||
type: 'folder',
|
||||
name: parts[i],
|
||||
children: [],
|
||||
}
|
||||
current.push(folder)
|
||||
}
|
||||
current = folder.children
|
||||
}
|
||||
|
||||
// Add/update the file in the structure if it doesn't exist
|
||||
const fileName = parts[parts.length - 1]
|
||||
const existingFile = current.find(
|
||||
(f) => f.type === 'file' && f.name === fileName
|
||||
)
|
||||
|
||||
if (!existingFile) {
|
||||
current.push({
|
||||
id: fileId,
|
||||
type: 'file',
|
||||
name: fileName,
|
||||
})
|
||||
}
|
||||
|
||||
this.refreshFileList?.(this.files)
|
||||
await this.sandbox.files.write(path.posix.join(this.dirName, fileId), body)
|
||||
this.fixPermissions()
|
||||
}
|
||||
|
||||
|
@ -13,10 +13,7 @@ export class SSHSocketClient {
|
||||
private conn: Client
|
||||
private config: SSHConfig
|
||||
private socketPath: string
|
||||
private _isConnected: boolean = false
|
||||
public get isConnected(): boolean {
|
||||
return this._isConnected
|
||||
}
|
||||
private isConnected: boolean = false
|
||||
|
||||
// Constructor initializes the SSH client and sets up configuration
|
||||
constructor(config: SSHConfig, socketPath: string) {
|
||||
@ -37,7 +34,7 @@ export class SSHSocketClient {
|
||||
private closeConnection() {
|
||||
console.log("Closing SSH connection...")
|
||||
this.conn.end()
|
||||
this._isConnected = false
|
||||
this.isConnected = false
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@ -47,17 +44,17 @@ export class SSHSocketClient {
|
||||
this.conn
|
||||
.on("ready", () => {
|
||||
console.log("SSH connection established")
|
||||
this._isConnected = true
|
||||
this.isConnected = true
|
||||
resolve()
|
||||
})
|
||||
.on("error", (err) => {
|
||||
console.error("SSH connection error:", err)
|
||||
this._isConnected = false
|
||||
this.isConnected = false
|
||||
reject(err)
|
||||
})
|
||||
.on("close", () => {
|
||||
console.log("SSH connection closed")
|
||||
this._isConnected = false
|
||||
this.isConnected = false
|
||||
})
|
||||
.connect(this.config)
|
||||
})
|
||||
@ -89,13 +86,10 @@ export class SSHSocketClient {
|
||||
)
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
// Netcat remains open until it is closed, so we close the connection once we receive data.
|
||||
resolve(data.toString())
|
||||
stream.close()
|
||||
})
|
||||
.stderr.on("data", (data: Buffer) => {
|
||||
reject(new Error(data.toString()))
|
||||
stream.close()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
@ -156,40 +156,6 @@ export class Sandbox {
|
||||
return { success: true, apps: await this.dokkuClient.listApps() }
|
||||
}
|
||||
|
||||
// Handle getting app creation timestamp
|
||||
const handleGetAppCreatedAt: SocketHandler = async ({ appName }) => {
|
||||
if (!this.dokkuClient)
|
||||
throw new Error(
|
||||
"Failed to retrieve app creation timestamp: No Dokku client"
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
createdAt: await this.dokkuClient.getAppCreatedAt(appName),
|
||||
}
|
||||
}
|
||||
|
||||
// Handle checking if an app exists
|
||||
const handleAppExists: SocketHandler = async ({ appName }) => {
|
||||
if (!this.dokkuClient) {
|
||||
console.log("Failed to check app existence: No Dokku client")
|
||||
return {
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
if (!this.dokkuClient.isConnected) {
|
||||
console.log(
|
||||
"Failed to check app existence: The Dokku client is not connected"
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
exists: await this.dokkuClient.appExists(appName),
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deploying code
|
||||
const handleDeploy: SocketHandler = async (_: any) => {
|
||||
if (!this.gitClient) throw Error("No git client")
|
||||
@ -287,9 +253,7 @@ export class Sandbox {
|
||||
getFolder: handleGetFolder,
|
||||
saveFile: handleSaveFile,
|
||||
moveFile: handleMoveFile,
|
||||
listApps: handleListApps,
|
||||
getAppCreatedAt: handleGetAppCreatedAt,
|
||||
getAppExists: handleAppExists,
|
||||
list: handleListApps,
|
||||
deploy: handleDeploy,
|
||||
createFile: handleCreateFile,
|
||||
createFolder: handleCreateFolder,
|
||||
|
@ -146,8 +146,6 @@ io.on("connection", async (socket) => {
|
||||
)
|
||||
})
|
||||
|
||||
socket.emit("ready")
|
||||
|
||||
// Handle disconnection event
|
||||
socket.on("disconnect", async () => {
|
||||
try {
|
||||
|
@ -387,16 +387,9 @@ export default function CodeEditor({
|
||||
(mergedCode: string, originalCode: string) => {
|
||||
if (!editorRef) return
|
||||
|
||||
const model = editorRef.getModel()
|
||||
if (!model) return // Store original content
|
||||
;(model as any).originalContent = originalCode
|
||||
|
||||
// Calculate the full range of the document
|
||||
const fullRange = model.getFullModelRange()
|
||||
|
||||
// Create decorations before applying the edit
|
||||
const originalLines = originalCode.split("\n")
|
||||
const mergedLines = mergedCode.split("\n")
|
||||
|
||||
const decorations: monaco.editor.IModelDeltaDecoration[] = []
|
||||
|
||||
for (
|
||||
@ -417,16 +410,14 @@ export default function CodeEditor({
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the edit operation
|
||||
editorRef.executeEdits("apply-code", [
|
||||
{
|
||||
range: fullRange,
|
||||
text: mergedCode,
|
||||
forceMoveMarkers: true,
|
||||
},
|
||||
])
|
||||
// Store original content in the model
|
||||
const model = editorRef.getModel()
|
||||
if (model) {
|
||||
;(model as any).originalContent = originalCode
|
||||
}
|
||||
editorRef.setValue(mergedCode)
|
||||
|
||||
// Apply decorations after the edit
|
||||
// Apply decorations
|
||||
const newDecorations = editorRef.createDecorationsCollection(decorations)
|
||||
setMergeDecorationsCollection(newDecorations)
|
||||
},
|
||||
@ -793,32 +784,31 @@ export default function CodeEditor({
|
||||
setGenerate((prev) => ({ ...prev, show: false }))
|
||||
|
||||
// Check if the tab already exists in the list of open tabs
|
||||
const existingTab = tabs.find((t) => t.id === tab.id)
|
||||
|
||||
if (existingTab) {
|
||||
// If the tab exists, just make it active
|
||||
setActiveFileId(existingTab.id)
|
||||
if (fileContents[existingTab.id]) {
|
||||
setActiveFileContent(fileContents[existingTab.id])
|
||||
const exists = tabs.find((t) => t.id === tab.id)
|
||||
setTabs((prev) => {
|
||||
if (exists) {
|
||||
// If the tab exists, make it the active tab
|
||||
setActiveFileId(exists.id)
|
||||
return prev
|
||||
}
|
||||
} else {
|
||||
// If the tab doesn't exist, add it to the list and make it active
|
||||
setTabs((prev) => [...prev, tab])
|
||||
// If the tab doesn't exist, add it to the list of tabs and make it active
|
||||
return [...prev, tab]
|
||||
})
|
||||
|
||||
// Fetch content if not cached
|
||||
if (!fileContents[tab.id]) {
|
||||
// If the file's content is already cached, set it as the active content
|
||||
if (fileContents[tab.id]) {
|
||||
setActiveFileContent(fileContents[tab.id])
|
||||
} else {
|
||||
// Otherwise, fetch the content of the file and cache it
|
||||
debouncedGetFile(tab.id, (response: string) => {
|
||||
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
|
||||
setActiveFileContent(response)
|
||||
})
|
||||
} else {
|
||||
setActiveFileContent(fileContents[tab.id])
|
||||
}
|
||||
}
|
||||
|
||||
// Set the editor language based on the file type
|
||||
setEditorLanguage(processFileType(tab.name))
|
||||
// Set the active file ID
|
||||
// Set the active file ID to the new tab
|
||||
setActiveFileId(tab.id)
|
||||
}
|
||||
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
import { useTerminal } from "@/context/TerminalContext"
|
||||
import { Sandbox, User } from "@/lib/types"
|
||||
import { Globe } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useState } from "react"
|
||||
|
||||
export default function DeployButtonModal({
|
||||
userData,
|
||||
@ -18,21 +18,8 @@ export default function DeployButtonModal({
|
||||
userData: User
|
||||
data: Sandbox
|
||||
}) {
|
||||
const { deploy, getAppExists } = useTerminal()
|
||||
const { deploy } = useTerminal()
|
||||
const [isDeploying, setIsDeploying] = useState(false)
|
||||
const [isDeployed, setIsDeployed] = useState(false)
|
||||
const [deployButtonVisible, setDeployButtonEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkDeployment = async () => {
|
||||
if (getAppExists) {
|
||||
const exists = await getAppExists(data.id)
|
||||
setDeployButtonEnabled(exists.success)
|
||||
setIsDeployed((exists.success && exists.exists) ?? false)
|
||||
}
|
||||
}
|
||||
checkDeployment()
|
||||
}, [data.id, getAppExists])
|
||||
|
||||
const handleDeploy = () => {
|
||||
if (isDeploying) {
|
||||
@ -43,7 +30,6 @@ export default function DeployButtonModal({
|
||||
setIsDeploying(true)
|
||||
deploy(() => {
|
||||
setIsDeploying(false)
|
||||
setIsDeployed(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -52,12 +38,10 @@ export default function DeployButtonModal({
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
{deployButtonVisible && (
|
||||
<Button variant="outline">
|
||||
<Globe className="w-4 h-4 mr-2" />
|
||||
Deploy
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-4 w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl rounded-lg shadow-lg"
|
||||
@ -68,9 +52,8 @@ export default function DeployButtonModal({
|
||||
<DeploymentOption
|
||||
icon={<Globe className="text-gray-500 w-5 h-5" />}
|
||||
domain={`${data.id}.gitwit.app`}
|
||||
timestamp="Deployed 1m ago"
|
||||
timestamp="Deployed 1h ago"
|
||||
user={userData.name}
|
||||
isDeployed={isDeployed}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@ -78,7 +61,7 @@ export default function DeployButtonModal({
|
||||
className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]"
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
{isDeploying ? "Deploying..." : isDeployed ? "Update" : "Deploy"}
|
||||
{isDeploying ? "Deploying..." : "Update"}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@ -91,19 +74,16 @@ function DeploymentOption({
|
||||
domain,
|
||||
timestamp,
|
||||
user,
|
||||
isDeployed,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
domain: string
|
||||
timestamp: string
|
||||
user: string
|
||||
isDeployed: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full text-left p-2 rounded-md border border-gray-700 bg-gray-900">
|
||||
<div className="flex items-start gap-2 relative">
|
||||
<div className="flex-shrink-0">{icon}</div>
|
||||
{isDeployed ? (
|
||||
<a
|
||||
href={`https://${domain}`}
|
||||
target="_blank"
|
||||
@ -112,12 +92,9 @@ function DeploymentOption({
|
||||
>
|
||||
{domain}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-semibold text-gray-300">{domain}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-0 ml-7">
|
||||
{isDeployed ? `${timestamp} • ${user}` : "Never deployed"}
|
||||
{timestamp} • {user}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
@ -20,9 +20,6 @@ interface TerminalContextType {
|
||||
createNewTerminal: (command?: string) => Promise<void>
|
||||
closeTerminal: (id: string) => void
|
||||
deploy: (callback: () => void) => void
|
||||
getAppExists:
|
||||
| ((appName: string) => Promise<{ success: boolean; exists?: boolean }>)
|
||||
| null
|
||||
}
|
||||
|
||||
const TerminalContext = createContext<TerminalContextType | undefined>(
|
||||
@ -38,19 +35,6 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
>([])
|
||||
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
|
||||
const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false)
|
||||
const [isSocketReady, setIsSocketReady] = useState<boolean>(false)
|
||||
|
||||
// Listen for the "ready" signal from the socket
|
||||
React.useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on("ready", () => {
|
||||
setIsSocketReady(true)
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
if (socket) socket.off("ready")
|
||||
}
|
||||
}, [socket])
|
||||
|
||||
const createNewTerminal = async (command?: string): Promise<void> => {
|
||||
if (!socket) return
|
||||
@ -94,20 +78,6 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const getAppExists = async (
|
||||
appName: string
|
||||
): Promise<{ success: boolean; exists?: boolean }> => {
|
||||
console.log("Is there a socket: " + !!socket)
|
||||
if (!socket) {
|
||||
console.error("Couldn't check if app exists: No socket")
|
||||
return { success: false }
|
||||
}
|
||||
const response: { success: boolean; exists?: boolean } = await new Promise(
|
||||
(resolve) => socket.emit("getAppExists", { appName }, resolve)
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
const value = {
|
||||
terminals,
|
||||
setTerminals,
|
||||
@ -118,7 +88,6 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
createNewTerminal,
|
||||
closeTerminal,
|
||||
deploy,
|
||||
getAppExists: isSocketReady ? getAppExists : null,
|
||||
}
|
||||
|
||||
return (
|
||||
|
Reference in New Issue
Block a user