This commit is contained in:
Hamzat Victor 2024-11-04 21:49:37 +01:00
commit 2195b27c7e
103 changed files with 2544 additions and 2113 deletions

View File

@ -1,4 +0,0 @@
# frontend/**
backend/ai/**
backend/database/**
backend/storage/**

262
README.md
View File

@ -1,103 +1,213 @@
# Sandbox 📦🪄
# GitWit Sandbox 📦🪄
<img width="1799" alt="Screenshot 2024-05-31 at 8 33 56AM" src="https://github.com/ishaan1013/sandbox/assets/69771365/3f73d7c0-f82a-4997-b01e-eaa043e95113">
![2024-10-2307 17 42-ezgif com-resize](https://github.com/user-attachments/assets/a4057129-81a7-4a31-a093-c8bc8189ae72)
Sandbox is an open-source cloud-based code editing environment with custom AI code autocompletion and real-time collaboration.
Sandbox is an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration and AI chat.
Check out the [Twitter thread](https://x.com/ishaandey_/status/1796338262002573526) with the demo video!
Check out this [guide](https://dev.to/jamesmurdza/how-to-setup-ishaan1013sandbox-locally-503p) made by [@jamesmurdza](https://x.com/jamesmurdza) on setting it up locally!
For the latest updates, join our Discord server: [discord.gitwit.dev](https://discord.gitwit.dev/).
## Running Locally
### Frontend
Notes:
Install dependencies
- Double check that whatever you change "SUPERDUPERSECRET" to, it's the same in all config files.
- Right now we are loading project templates from a custom Cloudflare bucket which isn't covered in this guide, but that be updated/fixed very soon.
### 0. Requirements
The application uses NodeJS for the backend, NextJS for the frontend and Cloudflare workers for additional backend tasks.
Needed accounts to set up:
- [Clerk](https://clerk.com/): Used for user authentication.
- [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).
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.
### 1. Initial setup
No surprise in the first step:
```bash
cd frontend
npm install
git clone https://github.com/jamesmurdza/sandbox
cd sandbox
```
Add the required environment variables in `.env` (example file provided in `.env.example`). You will need to make an account on [Clerk](https://clerk.com/) and [Liveblocks](https://liveblocks.io/) to get API keys.
Run `npm install` in:
Then, run in development mode
```bash
npm run dev
```
/frontend
/backend/database
/backend/storage
/backend/server
/backend/ai
```
### Backend
### 2. Adding Clerk
The backend consists of a primary Express and Socket.io server, and 3 Cloudflare Workers microservices for the D1 database, R2 storage, and Workers AI. The D1 database also contains a [service binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) to the R2 storage worker. Each open sandbox instantiates a secure Linux sandboxes on E2B, which is used for the terminal and live preview.
Setup the Clerk account.
Get the API keys from Clerk.
You will need to make an account on [E2B](https://e2b.dev/) to get an API key.
Update `/frontend/.env`:
#### Socket.io server
Install dependencies
```bash
cd backend/server
npm install
```
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY='🔑'
CLERK_SECRET_KEY='🔑'
```
Add the required environment variables in `.env` (example file provided in `.env.example`)
### 3. Deploying the storage bucket
Project files will be stored in the `projects/<project-id>` directory. The middleware contains basic authorization logic for connecting to the server.
Go to Cloudflare.
Create and name an R2 storage bucket in the control panel.
Copy the account ID of one domain.
Run in development mode
Update `/backend/storage/src/wrangler.toml`:
```bash
npm run dev
```
account_id = '🔑'
bucket_name = '🔑'
key = 'SUPERDUPERSECRET'
```
This directory is dockerized, so feel free to deploy a container on any platform of your choice! I chose not to deploy this project for public access due to costs & safety, but deploying your own for personal use should be no problem.
In the `/backend/storage/src` directory:
#### Cloudflare Workers (Database, Storage, AI)
Directories:
- `/backend/database`: D1 database
- `/backend/storage`: R2 storage
- `/backend/ai`: Workers AI
Install dependencies
```bash
cd backend/database
npm install
cd ../storage
npm install
cd ../ai
npm install
```
Read the [documentation](https://developers.cloudflare.com/workers/) to learn more about workers.
For each directory, add the required environment variables in `wrangler.toml` (example file provided in `wrangler.example.toml`). For the AI worker, you can define any value you want for the `CF_AI_KEY` -- set this in other `.env` files to authorize access.
Run in development mode
```bash
npm run dev
```
Deploy to Cloudflare with [Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
```bash
npx wrangler deploy
```
---
### 4. Deploying the database
Create a database:
```
npx wrangler d1 create sandbox-database
```
Use the output for the next setp.
Update `/backend/database/src/wrangler.toml`:
```
database_name = '🔑'
database_id = '🔑'
KEY = 'SUPERDUPERSECRET'
STORAGE_WORKER_URL = 'https://storage.🍎.workers.dev'
```
In the `/backend/database/src` directory:
```
npx wrangler deploy
```
### 5. Applying the database schema
Delete the `/backend/database/drizzle/meta` directory.
In the `/backend/database/` directory:
```
npm run generate
npx wrangler d1 execute sandbox-database --remote --file=./drizzle/0000_🍏_🍐.sql
```
### 6. Configuring the server
Update `/backend/server/.env`:
```
DATABASE_WORKER_URL='https://database.🍎.workers.dev'
STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
WORKERS_KEY='SUPERDUPERSECRET'
```
### 7. Adding Liveblocks
Setup the Liveblocks account.
Update `/frontend/.env`:
```
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY='🔑'
LIVEBLOCKS_SECRET_KEY='🔑'
```
### 8. Adding E2B
Setup the E2B account.
Update `/backend/server/.env`:
```
E2B_API_KEY='🔑'
```
### 9. Adding AI code generation
In the `/backend/ai` directory:
```
npx wrangler deploy
```
Update `/backend/server/.env`:
```
AI_WORKER_URL='https://ai.🍎.workers.dev'
```
### 10. Configuring the frontend
Update `/frontend/.env`:
```
NEXT_PUBLIC_DATABASE_WORKER_URL='https://database.🍎.workers.dev'
NEXT_PUBLIC_STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
NEXT_PUBLIC_WORKERS_KEY='SUPERDUPERSECRET'
```
### 11. Running the IDE
Run `npm run dev` simultaneously in:
```
/frontend
/backend/server
```
## Setting up Deployments
The steps above do not include steps to setup [Dokku](https://github.com/dokku/dokku), which is required for deployments.
**Note:** This is completely optional to set up if you just want to run GitWit Sandbox.
Setting up deployments first requires a separate domain (such as gitwit.app, which we use).
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
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
DOKKU_HOST=
DOKKU_USERNAME=
DOKKU_KEY=
```
## Creating Custom Templates
We're working on a process whereby anyone can contribute a custom template that others can use in the Sandbox environment. The process includes:
- Creating a [custom E2B Sandbox](https://e2b.dev/docs/sandbox-template) including the template files and dependencies
- Creating a file to specify the run command (e.g. "npm run dev")
- Testing the template with Dokku for deployment
Please reach out to us [on Discord](https://discord.gitwit.dev/) if you're interested in contributing.
## Contributing
Thanks for your interest in contributing! Review this section before submitting your first pull request. If you need any help, feel free to reach out to [@ishaandey\_](https://x.com/ishaandey_).
Please prioritize existing issues, but feel free to contribute new issues if you have ideas for a feature or bug that you think would be useful.
Thanks for your interest in contributing! Review this section before submitting your first pull request. If you need any help, feel free contact us [on Discord](https://discord.gitwit.dev/).
### Structure
@ -116,13 +226,13 @@ backend/
└── ai
```
| Path | Description |
| ------------------ | -------------------------------------------------------------------------- |
| `frontend` | The Next.js application for the frontend. |
| `backend/server` | The Express websocket server. |
| `backend/database` | API for interfacing with the D1 database (SQLite). |
| Path | Description |
| ------------------ | ------------------------------------------------------------ |
| `frontend` | The Next.js application for the frontend. |
| `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 . |
| `backend/ai` | API for making requests to Workers AI . |
### Development
@ -151,11 +261,15 @@ It should be in the form `category(scope or module): message` in your commit mes
- `feat / feature`: all changes that introduce completely new code or new
features
- `fix`: changes that fix a bug (ideally you will additionally reference an
issue if present)
- `refactor`: any code related change that is not a fix nor a feature
- `docs`: changing existing or creating new documentation (i.e. README, docs for
usage of a lib or cli usage)
- `chore`: all changes to the repository that do not fit into any of the above
categories

View File

@ -1,5 +0,0 @@
{
"tabWidth": 2,
"semi": false,
"singleQuote": false
}

View File

@ -1,22 +1,22 @@
{
"name": "ai",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.1.0",
"@cloudflare/workers-types": "^4.20240512.0",
"typescript": "^5.0.4",
"vitest": "1.3.0",
"wrangler": "^3.0.0"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.27.2"
}
}
"name": "ai",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.1.0",
"@cloudflare/workers-types": "^4.20240512.0",
"typescript": "^5.0.4",
"vitest": "1.3.0",
"wrangler": "^3.0.0"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.27.2"
}
}

View File

@ -1,57 +1,61 @@
import { Anthropic } from "@anthropic-ai/sdk";
import { MessageParam } from "@anthropic-ai/sdk/src/resources/messages.js";
import { Anthropic } from "@anthropic-ai/sdk"
import { MessageParam } from "@anthropic-ai/sdk/src/resources/messages.js"
export interface Env {
ANTHROPIC_API_KEY: string;
ANTHROPIC_API_KEY: string
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}
async fetch(request: Request, env: Env): Promise<Response> {
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
})
}
if (request.method !== "GET" && request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
if (request.method !== "GET" && request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 })
}
let body;
let isEditCodeWidget = false;
if (request.method === "POST") {
body = await request.json() as { messages: unknown; context: unknown; activeFileContent: string };
} else {
const url = new URL(request.url);
const fileName = url.searchParams.get("fileName") || "";
const code = url.searchParams.get("code") || "";
const line = url.searchParams.get("line") || "";
const instructions = url.searchParams.get("instructions") || "";
let body
let isEditCodeWidget = false
if (request.method === "POST") {
body = (await request.json()) as {
messages: unknown
context: unknown
activeFileContent: string
}
} else {
const url = new URL(request.url)
const fileName = url.searchParams.get("fileName") || ""
const code = url.searchParams.get("code") || ""
const line = url.searchParams.get("line") || ""
const instructions = url.searchParams.get("instructions") || ""
body = {
messages: [{ role: "human", content: instructions }],
context: `File: ${fileName}\nLine: ${line}\nCode:\n${code}`,
activeFileContent: code,
};
isEditCodeWidget = true;
}
body = {
messages: [{ role: "human", content: instructions }],
context: `File: ${fileName}\nLine: ${line}\nCode:\n${code}`,
activeFileContent: code,
}
isEditCodeWidget = true
}
const messages = body.messages;
const context = body.context;
const activeFileContent = body.activeFileContent;
const messages = body.messages
const context = body.context
const activeFileContent = body.activeFileContent
if (!Array.isArray(messages) || messages.length === 0) {
return new Response("Invalid or empty messages", { status: 400 });
}
if (!Array.isArray(messages) || messages.length === 0) {
return new Response("Invalid or empty messages", { status: 400 })
}
let systemMessage;
if (isEditCodeWidget) {
systemMessage = `You are an AI code editor. Your task is to modify the given code based on the user's instructions. Only output the modified code, without any explanations or markdown formatting. The code should be a direct replacement for the existing code.
let systemMessage
if (isEditCodeWidget) {
systemMessage = `You are an AI code editor. Your task is to modify the given code based on the user's instructions. Only output the modified code, without any explanations or markdown formatting. The code should be a direct replacement for the existing code.
Context:
${context}
@ -61,9 +65,9 @@ ${activeFileContent}
Instructions: ${messages[0].content}
Respond only with the modified code that can directly replace the existing code.`;
} else {
systemMessage = `You are an intelligent programming assistant. Please respond to the following request concisely. If your response includes code, please format it using triple backticks (\`\`\`) with the appropriate language identifier. For example:
Respond only with the modified code that can directly replace the existing code.`
} else {
systemMessage = `You are an intelligent programming assistant. Please respond to the following request concisely. If your response includes code, please format it using triple backticks (\`\`\`) with the appropriate language identifier. For example:
\`\`\`python
print("Hello, World!")
@ -71,51 +75,54 @@ print("Hello, World!")
Provide a clear and concise explanation along with any code snippets. Keep your response brief and to the point.
${context ? `Context:\n${context}\n` : ''}
${activeFileContent ? `Active File Content:\n${activeFileContent}\n` : ''}`;
}
${context ? `Context:\n${context}\n` : ""}
${activeFileContent ? `Active File Content:\n${activeFileContent}\n` : ""}`
}
const anthropicMessages = messages.map(msg => ({
role: msg.role === 'human' ? 'user' : 'assistant',
content: msg.content
})) as MessageParam[];
const anthropicMessages = messages.map((msg) => ({
role: msg.role === "human" ? "user" : "assistant",
content: msg.content,
})) as MessageParam[]
try {
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
try {
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })
const stream = await anthropic.messages.create({
model: "claude-3-5-sonnet-20240620",
max_tokens: 1024,
system: systemMessage,
messages: anthropicMessages,
stream: true,
});
const stream = await anthropic.messages.create({
model: "claude-3-5-sonnet-20240620",
max_tokens: 1024,
system: systemMessage,
messages: anthropicMessages,
stream: true,
})
const encoder = new TextEncoder();
const encoder = new TextEncoder()
const streamResponse = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
const bytes = encoder.encode(chunk.delta.text);
controller.enqueue(bytes);
}
}
controller.close();
},
});
const streamResponse = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
const bytes = encoder.encode(chunk.delta.text)
controller.enqueue(bytes)
}
}
controller.close()
},
})
return new Response(streamResponse, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
} catch (error) {
console.error("Error:", error);
return new Response("Internal Server Error", { status: 500 });
}
},
};
return new Response(streamResponse, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
})
} catch (error) {
console.error("Error:", error)
return new Response("Internal Server Error", { status: 500 })
}
},
}

View File

@ -1,25 +1,30 @@
// test/index.spec.ts
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import worker from '../src/index';
import {
createExecutionContext,
env,
SELF,
waitOnExecutionContext,
} from "cloudflare:test"
import { describe, expect, it } from "vitest"
import worker from "../src/index"
// For now, you'll need to do something like this to get a correctly-typed
// `Request` to pass to `worker.fetch()`.
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>
describe('Hello World worker', () => {
it('responds with Hello World! (unit style)', async () => {
const request = new IncomingRequest('http://example.com');
describe("Hello World worker", () => {
it("responds with Hello World! (unit style)", async () => {
const request = new IncomingRequest("http://example.com")
// Create an empty context to pass to `worker.fetch()`.
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
const ctx = createExecutionContext()
const response = await worker.fetch(request, env, ctx)
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
await waitOnExecutionContext(ctx);
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
});
await waitOnExecutionContext(ctx)
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
})
it('responds with Hello World! (integration style)', async () => {
const response = await SELF.fetch('https://example.com');
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
});
});
it("responds with Hello World! (integration style)", async () => {
const response = await SELF.fetch("https://example.com")
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
})
})

View File

@ -1,11 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": [
"@cloudflare/workers-types/experimental",
"@cloudflare/vitest-pool-workers"
]
},
"include": ["./**/*.ts", "../src/env.d.ts"],
"exclude": []
"extends": "../tsconfig.json",
"compilerOptions": {
"types": [
"@cloudflare/workers-types/experimental",
"@cloudflare/vitest-pool-workers"
]
},
"include": ["./**/*.ts", "../src/env.d.ts"],
"exclude": []
}

View File

@ -12,7 +12,9 @@
/* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */

View File

@ -1,11 +1,11 @@
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.toml" },
},
},
},
});
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.toml" },
},
},
},
})

View File

@ -1,4 +1,3 @@
// Generated by Wrangler
// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen`
interface Env {
}
interface Env {}

View File

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

View File

@ -1,5 +0,0 @@
{
"tabWidth": 2,
"semi": false,
"singleQuote": false
}

View File

@ -1,4 +1,4 @@
import type { Config } from "drizzle-kit";
import type { Config } from "drizzle-kit"
export default process.env.LOCAL_DB_PATH
? ({
@ -16,4 +16,4 @@ export default process.env.LOCAL_DB_PATH
wranglerConfigPath: "wrangler.toml",
dbName: "d1-sandbox",
},
} satisfies Config);
} satisfies Config)

View File

@ -1,32 +1,32 @@
{
"name": "database",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
"generate": "drizzle-kit generate:sqlite --schema=src/schema.ts",
"up": "drizzle-kit up:sqlite --schema=src/schema.ts",
"db:studio": "cross-env LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.1.0",
"@cloudflare/workers-types": "^4.20240405.0",
"@types/itty-router-extras": "^0.4.3",
"drizzle-kit": "^0.20.17",
"typescript": "^5.0.4",
"vitest": "1.3.0",
"wrangler": "^3.0.0"
},
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"better-sqlite3": "^9.5.0",
"cross-env": "^7.0.3",
"drizzle-orm": "^0.30.8",
"itty-router": "^5.0.16",
"itty-router-extras": "^0.4.6",
"zod": "^3.22.4"
}
}
"name": "database",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
"generate": "drizzle-kit generate:sqlite --schema=src/schema.ts",
"up": "drizzle-kit up:sqlite --schema=src/schema.ts",
"db:studio": "cross-env LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.1.0",
"@cloudflare/workers-types": "^4.20240405.0",
"@types/itty-router-extras": "^0.4.3",
"drizzle-kit": "^0.20.17",
"typescript": "^5.0.4",
"vitest": "1.3.0",
"wrangler": "^3.0.0"
},
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"better-sqlite3": "^9.5.0",
"cross-env": "^7.0.3",
"drizzle-orm": "^0.30.8",
"itty-router": "^5.0.16",
"itty-router-extras": "^0.4.6",
"zod": "^3.22.4"
}
}

View File

@ -1,11 +1,11 @@
// import type { DrizzleD1Database } from "drizzle-orm/d1";
import { drizzle } from "drizzle-orm/d1"
import { json } from "itty-router-extras"
import { ZodError, z } from "zod"
import { z } from "zod"
import { user, sandbox, usersToSandboxes } from "./schema"
import * as schema from "./schema"
import { and, eq, sql } from "drizzle-orm"
import * as schema from "./schema"
import { sandbox, user, usersToSandboxes } from "./schema"
export interface Env {
DB: D1Database

View File

@ -1,6 +1,6 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { createId } from "@paralleldrive/cuid2";
import { relations, sql } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2"
import { relations } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
export const user = sqliteTable("user", {
id: text("id")
@ -11,14 +11,14 @@ export const user = sqliteTable("user", {
email: text("email").notNull(),
image: text("image"),
generations: integer("generations").default(0),
});
})
export type User = typeof user.$inferSelect;
export type User = typeof user.$inferSelect
export const userRelations = relations(user, ({ many }) => ({
sandbox: many(sandbox),
usersToSandboxes: many(usersToSandboxes),
}));
}))
export const sandbox = sqliteTable("sandbox", {
id: text("id")
@ -32,9 +32,9 @@ export const sandbox = sqliteTable("sandbox", {
userId: text("user_id")
.notNull()
.references(() => user.id),
});
})
export type Sandbox = typeof sandbox.$inferSelect;
export type Sandbox = typeof sandbox.$inferSelect
export const sandboxRelations = relations(sandbox, ({ one, many }) => ({
author: one(user, {
@ -42,7 +42,7 @@ export const sandboxRelations = relations(sandbox, ({ one, many }) => ({
references: [user.id],
}),
usersToSandboxes: many(usersToSandboxes),
}));
}))
export const usersToSandboxes = sqliteTable("users_to_sandboxes", {
userId: text("userId")
@ -52,15 +52,18 @@ export const usersToSandboxes = sqliteTable("users_to_sandboxes", {
.notNull()
.references(() => sandbox.id),
sharedOn: integer("sharedOn", { mode: "timestamp_ms" }),
});
})
export const usersToSandboxesRelations = relations(usersToSandboxes, ({ one }) => ({
group: one(sandbox, {
fields: [usersToSandboxes.sandboxId],
references: [sandbox.id],
}),
user: one(user, {
fields: [usersToSandboxes.userId],
references: [user.id],
}),
}));
export const usersToSandboxesRelations = relations(
usersToSandboxes,
({ one }) => ({
group: one(sandbox, {
fields: [usersToSandboxes.sandboxId],
references: [sandbox.id],
}),
user: one(user, {
fields: [usersToSandboxes.userId],
references: [user.id],
}),
})
)

View File

@ -1,25 +1,30 @@
// test/index.spec.ts
import { env, createExecutionContext, waitOnExecutionContext, SELF } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import worker from "../src/index";
import {
createExecutionContext,
env,
SELF,
waitOnExecutionContext,
} from "cloudflare:test"
import { describe, expect, it } from "vitest"
import worker from "../src/index"
// For now, you'll need to do something like this to get a correctly-typed
// `Request` to pass to `worker.fetch()`.
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>
describe("Hello World worker", () => {
it("responds with Hello World! (unit style)", async () => {
const request = new IncomingRequest("http://example.com");
// Create an empty context to pass to `worker.fetch()`.
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
await waitOnExecutionContext(ctx);
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
});
it("responds with Hello World! (unit style)", async () => {
const request = new IncomingRequest("http://example.com")
// Create an empty context to pass to `worker.fetch()`.
const ctx = createExecutionContext()
const response = await worker.fetch(request, env, ctx)
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
await waitOnExecutionContext(ctx)
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
})
it("responds with Hello World! (integration style)", async () => {
const response = await SELF.fetch("https://example.com");
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
});
});
it("responds with Hello World! (integration style)", async () => {
const response = await SELF.fetch("https://example.com")
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
})
})

View File

@ -1,11 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": [
"@cloudflare/workers-types/experimental",
"@cloudflare/vitest-pool-workers"
]
},
"include": ["./**/*.ts", "../src/env.d.ts"],
"exclude": []
"extends": "../tsconfig.json",
"compilerOptions": {
"types": [
"@cloudflare/workers-types/experimental",
"@cloudflare/vitest-pool-workers"
]
},
"include": ["./**/*.ts", "../src/env.d.ts"],
"exclude": []
}

View File

@ -12,7 +12,9 @@
/* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */

View File

@ -1,11 +1,11 @@
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.toml" },
},
},
},
});
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.toml" },
},
},
},
})

View File

@ -0,0 +1,58 @@
import { Socket } from "socket.io"
class Counter {
private count: number = 0
increment() {
this.count++
}
decrement() {
this.count = Math.max(0, this.count - 1)
}
getValue(): number {
return this.count
}
}
// Owner Connection Management
export class ConnectionManager {
// Counts how many times the owner is connected to a sandbox
private ownerConnections: Record<string, Counter> = {}
// Stores all sockets connected to a given sandbox
private sockets: Record<string, Set<Socket>> = {}
// Checks if the owner of a sandbox is connected
ownerIsConnected(sandboxId: string): boolean {
return this.ownerConnections[sandboxId]?.getValue() > 0
}
// Adds a connection for a sandbox
addConnectionForSandbox(socket: Socket, sandboxId: string, isOwner: boolean) {
this.sockets[sandboxId] ??= new Set()
this.sockets[sandboxId].add(socket)
// If the connection is for the owner, increments the owner connection counter
if (isOwner) {
this.ownerConnections[sandboxId] ??= new Counter()
this.ownerConnections[sandboxId].increment()
}
}
// Removes a connection for a sandbox
removeConnectionForSandbox(socket: Socket, sandboxId: string, isOwner: boolean) {
this.sockets[sandboxId]?.delete(socket)
// If the connection being removed is for the owner, decrements the owner connection counter
if (isOwner) {
this.ownerConnections[sandboxId]?.decrement()
}
}
// Returns the set of sockets connected to a given sandbox
connectionsForSandbox(sandboxId: string): Set<Socket> {
return this.sockets[sandboxId] ?? new Set();
}
}

View File

@ -4,12 +4,6 @@ import RemoteFileStorage from "./RemoteFileStorage"
import { MAX_BODY_SIZE } from "./ratelimit"
import { TFile, TFileData, TFolder } from "./types"
// Define the structure for sandbox files
export type SandboxFiles = {
files: (TFolder | TFile)[]
fileData: TFileData[]
}
// Convert list of paths to the hierchical file structure used by the editor
function generateFileStructure(paths: string[]): (TFolder | TFile)[] {
const root: TFolder = { id: "/", type: "folder", name: "/", children: [] }
@ -52,20 +46,22 @@ function generateFileStructure(paths: string[]): (TFolder | TFile)[] {
export class FileManager {
private sandboxId: string
private sandbox: Sandbox
public sandboxFiles: SandboxFiles
public files: (TFolder | TFile)[]
public fileData: TFileData[]
private fileWatchers: WatchHandle[] = []
private dirName = "/home/user/project"
private refreshFileList: (files: SandboxFiles) => void
private refreshFileList: ((files: (TFolder | TFile)[]) => void) | null
// Constructor to initialize the FileManager
constructor(
sandboxId: string,
sandbox: Sandbox,
refreshFileList: (files: SandboxFiles) => void
refreshFileList: ((files: (TFolder | TFile)[]) => void) | null
) {
this.sandboxId = sandboxId
this.sandbox = sandbox
this.sandboxFiles = { files: [], fileData: [] }
this.files = []
this.fileData = []
this.refreshFileList = refreshFileList
}
@ -110,16 +106,16 @@ export class FileManager {
private async updateFileData(): Promise<TFileData[]> {
const remotePaths = await RemoteFileStorage.getSandboxPaths(this.sandboxId)
const localPaths = this.getLocalFileIds(remotePaths)
this.sandboxFiles.fileData = await this.generateFileData(localPaths)
return this.sandboxFiles.fileData
this.fileData = await this.generateFileData(localPaths)
return this.fileData
}
// Update file structure
private async updateFileStructure(): Promise<(TFolder | TFile)[]> {
const remotePaths = await RemoteFileStorage.getSandboxPaths(this.sandboxId)
const localPaths = this.getLocalFileIds(remotePaths)
this.sandboxFiles.files = generateFileStructure(localPaths)
return this.sandboxFiles.files
this.files = generateFileStructure(localPaths)
return this.files
}
// Initialize the FileManager
@ -130,9 +126,9 @@ export class FileManager {
await this.updateFileData()
// Copy all files from the project to the container
const promises = this.sandboxFiles.fileData.map(async (file) => {
const promises = this.fileData.map(async (file) => {
try {
const filePath = path.join(this.dirName, file.id)
const filePath = path.posix.join(this.dirName, file.id)
const parentDirectory = path.dirname(filePath)
if (!this.sandbox.files.exists(parentDirectory)) {
await this.sandbox.files.makeDir(parentDirectory)
@ -209,7 +205,7 @@ export class FileManager {
// Handle file/directory creation event
if (event.type === "create") {
const folder = findFolderById(
this.sandboxFiles.files,
this.files,
sandboxDirectory
) as TFolder
const isDir = await this.isDirectory(containerFilePath)
@ -232,7 +228,7 @@ export class FileManager {
folder.children.push(newItem)
} else {
// If folder doesn't exist, add the new item to the root
this.sandboxFiles.files.push(newItem)
this.files.push(newItem)
}
if (!isDir) {
@ -241,7 +237,7 @@ export class FileManager {
)
const fileContents =
typeof fileData === "string" ? fileData : ""
this.sandboxFiles.fileData.push({
this.fileData.push({
id: sandboxFilePath,
data: fileContents,
})
@ -253,7 +249,7 @@ export class FileManager {
// Handle file/directory removal or rename event
else if (event.type === "remove" || event.type == "rename") {
const folder = findFolderById(
this.sandboxFiles.files,
this.files,
sandboxDirectory
) as TFolder
const isDir = await this.isDirectory(containerFilePath)
@ -269,13 +265,13 @@ export class FileManager {
)
} else {
// Remove from the root if it's not inside a folder
this.sandboxFiles.files = this.sandboxFiles.files.filter(
this.files = this.files.filter(
(file: TFolder | TFile) => !isFileMatch(file)
)
}
// Also remove any corresponding file data
this.sandboxFiles.fileData = this.sandboxFiles.fileData.filter(
this.fileData = this.fileData.filter(
(file: TFileData) => !isFileMatch(file)
)
@ -285,10 +281,10 @@ export class FileManager {
// Handle file write event
else if (event.type === "write") {
const folder = findFolderById(
this.sandboxFiles.files,
this.files,
sandboxDirectory
) as TFolder
const fileToWrite = this.sandboxFiles.fileData.find(
const fileToWrite = this.fileData.find(
(file) => file.id === sandboxFilePath
)
@ -308,7 +304,7 @@ export class FileManager {
)
const fileContents =
typeof fileData === "string" ? fileData : ""
this.sandboxFiles.fileData.push({
this.fileData.push({
id: sandboxFilePath,
data: fileContents,
})
@ -318,7 +314,9 @@ export class FileManager {
}
// Tell the client to reload the file list
this.refreshFileList(this.sandboxFiles)
if (event.type !== "chmod") {
this.refreshFileList?.(this.files)
}
} catch (error) {
console.error(
`Error handling ${event.type} event for ${event.name}:`,
@ -350,7 +348,7 @@ export class FileManager {
// Get file content
async getFile(fileId: string): Promise<string | undefined> {
const file = this.sandboxFiles.fileData.find((f) => f.id === fileId)
const file = this.fileData.find((f) => f.id === fileId)
return file?.data
}
@ -368,7 +366,7 @@ export class FileManager {
throw new Error("File size too large. Please reduce the file size.")
}
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
const file = this.sandboxFiles.fileData.find((f) => f.id === fileId)
const file = this.fileData.find((f) => f.id === fileId)
if (!file) return
file.data = body
@ -381,9 +379,9 @@ export class FileManager {
fileId: string,
folderId: string
): Promise<(TFolder | TFile)[]> {
const fileData = this.sandboxFiles.fileData.find((f) => f.id === fileId)
const file = this.sandboxFiles.files.find((f) => f.id === fileId)
if (!fileData || !file) return this.sandboxFiles.files
const fileData = this.fileData.find((f) => f.id === fileId)
const file = this.files.find((f) => f.id === fileId)
if (!fileData || !file) return this.files
const parts = fileId.split("/")
const newFileId = folderId + "/" + parts.pop()
@ -427,13 +425,13 @@ export class FileManager {
await this.sandbox.files.write(path.posix.join(this.dirName, id), "")
await this.fixPermissions()
this.sandboxFiles.files.push({
this.files.push({
id,
name,
type: "file",
})
this.sandboxFiles.fileData.push({
this.fileData.push({
id,
data: "",
})
@ -451,8 +449,8 @@ export class FileManager {
// Rename a file
async renameFile(fileId: string, newName: string): Promise<void> {
const fileData = this.sandboxFiles.fileData.find((f) => f.id === fileId)
const file = this.sandboxFiles.files.find((f) => f.id === fileId)
const fileData = this.fileData.find((f) => f.id === fileId)
const file = this.files.find((f) => f.id === fileId)
if (!fileData || !file) return
const parts = fileId.split("/")
@ -468,11 +466,11 @@ export class FileManager {
// Delete a file
async deleteFile(fileId: string): Promise<(TFolder | TFile)[]> {
const file = this.sandboxFiles.fileData.find((f) => f.id === fileId)
if (!file) return this.sandboxFiles.files
const file = this.fileData.find((f) => f.id === fileId)
if (!file) return this.files
await this.sandbox.files.remove(path.posix.join(this.dirName, fileId))
this.sandboxFiles.fileData = this.sandboxFiles.fileData.filter(
this.fileData = this.fileData.filter(
(f) => f.id !== fileId
)
@ -487,7 +485,7 @@ export class FileManager {
await Promise.all(
files.map(async (file) => {
await this.sandbox.files.remove(path.posix.join(this.dirName, file))
this.sandboxFiles.fileData = this.sandboxFiles.fileData.filter(
this.fileData = this.fileData.filter(
(f) => f.id !== file
)
await RemoteFileStorage.deleteFile(this.getRemoteFileId(file))

View File

@ -0,0 +1,243 @@
import { Sandbox as E2BSandbox } from "e2b"
import { Socket } from "socket.io"
import { AIWorker } from "./AIWorker"
import { CONTAINER_TIMEOUT } from "./constants"
import { DokkuClient } from "./DokkuClient"
import { FileManager } from "./FileManager"
import {
createFileRL,
createFolderRL,
deleteFileRL,
renameFileRL,
saveFileRL,
} from "./ratelimit"
import { SecureGitClient } from "./SecureGitClient"
import { TerminalManager } from "./TerminalManager"
import { TFile, TFolder } from "./types"
import { LockManager } from "./utils"
const lockManager = new LockManager()
// Define a type for SocketHandler functions
type SocketHandler<T = Record<string, any>> = (args: T) => any;
// Extract port number from a string
function extractPortNumber(inputString: string): number | null {
const cleanedString = inputString.replace(/\x1B\[[0-9;]*m/g, "")
const regex = /http:\/\/localhost:(\d+)/
const match = cleanedString.match(regex)
return match ? parseInt(match[1]) : null
}
type ServerContext = {
aiWorker: AIWorker;
dokkuClient: DokkuClient | null;
gitClient: SecureGitClient | null;
};
export class Sandbox {
// Sandbox properties:
sandboxId: string;
fileManager: FileManager | null;
terminalManager: TerminalManager | null;
container: E2BSandbox | null;
// Server context:
dokkuClient: DokkuClient | null;
gitClient: SecureGitClient | null;
aiWorker: AIWorker;
constructor(sandboxId: string, { aiWorker, dokkuClient, gitClient }: ServerContext) {
// Sandbox properties:
this.sandboxId = sandboxId;
this.fileManager = null;
this.terminalManager = null;
this.container = null;
// Server context:
this.aiWorker = aiWorker;
this.dokkuClient = dokkuClient;
this.gitClient = gitClient;
}
// Initializes the container for the sandbox environment
async initialize(
fileWatchCallback: ((files: (TFolder | TFile)[]) => void) | undefined
) {
// Acquire a lock to ensure exclusive access to the sandbox environment
await lockManager.acquireLock(this.sandboxId, async () => {
// Check if a container already exists and is running
if (this.container && await this.container.isRunning()) {
console.log(`Found existing container ${this.sandboxId}`)
} else {
console.log("Creating container", this.sandboxId)
// Create a new container with a specified timeout
this.container = await E2BSandbox.create({
timeoutMs: CONTAINER_TIMEOUT,
})
}
})
// Ensure a container was successfully created
if (!this.container) throw new Error("Failed to create container")
// Initialize the terminal manager if it hasn't been set up yet
if (!this.terminalManager) {
this.terminalManager = new TerminalManager(this.container)
console.log(`Terminal manager set up for ${this.sandboxId}`)
}
// Initialize the file manager if it hasn't been set up yet
if (!this.fileManager) {
this.fileManager = new FileManager(
this.sandboxId,
this.container,
fileWatchCallback ?? null
)
// Initialize the file manager and emit the initial files
await this.fileManager.initialize()
}
}
// Called when the client disconnects from the Sandbox
async disconnect() {
// Close all terminals managed by the terminal manager
await this.terminalManager?.closeAllTerminals()
// This way the terminal manager will be set up again if we reconnect
this.terminalManager = null;
// Close all file watchers managed by the file manager
await this.fileManager?.closeWatchers()
// This way the file manager will be set up again if we reconnect
this.fileManager = null;
}
handlers(connection: { userId: string, isOwner: boolean, socket: Socket }) {
// Handle heartbeat from a socket connection
const handleHeartbeat: SocketHandler = (_: any) => {
// Only keep the sandbox alive if the owner is still connected
if (connection.isOwner) {
this.container?.setTimeout(CONTAINER_TIMEOUT)
}
}
// Handle getting a file
const handleGetFile: SocketHandler = ({ fileId }: any) => {
return this.fileManager?.getFile(fileId)
}
// Handle getting a folder
const handleGetFolder: SocketHandler = ({ folderId }: any) => {
return this.fileManager?.getFolder(folderId)
}
// Handle saving a file
const handleSaveFile: SocketHandler = async ({ fileId, body }: any) => {
await saveFileRL.consume(connection.userId, 1);
return this.fileManager?.saveFile(fileId, body)
}
// Handle moving a file
const handleMoveFile: SocketHandler = ({ fileId, folderId }: any) => {
return this.fileManager?.moveFile(fileId, folderId)
}
// Handle listing apps
const handleListApps: SocketHandler = async (_: any) => {
if (!this.dokkuClient) throw Error("Failed to retrieve apps list: No Dokku client")
return { success: true, apps: await this.dokkuClient.listApps() }
}
// Handle deploying code
const handleDeploy: SocketHandler = async (_: any) => {
if (!this.gitClient) throw Error("No git client")
if (!this.fileManager) throw Error("No file manager")
await this.gitClient.pushFiles(this.fileManager?.fileData, this.sandboxId)
return { success: true }
}
// Handle creating a file
const handleCreateFile: SocketHandler = async ({ name }: any) => {
await createFileRL.consume(connection.userId, 1);
return { "success": await this.fileManager?.createFile(name) }
}
// Handle creating a folder
const handleCreateFolder: SocketHandler = async ({ name }: any) => {
await createFolderRL.consume(connection.userId, 1);
return { "success": await this.fileManager?.createFolder(name) }
}
// Handle renaming a file
const handleRenameFile: SocketHandler = async ({ fileId, newName }: any) => {
await renameFileRL.consume(connection.userId, 1)
return this.fileManager?.renameFile(fileId, newName)
}
// Handle deleting a file
const handleDeleteFile: SocketHandler = async ({ fileId }: any) => {
await deleteFileRL.consume(connection.userId, 1)
return this.fileManager?.deleteFile(fileId)
}
// Handle deleting a folder
const handleDeleteFolder: SocketHandler = ({ folderId }: any) => {
return this.fileManager?.deleteFolder(folderId)
}
// Handle creating a terminal session
const handleCreateTerminal: SocketHandler = async ({ id }: any) => {
await lockManager.acquireLock(this.sandboxId, async () => {
await this.terminalManager?.createTerminal(id, (responseString: string) => {
connection.socket.emit("terminalResponse", { id, data: responseString })
const port = extractPortNumber(responseString)
if (port) {
connection.socket.emit(
"previewURL",
"https://" + this.container?.getHost(port)
)
}
})
})
}
// Handle resizing a terminal
const handleResizeTerminal: SocketHandler = ({ dimensions }: any) => {
this.terminalManager?.resizeTerminal(dimensions)
}
// Handle sending data to a terminal
const handleTerminalData: SocketHandler = ({ id, data }: any) => {
return this.terminalManager?.sendTerminalData(id, data)
}
// Handle closing a terminal
const handleCloseTerminal: SocketHandler = ({ id }: any) => {
return this.terminalManager?.closeTerminal(id)
}
// Handle generating code
const handleGenerateCode: SocketHandler = ({ fileName, code, line, instructions }: any) => {
return this.aiWorker.generateCode(connection.userId, fileName, code, line, instructions)
}
return {
"heartbeat": handleHeartbeat,
"getFile": handleGetFile,
"getFolder": handleGetFolder,
"saveFile": handleSaveFile,
"moveFile": handleMoveFile,
"list": handleListApps,
"deploy": handleDeploy,
"createFile": handleCreateFile,
"createFolder": handleCreateFolder,
"renameFile": handleRenameFile,
"deleteFile": handleDeleteFile,
"deleteFolder": handleDeleteFolder,
"createTerminal": handleCreateTerminal,
"resizeTerminal": handleResizeTerminal,
"terminalData": handleTerminalData,
"closeTerminal": handleCloseTerminal,
"generateCode": handleGenerateCode,
};
}
}

View File

@ -0,0 +1,2 @@
// The amount of time in ms that a container will stay alive without a hearbeat.
export const CONTAINER_TIMEOUT = 120_000

View File

@ -1,42 +1,39 @@
import cors from "cors"
import dotenv from "dotenv"
import { Sandbox } from "e2b"
import express, { Express } from "express"
import fs from "fs"
import { createServer } from "http"
import { Server } from "socket.io"
import { z } from "zod"
import { Server, Socket } from "socket.io"
import { AIWorker } from "./AIWorker"
import { ConnectionManager } from "./ConnectionManager"
import { DokkuClient } from "./DokkuClient"
import { FileManager, SandboxFiles } from "./FileManager"
import {
createFileRL,
createFolderRL,
deleteFileRL,
renameFileRL,
saveFileRL,
} from "./ratelimit"
import { Sandbox } from "./Sandbox"
import { SecureGitClient } from "./SecureGitClient"
import { TerminalManager } from "./TerminalManager"
import { User } from "./types"
import { LockManager } from "./utils"
import { socketAuth } from "./socketAuth"; // Import the new socketAuth middleware
import { TFile, TFolder } from "./types"
// Log errors and send a notification to the client
export const handleErrors = (message: string, error: any, socket: Socket) => {
console.error(message, error);
socket.emit("error", `${message} ${error.message ?? error}`);
};
// Handle uncaught exceptions
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error)
// Do not exit the process
// You can add additional logging or recovery logic here
})
// Handle unhandled promise rejections
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason)
// Do not exit the process
// You can also handle the rejected promise here if needed
})
// The amount of time in ms that a container will stay alive without a hearbeat.
const CONTAINER_TIMEOUT = 120_000
// Initialize containers and managers
const connections = new ConnectionManager()
const sandboxes: Record<string, Sandbox> = {}
// Load environment variables
dotenv.config()
@ -48,118 +45,39 @@ app.use(cors())
const httpServer = createServer(app)
const io = new Server(httpServer, {
cors: {
origin: "*",
origin: "*", // Allow connections from any origin
},
})
// Check if the sandbox owner is connected
function isOwnerConnected(sandboxId: string): boolean {
return (connections[sandboxId] ?? 0) > 0
}
// Extract port number from a string
function extractPortNumber(inputString: string): number | null {
const cleanedString = inputString.replace(/\x1B\[[0-9;]*m/g, "")
const regex = /http:\/\/localhost:(\d+)/
const match = cleanedString.match(regex)
return match ? parseInt(match[1]) : null
}
// Initialize containers and managers
const containers: Record<string, Sandbox> = {}
const connections: Record<string, number> = {}
const fileManagers: Record<string, FileManager> = {}
const terminalManagers: Record<string, TerminalManager> = {}
// Middleware for socket authentication
io.use(async (socket, next) => {
// Define the schema for handshake query validation
const handshakeSchema = z.object({
userId: z.string(),
sandboxId: z.string(),
EIO: z.string(),
transport: z.string(),
})
const q = socket.handshake.query
const parseQuery = handshakeSchema.safeParse(q)
// Check if the query is valid according to the schema
if (!parseQuery.success) {
next(new Error("Invalid request."))
return
}
const { sandboxId, userId } = parseQuery.data
// Fetch user data from the database
const dbUser = await fetch(
`${process.env.DATABASE_WORKER_URL}/api/user?id=${userId}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
)
const dbUserJSON = (await dbUser.json()) as User
// Check if user data was retrieved successfully
if (!dbUserJSON) {
next(new Error("DB error."))
return
}
// Check if the user owns the sandbox or has shared access
const sandbox = dbUserJSON.sandbox.find((s) => s.id === sandboxId)
const sharedSandboxes = dbUserJSON.usersToSandboxes.find(
(uts) => uts.sandboxId === sandboxId
)
// If user doesn't own or have shared access to the sandbox, deny access
if (!sandbox && !sharedSandboxes) {
next(new Error("Invalid credentials."))
return
}
// Set socket data with user information
socket.data = {
userId,
sandboxId: sandboxId,
isOwner: sandbox !== undefined,
}
// Allow the connection
next()
})
// Initialize lock manager
const lockManager = new LockManager()
io.use(socketAuth) // Use the new socketAuth middleware
// Check for required environment variables
if (!process.env.DOKKU_HOST)
console.error("Environment variable DOKKU_HOST is not defined")
console.warn("Environment variable DOKKU_HOST is not defined")
if (!process.env.DOKKU_USERNAME)
console.error("Environment variable DOKKU_USERNAME is not defined")
console.warn("Environment variable DOKKU_USERNAME is not defined")
if (!process.env.DOKKU_KEY)
console.error("Environment variable DOKKU_KEY is not defined")
console.warn("Environment variable DOKKU_KEY is not defined")
// Initialize Dokku client
const client =
const dokkuClient =
process.env.DOKKU_HOST && process.env.DOKKU_KEY && process.env.DOKKU_USERNAME
? new DokkuClient({
host: process.env.DOKKU_HOST,
username: process.env.DOKKU_USERNAME,
privateKey: fs.readFileSync(process.env.DOKKU_KEY),
})
host: process.env.DOKKU_HOST,
username: process.env.DOKKU_USERNAME,
privateKey: fs.readFileSync(process.env.DOKKU_KEY),
})
: null
client?.connect()
dokkuClient?.connect()
// Initialize Git client used to deploy Dokku apps
const git =
const gitClient =
process.env.DOKKU_HOST && process.env.DOKKU_KEY
? new SecureGitClient(
`dokku@${process.env.DOKKU_HOST}`,
process.env.DOKKU_KEY
)
`dokku@${process.env.DOKKU_HOST}`,
process.env.DOKKU_KEY
)
: null
// Add this near the top of the file, after other initializations
@ -170,357 +88,95 @@ const aiWorker = new AIWorker(
process.env.WORKERS_KEY!
)
// Handle socket connections
// Handle a client connecting to the server
io.on("connection", async (socket) => {
try {
// This data comes is added by our authentication middleware
const data = socket.data as {
userId: string
sandboxId: string
isOwner: boolean
}
// Handle connection based on user type (owner or not)
if (data.isOwner) {
connections[data.sandboxId] = (connections[data.sandboxId] ?? 0) + 1
} else {
if (!isOwnerConnected(data.sandboxId)) {
socket.emit("disableAccess", "The sandbox owner is not connected.")
return
}
// Register the connection
connections.addConnectionForSandbox(socket, data.sandboxId, data.isOwner)
// Disable access unless the sandbox owner is connected
if (!data.isOwner && !connections.ownerIsConnected(data.sandboxId)) {
socket.emit("disableAccess", "The sandbox owner is not connected.")
return
}
// Create or retrieve container
const createdContainer = await lockManager.acquireLock(
data.sandboxId,
async () => {
try {
// Create or retrieve the sandbox manager for the given sandbox ID
const sandbox = sandboxes[data.sandboxId] ?? new Sandbox(
data.sandboxId,
{
aiWorker, dokkuClient, gitClient,
}
)
sandboxes[data.sandboxId] = sandbox
// This callback recieves an update when the file list changes, and notifies all relevant connections.
const sendFileNotifications = (files: (TFolder | TFile)[]) => {
connections.connectionsForSandbox(data.sandboxId).forEach((socket: Socket) => {
socket.emit("loaded", files);
});
};
// Initialize the sandbox container
// The file manager and terminal managers will be set up if they have been closed
await sandbox.initialize(sendFileNotifications)
socket.emit("loaded", sandbox.fileManager?.files)
// Register event handlers for the sandbox
// For each event handler, listen on the socket for that event
// Pass connection-specific information to the handlers
Object.entries(sandbox.handlers({
userId: data.userId,
isOwner: data.isOwner,
socket
})).forEach(([event, handler]) => {
socket.on(event, async (options: any, callback?: (response: any) => void) => {
try {
const result = await handler(options)
callback?.(result);
} catch (e: any) {
handleErrors(`Error processing event "${event}":`, e, socket);
}
});
});
// Handle disconnection event
socket.on("disconnect", async () => {
try {
// Start a new container if the container doesn't exist or it timed out.
if (
!containers[data.sandboxId] ||
!(await containers[data.sandboxId].isRunning())
) {
containers[data.sandboxId] = await Sandbox.create({
timeoutMs: CONTAINER_TIMEOUT,
})
console.log("Created container ", data.sandboxId)
return true
// Deregister the connection
connections.removeConnectionForSandbox(socket, data.sandboxId, data.isOwner)
// If the owner has disconnected from all sockets, close open terminals and file watchers.o
// The sandbox itself will timeout after the heartbeat stops.
if (data.isOwner && !connections.ownerIsConnected(data.sandboxId)) {
await sandbox.disconnect()
socket.broadcast.emit(
"disableAccess",
"The sandbox owner has disconnected."
)
}
} catch (e: any) {
console.error(`Error creating container ${data.sandboxId}:`, e)
io.emit("error", `Error: container creation. ${e.message ?? e}`)
handleErrors("Error disconnecting:", e, socket);
}
}
)
})
// Function to send loaded event
const sendLoadedEvent = (files: SandboxFiles) => {
socket.emit("loaded", files.files)
} catch (e: any) {
handleErrors(`Error initializing sandbox ${data.sandboxId}:`, e, socket);
}
// Initialize file and terminal managers if container was created
if (createdContainer) {
fileManagers[data.sandboxId] = new FileManager(
data.sandboxId,
containers[data.sandboxId],
sendLoadedEvent
)
await fileManagers[data.sandboxId].initialize()
terminalManagers[data.sandboxId] = new TerminalManager(
containers[data.sandboxId]
)
}
const fileManager = fileManagers[data.sandboxId]
const terminalManager = terminalManagers[data.sandboxId]
// Load file list from the file manager into the editor
sendLoadedEvent(fileManager.sandboxFiles)
// Handle various socket events (heartbeat, file operations, terminal operations, etc.)
socket.on("heartbeat", async () => {
try {
// This keeps the container alive for another CONTAINER_TIMEOUT seconds.
// The E2B docs are unclear, but the timeout is relative to the time of this method call.
await containers[data.sandboxId].setTimeout(CONTAINER_TIMEOUT)
} catch (e: any) {
console.error("Error setting timeout:", e)
io.emit("error", `Error: set timeout. ${e.message ?? e}`)
}
})
// Handle request to get file content
socket.on("getFile", async (fileId: string, callback) => {
try {
const fileContent = await fileManager.getFile(fileId)
callback(fileContent)
} catch (e: any) {
console.error("Error getting file:", e)
io.emit("error", `Error: get file. ${e.message ?? e}`)
}
})
// Handle request to get folder contents
socket.on("getFolder", async (folderId: string, callback) => {
try {
const files = await fileManager.getFolder(folderId)
callback(files)
} catch (e: any) {
console.error("Error getting folder:", e)
io.emit("error", `Error: get folder. ${e.message ?? e}`)
}
})
// Handle request to save file
socket.on("saveFile", async (fileId: string, body: string) => {
try {
await saveFileRL.consume(data.userId, 1)
await fileManager.saveFile(fileId, body)
} catch (e: any) {
console.error("Error saving file:", e)
io.emit("error", `Error: file saving. ${e.message ?? e}`)
}
})
// Handle request to move file
socket.on(
"moveFile",
async (fileId: string, folderId: string, callback) => {
try {
const newFiles = await fileManager.moveFile(fileId, folderId)
callback(newFiles)
} catch (e: any) {
console.error("Error moving file:", e)
io.emit("error", `Error: file moving. ${e.message ?? e}`)
}
}
)
interface CallbackResponse {
success: boolean
apps?: string[]
message?: string
}
// Handle request to list apps
socket.on(
"list",
async (callback: (response: CallbackResponse) => void) => {
console.log("Retrieving apps list...")
try {
if (!client)
throw Error("Failed to retrieve apps list: No Dokku client")
callback({
success: true,
apps: await client.listApps(),
})
} catch (error) {
callback({
success: false,
message: "Failed to retrieve apps list",
})
}
}
)
// Handle request to deploy project
socket.on(
"deploy",
async (callback: (response: CallbackResponse) => void) => {
try {
// Push the project files to the Dokku server
console.log("Deploying project ${data.sandboxId}...")
if (!git) throw Error("Failed to retrieve apps list: No git client")
// Remove the /project/[id]/ component of each file path:
const fixedFilePaths = fileManager.sandboxFiles.fileData.map(
(file) => {
return {
...file,
id: file.id.split("/").slice(2).join("/"),
}
}
)
// Push all files to Dokku.
await git.pushFiles(fixedFilePaths, data.sandboxId)
callback({
success: true,
})
} catch (error) {
callback({
success: false,
message: "Failed to deploy project: " + error,
})
}
}
)
// Handle request to create a new file
socket.on("createFile", async (name: string, callback) => {
try {
await createFileRL.consume(data.userId, 1)
const success = await fileManager.createFile(name)
callback({ success })
} catch (e: any) {
console.error("Error creating file:", e)
io.emit("error", `Error: file creation. ${e.message ?? e}`)
}
})
// Handle request to create a new folder
socket.on("createFolder", async (name: string, callback) => {
try {
await createFolderRL.consume(data.userId, 1)
await fileManager.createFolder(name)
callback()
} catch (e: any) {
console.error("Error creating folder:", e)
io.emit("error", `Error: folder creation. ${e.message ?? e}`)
}
})
// Handle request to rename a file
socket.on("renameFile", async (fileId: string, newName: string) => {
try {
await renameFileRL.consume(data.userId, 1)
await fileManager.renameFile(fileId, newName)
} catch (e: any) {
console.error("Error renaming file:", e)
io.emit("error", `Error: file renaming. ${e.message ?? e}`)
}
})
// Handle request to delete a file
socket.on("deleteFile", async (fileId: string, callback) => {
try {
await deleteFileRL.consume(data.userId, 1)
const newFiles = await fileManager.deleteFile(fileId)
callback(newFiles)
} catch (e: any) {
console.error("Error deleting file:", e)
io.emit("error", `Error: file deletion. ${e.message ?? e}`)
}
})
// Handle request to delete a folder
socket.on("deleteFolder", async (folderId: string, callback) => {
try {
const newFiles = await fileManager.deleteFolder(folderId)
callback(newFiles)
} catch (e: any) {
console.error("Error deleting folder:", e)
io.emit("error", `Error: folder deletion. ${e.message ?? e}`)
}
})
// Handle request to create a new terminal
socket.on("createTerminal", async (id: string, callback) => {
try {
await lockManager.acquireLock(data.sandboxId, async () => {
await terminalManager.createTerminal(id, (responseString: string) => {
io.emit("terminalResponse", { id, data: responseString })
const port = extractPortNumber(responseString)
if (port) {
io.emit(
"previewURL",
"https://" + containers[data.sandboxId].getHost(port)
)
}
})
})
callback()
} catch (e: any) {
console.error(`Error creating terminal ${id}:`, e)
io.emit("error", `Error: terminal creation. ${e.message ?? e}`)
}
})
// Handle request to resize terminal
socket.on(
"resizeTerminal",
(dimensions: { cols: number; rows: number }) => {
try {
terminalManager.resizeTerminal(dimensions)
} catch (e: any) {
console.error("Error resizing terminal:", e)
io.emit("error", `Error: terminal resizing. ${e.message ?? e}`)
}
}
)
// Handle terminal input data
socket.on("terminalData", async (id: string, data: string) => {
try {
await terminalManager.sendTerminalData(id, data)
} catch (e: any) {
console.error("Error writing to terminal:", e)
io.emit("error", `Error: writing to terminal. ${e.message ?? e}`)
}
})
// Handle request to close terminal
socket.on("closeTerminal", async (id: string, callback) => {
try {
await terminalManager.closeTerminal(id)
callback()
} catch (e: any) {
console.error("Error closing terminal:", e)
io.emit("error", `Error: closing terminal. ${e.message ?? e}`)
}
})
// Handle request to generate code
socket.on(
"generateCode",
async (
fileName: string,
code: string,
line: number,
instructions: string,
callback
) => {
try {
const result = await aiWorker.generateCode(
data.userId,
fileName,
code,
line,
instructions
)
callback(result)
} catch (e: any) {
console.error("Error generating code:", e)
io.emit("error", `Error: code generation. ${e.message ?? e}`)
}
}
)
// Handle socket disconnection
socket.on("disconnect", async () => {
try {
if (data.isOwner) {
connections[data.sandboxId]--
}
await terminalManager.closeAllTerminals()
await fileManager.closeWatchers()
if (data.isOwner && connections[data.sandboxId] <= 0) {
socket.broadcast.emit(
"disableAccess",
"The sandbox owner has disconnected."
)
}
} catch (e: any) {
console.log("Error disconnecting:", e)
io.emit("error", `Error: disconnecting. ${e.message ?? e}`)
}
})
} catch (e: any) {
console.error("Error connecting:", e)
io.emit("error", `Error: connection. ${e.message ?? e}`)
handleErrors("Error connecting:", e, socket);
}
})
// Start the server
httpServer.listen(port, () => {
console.log(`Server running on port ${port}`)
})
})

View File

@ -0,0 +1,63 @@
import { Socket } from "socket.io"
import { z } from "zod"
import { User } from "./types"
// Middleware for socket authentication
export const socketAuth = async (socket: Socket, next: Function) => {
// Define the schema for handshake query validation
const handshakeSchema = z.object({
userId: z.string(),
sandboxId: z.string(),
EIO: z.string(),
transport: z.string(),
})
const q = socket.handshake.query
const parseQuery = handshakeSchema.safeParse(q)
// Check if the query is valid according to the schema
if (!parseQuery.success) {
next(new Error("Invalid request."))
return
}
const { sandboxId, userId } = parseQuery.data
// Fetch user data from the database
const dbUser = await fetch(
`${process.env.DATABASE_WORKER_URL}/api/user?id=${userId}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
)
const dbUserJSON = (await dbUser.json()) as User
// Check if user data was retrieved successfully
if (!dbUserJSON) {
next(new Error("DB error."))
return
}
// Check if the user owns the sandbox or has shared access
const sandbox = dbUserJSON.sandbox.find((s) => s.id === sandboxId)
const sharedSandboxes = dbUserJSON.usersToSandboxes.find(
(uts) => uts.sandboxId === sandboxId
)
// If user doesn't own or have shared access to the sandbox, deny access
if (!sandbox && !sharedSandboxes) {
next(new Error("Invalid credentials."))
return
}
// Set socket data with user information
socket.data = {
userId,
sandboxId: sandboxId,
isOwner: sandbox !== undefined,
}
// Allow the connection
next()
}

View File

@ -68,3 +68,8 @@ export type R2FileBody = R2FileData & {
json: Promise<any>
blob: Promise<Blob>
}
export interface DokkuResponse {
success: boolean
apps?: string[]
message?: string
}

View File

@ -20,4 +20,4 @@ export class LockManager {
}
return await this.locks[key]
}
}
}

View File

@ -1,5 +0,0 @@
{
"tabWidth": 2,
"semi": false,
"singleQuote": false
}

View File

@ -1,23 +1,23 @@
{
"name": "storage",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev --remote",
"start": "wrangler dev",
"test": "vitest",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.1.0",
"@cloudflare/workers-types": "^4.20240419.0",
"typescript": "^5.0.4",
"vitest": "1.3.0",
"wrangler": "^3.0.0"
},
"dependencies": {
"p-limit": "^6.1.0",
"zod": "^3.23.4"
}
}
"name": "storage",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev --remote",
"start": "wrangler dev",
"test": "vitest",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.1.0",
"@cloudflare/workers-types": "^4.20240419.0",
"typescript": "^5.0.4",
"vitest": "1.3.0",
"wrangler": "^3.0.0"
},
"dependencies": {
"p-limit": "^6.1.0",
"zod": "^3.23.4"
}
}

View File

@ -1,5 +1,5 @@
import pLimit from "p-limit"
import { z } from "zod"
import pLimit from 'p-limit';
export interface Env {
R2: R2Bucket
@ -144,20 +144,24 @@ export default {
const body = await request.json()
const { sandboxId, type } = initSchema.parse(body)
console.log(`Copying template: ${type}`);
console.log(`Copying template: ${type}`)
// List all objects under the directory
const { objects } = await env.Templates.list({ prefix: type });
const { objects } = await env.Templates.list({ prefix: type })
// Copy each object to the new directory with a 5 concurrency limit
const limit = pLimit(5);
await Promise.all(objects.map(({ key }) =>
limit(async () => {
const destinationKey = key.replace(type, `projects/${sandboxId}`);
const fileBody = await env.Templates.get(key).then(res => res?.body ?? "");
await env.R2.put(destinationKey, fileBody);
})
));
const limit = pLimit(5)
await Promise.all(
objects.map(({ key }) =>
limit(async () => {
const destinationKey = key.replace(type, `projects/${sandboxId}`)
const fileBody = await env.Templates.get(key).then(
(res) => res?.body ?? ""
)
await env.R2.put(destinationKey, fileBody)
})
)
)
return success
} else {

View File

@ -1,25 +1,30 @@
// test/index.spec.ts
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import worker from '../src/index';
import {
createExecutionContext,
env,
SELF,
waitOnExecutionContext,
} from "cloudflare:test"
import { describe, expect, it } from "vitest"
import worker from "../src/index"
// For now, you'll need to do something like this to get a correctly-typed
// `Request` to pass to `worker.fetch()`.
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>
describe('Hello World worker', () => {
it('responds with Hello World! (unit style)', async () => {
const request = new IncomingRequest('http://example.com');
describe("Hello World worker", () => {
it("responds with Hello World! (unit style)", async () => {
const request = new IncomingRequest("http://example.com")
// Create an empty context to pass to `worker.fetch()`.
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
const ctx = createExecutionContext()
const response = await worker.fetch(request, env, ctx)
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
await waitOnExecutionContext(ctx);
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
});
await waitOnExecutionContext(ctx)
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
})
it('responds with Hello World! (integration style)', async () => {
const response = await SELF.fetch('https://example.com');
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
});
});
it("responds with Hello World! (integration style)", async () => {
const response = await SELF.fetch("https://example.com")
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
})
})

View File

@ -1,11 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": [
"@cloudflare/workers-types/experimental",
"@cloudflare/vitest-pool-workers"
]
},
"include": ["./**/*.ts", "../src/env.d.ts"],
"exclude": []
"extends": "../tsconfig.json",
"compilerOptions": {
"types": [
"@cloudflare/workers-types/experimental",
"@cloudflare/vitest-pool-workers"
]
},
"include": ["./**/*.ts", "../src/env.d.ts"],
"exclude": []
}

View File

@ -12,7 +12,9 @@
/* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */

View File

@ -1,11 +1,11 @@
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.toml" },
},
},
},
});
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.toml" },
},
},
},
})

View File

@ -1,4 +1,3 @@
// Generated by Wrangler
// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen`
interface Env {
}
interface Env {}

View File

@ -1,5 +0,0 @@
{
"tabWidth": 2,
"semi": false,
"singleQuote": false
}

View File

@ -1,12 +1,11 @@
import Navbar from "@/components/editor/navbar"
import { Room } from "@/components/editor/live/room"
import Loading from "@/components/editor/loading"
import Navbar from "@/components/editor/navbar"
import { TerminalProvider } from "@/context/TerminalContext"
import { Sandbox, User, UsersToSandboxes } from "@/lib/types"
import { currentUser } from "@clerk/nextjs"
import { notFound, redirect } from "next/navigation"
import Loading from "@/components/editor/loading"
import dynamic from "next/dynamic"
import fs from "fs"
import { TerminalProvider } from "@/context/TerminalContext"
import { notFound, redirect } from "next/navigation"
export const revalidate = 0
@ -89,19 +88,20 @@ export default async function CodePage({ params }: { params: { id: string } }) {
return (
<>
<div className="overflow-hidden overscroll-none w-screen flex flex-col h-screen bg-background">
<Room id={sandboxId}>
<TerminalProvider>
<Navbar userData={userData} sandboxData={sandboxData} shared={shared} />
<div className="w-screen flex grow">
<CodeEditor
userData={userData}
sandboxData={sandboxData}
/>
</div>
</TerminalProvider>
</Room>
</div>
<div className="overflow-hidden overscroll-none w-screen flex flex-col h-screen bg-background">
<Room id={sandboxId}>
<TerminalProvider>
<Navbar
userData={userData}
sandboxData={sandboxData}
shared={shared}
/>
<div className="w-screen flex grow">
<CodeEditor userData={userData} sandboxData={sandboxData} />
</div>
</TerminalProvider>
</Room>
</div>
</>
)
}

View File

@ -1,8 +1,8 @@
import { UserButton, currentUser } from "@clerk/nextjs"
import { redirect } from "next/navigation"
import Dashboard from "@/components/dashboard"
import Navbar from "@/components/dashboard/navbar"
import { Sandbox, User } from "@/lib/types"
import { User } from "@/lib/types"
import { currentUser } from "@clerk/nextjs"
import { redirect } from "next/navigation"
export default async function DashboardPage() {
const user = await currentUser()

View File

@ -15,7 +15,7 @@ export const metadata: Metadata = {
}
export default function RootLayout({
children
children,
}: Readonly<{
children: React.ReactNode
}>) {
@ -29,9 +29,7 @@ export default function RootLayout({
disableTransitionOnChange
>
<SocketProvider>
<PreviewProvider>
{children}
</PreviewProvider>
<PreviewProvider>{children}</PreviewProvider>
</SocketProvider>
<Analytics />
<Toaster position="bottom-left" richColors />
@ -40,4 +38,4 @@ export default function RootLayout({
</html>
</ClerkProvider>
)
}
}

View File

@ -1,13 +1,13 @@
import { currentUser } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import Landing from "@/components/landing";
import Landing from "@/components/landing"
import { currentUser } from "@clerk/nextjs"
import { redirect } from "next/navigation"
export default async function Home() {
const user = await currentUser();
const user = await currentUser()
if (user) {
redirect("/dashboard");
redirect("/dashboard")
}
return <Landing />;
return <Landing />
}

View File

@ -3,16 +3,9 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import Image from "next/image"
import { useState } from "react"
import { Button } from "../ui/button"
import { ChevronRight } from "lucide-react"
export default function AboutModal({
open,

View File

@ -1,24 +1,16 @@
"use client"
import CustomButton from "@/components/ui/customButton"
import { Button } from "@/components/ui/button"
import {
Code2,
FolderDot,
HelpCircle,
Plus,
Settings,
Users,
} from "lucide-react"
import { useEffect, useState } from "react"
import CustomButton from "@/components/ui/customButton"
import { Sandbox } from "@/lib/types"
import { Code2, FolderDot, HelpCircle, Plus, Users } from "lucide-react"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import AboutModal from "./about"
import NewProjectModal from "./newProject"
import DashboardProjects from "./projects"
import DashboardSharedWithMe from "./shared"
import NewProjectModal from "./newProject"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import AboutModal from "./about"
import { toast } from "sonner"
type TScreen = "projects" | "shared" | "settings" | "search"
@ -49,8 +41,9 @@ export default function Dashboard({
const q = searchParams.get("q")
const router = useRouter()
useEffect(() => { // update the dashboard to show a new project
router.refresh()
useEffect(() => {
// update the dashboard to show a new project
router.refresh()
}, [])
return (
@ -102,7 +95,7 @@ export default function Dashboard({
</Button> */}
</div>
<div className="flex flex-col">
<a target="_blank" href="https://github.com/ishaan1013/sandbox">
<a target="_blank" href="https://github.com/jamesmurdza/sandbox">
<Button
variant="ghost"
className="justify-start w-full font-normal text-muted-foreground"

View File

@ -1,9 +1,10 @@
import Logo from "@/assets/logo.svg"
import { ThemeSwitcher } from "@/components/ui/theme-switcher"
import { User } from "@/lib/types"
import Image from "next/image"
import Link from "next/link"
import Logo from "@/assets/logo.svg"
import DashboardNavbarSearch from "./search"
import UserButton from "../../ui/userButton"
import { User } from "@/lib/types"
import DashboardNavbarSearch from "./search"
export default function DashboardNavbar({ userData }: { userData: User }) {
return (
@ -19,6 +20,7 @@ export default function DashboardNavbar({ userData }: { userData: User }) {
</div>
<div className="flex items-center space-x-4">
<DashboardNavbarSearch />
<ThemeSwitcher />
<UserButton userData={userData} />
</div>
</div>

View File

@ -1,13 +1,12 @@
"use client";
"use client"
import { Input } from "../../ui/input";
import { Search } from "lucide-react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react"
import { useRouter } from "next/navigation"
import { Input } from "../../ui/input"
export default function DashboardNavbarSearch() {
// const [search, setSearch] = useState("");
const router = useRouter();
const router = useRouter()
// useEffect(() => {
// const delayDebounceFn = setTimeout(() => {
@ -29,14 +28,14 @@ export default function DashboardNavbarSearch() {
// onChange={(e) => setSearch(e.target.value)}
onChange={(e) => {
if (e.target.value === "") {
router.push(`/dashboard`);
return;
router.push(`/dashboard`)
return
}
router.push(`/dashboard?q=${e.target.value}`);
router.push(`/dashboard?q=${e.target.value}`)
}}
placeholder="Search projects..."
className="pl-8"
/>
</div>
);
)
}

View File

@ -288,7 +288,7 @@ function SearchInput({
<form {...{ onSubmit }} className="w-40 h-8 ">
<label
htmlFor="template-search"
className="flex gap-2 rounded-sm transition-colors bg-gray-100 dark:bg-[#2e2e2e] border border-[--s-color] [--s-color:hsl(var(--muted-foreground))] focus-within:[--s-color:#fff] h-full items-center px-2"
className="flex gap-2 rounded-sm transition-colors bg-gray-100 dark:bg-[#2e2e2e] border border-[--s-color] [--s-color:hsl(var(--muted-foreground))] focus-within:[--s-color:hsl(var(--muted-foreground),50%)] h-full items-center px-2"
>
<Search className="size-4 text-[--s-color] transition-colors" />
<input
@ -298,7 +298,7 @@ function SearchInput({
placeholder="Search templates"
value={value}
onChange={(e) => onValueChange?.(e.target.value)}
className="bg-transparent placeholder:text-muted-foreground text-white w-full focus:outline-none text-xs"
className="bg-transparent placeholder:text-muted-foreground w-full focus:outline-none text-xs"
/>
</label>
</form>

View File

@ -1,30 +1,30 @@
"use client";
"use client"
import { Sandbox } from "@/lib/types";
import { Ellipsis, Globe, Lock, Trash2 } from "lucide-react";
import { Sandbox } from "@/lib/types"
import { Ellipsis, Globe, Lock, Trash2 } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/components/ui/dropdown-menu"
export default function ProjectCardDropdown({
sandbox,
onVisibilityChange,
onDelete,
}: {
sandbox: Sandbox;
onVisibilityChange: (sandbox: Sandbox) => void;
onDelete: (sandbox: Sandbox) => void;
sandbox: Sandbox
onVisibilityChange: (sandbox: Sandbox) => void
onDelete: (sandbox: Sandbox) => void
}) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
e.preventDefault()
e.stopPropagation()
}}
className="h-6 w-6 flex items-center justify-center transition-colors bg-transparent hover:bg-muted-foreground/25 rounded-sm outline-foreground"
>
@ -33,8 +33,8 @@ export default function ProjectCardDropdown({
<DropdownMenuContent className="w-40">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onVisibilityChange(sandbox);
e.stopPropagation()
onVisibilityChange(sandbox)
}}
className="cursor-pointer"
>
@ -52,8 +52,8 @@ export default function ProjectCardDropdown({
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete(sandbox);
e.stopPropagation()
onDelete(sandbox)
}}
className="!text-destructive cursor-pointer"
>
@ -62,5 +62,5 @@ export default function ProjectCardDropdown({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
)
}

View File

@ -1,14 +1,14 @@
"use client"
import { Card } from "@/components/ui/card"
import { projectTemplates } from "@/lib/data"
import { Sandbox } from "@/lib/types"
import { AnimatePresence, motion } from "framer-motion"
import { Clock, Globe, Lock } from "lucide-react"
import Image from "next/image"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import ProjectCardDropdown from "./dropdown"
import { Clock, Globe, Lock } from "lucide-react"
import { Sandbox } from "@/lib/types"
import { Card } from "@/components/ui/card"
import { useRouter } from "next/navigation"
import { projectTemplates } from "@/lib/data"
export default function ProjectCard({
children,

View File

@ -1,8 +1,8 @@
"use client";
import { cn } from "@/lib/utils";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import React, { useMemo, useRef } from "react";
import * as THREE from "three";
"use client"
import { cn } from "@/lib/utils"
import { Canvas, useFrame, useThree } from "@react-three/fiber"
import React, { useMemo, useRef } from "react"
import * as THREE from "three"
export const CanvasRevealEffect = ({
animationSpeed = 0.4,
@ -12,12 +12,12 @@ export const CanvasRevealEffect = ({
dotSize,
showGradient = true,
}: {
animationSpeed?: number;
opacities?: number[];
colors?: number[][];
containerClassName?: string;
dotSize?: number;
showGradient?: boolean;
animationSpeed?: number
opacities?: number[]
colors?: number[][]
containerClassName?: string
dotSize?: number
showGradient?: boolean
}) => {
return (
<div className={cn("h-full relative bg-white w-full", containerClassName)}>
@ -41,16 +41,16 @@ export const CanvasRevealEffect = ({
<div className="absolute inset-0 bg-gradient-to-t from-background to-[100%]" />
)}
</div>
);
};
)
}
interface DotMatrixProps {
colors?: number[][];
opacities?: number[];
totalSize?: number;
dotSize?: number;
shader?: string;
center?: ("x" | "y")[];
colors?: number[][]
opacities?: number[]
totalSize?: number
dotSize?: number
shader?: string
center?: ("x" | "y")[]
}
const DotMatrix: React.FC<DotMatrixProps> = ({
@ -69,7 +69,7 @@ const DotMatrix: React.FC<DotMatrixProps> = ({
colors[0],
colors[0],
colors[0],
];
]
if (colors.length === 2) {
colorsArray = [
colors[0],
@ -78,7 +78,7 @@ const DotMatrix: React.FC<DotMatrixProps> = ({
colors[1],
colors[1],
colors[1],
];
]
} else if (colors.length === 3) {
colorsArray = [
colors[0],
@ -87,7 +87,7 @@ const DotMatrix: React.FC<DotMatrixProps> = ({
colors[1],
colors[2],
colors[2],
];
]
}
return {
@ -111,8 +111,8 @@ const DotMatrix: React.FC<DotMatrixProps> = ({
value: dotSize,
type: "uniform1f",
},
};
}, [colors, opacities, totalSize, dotSize]);
}
}, [colors, opacities, totalSize, dotSize])
return (
<Shader
@ -168,87 +168,87 @@ const DotMatrix: React.FC<DotMatrixProps> = ({
uniforms={uniforms}
maxFps={60}
/>
);
};
)
}
type Uniforms = {
[key: string]: {
value: number[] | number[][] | number;
type: string;
};
};
value: number[] | number[][] | number
type: string
}
}
const ShaderMaterial = ({
source,
uniforms,
maxFps = 60,
}: {
source: string;
hovered?: boolean;
maxFps?: number;
uniforms: Uniforms;
source: string
hovered?: boolean
maxFps?: number
uniforms: Uniforms
}) => {
const { size } = useThree();
const ref = useRef<THREE.Mesh>();
let lastFrameTime = 0;
const { size } = useThree()
const ref = useRef<THREE.Mesh>()
let lastFrameTime = 0
useFrame(({ clock }) => {
if (!ref.current) return;
const timestamp = clock.getElapsedTime();
if (!ref.current) return
const timestamp = clock.getElapsedTime()
if (timestamp - lastFrameTime < 1 / maxFps) {
return;
return
}
lastFrameTime = timestamp;
lastFrameTime = timestamp
const material: any = ref.current.material;
const timeLocation = material.uniforms.u_time;
timeLocation.value = timestamp;
});
const material: any = ref.current.material
const timeLocation = material.uniforms.u_time
timeLocation.value = timestamp
})
const getUniforms = () => {
const preparedUniforms: any = {};
const preparedUniforms: any = {}
for (const uniformName in uniforms) {
const uniform: any = uniforms[uniformName];
const uniform: any = uniforms[uniformName]
switch (uniform.type) {
case "uniform1f":
preparedUniforms[uniformName] = { value: uniform.value, type: "1f" };
break;
preparedUniforms[uniformName] = { value: uniform.value, type: "1f" }
break
case "uniform3f":
preparedUniforms[uniformName] = {
value: new THREE.Vector3().fromArray(uniform.value),
type: "3f",
};
break;
}
break
case "uniform1fv":
preparedUniforms[uniformName] = { value: uniform.value, type: "1fv" };
break;
preparedUniforms[uniformName] = { value: uniform.value, type: "1fv" }
break
case "uniform3fv":
preparedUniforms[uniformName] = {
value: uniform.value.map((v: number[]) =>
new THREE.Vector3().fromArray(v)
),
type: "3fv",
};
break;
}
break
case "uniform2f":
preparedUniforms[uniformName] = {
value: new THREE.Vector2().fromArray(uniform.value),
type: "2f",
};
break;
}
break
default:
console.error(`Invalid uniform type for '${uniformName}'.`);
break;
console.error(`Invalid uniform type for '${uniformName}'.`)
break
}
}
preparedUniforms["u_time"] = { value: 0, type: "1f" };
preparedUniforms["u_time"] = { value: 0, type: "1f" }
preparedUniforms["u_resolution"] = {
value: new THREE.Vector2(size.width * 2, size.height * 2),
}; // Initialize u_resolution
return preparedUniforms;
};
} // Initialize u_resolution
return preparedUniforms
}
// Shader material
const material = useMemo(() => {
@ -272,33 +272,33 @@ const ShaderMaterial = ({
blending: THREE.CustomBlending,
blendSrc: THREE.SrcAlphaFactor,
blendDst: THREE.OneFactor,
});
})
return materialObject;
}, [size.width, size.height, source]);
return materialObject
}, [size.width, size.height, source])
return (
<mesh ref={ref as any}>
<planeGeometry args={[2, 2]} />
<primitive object={material} attach="material" />
</mesh>
);
};
)
}
const Shader: React.FC<ShaderProps> = ({ source, uniforms, maxFps = 60 }) => {
return (
<Canvas className="absolute inset-0 h-full w-full">
<ShaderMaterial source={source} uniforms={uniforms} maxFps={maxFps} />
</Canvas>
);
};
)
}
interface ShaderProps {
source: string;
source: string
uniforms: {
[key: string]: {
value: number[] | number[][] | number;
type: string;
};
};
maxFps?: number;
value: number[] | number[][] | number
type: string
}
}
maxFps?: number
}

View File

@ -1,16 +1,12 @@
"use client";
"use client"
import { Sandbox } from "@/lib/types";
import ProjectCard from "./projectCard";
import Image from "next/image";
import ProjectCardDropdown from "./projectCard/dropdown";
import { Clock, Globe, Lock } from "lucide-react";
import Link from "next/link";
import { Card } from "../ui/card";
import { deleteSandbox, updateSandbox } from "@/lib/actions";
import { toast } from "sonner";
import { useEffect, useState } from "react";
import { CanvasRevealEffect } from "./projectCard/revealEffect";
import { deleteSandbox, updateSandbox } from "@/lib/actions"
import { Sandbox } from "@/lib/types"
import Link from "next/link"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import ProjectCard from "./projectCard"
import { CanvasRevealEffect } from "./projectCard/revealEffect"
const colors: { [key: string]: number[][] } = {
react: [
@ -21,38 +17,37 @@ const colors: { [key: string]: number[][] } = {
[86, 184, 72],
[59, 112, 52],
],
};
}
export default function DashboardProjects({
sandboxes,
q,
}: {
sandboxes: Sandbox[];
q: string | null;
sandboxes: Sandbox[]
q: string | null
}) {
const [deletingId, setDeletingId] = useState<string>("");
const [deletingId, setDeletingId] = useState<string>("")
const onDelete = async (sandbox: Sandbox) => {
setDeletingId(sandbox.id);
toast(`Project ${sandbox.name} deleted.`);
await deleteSandbox(sandbox.id);
};
setDeletingId(sandbox.id)
toast(`Project ${sandbox.name} deleted.`)
await deleteSandbox(sandbox.id)
}
useEffect(() => {
if (deletingId) {
setDeletingId("");
setDeletingId("")
}
}, [sandboxes]);
}, [sandboxes])
const onVisibilityChange = async (sandbox: Sandbox) => {
const newVisibility =
sandbox.visibility === "public" ? "private" : "public";
toast(`Project ${sandbox.name} is now ${newVisibility}.`);
const newVisibility = sandbox.visibility === "public" ? "private" : "public"
toast(`Project ${sandbox.name} is now ${newVisibility}.`)
await updateSandbox({
id: sandbox.id,
visibility: newVisibility,
});
};
})
}
return (
<div className="grow p-4 flex flex-col">
@ -65,7 +60,7 @@ export default function DashboardProjects({
{sandboxes.map((sandbox) => {
if (q && q.length > 0) {
if (!sandbox.name.toLowerCase().includes(q.toLowerCase())) {
return null;
return null
}
}
return (
@ -93,7 +88,7 @@ export default function DashboardProjects({
<div className="absolute inset-0 [mask-image:radial-gradient(400px_at_center,white,transparent)] bg-background/75" />
</ProjectCard>
</Link>
);
)
})}
</div>
) : (
@ -103,5 +98,5 @@ export default function DashboardProjects({
)}
</div>
</div>
);
)
}

View File

@ -1,29 +1,27 @@
import { Sandbox } from "@/lib/types";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import Image from "next/image";
import Button from "../ui/customButton";
import { ChevronRight } from "lucide-react";
import Avatar from "../ui/avatar";
import Link from "next/link";
} from "@/components/ui/table"
import { ChevronRight } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import Avatar from "../ui/avatar"
import Button from "../ui/customButton"
export default function DashboardSharedWithMe({
shared,
}: {
shared: {
id: string;
name: string;
type: "react" | "node";
author: string;
sharedOn: Date;
}[];
id: string
name: string
type: "react" | "node"
author: string
sharedOn: Date
}[]
}) {
return (
<div className="grow p-4 flex flex-col">
@ -86,5 +84,5 @@ export default function DashboardSharedWithMe({
</div>
)}
</div>
);
)
}

View File

@ -1,36 +1,51 @@
import React from 'react';
import { Button } from '../../ui/button';
import { Send, StopCircle } from 'lucide-react';
import { Send, StopCircle } from "lucide-react"
import { Button } from "../../ui/button"
interface ChatInputProps {
input: string;
setInput: (input: string) => void;
isGenerating: boolean;
handleSend: () => void;
handleStopGeneration: () => void;
input: string
setInput: (input: string) => void
isGenerating: boolean
handleSend: () => void
handleStopGeneration: () => void
}
export default function ChatInput({ input, setInput, isGenerating, handleSend, handleStopGeneration }: ChatInputProps) {
export default function ChatInput({
input,
setInput,
isGenerating,
handleSend,
handleStopGeneration,
}: ChatInputProps) {
return (
<div className="flex space-x-2 min-w-0">
<input
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isGenerating && handleSend()}
onKeyPress={(e) => e.key === "Enter" && !isGenerating && handleSend()}
className="flex-grow p-2 border rounded-lg min-w-0 bg-input"
placeholder="Type your message..."
disabled={isGenerating}
/>
{isGenerating ? (
<Button onClick={handleStopGeneration} variant="destructive" size="icon" className="h-10 w-10">
<Button
onClick={handleStopGeneration}
variant="destructive"
size="icon"
className="h-10 w-10"
>
<StopCircle className="w-4 h-4" />
</Button>
) : (
<Button onClick={handleSend} disabled={isGenerating} size="icon" className="h-10 w-10">
<Button
onClick={handleSend}
disabled={isGenerating}
size="icon"
className="h-10 w-10"
>
<Send className="w-4 h-4" />
</Button>
)}
</div>
);
)
}

View File

@ -1,25 +1,31 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { ChevronUp, ChevronDown, Copy, Check, CornerUpLeft } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import remarkGfm from 'remark-gfm';
import { copyToClipboard, stringifyContent } from './lib/chatUtils';
import { Check, ChevronDown, ChevronUp, Copy, CornerUpLeft } from "lucide-react"
import React, { useState } from "react"
import ReactMarkdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
import remarkGfm from "remark-gfm"
import { Button } from "../../ui/button"
import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
interface MessageProps {
message: {
role: 'user' | 'assistant';
content: string;
context?: string;
};
setContext: (context: string | null) => void;
setIsContextExpanded: (isExpanded: boolean) => void;
role: "user" | "assistant"
content: string
context?: string
}
setContext: (context: string | null) => void
setIsContextExpanded: (isExpanded: boolean) => void
}
export default function ChatMessage({ message, setContext, setIsContextExpanded }: MessageProps) {
const [expandedMessageIndex, setExpandedMessageIndex] = useState<number | null>(null);
const [copiedText, setCopiedText] = useState<string | null>(null);
export default function ChatMessage({
message,
setContext,
setIsContextExpanded,
}: MessageProps) {
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
number | null
>(null)
const [copiedText, setCopiedText] = useState<string | null>(null)
const renderCopyButton = (text: any) => (
<Button
@ -34,17 +40,17 @@ export default function ChatMessage({ message, setContext, setIsContextExpanded
<Copy className="w-4 h-4" />
)}
</Button>
);
)
const askAboutCode = (code: any) => {
const contextString = stringifyContent(code);
setContext(`Regarding this code:\n${contextString}`);
setIsContextExpanded(false);
};
const contextString = stringifyContent(code)
setContext(`Regarding this code:\n${contextString}`)
setIsContextExpanded(false)
}
const renderMarkdownElement = (props: any) => {
const { node, children } = props;
const content = stringifyContent(children);
const { node, children } = props
const content = stringifyContent(children)
return (
<div className="relative group">
@ -59,22 +65,30 @@ export default function ChatMessage({ message, setContext, setIsContextExpanded
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
{React.createElement(node.tagName, {
...props,
className: `${props.className || ''} hover:bg-transparent rounded p-1 transition-colors`
}, children)}
{React.createElement(
node.tagName,
{
...props,
className: `${
props.className || ""
} hover:bg-transparent rounded p-1 transition-colors`,
},
children
)}
</div>
);
};
)
}
return (
<div className="text-left relative">
<div className={`relative p-2 rounded-lg ${
message.role === 'user'
? 'bg-[#262626] text-white'
: 'bg-transparent text-white'
} max-w-full`}>
{message.role === 'user' && (
<div
className={`relative p-2 rounded-lg ${
message.role === "user"
? "bg-[#262626] text-white"
: "bg-transparent text-white"
} max-w-full`}
>
{message.role === "user" && (
<div className="absolute top-0 right-0 flex opacity-0 group-hover:opacity-30 transition-opacity">
{renderCopyButton(message.content)}
<Button
@ -89,13 +103,13 @@ export default function ChatMessage({ message, setContext, setIsContextExpanded
)}
{message.context && (
<div className="mb-2 bg-input rounded-lg">
<div
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)}
onClick={() =>
setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)
}
>
<span className="text-sm text-gray-300">
Context
</span>
<span className="text-sm text-gray-300">Context</span>
{expandedMessageIndex === 0 ? (
<ChevronUp size={16} />
) : (
@ -105,41 +119,46 @@ export default function ChatMessage({ message, setContext, setIsContextExpanded
{expandedMessageIndex === 0 && (
<div className="relative">
<div className="absolute top-0 right-0 flex p-1">
{renderCopyButton(message.context.replace(/^Regarding this code:\n/, ''))}
{renderCopyButton(
message.context.replace(/^Regarding this code:\n/, "")
)}
</div>
{(() => {
const code = message.context.replace(/^Regarding this code:\n/, '');
const match = /language-(\w+)/.exec(code);
const language = match ? match[1] : 'typescript';
const code = message.context.replace(
/^Regarding this code:\n/,
""
)
const match = /language-(\w+)/.exec(code)
const language = match ? match[1] : "typescript"
return (
<div className="pt-6">
<textarea
value={code}
onChange={(e) => {
const updatedContext = `Regarding this code:\n${e.target.value}`;
setContext(updatedContext);
const updatedContext = `Regarding this code:\n${e.target.value}`
setContext(updatedContext)
}}
className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded"
rows={code.split('\n').length}
rows={code.split("\n").length}
style={{
resize: 'vertical',
minHeight: '100px',
maxHeight: '400px',
resize: "vertical",
minHeight: "100px",
maxHeight: "400px",
}}
/>
</div>
);
)
})()}
</div>
)}
</div>
)}
{message.role === 'assistant' ? (
{message.role === "assistant" ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({node, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '');
code({ node, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "")
return match ? (
<div className="relative border border-input rounded-md my-4">
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl">
@ -163,8 +182,8 @@ export default function ChatMessage({ message, setContext, setIsContextExpanded
PreTag="div"
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: '0.875rem',
padding: "0.5rem",
fontSize: "0.875rem",
}}
>
{stringifyContent(children)}
@ -175,7 +194,7 @@ export default function ChatMessage({ message, setContext, setIsContextExpanded
<code className={className} {...props}>
{children}
</code>
);
)
},
p: renderMarkdownElement,
h1: renderMarkdownElement,
@ -184,18 +203,24 @@ export default function ChatMessage({ message, setContext, setIsContextExpanded
h4: renderMarkdownElement,
h5: renderMarkdownElement,
h6: renderMarkdownElement,
ul: (props) => <ul className="list-disc pl-6 mb-4 space-y-2">{props.children}</ul>,
ol: (props) => <ol className="list-decimal pl-6 mb-4 space-y-2">{props.children}</ol>,
ul: (props) => (
<ul className="list-disc pl-6 mb-4 space-y-2">
{props.children}
</ul>
),
ol: (props) => (
<ol className="list-decimal pl-6 mb-4 space-y-2">
{props.children}
</ol>
),
}}
>
{message.content}
</ReactMarkdown>
) : (
<div className="whitespace-pre-wrap group">
{message.content}
</div>
<div className="whitespace-pre-wrap group">{message.content}</div>
)}
</div>
</div>
);
)
}

View File

@ -1,48 +1,60 @@
import React from 'react';
import { ChevronUp, ChevronDown, X } from 'lucide-react';
import { ChevronDown, ChevronUp, X } from "lucide-react"
interface ContextDisplayProps {
context: string | null;
isContextExpanded: boolean;
setIsContextExpanded: (isExpanded: boolean) => void;
setContext: (context: string | null) => void;
context: string | null
isContextExpanded: boolean
setIsContextExpanded: (isExpanded: boolean) => void
setContext: (context: string | null) => void
}
export default function ContextDisplay({ context, isContextExpanded, setIsContextExpanded, setContext }: ContextDisplayProps) {
if (!context) return null;
export default function ContextDisplay({
context,
isContextExpanded,
setIsContextExpanded,
setContext,
}: ContextDisplayProps) {
if (!context) return null
return (
<div className="mb-2 bg-input p-2 rounded-lg">
<div className="flex justify-between items-center">
<div
className="flex-grow cursor-pointer"
<div
className="flex-grow cursor-pointer"
onClick={() => setIsContextExpanded(!isContextExpanded)}
>
<span className="text-sm text-gray-300">
Context
</span>
<span className="text-sm text-gray-300">Context</span>
</div>
<div className="flex items-center">
{isContextExpanded ? (
<ChevronUp size={16} className="cursor-pointer" onClick={() => setIsContextExpanded(false)} />
<ChevronUp
size={16}
className="cursor-pointer"
onClick={() => setIsContextExpanded(false)}
/>
) : (
<ChevronDown size={16} className="cursor-pointer" onClick={() => setIsContextExpanded(true)} />
<ChevronDown
size={16}
className="cursor-pointer"
onClick={() => setIsContextExpanded(true)}
/>
)}
<X
size={16}
className="ml-2 cursor-pointer text-gray-400 hover:text-gray-200"
<X
size={16}
className="ml-2 cursor-pointer text-gray-400 hover:text-gray-200"
onClick={() => setContext(null)}
/>
</div>
</div>
{isContextExpanded && (
<textarea
value={context.replace(/^Regarding this code:\n/, '')}
onChange={(e) => setContext(`Regarding this code:\n${e.target.value}`)}
value={context.replace(/^Regarding this code:\n/, "")}
onChange={(e) =>
setContext(`Regarding this code:\n${e.target.value}`)
}
className="w-full mt-2 p-2 bg-#1e1e1e text-white rounded"
rows={5}
/>
)}
</div>
);
)
}

View File

@ -1,52 +1,76 @@
import React, { useState, useEffect, useRef } from 'react';
import LoadingDots from '../../ui/LoadingDots';
import ChatMessage from './ChatMessage';
import ChatInput from './ChatInput';
import ContextDisplay from './ContextDisplay';
import { handleSend, handleStopGeneration } from './lib/chatUtils';
import { X } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import LoadingDots from "../../ui/LoadingDots"
import ChatInput from "./ChatInput"
import ChatMessage from "./ChatMessage"
import ContextDisplay from "./ContextDisplay"
import { handleSend, handleStopGeneration } from "./lib/chatUtils"
interface Message {
role: 'user' | 'assistant';
content: string;
context?: string;
role: "user" | "assistant"
content: string
context?: string
}
export default function AIChat({ activeFileContent, activeFileName }: { activeFileContent: string, activeFileName: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const chatContainerRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const [context, setContext] = useState<string | null>(null);
const [isContextExpanded, setIsContextExpanded] = useState(false);
const [isLoading, setIsLoading] = useState(false);
export default function AIChat({
activeFileContent,
activeFileName,
onClose,
}: {
activeFileContent: string
activeFileName: string
onClose: () => void
}) {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState("")
const [isGenerating, setIsGenerating] = useState(false)
const chatContainerRef = useRef<HTMLDivElement>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const [context, setContext] = useState<string | null>(null)
const [isContextExpanded, setIsContextExpanded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
scrollToBottom();
}, [messages]);
scrollToBottom()
}, [messages])
const scrollToBottom = () => {
if (chatContainerRef.current) {
setTimeout(() => {
chatContainerRef.current?.scrollTo({
top: chatContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}, 100);
behavior: "smooth",
})
}, 100)
}
};
}
return (
<div className="flex flex-col h-screen w-full">
<div className="flex justify-between items-center p-2 border-b">
<span className="text-muted-foreground/50 font-medium">CHAT</span>
<span className="text-muted-foreground/50 font-medium truncate max-w-[50%]" title={activeFileName}>{activeFileName}</span>
<div className="flex items-center h-full">
<span className="text-muted-foreground/50 font-medium">
{activeFileName}
</span>
<div className="mx-2 h-full w-px bg-muted-foreground/20"></div>
<button
onClick={onClose}
className="text-muted-foreground/50 hover:text-muted-foreground focus:outline-none"
aria-label="Close AI Chat"
>
<X size={18} />
</button>
</div>
</div>
<div ref={chatContainerRef} className="flex-grow overflow-y-auto p-4 space-y-4">
<div
ref={chatContainerRef}
className="flex-grow overflow-y-auto p-4 space-y-4"
>
{messages.map((message, messageIndex) => (
<ChatMessage
key={messageIndex}
message={message}
<ChatMessage
key={messageIndex}
message={message}
setContext={setContext}
setIsContextExpanded={setIsContextExpanded}
/>
@ -54,20 +78,33 @@ export default function AIChat({ activeFileContent, activeFileName }: { activeFi
{isLoading && <LoadingDots />}
</div>
<div className="p-4 border-t mb-14">
<ContextDisplay
context={context}
<ContextDisplay
context={context}
isContextExpanded={isContextExpanded}
setIsContextExpanded={setIsContextExpanded}
setContext={setContext}
/>
<ChatInput
<ChatInput
input={input}
setInput={setInput}
isGenerating={isGenerating}
handleSend={() => handleSend(input, context, messages, setMessages, setInput, setIsContextExpanded, setIsGenerating, setIsLoading, abortControllerRef, activeFileContent)}
handleSend={() =>
handleSend(
input,
context,
messages,
setMessages,
setInput,
setIsContextExpanded,
setIsGenerating,
setIsLoading,
abortControllerRef,
activeFileContent
)
}
handleStopGeneration={() => handleStopGeneration(abortControllerRef)}
/>
</div>
</div>
);
)
}

View File

@ -1,58 +1,68 @@
import React from 'react';
import React from "react"
export const stringifyContent = (content: any, seen = new WeakSet()): string => {
if (typeof content === 'string') {
return content;
export const stringifyContent = (
content: any,
seen = new WeakSet()
): string => {
if (typeof content === "string") {
return content
}
if (content === null) {
return 'null';
return "null"
}
if (content === undefined) {
return 'undefined';
return "undefined"
}
if (typeof content === 'number' || typeof content === 'boolean') {
return content.toString();
if (typeof content === "number" || typeof content === "boolean") {
return content.toString()
}
if (typeof content === 'function') {
return content.toString();
if (typeof content === "function") {
return content.toString()
}
if (typeof content === 'symbol') {
return content.toString();
if (typeof content === "symbol") {
return content.toString()
}
if (typeof content === 'bigint') {
return content.toString() + 'n';
if (typeof content === "bigint") {
return content.toString() + "n"
}
if (React.isValidElement(content)) {
return React.Children.toArray((content as React.ReactElement).props.children)
.map(child => stringifyContent(child, seen))
.join('');
return React.Children.toArray(
(content as React.ReactElement).props.children
)
.map((child) => stringifyContent(child, seen))
.join("")
}
if (Array.isArray(content)) {
return '[' + content.map(item => stringifyContent(item, seen)).join(', ') + ']';
return (
"[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]"
)
}
if (typeof content === 'object') {
if (typeof content === "object") {
if (seen.has(content)) {
return '[Circular]';
return "[Circular]"
}
seen.add(content);
seen.add(content)
try {
const pairs = Object.entries(content).map(
([key, value]) => `${key}: ${stringifyContent(value, seen)}`
);
return '{' + pairs.join(', ') + '}';
)
return "{" + pairs.join(", ") + "}"
} catch (error) {
return Object.prototype.toString.call(content);
return Object.prototype.toString.call(content)
}
}
return String(content);
};
return String(content)
}
export const copyToClipboard = (text: string, setCopiedText: (text: string | null) => void) => {
export const copyToClipboard = (
text: string,
setCopiedText: (text: string | null) => void
) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedText(text);
setTimeout(() => setCopiedText(null), 2000);
});
};
setCopiedText(text)
setTimeout(() => setCopiedText(null), 2000)
})
}
export const handleSend = async (
input: string,
@ -66,97 +76,105 @@ export const handleSend = async (
abortControllerRef: React.MutableRefObject<AbortController | null>,
activeFileContent: string
) => {
if (input.trim() === '' && !context) return;
if (input.trim() === "" && !context) return
const newMessage = {
role: 'user' as const,
const newMessage = {
role: "user" as const,
content: input,
context: context || undefined
};
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
setInput('');
setIsContextExpanded(false);
setIsGenerating(true);
setIsLoading(true);
context: context || undefined,
}
const updatedMessages = [...messages, newMessage]
setMessages(updatedMessages)
setInput("")
setIsContextExpanded(false)
setIsGenerating(true)
setIsLoading(true)
abortControllerRef.current = new AbortController();
abortControllerRef.current = new AbortController()
try {
const anthropicMessages = updatedMessages.map(msg => ({
role: msg.role === 'user' ? 'human' : 'assistant',
content: msg.content
}));
const anthropicMessages = updatedMessages.map((msg) => ({
role: msg.role === "user" ? "human" : "assistant",
content: msg.content,
}))
const response = await fetch(`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: anthropicMessages,
context: context || undefined,
activeFileContent: activeFileContent,
}),
signal: abortControllerRef.current.signal,
});
const response = await fetch(
`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: anthropicMessages,
context: context || undefined,
activeFileContent: activeFileContent,
}),
signal: abortControllerRef.current.signal,
}
)
if (!response.ok) {
throw new Error('Failed to get AI response');
throw new Error("Failed to get AI response")
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
const assistantMessage = { role: 'assistant' as const, content: '' };
setMessages([...updatedMessages, assistantMessage]);
setIsLoading(false);
const reader = response.body?.getReader()
const decoder = new TextDecoder()
const assistantMessage = { role: "assistant" as const, content: "" }
setMessages([...updatedMessages, assistantMessage])
setIsLoading(false)
let buffer = '';
const updateInterval = 100;
let lastUpdateTime = Date.now();
let buffer = ""
const updateInterval = 100
let lastUpdateTime = Date.now()
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const currentTime = Date.now();
const currentTime = Date.now()
if (currentTime - lastUpdateTime > updateInterval) {
setMessages(prev => {
const updatedMessages = [...prev];
const lastMessage = updatedMessages[updatedMessages.length - 1];
lastMessage.content = buffer;
return updatedMessages;
});
lastUpdateTime = currentTime;
setMessages((prev) => {
const updatedMessages = [...prev]
const lastMessage = updatedMessages[updatedMessages.length - 1]
lastMessage.content = buffer
return updatedMessages
})
lastUpdateTime = currentTime
}
}
setMessages(prev => {
const updatedMessages = [...prev];
const lastMessage = updatedMessages[updatedMessages.length - 1];
lastMessage.content = buffer;
return updatedMessages;
});
setMessages((prev) => {
const updatedMessages = [...prev]
const lastMessage = updatedMessages[updatedMessages.length - 1]
lastMessage.content = buffer
return updatedMessages
})
}
} catch (error: any) {
if (error.name === 'AbortError') {
console.log('Generation aborted');
if (error.name === "AbortError") {
console.log("Generation aborted")
} else {
console.error('Error fetching AI response:', error);
const errorMessage = { role: 'assistant' as const, content: 'Sorry, I encountered an error. Please try again.' };
setMessages(prev => [...prev, errorMessage]);
console.error("Error fetching AI response:", error)
const errorMessage = {
role: "assistant" as const,
content: "Sorry, I encountered an error. Please try again.",
}
setMessages((prev) => [...prev, errorMessage])
}
} finally {
setIsGenerating(false);
setIsLoading(false);
abortControllerRef.current = null;
setIsGenerating(false)
setIsLoading(false)
abortControllerRef.current = null
}
};
}
export const handleStopGeneration = (abortControllerRef: React.MutableRefObject<AbortController | null>) => {
export const handleStopGeneration = (
abortControllerRef: React.MutableRefObject<AbortController | null>
) => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current.abort()
}
};
}

View File

@ -1,13 +1,13 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Button } from "../ui/button"
import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"
import { Socket } from "socket.io-client"
import { Editor } from "@monaco-editor/react"
import { User } from "@/lib/types"
import { toast } from "sonner"
import { Editor } from "@monaco-editor/react"
import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"
import { usePathname, useRouter } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react"
import { Socket } from "socket.io-client"
import { toast } from "sonner"
import { Button } from "../ui/button"
// import monaco from "monaco-editor"
export default function GenerateInput({
@ -68,10 +68,12 @@ export default function GenerateInput({
setCurrentPrompt(input)
socket.emit(
"generateCode",
data.fileName,
data.code,
data.line,
regenerate ? currentPrompt : input,
{
fileName: data.fileName,
code: data.code,
line: data.line,
instructions: regenerate ? currentPrompt : input
},
(res: { response: string; success: boolean }) => {
console.log("Generated code", res.response, res.success)
// if (!res.success) {

View File

@ -91,6 +91,7 @@ export default function CodeEditor({
// Layout state
const [isHorizontalLayout, setIsHorizontalLayout] = useState(false)
const [previousLayout, setPreviousLayout] = useState(false)
// AI Chat state
const [isAIChatOpen, setIsAIChatOpen] = useState(false)
@ -106,7 +107,6 @@ export default function CodeEditor({
// Editor state
const [editorLanguage, setEditorLanguage] = useState("plaintext")
console.log("editor language: ",editorLanguage)
const [cursorLine, setCursorLine] = useState(0)
const [editorRef, setEditorRef] =
useState<monaco.editor.IStandaloneCodeEditor>()
@ -206,7 +206,7 @@ export default function CodeEditor({
)
const fetchFileContent = (fileId: string): Promise<string> => {
return new Promise((resolve) => {
socket?.emit("getFile", fileId, (content: string) => {
socket?.emit("getFile", { fileId }, (content: string) => {
resolve(content)
})
})
@ -531,7 +531,7 @@ export default function CodeEditor({
)
console.log(`Saving file...${activeFileId}`)
console.log(`Saving file...${content}`)
socket?.emit("saveFile", activeFileId, content)
socket?.emit("saveFile", { fileId: activeFileId, body: content })
}
}, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY) || 1000),
[socket, fileContents]
@ -548,12 +548,18 @@ export default function CodeEditor({
setIsAIChatOpen((prev) => !prev)
}
}
document.addEventListener("keydown", down)
// Added this line to prevent Monaco editor from handling Cmd/Ctrl+L
editorRef?.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL, () => {
setIsAIChatOpen((prev) => !prev)
})
return () => {
document.removeEventListener("keydown", down)
}
}, [activeFileId, tabs, debouncedSaveData, setIsAIChatOpen])
}, [activeFileId, tabs, debouncedSaveData, setIsAIChatOpen, editorRef])
// Liveblocks live collaboration setup effect
useEffect(() => {
@ -642,7 +648,7 @@ export default function CodeEditor({
// Socket event listener effect
useEffect(() => {
const onConnect = () => {}
const onConnect = () => { }
const onDisconnect = () => {
setTerminals([])
@ -708,7 +714,7 @@ export default function CodeEditor({
// Debounced function to get file content
const debouncedGetFile = (tabId: any, callback: any) => {
socket?.emit("getFile", tabId, callback)
socket?.emit("getFile", { fileId: tabId }, callback)
} // 300ms debounce delay, adjust as needed
const selectFile = (tab: TTab) => {
@ -770,8 +776,8 @@ export default function CodeEditor({
? numTabs === 1
? null
: index < numTabs - 1
? tabs[index + 1].id
: tabs[index - 1].id
? tabs[index + 1].id
: tabs[index - 1].id
: activeFileId
setTabs((prev) => prev.filter((t) => t.id !== id))
@ -828,7 +834,7 @@ export default function CodeEditor({
return false
}
socket?.emit("renameFile", id, newName)
socket?.emit("renameFile", { fileId: id, newName })
setTabs((prev) =>
prev.map((tab) => (tab.id === id ? { ...tab, name: newName } : tab))
)
@ -837,7 +843,7 @@ export default function CodeEditor({
}
const handleDeleteFile = (file: TFile) => {
socket?.emit("deleteFile", file.id, (response: (TFolder | TFile)[]) => {
socket?.emit("deleteFile", { fileId: file.id }, (response: (TFolder | TFile)[]) => {
setFiles(response)
})
closeTab(file.id)
@ -847,11 +853,11 @@ export default function CodeEditor({
setDeletingFolderId(folder.id)
console.log("deleting folder", folder.id)
socket?.emit("getFolder", folder.id, (response: string[]) =>
socket?.emit("getFolder", { folderId: folder.id }, (response: string[]) =>
closeTabs(response)
)
socket?.emit("deleteFolder", folder.id, (response: (TFolder | TFile)[]) => {
socket?.emit("deleteFolder", { folderId: folder.id }, (response: (TFolder | TFile)[]) => {
setFiles(response)
setDeletingFolderId("")
})
@ -868,7 +874,24 @@ export default function CodeEditor({
}
const toggleLayout = () => {
setIsHorizontalLayout((prev) => !prev)
if (!isAIChatOpen) {
setIsHorizontalLayout((prev) => !prev)
}
}
// Add an effect to handle layout changes when AI chat is opened/closed
useEffect(() => {
if (isAIChatOpen) {
setPreviousLayout(isHorizontalLayout)
setIsHorizontalLayout(true)
} else {
setIsHorizontalLayout(previousLayout)
}
}, [isAIChatOpen])
// Modify the toggleAIChat function
const toggleAIChat = () => {
setIsAIChatOpen((prev) => !prev)
}
// On disabled access for shared users, show un-interactable loading placeholder + info modal
@ -878,7 +901,7 @@ export default function CodeEditor({
<DisableAccessModal
message={disableAccess.message}
open={disableAccess.isDisabled}
setOpen={() => {}}
setOpen={() => { }}
/>
<Loading />
</>
@ -920,8 +943,8 @@ export default function CodeEditor({
code:
(isSelected && editorRef?.getSelection()
? editorRef
?.getModel()
?.getValueInRange(editorRef?.getSelection()!)
?.getModel()
?.getValueInRange(editorRef?.getSelection()!)
: editorRef?.getValue()) ?? "",
line: generate.line,
}}
@ -1005,9 +1028,13 @@ export default function CodeEditor({
setFiles={setFiles}
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
deletingFolderId={deletingFolderId}
toggleAIChat={toggleAIChat}
isAIChatOpen={isAIChatOpen}
/>
{/* Outer ResizablePanelGroup for main layout */}
<ResizablePanelGroup direction="horizontal">
<ResizablePanelGroup
direction={isHorizontalLayout ? "horizontal" : "vertical"}
>
{/* Left side: Editor and Preview/Terminal */}
<ResizablePanel defaultSize={isAIChatOpen ? 80 : 100} minSize={50}>
<ResizablePanelGroup
@ -1049,62 +1076,62 @@ export default function CodeEditor({
</div>
</>
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
clerk.loaded ? (
<>
{provider && userInfo ? (
<Cursors yProvider={provider} userInfo={userInfo} />
) : null}
<Editor
height="100%"
language={editorLanguage}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
onChange={(value) => {
// If the new content is different from the cached content, update it
if (value !== fileContents[activeFileId]) {
setActiveFileContent(value ?? "") // Update the active file content
// Mark the file as unsaved by setting 'saved' to false
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: false }
: tab
clerk.loaded ? (
<>
{provider && userInfo ? (
<Cursors yProvider={provider} userInfo={userInfo} />
) : null}
<Editor
height="100%"
language={editorLanguage}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
onChange={(value) => {
// If the new content is different from the cached content, update it
if (value !== fileContents[activeFileId]) {
setActiveFileContent(value ?? "") // Update the active file content
// Mark the file as unsaved by setting 'saved' to false
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: false }
: tab
)
)
)
} else {
// If the content matches the cached content, mark the file as saved
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: true }
: tab
} else {
// If the content matches the cached content, mark the file as saved
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: true }
: tab
)
)
)
}
}}
options={{
tabSize: 2,
minimap: {
enabled: false,
},
padding: {
bottom: 4,
top: 4,
},
scrollBeyondLastLine: false,
fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)",
}}
theme={theme === "light" ? "vs" : "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>
)}
}
}}
options={{
tabSize: 2,
minimap: {
enabled: false,
},
padding: {
bottom: 4,
top: 4,
},
scrollBeyondLastLine: false,
fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)",
}}
theme={theme === "light" ? "vs" : "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 />
@ -1114,10 +1141,10 @@ export default function CodeEditor({
isAIChatOpen && isHorizontalLayout
? "horizontal"
: isAIChatOpen
? "vertical"
: isHorizontalLayout
? "horizontal"
: "vertical"
? "vertical"
: isHorizontalLayout
? "horizontal"
: "vertical"
}
>
<ResizablePanel
@ -1136,6 +1163,7 @@ export default function CodeEditor({
size="sm"
variant="ghost"
className="mr-2 border"
disabled={isAIChatOpen}
>
{isHorizontalLayout ? (
<ArrowRightToLine className="w-4 h-4" />
@ -1190,6 +1218,7 @@ export default function CodeEditor({
tabs.find((tab) => tab.id === activeFileId)?.name ||
"No file selected"
}
onClose={toggleAIChat}
/>
</ResizablePanel>
</>

View File

@ -1,6 +1,6 @@
"use client";
"use client"
import { useOthers } from "@/liveblocks.config";
import { useOthers } from "@/liveblocks.config"
const classNames = {
red: "w-8 h-8 leading-none font-mono rounded-full ring-1 ring-red-700 ring-offset-2 ring-offset-background overflow-hidden bg-gradient-to-tr from-red-950 to-red-600 flex items-center justify-center text-xs font-medium",
@ -14,10 +14,10 @@ const classNames = {
purple:
"w-8 h-8 leading-none font-mono rounded-full ring-1 ring-purple-700 ring-offset-2 ring-offset-background overflow-hidden bg-gradient-to-tr from-purple-950 to-purple-600 flex items-center justify-center text-xs font-medium",
pink: "w-8 h-8 leading-none font-mono rounded-full ring-1 ring-pink-700 ring-offset-2 ring-offset-background overflow-hidden bg-gradient-to-tr from-pink-950 to-pink-600 flex items-center justify-center text-xs font-medium",
};
}
export function Avatars() {
const users = useOthers();
const users = useOthers()
return (
<>
@ -30,12 +30,12 @@ export function Avatars() {
.slice(0, 2)
.map((letter) => letter[0].toUpperCase())}
</div>
);
)
})}
</div>
{users.length > 0 ? (
<div className="h-full w-[1px] bg-border mx-2" />
) : null}
</>
);
)
}

View File

@ -1,11 +1,10 @@
import { useEffect, useMemo, useState } from "react"
import { colors } from "@/lib/colors"
import {
AwarenessList,
TypedLiveblocksProvider,
UserAwareness,
useSelf,
} from "@/liveblocks.config"
import { colors } from "@/lib/colors"
import { useEffect, useMemo, useState } from "react"
export function Cursors({
yProvider,

View File

@ -1,43 +1,35 @@
"use client";
"use client"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
} from "@/components/ui/dialog"
import {
ChevronRight,
FileStack,
Globe,
Loader2,
TextCursor,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Loader2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
export default function DisableAccessModal({
open,
setOpen,
message,
}: {
open: boolean;
setOpen: (open: boolean) => void;
message: string;
open: boolean
setOpen: (open: boolean) => void
message: string
}) {
const router = useRouter();
const router = useRouter()
useEffect(() => {
if (open) {
const timeout = setTimeout(() => {
router.push("/dashboard");
}, 5000);
return () => clearTimeout(timeout);
router.push("/dashboard")
}, 5000)
return () => clearTimeout(timeout)
}
}, []);
}, [])
return (
<Dialog open={open} onOpenChange={setOpen}>
@ -54,5 +46,5 @@ export default function DisableAccessModal({
</div>
</DialogContent>
</Dialog>
);
)
}

View File

@ -1,14 +1,13 @@
"use client";
"use client"
import { RoomProvider } from "@/liveblocks.config";
import { ClientSideSuspense } from "@liveblocks/react";
import { RoomProvider } from "@/liveblocks.config"
export function Room({
id,
children,
}: {
id: string;
children: React.ReactNode;
id: string
children: React.ReactNode
}) {
return (
<RoomProvider
@ -21,5 +20,5 @@ export function Room({
{children}
{/* </ClientSideSuspense> */}
</RoomProvider>
);
)
}

View File

@ -1,9 +1,6 @@
"use client"
import Image from "next/image"
import Logo from "@/assets/logo.svg"
import { Skeleton } from "@/components/ui/skeleton"
import { Loader2, X } from "lucide-react"
import {
Dialog,
DialogContent,
@ -11,6 +8,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { Loader2, X } from "lucide-react"
import Image from "next/image"
import { useEffect, useState } from "react"
export default function Loading({

View File

@ -1,34 +1,38 @@
"use client";
"use client"
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useTerminal } from "@/context/TerminalContext";
import { Play, Pause, Globe, Globe2 } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Sandbox, User } from "@/lib/types";
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useTerminal } from "@/context/TerminalContext"
import { Sandbox, User } from "@/lib/types"
import { Globe } from "lucide-react"
import { useState } from "react"
export default function DeployButtonModal({
userData,
data,
}: {
userData: User;
data: Sandbox;
userData: User
data: Sandbox
}) {
const { deploy } = useTerminal();
const [isDeploying, setIsDeploying] = useState(false);
const { deploy } = useTerminal()
const [isDeploying, setIsDeploying] = useState(false)
const handleDeploy = () => {
if (isDeploying) {
console.log("Stopping deployment...");
setIsDeploying(false);
console.log("Stopping deployment...")
setIsDeploying(false)
} else {
console.log("Starting deployment...");
setIsDeploying(true);
console.log("Starting deployment...")
setIsDeploying(true)
deploy(() => {
setIsDeploying(false);
});
setIsDeploying(false)
})
}
};
}
return (
<>
@ -39,7 +43,10 @@ export default function DeployButtonModal({
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" style={{ backgroundColor: 'rgb(10,10,10)', color: 'white' }}>
<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"
style={{ backgroundColor: "rgb(10,10,10)", color: "white" }}
>
<h3 className="font-semibold text-gray-300 mb-2">Domains</h3>
<div className="flex flex-col gap-4">
<DeploymentOption
@ -49,16 +56,30 @@ export default function DeployButtonModal({
user={userData.name}
/>
</div>
<Button variant="outline" className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]" onClick={handleDeploy}>
{isDeploying ? "Deploying..." : "Update"}
<Button
variant="outline"
className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]"
onClick={handleDeploy}
>
{isDeploying ? "Deploying..." : "Update"}
</Button>
</PopoverContent>
</Popover>
</>
);
)
}
function DeploymentOption({ icon, domain, timestamp, user }: { icon: React.ReactNode; domain: string; timestamp: string; user: string }) {
function DeploymentOption({
icon,
domain,
timestamp,
user,
}: {
icon: React.ReactNode
domain: string
timestamp: string
user: string
}) {
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">
@ -72,7 +93,9 @@ function DeploymentOption({ icon, domain, timestamp, user }: { icon: React.React
{domain}
</a>
</div>
<p className="text-sm text-gray-400 mt-0 ml-7">{timestamp} {user}</p>
<p className="text-sm text-gray-400 mt-0 ml-7">
{timestamp} {user}
</p>
</div>
);
)
}

View File

@ -1,60 +1,57 @@
"use client";
"use client"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
} from "@/components/ui/dialog"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { Sandbox } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { deleteSandbox, updateSandbox } from "@/lib/actions";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
} from "@/components/ui/select"
import { deleteSandbox, updateSandbox } from "@/lib/actions"
import { Sandbox } from "@/lib/types"
import { Loader2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { toast } from "sonner"
const formSchema = z.object({
name: z.string().min(1).max(16),
visibility: z.enum(["public", "private"]),
});
})
export default function EditSandboxModal({
open,
setOpen,
data,
}: {
open: boolean;
setOpen: (open: boolean) => void;
data: Sandbox;
open: boolean
setOpen: (open: boolean) => void
data: Sandbox
}) {
const [loading, setLoading] = useState(false);
const [loadingDelete, setLoadingDelete] = useState(false);
const [loading, setLoading] = useState(false)
const [loadingDelete, setLoadingDelete] = useState(false)
const router = useRouter();
const router = useRouter()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -62,22 +59,22 @@ export default function EditSandboxModal({
name: data.name,
visibility: data.visibility,
},
});
})
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
await updateSandbox({ id: data.id, ...values });
setLoading(true)
await updateSandbox({ id: data.id, ...values })
toast.success("Sandbox updated successfully");
toast.success("Sandbox updated successfully")
setLoading(false);
setLoading(false)
}
async function onDelete() {
setLoadingDelete(true);
await deleteSandbox(data.id);
setLoadingDelete(true)
await deleteSandbox(data.id)
router.push("/dashboard");
router.push("/dashboard")
}
return (
@ -153,5 +150,5 @@ export default function EditSandboxModal({
</Button>
</DialogContent>
</Dialog>
);
)
}

View File

@ -1,73 +1,78 @@
"use client";
"use client"
import React, { useEffect, useRef } from 'react';
import { Play, StopCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTerminal } from "@/context/TerminalContext";
import { usePreview } from "@/context/PreviewContext";
import { toast } from "sonner";
import { Sandbox } from "@/lib/types";
import { Button } from "@/components/ui/button"
import { usePreview } from "@/context/PreviewContext"
import { useTerminal } from "@/context/TerminalContext"
import { Sandbox } from "@/lib/types"
import { Play, StopCircle } from "lucide-react"
import { useEffect, useRef } from "react"
import { toast } from "sonner"
export default function RunButtonModal({
isRunning,
setIsRunning,
sandboxData,
}: {
isRunning: boolean;
setIsRunning: (running: boolean) => void;
sandboxData: Sandbox;
isRunning: boolean
setIsRunning: (running: boolean) => void
sandboxData: Sandbox
}) {
const { createNewTerminal, closeTerminal, terminals } = useTerminal();
const { setIsPreviewCollapsed, previewPanelRef } = usePreview();
const { createNewTerminal, closeTerminal, terminals } = useTerminal()
const { setIsPreviewCollapsed, previewPanelRef } = usePreview()
// Ref to keep track of the last created terminal's ID
const lastCreatedTerminalRef = useRef<string | null>(null);
const lastCreatedTerminalRef = useRef<string | null>(null)
// Effect to update the lastCreatedTerminalRef when a new terminal is added
useEffect(() => {
if (terminals.length > 0 && !isRunning) {
const latestTerminal = terminals[terminals.length - 1];
if (latestTerminal && latestTerminal.id !== lastCreatedTerminalRef.current) {
lastCreatedTerminalRef.current = latestTerminal.id;
const latestTerminal = terminals[terminals.length - 1]
if (
latestTerminal &&
latestTerminal.id !== lastCreatedTerminalRef.current
) {
lastCreatedTerminalRef.current = latestTerminal.id
}
}
}, [terminals, isRunning]);
}, [terminals, isRunning])
const handleRun = async () => {
if (isRunning && lastCreatedTerminalRef.current)
{
await closeTerminal(lastCreatedTerminalRef.current);
lastCreatedTerminalRef.current = null;
setIsPreviewCollapsed(true);
previewPanelRef.current?.collapse();
}
else if (!isRunning && terminals.length < 4)
{
const command = sandboxData.type === "streamlit"
? "pip install -r requirements.txt && streamlit run main.py --server.runOnSave true"
: "yarn install && yarn dev";
if (isRunning && lastCreatedTerminalRef.current) {
await closeTerminal(lastCreatedTerminalRef.current)
lastCreatedTerminalRef.current = null
setIsPreviewCollapsed(true)
previewPanelRef.current?.collapse()
} else if (!isRunning && terminals.length < 4) {
const command =
sandboxData.type === "streamlit"
? "pip install -r requirements.txt && streamlit run main.py --server.runOnSave true"
: "yarn install && yarn dev"
try {
// Create a new terminal with the appropriate command
await createNewTerminal(command);
setIsPreviewCollapsed(false);
previewPanelRef.current?.expand();
await createNewTerminal(command)
setIsPreviewCollapsed(false)
previewPanelRef.current?.expand()
} catch (error) {
toast.error("Failed to create new terminal.");
console.error("Error creating new terminal:", error);
return;
toast.error("Failed to create new terminal.")
console.error("Error creating new terminal:", error)
return
}
} else if (!isRunning) {
toast.error("You've reached the maximum number of terminals.");
return;
toast.error("You've reached the maximum number of terminals.")
return
}
setIsRunning(!isRunning);
};
setIsRunning(!isRunning)
}
return (
<Button variant="outline" onClick={handleRun}>
{isRunning ? <StopCircle className="w-4 h-4 mr-2" /> : <Play className="w-4 h-4 mr-2" />}
{isRunning ? 'Stop' : 'Run'}
{isRunning ? (
<StopCircle className="w-4 h-4 mr-2" />
) : (
<Play className="w-4 h-4 mr-2" />
)}
{isRunning ? "Stop" : "Run"}
</Button>
);
}
)
}

View File

@ -6,10 +6,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
@ -18,14 +19,13 @@ import {
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Link, Loader2, UserPlus, X } from "lucide-react"
import { useState } from "react"
import { Sandbox } from "@/lib/types"
import { Button } from "@/components/ui/button"
import { shareSandbox } from "@/lib/actions"
import { Sandbox } from "@/lib/types"
import { DialogDescription } from "@radix-ui/react-dialog"
import { Link, Loader2, UserPlus } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import SharedUser from "./sharedUser"
import { DialogDescription } from "@radix-ui/react-dialog"
const formSchema = z.object({
email: z.string().email(),

View File

@ -1,66 +1,69 @@
"use client"
import { Link, RotateCw, UnfoldVertical } from "lucide-react"
import {
Link,
RotateCw,
TerminalSquare,
UnfoldVertical,
} from "lucide-react"
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from "react"
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { toast } from "sonner"
export default forwardRef(function PreviewWindow({
collapsed,
open,
src
}: {
collapsed: boolean
open: () => void
src: string
},
ref: React.Ref<{
refreshIframe: () => void
}>) {
export default forwardRef(function PreviewWindow(
{
collapsed,
open,
src,
}: {
collapsed: boolean
open: () => void
src: string
},
ref: React.Ref<{
refreshIframe: () => void
}>
) {
const frameRef = useRef<HTMLIFrameElement>(null)
const [iframeKey, setIframeKey] = useState(0)
const refreshIframe = () => {
setIframeKey(prev => prev + 1)
setIframeKey((prev) => prev + 1)
}
// Refresh the preview when the URL changes.
// Refresh the preview when the URL changes.
useEffect(refreshIframe, [src])
// Expose refreshIframe method to the parent.
useImperativeHandle(ref, () => ({ refreshIframe }))
return (
<>
<div className="h-8 rounded-md px-3 bg-secondary flex items-center w-full justify-between">
<div className="text-xs">Preview</div>
<div className="flex space-x-1 translate-x-1">
{collapsed ? (
<div className="h-8 rounded-md px-3 bg-secondary flex items-center w-full justify-between">
<div className="text-xs">Preview</div>
<div className="flex space-x-1 translate-x-1">
{collapsed ? (
<PreviewButton onClick={open}>
<UnfoldVertical className="w-4 h-4" />
</PreviewButton>
) : (
<>
<PreviewButton onClick={open}>
<UnfoldVertical className="w-4 h-4" />
</PreviewButton>
) : (
<>
<PreviewButton onClick={open}>
<UnfoldVertical className="w-4 h-4" />
</PreviewButton>
<PreviewButton
onClick={() => {
navigator.clipboard.writeText(src)
toast.info("Copied preview link to clipboard")
}}
>
<Link className="w-4 h-4" />
</PreviewButton>
<PreviewButton onClick={refreshIframe}>
<RotateCw className="w-3 h-3" />
</PreviewButton>
</>
)}
</div>
<PreviewButton
onClick={() => {
navigator.clipboard.writeText(src)
toast.info("Copied preview link to clipboard")
}}
>
<Link className="w-4 h-4" />
</PreviewButton>
<PreviewButton onClick={refreshIframe}>
<RotateCw className="w-3 h-3" />
</PreviewButton>
</>
)}
</div>
</div>
</>
)
})
@ -76,8 +79,9 @@ function PreviewButton({
}) {
return (
<div
className={`${disabled ? "pointer-events-none opacity-50" : ""
} p-0.5 h-5 w-5 ml-0.5 flex items-center justify-center transition-colors bg-transparent hover:bg-muted-foreground/25 cursor-pointer rounded-sm`}
className={`${
disabled ? "pointer-events-none opacity-50" : ""
} p-0.5 h-5 w-5 ml-0.5 flex items-center justify-center transition-colors bg-transparent hover:bg-muted-foreground/25 cursor-pointer rounded-sm`}
onClick={onClick}
>
{children}

View File

@ -1,18 +1,18 @@
"use client";
"use client"
import Image from "next/image";
import { getIconForFile } from "vscode-icons-js";
import { TFile, TTab } from "@/lib/types";
import { useEffect, useRef, useState } from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Loader2, Pencil, Trash2 } from "lucide-react";
} from "@/components/ui/context-menu"
import { TFile, TTab } from "@/lib/types"
import { Loader2, Pencil, Trash2 } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import { getIconForFile } from "vscode-icons-js"
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
export default function SidebarFile({
data,
@ -22,36 +22,36 @@ export default function SidebarFile({
movingId,
deletingFolderId,
}: {
data: TFile;
selectFile: (file: TTab) => void;
data: TFile
selectFile: (file: TTab) => void
handleRename: (
id: string,
newName: string,
oldName: string,
type: "file" | "folder"
) => boolean;
handleDeleteFile: (file: TFile) => void;
movingId: string;
deletingFolderId: string;
) => boolean
handleDeleteFile: (file: TFile) => void
movingId: string
deletingFolderId: string
}) {
const isMoving = movingId === data.id;
const isMoving = movingId === data.id
const isDeleting =
deletingFolderId.length > 0 && data.id.startsWith(deletingFolderId);
deletingFolderId.length > 0 && data.id.startsWith(deletingFolderId)
const ref = useRef(null); // for draggable
const [dragging, setDragging] = useState(false);
const ref = useRef(null) // for draggable
const [dragging, setDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null);
const [imgSrc, setImgSrc] = useState(`/icons/${getIconForFile(data.name)}`);
const [editing, setEditing] = useState(false);
const [pendingDelete, setPendingDelete] = useState(isDeleting);
const inputRef = useRef<HTMLInputElement>(null)
const [imgSrc, setImgSrc] = useState(`/icons/${getIconForFile(data.name)}`)
const [editing, setEditing] = useState(false)
const [pendingDelete, setPendingDelete] = useState(isDeleting)
useEffect(() => {
setPendingDelete(isDeleting);
}, [isDeleting]);
setPendingDelete(isDeleting)
}, [isDeleting])
useEffect(() => {
const el = ref.current;
const el = ref.current
if (el)
return draggable({
@ -59,14 +59,14 @@ export default function SidebarFile({
onDragStart: () => setDragging(true),
onDrop: () => setDragging(false),
getInitialData: () => ({ id: data.id }),
});
}, []);
})
}, [])
useEffect(() => {
if (editing) {
setTimeout(() => inputRef.current?.focus(), 0);
setTimeout(() => inputRef.current?.focus(), 0)
}
}, [editing, inputRef.current]);
}, [editing, inputRef.current])
const renameFile = () => {
const renamed = handleRename(
@ -74,12 +74,12 @@ export default function SidebarFile({
inputRef.current?.value ?? data.name,
data.name,
"file"
);
)
if (!renamed && inputRef.current) {
inputRef.current.value = data.name;
inputRef.current.value = data.name
}
setEditing(false);
};
setEditing(false)
}
return (
<ContextMenu>
@ -88,7 +88,7 @@ export default function SidebarFile({
disabled={pendingDelete || dragging || isMoving}
onClick={() => {
if (!editing && !pendingDelete && !isMoving)
selectFile({ ...data, saved: true });
selectFile({ ...data, saved: true })
}}
onDoubleClick={() => {
setEditing(true)
@ -119,8 +119,8 @@ export default function SidebarFile({
) : (
<form
onSubmit={(e) => {
e.preventDefault();
renameFile();
e.preventDefault()
renameFile()
}}
>
<input
@ -138,8 +138,8 @@ export default function SidebarFile({
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
console.log("rename");
setEditing(true);
console.log("rename")
setEditing(true)
}}
>
<Pencil className="w-4 h-4 mr-2" />
@ -148,9 +148,9 @@ export default function SidebarFile({
<ContextMenuItem
disabled={pendingDelete}
onClick={() => {
console.log("delete");
setPendingDelete(true);
handleDeleteFile(data);
console.log("delete")
setPendingDelete(true)
handleDeleteFile(data)
}}
>
<Trash2 className="w-4 h-4 mr-2" />
@ -158,5 +158,5 @@ export default function SidebarFile({
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
)
}

View File

@ -1,20 +1,20 @@
"use client"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import { getIconForFolder, getIconForOpenFolder } from "vscode-icons-js"
import { TFile, TFolder, TTab } from "@/lib/types"
import SidebarFile from "./file"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { ChevronRight, Loader2, Pencil, Trash2 } from "lucide-react"
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { TFile, TFolder, TTab } from "@/lib/types"
import { cn } from "@/lib/utils"
import { motion, AnimatePresence } from "framer-motion"
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { AnimatePresence, motion } from "framer-motion"
import { ChevronRight, Pencil, Trash2 } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import { getIconForFolder, getIconForOpenFolder } from "vscode-icons-js"
import SidebarFile from "./file"
// Note: Renaming has not been implemented in the backend yet, so UI relating to renaming is commented out

View File

@ -1,28 +1,20 @@
"use client"
import {
FilePlus,
FolderPlus,
Loader2,
MonitorPlay,
Search,
Sparkles,
} from "lucide-react"
import { Sandbox, TFile, TFolder, TTab } from "@/lib/types"
import { FilePlus, FolderPlus, MessageSquareMore, Sparkles } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { Socket } from "socket.io-client"
import SidebarFile from "./file"
import SidebarFolder from "./folder"
import { Sandbox, TFile, TFolder, TTab } from "@/lib/types"
import { useEffect, useMemo, useRef, useState } from "react"
import New from "./new"
import { Socket } from "socket.io-client"
import { Switch } from "@/components/ui/switch"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { cn, sortFileExplorer } from "@/lib/utils"
import {
dropTargetForElements,
monitorForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import Button from "@/components/ui/customButton"
import { Skeleton } from "@/components/ui/skeleton"
import { sortFileExplorer } from "@/lib/utils"
export default function Sidebar({
sandboxData,
@ -35,6 +27,8 @@ export default function Sidebar({
setFiles,
addNew,
deletingFolderId,
toggleAIChat,
isAIChatOpen,
}: {
sandboxData: Sandbox
files: (TFile | TFolder)[]
@ -51,6 +45,8 @@ export default function Sidebar({
setFiles: (files: (TFile | TFolder)[]) => void
addNew: (name: string, type: "file" | "folder") => void
deletingFolderId: string
toggleAIChat: () => void
isAIChatOpen: boolean
}) {
const ref = useRef(null) // drop target
@ -95,8 +91,10 @@ export default function Sidebar({
setMovingId(fileId)
socket.emit(
"moveFile",
fileId,
folderId,
{
fileId,
folderId
},
(response: (TFolder | TFile)[]) => {
setFiles(response)
setMovingId("")
@ -107,9 +105,9 @@ export default function Sidebar({
}, [])
return (
<div className="h-full w-56 select-none flex flex-col text-sm items-start justify-between p-2">
<div className="w-full flex flex-col items-start">
<div className="flex w-full items-center justify-between h-8 mb-1 ">
<div className="h-full w-56 select-none flex flex-col text-sm">
<div className="flex-grow overflow-auto p-2 pb-[84px]">
<div className="flex w-full items-center justify-between h-8 mb-1">
<div className="text-muted-foreground">Explorer</div>
<div className="flex space-x-1">
<button
@ -185,10 +183,49 @@ export default function Sidebar({
)}
</div>
</div>
<div className="w-full space-y-4">
{/* <Button className="w-full">
<MonitorPlay className="w-4 h-4 mr-2" /> Run
</Button> */}
<div className="fixed bottom-0 w-48 flex flex-col p-2 bg-background">
<Button
variant="ghost"
className="w-full justify-start text-sm text-muted-foreground font-normal h-8 px-2 mb-2"
disabled
aria-disabled="true"
style={{ opacity: 1 }}
>
<Sparkles className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
AI Editor
<div className="ml-auto">
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
<span className="text-xs"></span>G
</kbd>
</div>
</Button>
<Button
variant="ghost"
className={cn(
"w-full justify-start text-sm font-normal h-8 px-2 mb-2 border-t",
isAIChatOpen
? "bg-muted-foreground/25 text-foreground"
: "text-muted-foreground"
)}
onClick={toggleAIChat}
aria-disabled={false}
style={{ opacity: 1 }}
>
<MessageSquareMore
className={cn(
"h-4 w-4 mr-2",
isAIChatOpen
? "text-indigo-500"
: "text-indigo-500 opacity-70"
)}
/>
AI Chat
<div className="ml-auto">
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
<span className="text-xs"></span>L
</kbd>
</div>
</Button>
</div>
</div>
)

View File

@ -1,9 +1,9 @@
"use client";
"use client"
import { validateName } from "@/lib/utils";
import Image from "next/image";
import { useEffect, useRef } from "react";
import { Socket } from "socket.io-client";
import { validateName } from "@/lib/utils"
import Image from "next/image"
import { useEffect, useRef } from "react"
import { Socket } from "socket.io-client"
export default function New({
socket,
@ -11,42 +11,42 @@ export default function New({
stopEditing,
addNew,
}: {
socket: Socket;
type: "file" | "folder";
stopEditing: () => void;
addNew: (name: string, type: "file" | "folder") => void;
socket: Socket
type: "file" | "folder"
stopEditing: () => void
addNew: (name: string, type: "file" | "folder") => void
}) {
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null)
function createNew() {
const name = inputRef.current?.value;
const name = inputRef.current?.value
if (name) {
const valid = validateName(name, "", type);
const valid = validateName(name, "", type)
if (valid.status) {
if (type === "file") {
socket.emit(
"createFile",
name,
{ name },
({ success }: { success: boolean }) => {
if (success) {
addNew(name, type);
addNew(name, type)
}
}
);
)
} else {
socket.emit("createFolder", name, () => {
addNew(name, type);
});
socket.emit("createFolder", { name }, () => {
addNew(name, type)
})
}
}
}
stopEditing();
stopEditing()
}
useEffect(() => {
inputRef.current?.focus();
}, []);
inputRef.current?.focus()
}, [])
return (
<div className="w-full flex items-center h-7 px-1 hover:bg-secondary rounded-sm cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
@ -63,8 +63,8 @@ export default function New({
/>
<form
onSubmit={(e) => {
e.preventDefault();
createNew();
e.preventDefault()
createNew()
}}
>
<input
@ -74,5 +74,5 @@ export default function New({
/>
</form>
</div>
);
)
}

View File

@ -1,18 +1,17 @@
"use client";
"use client"
import { Button } from "@/components/ui/button";
import Tab from "@/components/ui/tab";
import { Terminal } from "@xterm/xterm";
import { Loader2, Plus, SquareTerminal, TerminalSquare } from "lucide-react";
import { toast } from "sonner";
import EditorTerminal from "./terminal";
import { useTerminal } from "@/context/TerminalContext";
import { useEffect } from "react";
import { Button } from "@/components/ui/button"
import Tab from "@/components/ui/tab"
import { useSocket } from "@/context/SocketContext"
import { useTerminal } from "@/context/TerminalContext"
import { Terminal } from "@xterm/xterm"
import { Loader2, Plus, SquareTerminal, TerminalSquare } from "lucide-react"
import { useEffect } from "react"
import { toast } from "sonner"
import EditorTerminal from "./terminal"
export default function Terminals() {
const { socket } = useSocket();
const { socket } = useSocket()
const {
terminals,
@ -22,24 +21,24 @@ export default function Terminals() {
activeTerminalId,
setActiveTerminalId,
creatingTerminal,
} = useTerminal();
} = useTerminal()
const activeTerminal = terminals.find((t) => t.id === activeTerminalId);
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
// Effect to set the active terminal when a new one is created
useEffect(() => {
if (terminals.length > 0 && !activeTerminalId) {
setActiveTerminalId(terminals[terminals.length - 1].id);
setActiveTerminalId(terminals[terminals.length - 1].id)
}
}, [terminals, activeTerminalId, setActiveTerminalId]);
}, [terminals, activeTerminalId, setActiveTerminalId])
const handleCreateTerminal = () => {
if (terminals.length >= 4) {
toast.error("You reached the maximum # of terminals.");
return;
toast.error("You reached the maximum # of terminals.")
return
}
createNewTerminal();
};
createNewTerminal()
}
return (
<>
@ -85,7 +84,7 @@ export default function Terminals() {
? { ...term, terminal: t }
: term
)
);
)
}}
visible={activeTerminalId === term.id}
/>
@ -98,5 +97,5 @@ export default function Terminals() {
</div>
)}
</>
);
}
)
}

View File

@ -1,14 +1,14 @@
"use client"
import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit"
import { Terminal } from "@xterm/xterm"
import "./xterm.css"
import { ElementRef, useEffect, useRef, useState } from "react"
import { Socket } from "socket.io-client"
import { Loader2 } from "lucide-react"
import { debounce } from "@/lib/utils"
import { Loader2 } from "lucide-react"
import { useTheme } from "next-themes"
import { ElementRef, useEffect, useRef } from "react"
import { Socket } from "socket.io-client"
export default function EditorTerminal({
socket,
id,
@ -22,38 +22,17 @@ export default function EditorTerminal({
setTerm: (term: Terminal) => void
visible: boolean
}) {
const terminalRef = useRef<ElementRef<"div">>(null)
const { theme } = useTheme()
const terminalContainerRef = useRef<ElementRef<"div">>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
useEffect(() => {
if (!terminalRef.current) return
if (!terminalContainerRef.current) return
// console.log("new terminal", id, term ? "reusing" : "creating");
const terminal = new Terminal({
cursorBlink: true,
theme: {
foreground: "#2e3436",
background: "#ffffff",
black: "#2e3436",
brightBlack: "#555753",
red: "#cc0000",
brightRed: "#ef2929",
green: "#4e9a06",
brightGreen: "#8ae234",
yellow: "#c4a000",
brightYellow: "#fce94f",
blue: "#3465a4",
brightBlue: "#729fcf",
magenta: "#75507b",
brightMagenta: "#ad7fa8",
cyan: "#06989a",
brightCyan: "#34e2e2",
white: "#d3d7cf",
brightWhite: "#eeeeec",
cursor: "#2e3436",
cursorAccent: "#ffffff",
selection: "rgba(52, 101, 164, 0.3)",
},
theme: theme === "light" ? lightTheme : darkTheme,
fontFamily: "var(--font-geist-mono)",
fontSize: 14,
lineHeight: 1.5,
@ -67,29 +46,35 @@ export default function EditorTerminal({
return dispose
}, [])
useEffect(() => {
if (term) {
term.options.theme = theme === "light" ? lightTheme : darkTheme
}
}, [theme])
useEffect(() => {
if (!term) return
if (!terminalRef.current) return
if (!terminalContainerRef.current) return
if (!fitAddonRef.current) {
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(terminalRef.current)
term.open(terminalContainerRef.current)
fitAddon.fit()
fitAddonRef.current = fitAddon
}
const disposableOnData = term.onData((data) => {
socket.emit("terminalData", id, data)
socket.emit("terminalData", { id, data })
})
const disposableOnResize = term.onResize((dimensions) => {
fitAddonRef.current?.fit()
socket.emit("terminalResize", dimensions)
socket.emit("terminalResize", { dimensions })
})
const resizeObserver = new ResizeObserver(
debounce((entries) => {
if (!fitAddonRef.current || !terminalRef.current) return
if (!fitAddonRef.current || !terminalContainerRef.current) return
const entry = entries[0]
if (!entry) return
@ -98,8 +83,8 @@ export default function EditorTerminal({
// Only call fit if the size has actually changed
if (
width !== terminalRef.current.offsetWidth ||
height !== terminalRef.current.offsetHeight
width !== terminalContainerRef.current.offsetWidth ||
height !== terminalContainerRef.current.offsetHeight
) {
try {
fitAddonRef.current.fit()
@ -111,13 +96,13 @@ export default function EditorTerminal({
)
// start observing for resize
resizeObserver.observe(terminalRef.current)
resizeObserver.observe(terminalContainerRef.current)
return () => {
disposableOnData.dispose()
disposableOnResize.dispose()
resizeObserver.disconnect()
}
}, [term, terminalRef.current])
}, [term, terminalContainerRef.current])
useEffect(() => {
if (!term) return
@ -136,7 +121,7 @@ export default function EditorTerminal({
return (
<>
<div
ref={terminalRef}
ref={terminalContainerRef}
style={{ display: visible ? "block" : "none" }}
className="w-full h-full text-left"
>
@ -150,3 +135,56 @@ export default function EditorTerminal({
</>
)
}
const lightTheme = {
foreground: "#2e3436",
background: "#ffffff",
black: "#2e3436",
brightBlack: "#555753",
red: "#cc0000",
brightRed: "#ef2929",
green: "#4e9a06",
brightGreen: "#8ae234",
yellow: "#c4a000",
brightYellow: "#fce94f",
blue: "#3465a4",
brightBlue: "#729fcf",
magenta: "#75507b",
brightMagenta: "#ad7fa8",
cyan: "#06989a",
brightCyan: "#34e2e2",
white: "#d3d7cf",
brightWhite: "#eeeeec",
cursor: "#2e3436",
cursorAccent: "#ffffff",
selectionBackground: "#3465a4",
selectionForeground: "#ffffff",
selectionInactiveBackground: "#264973",
}
// Dark Theme
const darkTheme = {
foreground: "#f8f8f2",
background: "#0a0a0a",
black: "#21222c",
brightBlack: "#6272a4",
red: "#ff5555",
brightRed: "#ff6e6e",
green: "#50fa7b",
brightGreen: "#69ff94",
yellow: "#f1fa8c",
brightYellow: "#ffffa5",
blue: "#bd93f9",
brightBlue: "#d6acff",
magenta: "#ff79c6",
brightMagenta: "#ff92df",
cyan: "#8be9fd",
brightCyan: "#a4ffff",
white: "#f8f8f2",
brightWhite: "#ffffff",
cursor: "#f8f8f2",
cursorAccent: "#0a0a0a",
selectionBackground: "#264973",
selectionForeground: "#ffffff",
selectionInactiveBackground: "#1a3151",
}

View File

@ -35,7 +35,7 @@
* Default styles for xterm.js
*/
.xterm {
.xterm {
cursor: text;
position: relative;
user-select: none;
@ -80,7 +80,7 @@
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: transparent;
color: #FFF;
color: #fff;
display: none;
position: absolute;
white-space: nowrap;
@ -154,12 +154,12 @@
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
user-select: text;
white-space: pre;
}
.xterm .live-region {
@ -176,33 +176,55 @@ white-space: pre;
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-underline-1 {
text-decoration: underline;
}
.xterm-underline-2 {
text-decoration: double underline;
}
.xterm-underline-3 {
text-decoration: wavy underline;
}
.xterm-underline-4 {
text-decoration: dotted underline;
}
.xterm-underline-5 {
text-decoration: dashed underline;
}
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-overline.xterm-underline-1 {
text-decoration: overline underline;
}
.xterm-overline.xterm-underline-2 {
text-decoration: overline double underline;
}
.xterm-overline.xterm-underline-3 {
text-decoration: overline wavy underline;
}
.xterm-overline.xterm-underline-4 {
text-decoration: overline dotted underline;
}
.xterm-overline.xterm-underline-5 {
text-decoration: overline dashed underline;
}
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
.xterm-screen
.xterm-decoration-container
.xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
@ -216,4 +238,4 @@ z-index: 7;
.xterm-decoration-top {
z-index: 2;
position: relative;
}
}

View File

@ -22,7 +22,7 @@ export default function Landing() {
</div>
<div className="flex items-center space-x-4">
<Button variant="outline" size="icon" asChild>
<a href="https://www.x.com/ishaandey_" target="_blank">
<a href="https://x.com/gitwitdev" target="_blank">
<svg
width="1200"
height="1227"
@ -54,7 +54,7 @@ export default function Landing() {
<CustomButton>Go To App</CustomButton>
</Link>
<a
href="https://github.com/ishaan1013/sandbox"
href="https://github.com/jamesmurdza/sandbox"
target="_blank"
className="group h-9 px-4 py-2 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
>

View File

@ -1,4 +1,4 @@
import React from 'react';
import React from "react"
const LoadingDots: React.FC = () => {
return (
@ -9,24 +9,35 @@ const LoadingDots: React.FC = () => {
<style jsx>{`
.loading-dots {
display: inline-block;
font-size: 24px;
font-size: 24px;
}
.dot {
opacity: 0;
animation: showHideDot 1.5s ease-in-out infinite;
}
.dot:nth-child(1) { animation-delay: 0s; }
.dot:nth-child(2) { animation-delay: 0.5s; }
.dot:nth-child(3) { animation-delay: 1s; }
.dot:nth-child(1) {
animation-delay: 0s;
}
.dot:nth-child(2) {
animation-delay: 0.5s;
}
.dot:nth-child(3) {
animation-delay: 1s;
}
@keyframes showHideDot {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
`}</style>
</span>
);
};
export default LoadingDots;
)
}
export default LoadingDots

View File

@ -1,10 +1,10 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import * as React from "react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
const AlertDialog = AlertDialogPrimitive.Root
@ -128,14 +128,14 @@ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@ -1,6 +1,6 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@ -73,4 +73,4 @@ const CardFooter = React.forwardRef<
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }

View File

@ -1,12 +1,12 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons"
import * as React from "react"
import { cn } from "@/lib/utils"
@ -187,18 +187,18 @@ ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
ContextMenuTrigger,
}

View File

@ -1,18 +1,18 @@
"use client";
"use client"
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Cross2Icon } from "@radix-ui/react-icons";
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import * as React from "react"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root;
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger;
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal;
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close;
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -26,8 +26,8 @@ const DialogOverlay = React.forwardRef<
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@ -50,8 +50,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogContentNoClose = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@ -70,9 +70,9 @@ const DialogContentNoClose = React.forwardRef<
{children}
</DialogPrimitive.Content>
</DialogPortal>
));
))
DialogContentNoClose.displayName =
DialogPrimitive.Content.displayName + "NoClose";
DialogPrimitive.Content.displayName + "NoClose"
const DialogHeader = ({
className,
@ -85,8 +85,8 @@ const DialogHeader = ({
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
@ -99,8 +99,8 @@ const DialogFooter = ({
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@ -114,8 +114,8 @@ const DialogTitle = React.forwardRef<
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
@ -126,19 +126,19 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogContentNoClose,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -1,12 +1,12 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons"
import * as React from "react"
import { cn } from "@/lib/utils"
@ -188,18 +188,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
DropdownMenuTrigger,
}

View File

@ -1,6 +1,6 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import * as React from "react"
import {
Controller,
ControllerProps,
@ -10,8 +10,8 @@ import {
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
const Form = FormProvider
@ -165,12 +165,12 @@ const FormMessage = React.forwardRef<
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
}

View File

@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react"
import { cn } from "@/lib/utils"
@ -30,4 +30,4 @@ const PopoverContent = React.forwardRef<
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }

View File

@ -42,4 +42,4 @@ const ResizableHandle = ({
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

View File

@ -1,6 +1,5 @@
"use client"
import * as React from "react"
import {
CaretSortIcon,
CheckIcon,
@ -8,6 +7,7 @@ import {
ChevronUpIcon,
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import * as React from "react"
import { cn } from "@/lib/utils"
@ -152,13 +152,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectGroup,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@ -1,8 +1,8 @@
"use client";
"use client"
import { Loader2, X } from "lucide-react";
import { Button } from "./button";
import { MouseEvent, MouseEventHandler, useEffect } from "react";
import { Loader2, X } from "lucide-react"
import { MouseEventHandler } from "react"
import { Button } from "./button"
export default function Tab({
children,
@ -13,13 +13,13 @@ export default function Tab({
onClose,
closing = false,
}: {
children: React.ReactNode;
creating?: boolean;
saved?: boolean;
selected?: boolean;
onClick?: MouseEventHandler<HTMLButtonElement>;
onClose?: () => void;
closing?: boolean;
children: React.ReactNode
creating?: boolean
saved?: boolean
selected?: boolean
onClick?: MouseEventHandler<HTMLButtonElement>
onClose?: () => void
closing?: boolean
}) {
return (
<Button
@ -28,7 +28,7 @@ export default function Tab({
variant={"secondary"}
className={`font-normal select-none ${
selected
? "bg-neutral-700 hover:bg-neutral-600 text-foreground"
? "bg-muted-foreground/50 hover:bg-muted-foreground/40 text-foreground"
: "text-muted-foreground"
}`}
>
@ -37,9 +37,9 @@ export default function Tab({
onClick={
onClose && !closing
? (e) => {
e.stopPropagation();
e.preventDefault();
onClose();
e.stopPropagation()
e.preventDefault()
onClose()
}
: undefined
}
@ -57,5 +57,5 @@ export default function Tab({
)}
</div>
</Button>
);
)
}

View File

@ -110,11 +110,11 @@ TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TableCell,
TableCaption,
}

View File

@ -1,23 +1,22 @@
"use client";
"use client"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { User } from "@/lib/types";
import { useClerk } from "@clerk/nextjs";
import { LogOut, Pencil, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
} from "@/components/ui/dropdown-menu"
import { User } from "@/lib/types"
import { useClerk } from "@clerk/nextjs"
import { LogOut, Sparkles } from "lucide-react"
import { useRouter } from "next/navigation"
export default function UserButton({ userData }: { userData: User }) {
if (!userData) return null;
if (!userData) return null
const { signOut } = useClerk();
const router = useRouter();
const { signOut } = useClerk()
const router = useRouter()
return (
<DropdownMenu>
@ -68,5 +67,5 @@ export default function UserButton({ userData }: { userData: User }) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
)
}

View File

@ -1,34 +1,44 @@
"use client"
import React, { createContext, useContext, useState, useRef } from 'react';
import { ImperativePanelHandle } from "react-resizable-panels";
import React, { createContext, useContext, useRef, useState } from "react"
import { ImperativePanelHandle } from "react-resizable-panels"
interface PreviewContextType {
isPreviewCollapsed: boolean;
setIsPreviewCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
previewURL: string;
setPreviewURL: React.Dispatch<React.SetStateAction<string>>;
previewPanelRef: React.RefObject<ImperativePanelHandle>;
isPreviewCollapsed: boolean
setIsPreviewCollapsed: React.Dispatch<React.SetStateAction<boolean>>
previewURL: string
setPreviewURL: React.Dispatch<React.SetStateAction<string>>
previewPanelRef: React.RefObject<ImperativePanelHandle>
}
const PreviewContext = createContext<PreviewContextType | undefined>(undefined);
const PreviewContext = createContext<PreviewContextType | undefined>(undefined)
export const PreviewProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true);
const [previewURL, setPreviewURL] = useState<string>("");
const previewPanelRef = useRef<ImperativePanelHandle>(null);
export const PreviewProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true)
const [previewURL, setPreviewURL] = useState<string>("")
const previewPanelRef = useRef<ImperativePanelHandle>(null)
return (
<PreviewContext.Provider value={{ isPreviewCollapsed, setIsPreviewCollapsed, previewURL, setPreviewURL, previewPanelRef }}>
<PreviewContext.Provider
value={{
isPreviewCollapsed,
setIsPreviewCollapsed,
previewURL,
setPreviewURL,
previewPanelRef,
}}
>
{children}
</PreviewContext.Provider>
);
};
)
}
export const usePreview = () => {
const context = useContext(PreviewContext);
const context = useContext(PreviewContext)
if (context === undefined) {
throw new Error('usePreview must be used within a PreviewProvider');
throw new Error("usePreview must be used within a PreviewProvider")
}
return context;
};
return context
}

View File

@ -1,63 +1,65 @@
"use client";
"use client"
import React, { createContext, useContext, useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import React, { createContext, useContext, useEffect, useState } from "react"
import { io, Socket } from "socket.io-client"
interface SocketContextType {
socket: Socket | null;
setUserAndSandboxId: (userId: string, sandboxId: string) => void;
socket: Socket | null
setUserAndSandboxId: (userId: string, sandboxId: string) => void
}
const SocketContext = createContext<SocketContextType | undefined>(undefined);
const SocketContext = createContext<SocketContextType | undefined>(undefined)
export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [socket, setSocket] = useState<Socket | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [sandboxId, setSandboxId] = useState<string | null>(null);
export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [socket, setSocket] = useState<Socket | null>(null)
const [userId, setUserId] = useState<string | null>(null)
const [sandboxId, setSandboxId] = useState<string | null>(null)
useEffect(() => {
if (userId && sandboxId) {
console.log("Initializing socket connection...");
const newSocket = io(`${process.env.NEXT_PUBLIC_SERVER_URL}?userId=${userId}&sandboxId=${sandboxId}`);
console.log("Socket instance:", newSocket);
setSocket(newSocket);
console.log("Initializing socket connection...")
const newSocket = io(
`${process.env.NEXT_PUBLIC_SERVER_URL}?userId=${userId}&sandboxId=${sandboxId}`
)
console.log("Socket instance:", newSocket)
setSocket(newSocket)
newSocket.on('connect', () => {
console.log("Socket connected:", newSocket.id);
});
newSocket.on("connect", () => {
console.log("Socket connected:", newSocket.id)
})
newSocket.on('disconnect', () => {
console.log("Socket disconnected");
});
newSocket.on("disconnect", () => {
console.log("Socket disconnected")
})
return () => {
console.log("Disconnecting socket...");
newSocket.disconnect();
};
console.log("Disconnecting socket...")
newSocket.disconnect()
}
}
}, [userId, sandboxId]);
}, [userId, sandboxId])
const setUserAndSandboxId = (newUserId: string, newSandboxId: string) => {
setUserId(newUserId);
setSandboxId(newSandboxId);
};
setUserId(newUserId)
setSandboxId(newSandboxId)
}
const value = {
socket,
setUserAndSandboxId,
};
}
return (
<SocketContext.Provider value={ value }>
{children}
</SocketContext.Provider>
);
};
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
)
}
export const useSocket = (): SocketContextType => {
const context = useContext(SocketContext);
const context = useContext(SocketContext)
if (!context) {
throw new Error('useSocket must be used within a SocketProvider');
throw new Error("useSocket must be used within a SocketProvider")
}
return context;
};
return context
}

View File

@ -1,33 +1,44 @@
"use client";
"use client"
import React, { createContext, useContext, useState } from 'react';
import { Terminal } from '@xterm/xterm';
import { createTerminal as createTerminalHelper, closeTerminal as closeTerminalHelper } from '@/lib/terminal';
import { useSocket } from '@/context/SocketContext';
import { useSocket } from "@/context/SocketContext"
import {
closeTerminal as closeTerminalHelper,
createTerminal as createTerminalHelper,
} from "@/lib/terminal"
import { Terminal } from "@xterm/xterm"
import React, { createContext, useContext, useState } from "react"
interface TerminalContextType {
terminals: { id: string; terminal: Terminal | null }[];
setTerminals: React.Dispatch<React.SetStateAction<{ id: string; terminal: Terminal | null }[]>>;
activeTerminalId: string;
setActiveTerminalId: React.Dispatch<React.SetStateAction<string>>;
creatingTerminal: boolean;
setCreatingTerminal: React.Dispatch<React.SetStateAction<boolean>>;
createNewTerminal: (command?: string) => Promise<void>;
closeTerminal: (id: string) => void;
deploy: (callback: () => void) => void;
terminals: { id: string; terminal: Terminal | null }[]
setTerminals: React.Dispatch<
React.SetStateAction<{ id: string; terminal: Terminal | null }[]>
>
activeTerminalId: string
setActiveTerminalId: React.Dispatch<React.SetStateAction<string>>
creatingTerminal: boolean
setCreatingTerminal: React.Dispatch<React.SetStateAction<boolean>>
createNewTerminal: (command?: string) => Promise<void>
closeTerminal: (id: string) => void
deploy: (callback: () => void) => void
}
const TerminalContext = createContext<TerminalContextType | undefined>(undefined);
const TerminalContext = createContext<TerminalContextType | undefined>(
undefined
)
export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { socket } = useSocket();
const [terminals, setTerminals] = useState<{ id: string; terminal: Terminal | null }[]>([]);
const [activeTerminalId, setActiveTerminalId] = useState<string>('');
const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false);
export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const { socket } = useSocket()
const [terminals, setTerminals] = useState<
{ id: string; terminal: Terminal | null }[]
>([])
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false)
const createNewTerminal = async (command?: string): Promise<void> => {
if (!socket) return;
setCreatingTerminal(true);
if (!socket) return
setCreatingTerminal(true)
try {
createTerminalHelper({
setTerminals,
@ -35,36 +46,36 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setCreatingTerminal,
command,
socket,
});
})
} catch (error) {
console.error("Error creating terminal:", error);
console.error("Error creating terminal:", error)
} finally {
setCreatingTerminal(false);
setCreatingTerminal(false)
}
};
}
const closeTerminal = (id: string) => {
if (!socket) return;
const terminalToClose = terminals.find(term => term.id === id);
if (!socket) return
const terminalToClose = terminals.find((term) => term.id === id)
if (terminalToClose) {
closeTerminalHelper({
term: terminalToClose,
terminals,
setTerminals,
setActiveTerminalId,
setClosingTerminal: () => {},
setClosingTerminal: () => { },
socket,
activeTerminalId,
});
})
}
};
}
const deploy = (callback: () => void) => {
if (!socket) console.error("Couldn't deploy: No socket");
if (!socket) console.error("Couldn't deploy: No socket")
console.log("Deploying...")
socket?.emit("deploy", () => {
callback();
});
socket?.emit("deploy", {}, () => {
callback()
})
}
const value = {
@ -76,20 +87,20 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setCreatingTerminal,
createNewTerminal,
closeTerminal,
deploy
};
deploy,
}
return (
<TerminalContext.Provider value={value}>
{children}
</TerminalContext.Provider>
);
};
)
}
export const useTerminal = (): TerminalContextType => {
const context = useContext(TerminalContext);
const context = useContext(TerminalContext)
if (!context) {
throw new Error('useTerminal must be used within a TerminalProvider');
throw new Error("useTerminal must be used within a TerminalProvider")
}
return context;
};
return context
}

View File

@ -1,8 +1,8 @@
// Helper functions for terminal instances
import { createId } from "@paralleldrive/cuid2";
import { Terminal } from "@xterm/xterm";
import { Socket } from "socket.io-client";
import { createId } from "@paralleldrive/cuid2"
import { Terminal } from "@xterm/xterm"
import { Socket } from "socket.io-client"
export const createTerminal = ({
setTerminals,
@ -11,30 +11,33 @@ export const createTerminal = ({
command,
socket,
}: {
setTerminals: React.Dispatch<React.SetStateAction<{
id: string;
terminal: Terminal | null;
}[]>>;
setActiveTerminalId: React.Dispatch<React.SetStateAction<string>>;
setCreatingTerminal: React.Dispatch<React.SetStateAction<boolean>>;
command?: string;
socket: Socket;
setTerminals: React.Dispatch<
React.SetStateAction<
{
id: string
terminal: Terminal | null
}[]
>
>
setActiveTerminalId: React.Dispatch<React.SetStateAction<string>>
setCreatingTerminal: React.Dispatch<React.SetStateAction<boolean>>
command?: string
socket: Socket
}) => {
setCreatingTerminal(true);
const id = createId();
console.log("creating terminal, id:", id);
setCreatingTerminal(true)
const id = createId()
console.log("creating terminal, id:", id)
setTerminals((prev) => [...prev, { id, terminal: null }]);
setActiveTerminalId(id);
setTerminals((prev) => [...prev, { id, terminal: null }])
setActiveTerminalId(id)
setTimeout(() => {
socket.emit("createTerminal", id, () => {
setCreatingTerminal(false);
if (command) socket.emit("terminalData", id, command + "\n");
});
}, 1000);
};
socket.emit("createTerminal", { id }, () => {
setCreatingTerminal(false)
if (command) socket.emit("terminalData", { id, data: command + "\n" })
})
}, 1000)
}
export const closeTerminal = ({
term,
@ -44,51 +47,55 @@ export const closeTerminal = ({
setClosingTerminal,
socket,
activeTerminalId,
} : {
term: {
id: string;
terminal: Terminal | null
}: {
term: {
id: string
terminal: Terminal | null
}
terminals: {
id: string;
terminal: Terminal | null
terminals: {
id: string
terminal: Terminal | null
}[]
setTerminals: React.Dispatch<React.SetStateAction<{
id: string;
terminal: Terminal | null
}[]>>
setTerminals: React.Dispatch<
React.SetStateAction<
{
id: string
terminal: Terminal | null
}[]
>
>
setActiveTerminalId: React.Dispatch<React.SetStateAction<string>>
setClosingTerminal: React.Dispatch<React.SetStateAction<string>>
socket: Socket
activeTerminalId: string
}) => {
const numTerminals = terminals.length;
const index = terminals.findIndex((t) => t.id === term.id);
if (index === -1) return;
const numTerminals = terminals.length
const index = terminals.findIndex((t) => t.id === term.id)
if (index === -1) return
setClosingTerminal(term.id);
setClosingTerminal(term.id)
socket.emit("closeTerminal", term.id, () => {
setClosingTerminal("");
socket.emit("closeTerminal", { id: term.id }, () => {
setClosingTerminal("")
const nextId =
activeTerminalId === term.id
? numTerminals === 1
? null
: index < numTerminals - 1
? terminals[index + 1].id
: terminals[index - 1].id
: activeTerminalId;
? terminals[index + 1].id
: terminals[index - 1].id
: activeTerminalId
setTerminals((prev) => prev.filter((t) => t.id !== term.id));
setTerminals((prev) => prev.filter((t) => t.id !== term.id))
if (!nextId) {
setActiveTerminalId("");
setActiveTerminalId("")
} else {
const nextTerminal = terminals.find((t) => t.id === nextId);
const nextTerminal = terminals.find((t) => t.id === nextId)
if (nextTerminal) {
setActiveTerminalId(nextTerminal.id);
setActiveTerminalId(nextTerminal.id)
}
}
});
};
})
}

View File

@ -73,7 +73,11 @@ function mapModule(module: string): monaco.languages.typescript.ModuleKind {
)
}
function mapJSX(jsx: string): monaco.languages.typescript.JsxEmit {
function mapJSX(jsx: string | undefined): monaco.languages.typescript.JsxEmit {
if (!jsx || typeof jsx !== 'string') {
return monaco.languages.typescript.JsxEmit.React // Default value
}
const jsxMap: { [key: string]: monaco.languages.typescript.JsxEmit } = {
preserve: monaco.languages.typescript.JsxEmit.Preserve,
react: monaco.languages.typescript.JsxEmit.React,

View File

@ -1,65 +1,65 @@
// DB Types
export type User = {
id: string;
name: string;
email: string;
generations: number;
sandbox: Sandbox[];
usersToSandboxes: UsersToSandboxes[];
};
id: string
name: string
email: string
generations: number
sandbox: Sandbox[]
usersToSandboxes: UsersToSandboxes[]
}
export type Sandbox = {
id: string;
name: string;
type: string;
visibility: "public" | "private";
createdAt: Date;
userId: string;
usersToSandboxes: UsersToSandboxes[];
};
id: string
name: string
type: string
visibility: "public" | "private"
createdAt: Date
userId: string
usersToSandboxes: UsersToSandboxes[]
}
export type UsersToSandboxes = {
userId: string;
sandboxId: string;
sharedOn: Date;
};
userId: string
sandboxId: string
sharedOn: Date
}
export type R2Files = {
objects: R2FileData[];
truncated: boolean;
delimitedPrefixes: any[];
};
objects: R2FileData[]
truncated: boolean
delimitedPrefixes: any[]
}
export type R2FileData = {
storageClass: string;
uploaded: string;
checksums: any;
httpEtag: string;
etag: string;
size: number;
version: string;
key: string;
};
storageClass: string
uploaded: string
checksums: any
httpEtag: string
etag: string
size: number
version: string
key: string
}
export type TFolder = {
id: string;
type: "folder";
name: string;
children: (TFile | TFolder)[];
};
id: string
type: "folder"
name: string
children: (TFile | TFolder)[]
}
export type TFile = {
id: string;
type: "file";
name: string;
};
id: string
type: "file"
name: string
}
export type TTab = TFile & {
saved: boolean;
};
saved: boolean
}
export type TFileData = {
id: string;
data: string;
};
id: string
data: string
}

View File

@ -1,8 +1,8 @@
import { type ClassValue, clsx } from "clsx"
// import { toast } from "sonner"
import { twMerge } from "tailwind-merge"
import { Sandbox, TFile, TFolder } from "./types"
import fileExtToLang from "./file-extension-to-language.json"
import { Sandbox, TFile, TFolder } from "./types"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))

Some files were not shown because too many files have changed in this diff Show More