Compare commits

...

80 Commits

Author SHA1 Message Date
a9c5db92ff fix: ignore certains files and folders from the file tree
- Created new config file for ignored paths in file system traversal
- Separated ignored folders and files into dedicated arrays
- Includes comprehensive ignore patterns for:
  - Package managers (node_modules, venv)
  - Build outputs and caches
  - Version control
  - IDE specific folders
  - Framework specific directories
  - System and config files
  - Lock files and compiled assets
2024-11-04 17:52:26 -05:00
2c9f130a37 chore: delete unused files 2024-11-04 17:23:16 -05:00
fac1404e14 feat: multi-file context, context tabs
- added context tabs
- added multifile context including file and image uploads to the context along with all the files from the project
- added file/image previews on input
- added code paste from the editor and file lines recognition
- added image paste from clipboard and preview
2024-11-04 14:21:13 -05:00
2317cf49e9 feat: enhance AI Chat with context management, file integration, image support, and improved code handling
- Added context tabs system for managing multiple types of context (files, code snippets, images)
   - Added preview functionality for context items
   - Added ability to expand/collapse context previews
   - Added file selection popup/dropdown
   - Added file search functionality
   - Added image upload button
   - Added image paste support
   - Added image preview in context tabs
   - Added automatic code detection on paste
   - Added line number tracking for code snippets
   - Added source file name preservation
   - Added line range display for code contexts
   - Added model selection dropdown (Claude 3.5 Sonnet/Claude 3)
   - Added Ctrl+Enter for sending with full context
   - Added Backspace to remove last context tab when input is empty
   - Added smart code detection on paste
2024-10-29 01:37:46 -04:00
24332794f1 chore: changing the links 2024-10-27 17:03:41 -04:00
a8b8a25e4c feat: add AI chat button to open it 2024-10-27 16:58:17 -04:00
88058ca710 fix: jsx.tolowercase error 2024-10-27 14:20:39 -04:00
7f6e2bf62d chore: removing unnecessary code 2024-10-27 14:17:31 -04:00
b48b08a274 chore: add posix to fix file not found errors 2024-10-27 14:17:08 -04:00
b64913a8f3 Merge branch 'refs/heads/refactor-server' 2024-10-26 18:43:08 -06:00
0809eaca4e refactor: rename SandboxManager to Sandbox 2024-10-26 18:41:10 -06:00
8b890fdffe fix: remove editor red squiggly lines
by dynamically loading project's tsconfig file and adding nice defaults

# Conflicts:
#	frontend/components/editor/index.tsx
#	frontend/lib/utils.ts
2024-10-26 18:41:10 -06:00
224d190468 refactor: improve readability of connection manager code 2024-10-26 18:41:10 -06:00
7ace8f569a fix: forward filesystem change notifications to all relevant connections 2024-10-26 18:40:50 -06:00
a87a4b5160 fix: call event handlers when there is no callback 2024-10-26 18:38:09 -06:00
e229dab826 fix: wait until the owner is disconnected from all sockets to close terminals and file manager 2024-10-26 18:38:09 -06:00
3ad7e5d9bc refactor: improve names of server variables 2024-10-26 18:38:09 -06:00
935c314357 chore: add comments to backend server 2024-10-26 18:38:09 -06:00
0b6085c57c refactor: create connection manager class 2024-10-26 18:38:09 -06:00
87a74d40d6 refactor: simplify server error handling 2024-10-26 18:38:09 -06:00
aa554fa39d fix: use entire file paths when pushing files to Dokku 2024-10-26 18:38:09 -06:00
28e6e2f889 refactor: simplify file manager properties 2024-10-26 18:38:09 -06:00
dc4be6392a refactor: restructure try...catch blocks in server 2024-10-26 18:38:09 -06:00
3e891e6ab1 refactor: move initialization code to SandboxManager 2024-10-26 18:38:09 -06:00
16e0c250d6 refactor: create sandboxManager class 2024-10-26 18:38:09 -06:00
fcc7a836a6 refactor: export all event handlers as one object 2024-10-26 18:38:09 -06:00
09ab81f5bd refactor: move rate limiting to handler functions 2024-10-26 18:38:09 -06:00
5ba6bdba15 fix: fix problems with event handler arguments 2024-10-26 18:38:02 -06:00
1479d25d49 refactor: reuse try...catch and rate limiting code across handlers 2024-10-26 18:35:29 -06:00
1de980cdd6 refactor: pass event handler arguments as a single object 2024-10-26 18:35:29 -06:00
c644b0054e refactor: add callback usage to all event handlers 2024-10-26 18:35:21 -06:00
33c8ed8b32 chore: change Dokku errors to warnings 2024-10-26 18:19:37 -06:00
162da9f7ce refactor: move socket authentication middleware to a separate file 2024-10-26 18:19:37 -06:00
af83b33f51 refactor: pass context as object to event handlers 2024-10-26 18:19:37 -06:00
98eda3b080 refactor: move event handlers to a separate file 2024-10-26 18:19:28 -06:00
67f3efa038 refactor: move DokkuResponse to types 2024-10-26 06:44:30 -06:00
76f6e4b0bb refactor: format Cloudflare Worker code 2024-10-26 06:44:10 -06:00
52aa9d92fd chore: format Cloudflare Worker code 2024-10-24 12:45:21 -06:00
63aa2b1e09 chore: enable code formatting for Cloudflare Worker code 2024-10-24 12:45:03 -06:00
3b93090d97 Merge pull request #11 from Code-Victor/feat/light-theme
Feat/light theme
2024-10-24 12:11:00 -06:00
0dc9a4ae96 chore: upload preview GIF to GitHub 2024-10-24 11:38:20 -06:00
5b72799046 chore: use downsized preview GIF 2024-10-23 07:52:39 -06:00
e6642514a3 chore: update README with new instructions and references to GitWit 2024-10-23 07:45:31 -06:00
bf5bc8b1cf feat: improve accessiblity 2024-10-23 12:07:17 +01:00
107cd3ddcc feat: improve accessibility 2024-10-23 12:05:54 +01:00
009d4d5164 feat: add theme swithcer to dashboard 2024-10-23 12:05:43 +01:00
e2d237fe09 feat: add theme to terminal 2024-10-23 11:55:38 +01:00
9546d4ad7b Merge branch 'main' of https://github.com/Code-Victor/sandbox into feat/light-theme 2024-10-23 11:00:24 +01:00
eb4e34cf10 feat: add light theme WIP 2024-10-23 10:51:50 +01:00
68964c2c8f feat: add css syntax highlight 2024-10-23 10:51:16 +01:00
7563df85a7 chore: add ANTHROPIC_API_KEY to wranger.example.toml 2024-10-22 14:19:44 -06:00
06070a6b2f fix: use socket.emit instead of io.emit for errors 2024-10-22 03:39:50 -06:00
f299f2ee3d fix: use normal button component for AI copilot buttons 2024-10-21 18:24:21 -06:00
4f7a4a5312 Merge branch 'refs/heads/main' into feature/ai-chat
# Conflicts:
#	frontend/components/dashboard/newProject.tsx
#	frontend/components/editor/AIChat/ChatMessage.tsx
#	frontend/components/editor/AIChat/ContextDisplay.tsx
#	frontend/components/editor/AIChat/index.tsx
#	frontend/components/editor/index.tsx
#	frontend/components/editor/sidebar/index.tsx
#	frontend/components/editor/terminals/terminal.tsx
2024-10-21 18:16:26 -06:00
6fb1364d6f chore: format frontend code 2024-10-21 13:57:45 -06:00
3f8e27d969 chore: format frontend code 2024-10-21 13:57:17 -06:00
305939c15c chore: enable code formatting for frontend code
# Conflicts:
#	.prettierignore
2024-10-21 13:53:51 -06:00
2897b908fd chore: add code formatting settings 2024-10-21 13:51:18 -06:00
c2156021f7 Merge branch 'refs/heads/refactor-server'
# Conflicts:
#	backend/server/src/index.ts
2024-10-20 17:26:44 -06:00
428d2366ff Merge branch 'refs/heads/feature/ai-chat'
# Conflicts:
#	frontend/components/editor/index.tsx
2024-10-20 17:22:16 -06:00
a08848148b chore: add GitWit copyright to MIT License 2024-10-20 17:20:59 -06:00
a459da6e6f chore: create separate functions to manage file structure and file data 2024-10-19 18:42:44 -06:00
4221d7d09a chore: use fixed path for the project directory 2024-10-19 16:44:36 -06:00
ae38a77759 chore: refactor into RemoteFileStorage 2024-10-19 16:23:31 -06:00
fe0adb8e84 chore: refactor into AIWorker class 2024-10-19 15:48:59 -06:00
7722c533a4 chore: add comments 2024-10-19 15:48:55 -06:00
54706314ea chore: refactor into FileManager and TerminalManager classes 2024-10-19 15:12:52 -06:00
ce4137d697 chore: increase timeout for E2B sandboxes 2024-10-19 05:45:35 -06:00
cc8e0ce187 fix: close all E2B terminals when a sandbox is closed 2024-10-19 05:44:30 -06:00
ad9457b157 chore: format backend server code 2024-10-19 05:25:26 -06:00
1416c225a2 chore: add code formatting settings 2024-10-19 05:22:42 -06:00
77265dde28 chore: fix file paths 2024-10-19 04:48:38 -06:00
930519515c Merge pull request #10 from Code-Victor/feat/ui-fixes-n-updates
Feat/UI fixes n updates
2024-10-14 07:43:51 -07:00
3c4e8ec2de fix: resolve border clipping issue 2024-10-14 12:21:20 +01:00
82c6a12883 chore: remove unsused styles 2024-10-14 12:15:46 +01:00
6e14f676cf feat: sort files in explorer 2024-10-14 12:09:17 +01:00
fa5d1e9a57 feat: add skeleton loader to file explorer 2024-10-14 12:06:54 +01:00
33fc082217 feat: terminal now resize appropriately 2024-10-14 10:10:47 +01:00
a0183451ad feat: update project image and description 2024-10-13 23:48:36 +01:00
eabc9fa2f6 feat: update create new project dialog 2024-10-13 23:34:27 +01:00
129 changed files with 6653 additions and 3156 deletions

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"tabWidth": 2,
"semi": false,
"singleQuote": false,
"insertFinalNewline": true
}

8
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
}
}

View File

@ -1,6 +1,7 @@
MIT License MIT License
Copyright (c) 2024 Ishaan Dey Copyright (c) 2024 Ishaan Dey
Copyright (c) 2024 GitWit, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

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! For the latest updates, join our Discord server: [discord.gitwit.dev](https://discord.gitwit.dev/).
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!
## Running Locally ## 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 ```bash
cd frontend git clone https://github.com/jamesmurdza/sandbox
npm install 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 ```
/frontend
```bash /backend/database
npm run dev /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 ```
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY='🔑'
Install dependencies CLERK_SECRET_KEY='🔑'
```bash
cd backend/server
npm install
``` ```
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 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 ## 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_). 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/).
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.
### Structure ### Structure
@ -116,13 +226,13 @@ backend/
└── ai └── ai
``` ```
| Path | Description | | Path | Description |
| ------------------ | -------------------------------------------------------------------------- | | ------------------ | ------------------------------------------------------------ |
| `frontend` | The Next.js application for the frontend. | | `frontend` | The Next.js application for the frontend. |
| `backend/server` | The Express websocket server. | | `backend/server` | The Express websocket server. |
| `backend/database` | API for interfacing with the D1 database (SQLite). | | `backend/database` | API for interfacing with the D1 database (SQLite). |
| `backend/storage` | API for interfacing with R2 storage. Service-bound to `/backend/database`. | | `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 ### 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 - `feat / feature`: all changes that introduce completely new code or new
features features
- `fix`: changes that fix a bug (ideally you will additionally reference an - `fix`: changes that fix a bug (ideally you will additionally reference an
issue if present) issue if present)
- `refactor`: any code related change that is not a fix nor a feature - `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 - `docs`: changing existing or creating new documentation (i.e. README, docs for
usage of a lib or cli usage) usage of a lib or cli usage)
- `chore`: all changes to the repository that do not fit into any of the above - `chore`: all changes to the repository that do not fit into any of the above
categories categories

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,9 @@
/* Language and Environment */ /* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, "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. */, "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "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({ export default defineWorkersConfig({
test: { test: {
poolOptions: { poolOptions: {
workers: { workers: {
wrangler: { configPath: "./wrangler.toml" }, wrangler: { configPath: "./wrangler.toml" },
}, },
}, },
}, },
}); })

View File

@ -1,4 +1,3 @@
// Generated by Wrangler // Generated by Wrangler
// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen` // 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] [ai]
binding = "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 export default process.env.LOCAL_DB_PATH
? ({ ? ({
@ -16,4 +16,4 @@ export default process.env.LOCAL_DB_PATH
wranglerConfigPath: "wrangler.toml", wranglerConfigPath: "wrangler.toml",
dbName: "d1-sandbox", dbName: "d1-sandbox",
}, },
} satisfies Config); } satisfies Config)

View File

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

View File

@ -1,11 +1,11 @@
// import type { DrizzleD1Database } from "drizzle-orm/d1"; // import type { DrizzleD1Database } from "drizzle-orm/d1";
import { drizzle } from "drizzle-orm/d1" import { drizzle } from "drizzle-orm/d1"
import { json } from "itty-router-extras" 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 { and, eq, sql } from "drizzle-orm"
import * as schema from "./schema"
import { sandbox, user, usersToSandboxes } from "./schema"
export interface Env { export interface Env {
DB: D1Database DB: D1Database

View File

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

View File

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

View File

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

View File

@ -12,7 +12,9 @@
/* Language and Environment */ /* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, "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. */, "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "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({ export default defineWorkersConfig({
test: { test: {
poolOptions: { poolOptions: {
workers: { workers: {
wrangler: { configPath: "./wrangler.toml" }, wrangler: { configPath: "./wrangler.toml" },
}, },
}, },
}, },
}); })

View File

@ -1,5 +1,7 @@
{ {
"watch": ["src"], "watch": [
"src"
],
"ext": "ts", "ext": "ts",
"exec": "concurrently \"npx tsc --watch\" \"ts-node src/index.ts\"" "exec": "concurrently \"npx tsc --watch\" \"ts-node src/index.ts\""
} }

View File

@ -31,4 +31,4 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
} }

View File

@ -0,0 +1,90 @@
// AIWorker class for handling AI-related operations
export class AIWorker {
private aiWorkerUrl: string
private cfAiKey: string
private databaseWorkerUrl: string
private workersKey: string
// Constructor to initialize AIWorker with necessary URLs and keys
constructor(
aiWorkerUrl: string,
cfAiKey: string,
databaseWorkerUrl: string,
workersKey: string
) {
this.aiWorkerUrl = aiWorkerUrl
this.cfAiKey = cfAiKey
this.databaseWorkerUrl = databaseWorkerUrl
this.workersKey = workersKey
}
// Method to generate code based on user input
async generateCode(
userId: string,
fileName: string,
code: string,
line: number,
instructions: string
): Promise<{ response: string; success: boolean }> {
try {
const fetchPromise = fetch(
`${process.env.DATABASE_WORKER_URL}/api/sandbox/generate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.WORKERS_KEY}`,
},
body: JSON.stringify({
userId: userId,
}),
}
)
// Generate code from cloudflare workers AI
const generateCodePromise = fetch(
`${process.env.AI_WORKER_URL}/api?fileName=${encodeURIComponent(
fileName
)}&code=${encodeURIComponent(code)}&line=${encodeURIComponent(
line
)}&instructions=${encodeURIComponent(instructions)}`,
{
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.CF_AI_KEY}`,
},
}
)
const [fetchResponse, generateCodeResponse] = await Promise.all([
fetchPromise,
generateCodePromise,
])
if (!generateCodeResponse.ok) {
throw new Error(`HTTP error! status: ${generateCodeResponse.status}`)
}
const reader = generateCodeResponse.body?.getReader()
const decoder = new TextDecoder()
let result = ""
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value, { stream: true })
}
}
// The result should now contain only the modified code
return { response: result.trim(), success: true }
} catch (e: any) {
console.error("Error generating code:", e)
return {
response: "Error generating code. Please try again.",
success: false,
}
}
}
}

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

@ -1,37 +1,40 @@
import { SSHSocketClient, SSHConfig } from "./SSHSocketClient" import { SSHConfig, SSHSocketClient } from "./SSHSocketClient"
// Interface for the response structure from Dokku commands
export interface DokkuResponse { export interface DokkuResponse {
ok: boolean; ok: boolean
output: string; output: string
} }
// DokkuClient class extends SSHSocketClient to interact with Dokku via SSH
export class DokkuClient extends SSHSocketClient { export class DokkuClient extends SSHSocketClient {
constructor(config: SSHConfig) { constructor(config: SSHConfig) {
super( // Initialize with Dokku daemon socket path
config, super(config, "/var/run/dokku-daemon/dokku-daemon.sock")
"/var/run/dokku-daemon/dokku-daemon.sock"
)
} }
// Send a command to Dokku and parse the response
async sendCommand(command: string): Promise<DokkuResponse> { async sendCommand(command: string): Promise<DokkuResponse> {
try { try {
const response = await this.sendData(command); const response = await this.sendData(command)
if (typeof response !== "string") { if (typeof response !== "string") {
throw new Error("Received data is not a string"); throw new Error("Received data is not a string")
} }
return JSON.parse(response); // Parse the JSON response from Dokku
return JSON.parse(response)
} catch (error: any) { } catch (error: any) {
throw new Error(`Failed to send command: ${error.message}`); throw new Error(`Failed to send command: ${error.message}`)
} }
} }
// List all deployed Dokku apps
async listApps(): Promise<string[]> { async listApps(): Promise<string[]> {
const response = await this.sendCommand("apps:list"); const response = await this.sendCommand("apps:list")
return response.output.split("\n").slice(1); // Split by newline and ignore the first line (header) // Split the output by newline and remove the header
return response.output.split("\n").slice(1)
} }
} }
export { SSHConfig }; export { SSHConfig }

View File

@ -0,0 +1,506 @@
import { FilesystemEvent, Sandbox, WatchHandle } from "e2b"
import path from "path"
import RemoteFileStorage from "./RemoteFileStorage"
import { MAX_BODY_SIZE } from "./ratelimit"
import { TFile, TFileData, TFolder } from "./types"
// 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: [] }
paths.forEach((path) => {
const parts = path.split("/")
let current: TFolder = root
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const isFile = i === parts.length - 1 && part.length
const existing = current.children.find((child) => child.name === part)
if (existing) {
if (!isFile) {
current = existing as TFolder
}
} else {
if (isFile) {
const file: TFile = { id: `/${parts.join("/")}`, type: "file", name: part }
current.children.push(file)
} else {
const folder: TFolder = {
id: `/${parts.slice(0, i + 1).join("/")}`,
type: "folder",
name: part,
children: [],
}
current.children.push(folder)
current = folder
}
}
}
})
return root.children
}
// FileManager class to handle file operations in a sandbox
export class FileManager {
private sandboxId: string
private sandbox: Sandbox
public files: (TFolder | TFile)[]
public fileData: TFileData[]
private fileWatchers: WatchHandle[] = []
private dirName = "/home/user/project"
private refreshFileList: ((files: (TFolder | TFile)[]) => void) | null
// Constructor to initialize the FileManager
constructor(
sandboxId: string,
sandbox: Sandbox,
refreshFileList: ((files: (TFolder | TFile)[]) => void) | null
) {
this.sandboxId = sandboxId
this.sandbox = sandbox
this.files = []
this.fileData = []
this.refreshFileList = refreshFileList
}
// Fetch file data from list of paths
private async generateFileData(paths: string[]): Promise<TFileData[]> {
const fileData: TFileData[] = []
for (const path of paths) {
const parts = path.split("/")
const isFile = parts.length > 0 && parts[parts.length - 1].length > 0
if (isFile) {
const fileId = `/${parts.join("/")}`
const data = await RemoteFileStorage.fetchFileContent(`projects/${this.sandboxId}${fileId}`)
fileData.push({ id: fileId, data })
}
}
return fileData
}
// Convert local file path to remote path
private getRemoteFileId(localId: string): string {
return `projects/${this.sandboxId}${localId}`
}
// Convert remote file path to local file path
private getLocalFileId(remoteId: string): string | undefined {
const allParts = remoteId.split("/")
if (allParts[1] !== this.sandboxId) return undefined;
return allParts.slice(2).join("/")
}
// Convert remote file paths to local file paths
private getLocalFileIds(remoteIds: string[]): string[] {
return remoteIds
.map(this.getLocalFileId.bind(this))
.filter((id) => id !== undefined);
}
// Download files from remote storage
private async updateFileData(): Promise<TFileData[]> {
const remotePaths = await RemoteFileStorage.getSandboxPaths(this.sandboxId)
const localPaths = this.getLocalFileIds(remotePaths)
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.files = generateFileStructure(localPaths)
return this.files
}
// Initialize the FileManager
async initialize() {
// Download files from remote file storage
await this.updateFileStructure()
await this.updateFileData()
// Copy all files from the project to the container
const promises = this.fileData.map(async (file) => {
try {
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)
}
await this.sandbox.files.write(filePath, file.data)
} catch (e: any) {
console.log("Failed to create file: " + e)
}
})
await Promise.all(promises)
// Make the logged in user the owner of all project files
this.fixPermissions()
await this.watchDirectory(this.dirName)
await this.watchSubdirectories(this.dirName)
}
// Check if the given path is a directory
private async isDirectory(directoryPath: string): Promise<boolean> {
try {
const result = await this.sandbox.commands.run(
`[ -d "${directoryPath}" ] && echo "true" || echo "false"`
)
return result.stdout.trim() === "true"
} catch (e: any) {
console.log("Failed to check if directory: " + e)
return false
}
}
// Change the owner of the project directory to user
private async fixPermissions() {
try {
await this.sandbox.commands.run(
`sudo chown -R user "${this.dirName}"`
)
} catch (e: any) {
console.log("Failed to fix permissions: " + e)
}
}
// Watch a directory for changes
async watchDirectory(directory: string): Promise<WatchHandle | undefined> {
try {
const handle = await this.sandbox.files.watch(
directory,
async (event: FilesystemEvent) => {
try {
function removeDirName(path: string, dirName: string) {
return path.startsWith(dirName)
? path.slice(dirName.length)
: path
}
// This is the absolute file path in the container
const containerFilePath = path.posix.join(directory, event.name)
// This is the file path relative to the project directory
const sandboxFilePath = removeDirName(containerFilePath, this.dirName)
// This is the directory being watched relative to the project directory
const sandboxDirectory = removeDirName(directory, this.dirName)
// Helper function to find a folder by id
function findFolderById(
files: (TFolder | TFile)[],
folderId: string
) {
return files.find(
(file: TFolder | TFile) =>
file.type === "folder" && file.id === folderId
)
}
// Handle file/directory creation event
if (event.type === "create") {
const folder = findFolderById(
this.files,
sandboxDirectory
) as TFolder
const isDir = await this.isDirectory(containerFilePath)
const newItem = isDir
? ({
id: sandboxFilePath,
name: event.name,
type: "folder",
children: [],
} as TFolder)
: ({
id: sandboxFilePath,
name: event.name,
type: "file",
} as TFile)
if (folder) {
// If the folder exists, add the new item (file/folder) as a child
folder.children.push(newItem)
} else {
// If folder doesn't exist, add the new item to the root
this.files.push(newItem)
}
if (!isDir) {
const fileData = await this.sandbox.files.read(
containerFilePath
)
const fileContents =
typeof fileData === "string" ? fileData : ""
this.fileData.push({
id: sandboxFilePath,
data: fileContents,
})
}
console.log(`Create ${sandboxFilePath}`)
}
// Handle file/directory removal or rename event
else if (event.type === "remove" || event.type == "rename") {
const folder = findFolderById(
this.files,
sandboxDirectory
) as TFolder
const isDir = await this.isDirectory(containerFilePath)
const isFileMatch = (file: TFolder | TFile | TFileData) =>
file.id === sandboxFilePath ||
file.id.startsWith(containerFilePath + "/")
if (folder) {
// Remove item from its parent folder
folder.children = folder.children.filter(
(file: TFolder | TFile) => !isFileMatch(file)
)
} else {
// Remove from the root if it's not inside a folder
this.files = this.files.filter(
(file: TFolder | TFile) => !isFileMatch(file)
)
}
// Also remove any corresponding file data
this.fileData = this.fileData.filter(
(file: TFileData) => !isFileMatch(file)
)
console.log(`Removed: ${sandboxFilePath}`)
}
// Handle file write event
else if (event.type === "write") {
const folder = findFolderById(
this.files,
sandboxDirectory
) as TFolder
const fileToWrite = this.fileData.find(
(file) => file.id === sandboxFilePath
)
if (fileToWrite) {
fileToWrite.data = await this.sandbox.files.read(
containerFilePath
)
console.log(`Write to ${sandboxFilePath}`)
} else {
// If the file is part of a folder structure, locate it and update its data
const fileInFolder = folder?.children.find(
(file) => file.id === sandboxFilePath
)
if (fileInFolder) {
const fileData = await this.sandbox.files.read(
containerFilePath
)
const fileContents =
typeof fileData === "string" ? fileData : ""
this.fileData.push({
id: sandboxFilePath,
data: fileContents,
})
console.log(`Write to ${sandboxFilePath}`)
}
}
}
// Tell the client to reload the file list
if (event.type !== "chmod") {
this.refreshFileList?.(this.files)
}
} catch (error) {
console.error(
`Error handling ${event.type} event for ${event.name}:`,
error
)
}
},
{ timeout: 0 }
)
this.fileWatchers.push(handle)
return handle
} catch (error) {
console.error(`Error watching filesystem:`, error)
}
}
// Watch subdirectories recursively
async watchSubdirectories(directory: string) {
const dirContent = await this.sandbox.files.list(directory)
await Promise.all(
dirContent.map(async (item) => {
if (item.type === "dir") {
console.log("Watching " + item.path)
await this.watchDirectory(item.path)
}
})
)
}
// Get file content
async getFile(fileId: string): Promise<string | undefined> {
const file = this.fileData.find((f) => f.id === fileId)
return file?.data
}
// Get folder content
async getFolder(folderId: string): Promise<string[]> {
const remotePaths = await RemoteFileStorage.getFolder(this.getRemoteFileId(folderId))
return this.getLocalFileIds(remotePaths)
}
// Save file content
async saveFile(fileId: string, body: string): Promise<void> {
if (!fileId) return // handles saving when no file is open
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
throw new Error("File size too large. Please reduce the file size.")
}
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
const file = this.fileData.find((f) => f.id === fileId)
if (!file) return
file.data = body
await this.sandbox.files.write(path.posix.join(this.dirName, file.id), body)
this.fixPermissions()
}
// Move a file to a different folder
async moveFile(
fileId: string,
folderId: string
): Promise<(TFolder | TFile)[]> {
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()
await this.moveFileInContainer(fileId, newFileId)
await this.fixPermissions()
fileData.id = newFileId
file.id = newFileId
await RemoteFileStorage.renameFile(this.getRemoteFileId(fileId), this.getRemoteFileId(newFileId), fileData.data)
return this.updateFileStructure()
}
// Move a file within the container
private async moveFileInContainer(oldPath: string, newPath: string) {
try {
const fileContents = await this.sandbox.files.read(
path.posix.join(this.dirName, oldPath)
)
await this.sandbox.files.write(
path.posix.join(this.dirName, newPath),
fileContents
)
await this.sandbox.files.remove(path.posix.join(this.dirName, oldPath))
} catch (e) {
console.error(`Error moving file from ${oldPath} to ${newPath}:`, e)
}
}
// Create a new file
async createFile(name: string): Promise<boolean> {
const size: number = await RemoteFileStorage.getProjectSize(this.sandboxId)
if (size > 200 * 1024 * 1024) {
throw new Error("Project size exceeded. Please delete some files.")
}
const id = `/${name}`
await this.sandbox.files.write(path.posix.join(this.dirName, id), "")
await this.fixPermissions()
this.files.push({
id,
name,
type: "file",
})
this.fileData.push({
id,
data: "",
})
await RemoteFileStorage.createFile(this.getRemoteFileId(id))
return true
}
// Create a new folder
async createFolder(name: string): Promise<void> {
const id = `/${name}`
await this.sandbox.files.makeDir(path.posix.join(this.dirName, id))
}
// Rename a file
async renameFile(fileId: string, newName: string): Promise<void> {
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("/")
const newFileId = parts.slice(0, parts.length - 1).join("/") + "/" + newName
await this.moveFileInContainer(fileId, newFileId)
await this.fixPermissions()
await RemoteFileStorage.renameFile(this.getRemoteFileId(fileId), this.getRemoteFileId(newFileId), fileData.data)
fileData.id = newFileId
file.id = newFileId
}
// Delete a file
async deleteFile(fileId: string): Promise<(TFolder | TFile)[]> {
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.fileData = this.fileData.filter(
(f) => f.id !== fileId
)
await RemoteFileStorage.deleteFile(this.getRemoteFileId(fileId))
return this.updateFileStructure()
}
// Delete a folder
async deleteFolder(folderId: string): Promise<(TFolder | TFile)[]> {
const files = await RemoteFileStorage.getFolder(this.getRemoteFileId(folderId))
await Promise.all(
files.map(async (file) => {
await this.sandbox.files.remove(path.posix.join(this.dirName, file))
this.fileData = this.fileData.filter(
(f) => f.id !== file
)
await RemoteFileStorage.deleteFile(this.getRemoteFileId(file))
})
)
return this.updateFileStructure()
}
// Close all file watchers
async closeWatchers() {
await Promise.all(
this.fileWatchers.map(async (handle: WatchHandle) => {
await handle.close()
})
)
}
}

View File

@ -0,0 +1,117 @@
import * as dotenv from "dotenv"
import { R2Files } from "./types"
dotenv.config()
export const RemoteFileStorage = {
getSandboxPaths: async (id: string) => {
const res = await fetch(
`${process.env.STORAGE_WORKER_URL}/api?sandboxId=${id}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
)
const data: R2Files = await res.json()
return data.objects.map((obj) => obj.key)
},
getFolder: async (folderId: string) => {
const res = await fetch(
`${process.env.STORAGE_WORKER_URL}/api?folderId=${folderId}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
)
const data: R2Files = await res.json()
return data.objects.map((obj) => obj.key)
},
fetchFileContent: async (fileId: string): Promise<string> => {
try {
const fileRes = await fetch(
`${process.env.STORAGE_WORKER_URL}/api?fileId=${fileId}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
)
return await fileRes.text()
} catch (error) {
console.error("ERROR fetching file:", error)
return ""
}
},
createFile: async (fileId: string) => {
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.WORKERS_KEY}`,
},
body: JSON.stringify({ fileId }),
})
return res.ok
},
renameFile: async (
fileId: string,
newFileId: string,
data: string
) => {
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api/rename`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.WORKERS_KEY}`,
},
body: JSON.stringify({ fileId, newFileId, data }),
})
return res.ok
},
saveFile: async (fileId: string, data: string) => {
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api/save`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.WORKERS_KEY}`,
},
body: JSON.stringify({ fileId, data }),
})
return res.ok
},
deleteFile: async (fileId: string) => {
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.WORKERS_KEY}`,
},
body: JSON.stringify({ fileId }),
})
return res.ok
},
getProjectSize: async (id: string) => {
const res = await fetch(
`${process.env.STORAGE_WORKER_URL}/api/size?sandboxId=${id}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
)
return (await res.json()).size
}
}
export default RemoteFileStorage

View File

@ -1,90 +1,98 @@
import { Client } from "ssh2"; import { Client } from "ssh2"
// Interface defining the configuration for SSH connection
export interface SSHConfig { export interface SSHConfig {
host: string; host: string
port?: number; port?: number
username: string; username: string
privateKey: Buffer; privateKey: Buffer
} }
// Class to handle SSH connections and communicate with a Unix socket
export class SSHSocketClient { export class SSHSocketClient {
private conn: Client; private conn: Client
private config: SSHConfig; private config: SSHConfig
private socketPath: string; private socketPath: string
private isConnected: boolean = false; private isConnected: boolean = false
constructor(config: SSHConfig, socketPath: string) { // Constructor initializes the SSH client and sets up configuration
this.conn = new Client(); constructor(config: SSHConfig, socketPath: string) {
this.config = { ...config, port: 22}; this.conn = new Client()
this.socketPath = socketPath; this.config = { ...config, port: 22 } // Default port to 22 if not provided
this.socketPath = socketPath
this.setupTerminationHandlers();
} this.setupTerminationHandlers()
}
private setupTerminationHandlers() {
process.on("SIGINT", this.closeConnection.bind(this)); // Set up handlers for graceful termination
process.on("SIGTERM", this.closeConnection.bind(this)); private setupTerminationHandlers() {
} process.on("SIGINT", this.closeConnection.bind(this))
process.on("SIGTERM", this.closeConnection.bind(this))
private closeConnection() { }
console.log("Closing SSH connection...");
this.conn.end(); // Method to close the SSH connection
this.isConnected = false; private closeConnection() {
process.exit(0); console.log("Closing SSH connection...")
} this.conn.end()
this.isConnected = false
connect(): Promise<void> { process.exit(0)
return new Promise((resolve, reject) => { }
this.conn
.on("ready", () => { // Method to establish the SSH connection
console.log("SSH connection established"); connect(): Promise<void> {
this.isConnected = true; return new Promise((resolve, reject) => {
resolve(); this.conn
}) .on("ready", () => {
.on("error", (err) => { console.log("SSH connection established")
console.error("SSH connection error:", err); this.isConnected = true
this.isConnected = false; resolve()
reject(err); })
}) .on("error", (err) => {
.on("close", () => { console.error("SSH connection error:", err)
console.log("SSH connection closed"); this.isConnected = false
this.isConnected = false; reject(err)
}) })
.connect(this.config); .on("close", () => {
}); console.log("SSH connection closed")
} this.isConnected = false
})
sendData(data: string): Promise<string> { .connect(this.config)
return new Promise((resolve, reject) => { })
if (!this.isConnected) { }
reject(new Error("SSH connection is not established"));
return; // Method to send data through the SSH connection to the Unix socket
} sendData(data: string): Promise<string> {
return new Promise((resolve, reject) => {
this.conn.exec( if (!this.isConnected) {
`echo "${data}" | nc -U ${this.socketPath}`, reject(new Error("SSH connection is not established"))
(err, stream) => { return
if (err) { }
reject(err);
return; // Use netcat to send data to the Unix socket
} this.conn.exec(
`echo "${data}" | nc -U ${this.socketPath}`,
stream (err, stream) => {
.on("close", (code: number, signal: string) => { if (err) {
reject( reject(err)
new Error( return
`Stream closed with code ${code} and signal ${signal}`
)
);
})
.on("data", (data: Buffer) => {
resolve(data.toString());
})
.stderr.on("data", (data: Buffer) => {
reject(new Error(data.toString()));
});
} }
);
}); stream
} .on("close", (code: number, signal: string) => {
} reject(
new Error(
`Stream closed with code ${code} and signal ${signal}`
)
)
})
.on("data", (data: Buffer) => {
resolve(data.toString())
})
.stderr.on("data", (data: Buffer) => {
reject(new Error(data.toString()))
})
}
)
})
}
}

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

@ -1,82 +1,84 @@
import simpleGit, { SimpleGit } from "simple-git"; import fs from "fs"
import path from "path"; import os from "os"
import fs from "fs"; import path from "path"
import os from "os"; import simpleGit, { SimpleGit } from "simple-git"
export type FileData = { export type FileData = {
id: string; id: string
data: string; data: string
}; }
export class SecureGitClient { export class SecureGitClient {
private gitUrl: string; private gitUrl: string
private sshKeyPath: string; private sshKeyPath: string
constructor(gitUrl: string, sshKeyPath: string) { constructor(gitUrl: string, sshKeyPath: string) {
this.gitUrl = gitUrl; this.gitUrl = gitUrl
this.sshKeyPath = sshKeyPath; this.sshKeyPath = sshKeyPath
} }
async pushFiles(fileData: FileData[], repository: string): Promise<void> { async pushFiles(fileData: FileData[], repository: string): Promise<void> {
let tempDir: string | undefined; let tempDir: string | undefined
try { try {
// Create a temporary directory // Create a temporary directory
tempDir = fs.mkdtempSync(path.posix.join(os.tmpdir(), 'git-push-')); tempDir = fs.mkdtempSync(path.posix.join(os.tmpdir(), "git-push-"))
console.log(`Temporary directory created: ${tempDir}`); console.log(`Temporary directory created: ${tempDir}`)
// Write files to the temporary directory // Write files to the temporary directory
console.log(`Writing ${fileData.length} files.`); console.log(`Writing ${fileData.length} files.`)
for (const { id, data } of fileData) { for (const { id, data } of fileData) {
const filePath = path.posix.join(tempDir, id); const filePath = path.posix.join(tempDir, id)
const dirPath = path.dirname(filePath); const dirPath = path.dirname(filePath)
if (!fs.existsSync(dirPath)) { if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true })
} }
fs.writeFileSync(filePath, data); fs.writeFileSync(filePath, data)
} }
// Initialize the simple-git instance with the temporary directory and custom SSH command // Initialize the simple-git instance with the temporary directory and custom SSH command
const git: SimpleGit = simpleGit(tempDir, { const git: SimpleGit = simpleGit(tempDir, {
config: [ config: [
'core.sshCommand=ssh -i ' + this.sshKeyPath + ' -o IdentitiesOnly=yes' "core.sshCommand=ssh -i " +
] this.sshKeyPath +
" -o IdentitiesOnly=yes",
],
}).outputHandler((_command, stdout, stderr) => { }).outputHandler((_command, stdout, stderr) => {
stdout.pipe(process.stdout); stdout.pipe(process.stdout)
stderr.pipe(process.stderr); stderr.pipe(process.stderr)
});; })
// Initialize a new Git repository // Initialize a new Git repository
await git.init(); await git.init()
// Add remote repository // Add remote repository
await git.addRemote("origin", `${this.gitUrl}:${repository}`); await git.addRemote("origin", `${this.gitUrl}:${repository}`)
// Add files to the repository // Add files to the repository
for (const {id, data} of fileData) { for (const { id, data } of fileData) {
await git.add(id); await git.add(id)
} }
// Commit the changes // Commit the changes
await git.commit("Add files."); await git.commit("Add files.")
// Push the changes to the remote repository // Push the changes to the remote repository
await git.push("origin", "master", {'--force': null}); await git.push("origin", "master", { "--force": null })
console.log("Files successfully pushed to the repository"); console.log("Files successfully pushed to the repository")
if (tempDir) { if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true })
console.log(`Temporary directory removed: ${tempDir}`); console.log(`Temporary directory removed: ${tempDir}`)
} }
} catch (error) { } catch (error) {
if (tempDir) { if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true })
console.log(`Temporary directory removed: ${tempDir}`); console.log(`Temporary directory removed: ${tempDir}`)
} }
console.error("Error pushing files to the repository:", error); console.error("Error pushing files to the repository:", error)
throw error; throw error
} }
} }
} }

View File

@ -1,13 +1,13 @@
import { Sandbox, ProcessHandle } from "e2b"; import { ProcessHandle, Sandbox } from "e2b"
// Terminal class to manage a pseudo-terminal (PTY) in a sandbox environment // Terminal class to manage a pseudo-terminal (PTY) in a sandbox environment
export class Terminal { export class Terminal {
private pty: ProcessHandle | undefined; // Holds the PTY process handle private pty: ProcessHandle | undefined // Holds the PTY process handle
private sandbox: Sandbox; // Reference to the sandbox environment private sandbox: Sandbox // Reference to the sandbox environment
// Constructor initializes the Terminal with a sandbox // Constructor initializes the Terminal with a sandbox
constructor(sandbox: Sandbox) { constructor(sandbox: Sandbox) {
this.sandbox = sandbox; this.sandbox = sandbox
} }
// Initialize the terminal with specified rows, columns, and data handler // Initialize the terminal with specified rows, columns, and data handler
@ -16,9 +16,9 @@ export class Terminal {
cols = 80, cols = 80,
onData, onData,
}: { }: {
rows?: number; rows?: number
cols?: number; cols?: number
onData: (responseData: string) => void; onData: (responseData: string) => void
}): Promise<void> { }): Promise<void> {
// Create a new PTY process // Create a new PTY process
this.pty = await this.sandbox.pty.create({ this.pty = await this.sandbox.pty.create({
@ -26,35 +26,38 @@ export class Terminal {
cols, cols,
timeout: 0, timeout: 0,
onData: (data: Uint8Array) => { onData: (data: Uint8Array) => {
onData(new TextDecoder().decode(data)); // Convert received data to string and pass to handler onData(new TextDecoder().decode(data)) // Convert received data to string and pass to handler
}, },
}); })
} }
// Send data to the terminal // Send data to the terminal
async sendData(data: string) { async sendData(data: string) {
if (this.pty) { if (this.pty) {
await this.sandbox.pty.sendInput(this.pty.pid, new TextEncoder().encode(data)); await this.sandbox.pty.sendInput(
this.pty.pid,
new TextEncoder().encode(data)
)
} else { } else {
console.log("Cannot send data because pty is not initialized."); console.log("Cannot send data because pty is not initialized.")
} }
} }
// Resize the terminal // Resize the terminal
async resize(size: { cols: number; rows: number }): Promise<void> { async resize(size: { cols: number; rows: number }): Promise<void> {
if (this.pty) { if (this.pty) {
await this.sandbox.pty.resize(this.pty.pid, size); await this.sandbox.pty.resize(this.pty.pid, size)
} else { } else {
console.log("Cannot resize terminal because pty is not initialized."); console.log("Cannot resize terminal because pty is not initialized.")
} }
} }
// Close the terminal, killing the PTY process and stopping the input stream // Close the terminal, killing the PTY process and stopping the input stream
async close(): Promise<void> { async close(): Promise<void> {
if (this.pty) { if (this.pty) {
await this.pty.kill(); await this.pty.kill()
} else { } else {
console.log("Cannot kill pty because it is not initialized."); console.log("Cannot kill pty because it is not initialized.")
} }
} }
} }
@ -64,4 +67,4 @@ export class Terminal {
// await terminal.init(); // await terminal.init();
// terminal.sendData('ls -la'); // terminal.sendData('ls -la');
// await terminal.resize({ cols: 100, rows: 30 }); // await terminal.resize({ cols: 100, rows: 30 });
// await terminal.close(); // await terminal.close();

View File

@ -0,0 +1,74 @@
import { Sandbox } from "e2b"
import { Terminal } from "./Terminal"
export class TerminalManager {
private sandbox: Sandbox
private terminals: Record<string, Terminal> = {}
constructor(sandbox: Sandbox) {
this.sandbox = sandbox
}
async createTerminal(
id: string,
onData: (responseString: string) => void
): Promise<void> {
if (this.terminals[id]) {
return
}
this.terminals[id] = new Terminal(this.sandbox)
await this.terminals[id].init({
onData,
cols: 80,
rows: 20,
})
const defaultDirectory = "/home/user/project"
const defaultCommands = [
`cd "${defaultDirectory}"`,
"export PS1='user> '",
"clear",
]
for (const command of defaultCommands) {
await this.terminals[id].sendData(command + "\r")
}
console.log("Created terminal", id)
}
async resizeTerminal(dimensions: {
cols: number
rows: number
}): Promise<void> {
Object.values(this.terminals).forEach((t) => {
t.resize(dimensions)
})
}
async sendTerminalData(id: string, data: string): Promise<void> {
if (!this.terminals[id]) {
return
}
await this.terminals[id].sendData(data)
}
async closeTerminal(id: string): Promise<void> {
if (!this.terminals[id]) {
return
}
await this.terminals[id].close()
delete this.terminals[id]
}
async closeAllTerminals(): Promise<void> {
await Promise.all(
Object.entries(this.terminals).map(async ([key, terminal]) => {
await terminal.close()
delete this.terminals[key]
})
)
}
}

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,177 +0,0 @@
import * as dotenv from "dotenv";
import {
R2FileBody,
R2Files,
Sandbox,
TFile,
TFileData,
TFolder,
} from "./types";
dotenv.config();
export const getSandboxFiles = async (id: string) => {
const res = await fetch(
`${process.env.STORAGE_WORKER_URL}/api?sandboxId=${id}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
);
const data: R2Files = await res.json();
const paths = data.objects.map((obj) => obj.key);
const processedFiles = await processFiles(paths, id);
return processedFiles;
};
export const getFolder = async (folderId: string) => {
const res = await fetch(
`${process.env.STORAGE_WORKER_URL}/api?folderId=${folderId}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
);
const data: R2Files = await res.json();
return data.objects.map((obj) => obj.key);
};
const processFiles = async (paths: string[], id: string) => {
const root: TFolder = { id: "/", type: "folder", name: "/", children: [] };
const fileData: TFileData[] = [];
paths.forEach((path) => {
const allParts = path.split("/");
if (allParts[1] !== id) {
return;
}
const parts = allParts.slice(2);
let current: TFolder = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isFile = i === parts.length - 1 && part.length;
const existing = current.children.find((child) => child.name === part);
if (existing) {
if (!isFile) {
current = existing as TFolder;
}
} else {
if (isFile) {
const file: TFile = { id: path, type: "file", name: part };
current.children.push(file);
fileData.push({ id: path, data: "" });
} else {
const folder: TFolder = {
// id: path, // todo: wrong id. for example, folder "src" ID is: projects/a7vgttfqbgy403ratp7du3ln/src/App.css
id: `projects/${id}/${parts.slice(0, i + 1).join("/")}`,
type: "folder",
name: part,
children: [],
};
current.children.push(folder);
current = folder;
}
}
}
});
await Promise.all(
fileData.map(async (file) => {
const data = await fetchFileContent(file.id);
file.data = data;
})
);
return {
files: root.children,
fileData,
};
};
const fetchFileContent = async (fileId: string): Promise<string> => {
try {
const fileRes = await fetch(
`${process.env.STORAGE_WORKER_URL}/api?fileId=${fileId}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
);
return await fileRes.text();
} catch (error) {
console.error("ERROR fetching file:", error);
return "";
}
};
export const createFile = async (fileId: string) => {
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.WORKERS_KEY}`,
},
body: JSON.stringify({ fileId }),
});
return res.ok;
};
export const renameFile = async (
fileId: string,
newFileId: string,
data: string
) => {
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api/rename`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.WORKERS_KEY}`,
},
body: JSON.stringify({ fileId, newFileId, data }),
});
return res.ok;
};
export const saveFile = async (fileId: string, data: string) => {
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api/save`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.WORKERS_KEY}`,
},
body: JSON.stringify({ fileId, data }),
});
return res.ok;
};
export const deleteFile = async (fileId: string) => {
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.WORKERS_KEY}`,
},
body: JSON.stringify({ fileId }),
});
return res.ok;
};
export const getProjectSize = async (id: string) => {
const res = await fetch(
`${process.env.STORAGE_WORKER_URL}/api/size?sandboxId=${id}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
);
return (await res.json()).size;
};

File diff suppressed because it is too large Load Diff

View File

@ -30,4 +30,4 @@ export const deleteFileRL = new RateLimiterMemory({
export const deleteFolderRL = new RateLimiterMemory({ export const deleteFolderRL = new RateLimiterMemory({
points: 1, points: 1,
duration: 2, duration: 2,
}) })

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

@ -1,70 +1,75 @@
// DB Types // DB Types
export type User = { export type User = {
id: string; id: string
name: string; name: string
email: string; email: string
generations: number; generations: number
sandbox: Sandbox[]; sandbox: Sandbox[]
usersToSandboxes: UsersToSandboxes[]; usersToSandboxes: UsersToSandboxes[]
}; }
export type Sandbox = { export type Sandbox = {
id: string; id: string
name: string; name: string
type: "react" | "node"; type: "react" | "node"
visibility: "public" | "private"; visibility: "public" | "private"
createdAt: Date; createdAt: Date
userId: string; userId: string
usersToSandboxes: UsersToSandboxes[]; usersToSandboxes: UsersToSandboxes[]
}; }
export type UsersToSandboxes = { export type UsersToSandboxes = {
userId: string; userId: string
sandboxId: string; sandboxId: string
sharedOn: Date; sharedOn: Date
}; }
export type TFolder = { export type TFolder = {
id: string; id: string
type: "folder"; type: "folder"
name: string; name: string
children: (TFile | TFolder)[]; children: (TFile | TFolder)[]
}; }
export type TFile = { export type TFile = {
id: string; id: string
type: "file"; type: "file"
name: string; name: string
}; }
export type TFileData = { export type TFileData = {
id: string; id: string
data: string; data: string
}; }
export type R2Files = { export type R2Files = {
objects: R2FileData[]; objects: R2FileData[]
truncated: boolean; truncated: boolean
delimitedPrefixes: any[]; delimitedPrefixes: any[]
}; }
export type R2FileData = { export type R2FileData = {
storageClass: string; storageClass: string
uploaded: string; uploaded: string
checksums: any; checksums: any
httpEtag: string; httpEtag: string
etag: string; etag: string
size: number; size: number
version: string; version: string
key: string; key: string
}; }
export type R2FileBody = R2FileData & { export type R2FileBody = R2FileData & {
body: ReadableStream; body: ReadableStream
bodyUsed: boolean; bodyUsed: boolean
arrayBuffer: Promise<ArrayBuffer>; arrayBuffer: Promise<ArrayBuffer>
text: Promise<string>; text: Promise<string>
json: Promise<any>; json: Promise<any>
blob: Promise<Blob>; blob: Promise<Blob>
}; }
export interface DokkuResponse {
success: boolean
apps?: string[]
message?: string
}

View File

@ -1,23 +1,23 @@
export class LockManager { export class LockManager {
private locks: { [key: string]: Promise<any> }; private locks: { [key: string]: Promise<any> }
constructor() { constructor() {
this.locks = {}; this.locks = {}
} }
async acquireLock<T>(key: string, task: () => Promise<T>): Promise<T> { async acquireLock<T>(key: string, task: () => Promise<T>): Promise<T> {
if (!this.locks[key]) { if (!this.locks[key]) {
this.locks[key] = new Promise<T>(async (resolve, reject) => { this.locks[key] = new Promise<T>(async (resolve, reject) => {
try { try {
const result = await task(); const result = await task()
resolve(result); resolve(result)
} catch (error) { } catch (error) {
reject(error); reject(error)
} finally { } finally {
delete this.locks[key]; delete this.locks[key]
} }
}); })
} }
return await this.locks[key]; 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", "name": "storage",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"deploy": "wrangler deploy", "deploy": "wrangler deploy",
"dev": "wrangler dev --remote", "dev": "wrangler dev --remote",
"start": "wrangler dev", "start": "wrangler dev",
"test": "vitest", "test": "vitest",
"cf-typegen": "wrangler types" "cf-typegen": "wrangler types"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.1.0", "@cloudflare/vitest-pool-workers": "^0.1.0",
"@cloudflare/workers-types": "^4.20240419.0", "@cloudflare/workers-types": "^4.20240419.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"vitest": "1.3.0", "vitest": "1.3.0",
"wrangler": "^3.0.0" "wrangler": "^3.0.0"
}, },
"dependencies": { "dependencies": {
"p-limit": "^6.1.0", "p-limit": "^6.1.0",
"zod": "^3.23.4" "zod": "^3.23.4"
} }
} }

View File

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

View File

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

View File

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

View File

@ -12,7 +12,9 @@
/* Language and Environment */ /* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, "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. */, "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "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({ export default defineWorkersConfig({
test: { test: {
poolOptions: { poolOptions: {
workers: { workers: {
wrangler: { configPath: "./wrangler.toml" }, wrangler: { configPath: "./wrangler.toml" },
}, },
}, },
}, },
}); })

View File

@ -1,4 +1,3 @@
// Generated by Wrangler // Generated by Wrangler
// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen` // 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 { 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 { Sandbox, User, UsersToSandboxes } from "@/lib/types"
import { currentUser } from "@clerk/nextjs" import { currentUser } from "@clerk/nextjs"
import { notFound, redirect } from "next/navigation"
import Loading from "@/components/editor/loading"
import dynamic from "next/dynamic" import dynamic from "next/dynamic"
import fs from "fs" import { notFound, redirect } from "next/navigation"
import { TerminalProvider } from "@/context/TerminalContext"
export const revalidate = 0 export const revalidate = 0
@ -89,19 +88,20 @@ export default async function CodePage({ params }: { params: { id: string } }) {
return ( return (
<> <>
<div className="overflow-hidden overscroll-none w-screen flex flex-col h-screen bg-background"> <div className="overflow-hidden overscroll-none w-screen flex flex-col h-screen bg-background">
<Room id={sandboxId}> <Room id={sandboxId}>
<TerminalProvider> <TerminalProvider>
<Navbar userData={userData} sandboxData={sandboxData} shared={shared} /> <Navbar
<div className="w-screen flex grow"> userData={userData}
<CodeEditor sandboxData={sandboxData}
userData={userData} shared={shared}
sandboxData={sandboxData} />
/> <div className="w-screen flex grow">
</div> <CodeEditor userData={userData} sandboxData={sandboxData} />
</TerminalProvider> </div>
</Room> </TerminalProvider>
</div> </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 Dashboard from "@/components/dashboard"
import Navbar from "@/components/dashboard/navbar" 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() { export default async function DashboardPage() {
const user = await currentUser() const user = await currentUser()

View File

@ -99,6 +99,29 @@
); /* violet 900 -> bg */ ); /* violet 900 -> bg */
} }
.light .gradient-button-bg {
background: radial-gradient(
circle at top,
#262626 0%,
#f5f5f5 50%
); /* Dark gray -> Light gray */
}
.light .gradient-button {
background: radial-gradient(
circle at bottom,
hsl(0, 10%, 25%) -10%,
#9d9d9d 50%
); /* Light gray -> Almost white */
}
.light .gradient-button-bg > div:hover {
background: radial-gradient(
circle at bottom,
hsl(0, 10%, 25%) -10%,
#9d9d9d 80%
); /* Light gray -> Almost white */
}
.inline-decoration::before { .inline-decoration::before {
content: "Generate"; content: "Generate";
color: #525252; color: #525252;

View File

@ -1,13 +1,13 @@
import type { Metadata } from "next"
import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"
import "./globals.css"
import { ThemeProvider } from "@/components/layout/themeProvider"
import { ClerkProvider } from "@clerk/nextjs"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { Analytics } from "@vercel/analytics/react" import { ThemeProvider } from "@/components/ui/theme-provider"
import { PreviewProvider } from "@/context/PreviewContext"; import { PreviewProvider } from "@/context/PreviewContext"
import { SocketProvider } from '@/context/SocketContext' import { SocketProvider } from '@/context/SocketContext'
import { ClerkProvider } from "@clerk/nextjs"
import { Analytics } from "@vercel/analytics/react"
import { GeistMono } from "geist/font/mono"
import { GeistSans } from "geist/font/sans"
import type { Metadata } from "next"
import "./globals.css"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sandbox", title: "Sandbox",
@ -15,7 +15,7 @@ export const metadata: Metadata = {
} }
export default function RootLayout({ export default function RootLayout({
children children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode children: React.ReactNode
}>) { }>) {
@ -25,14 +25,11 @@ export default function RootLayout({
<body> <body>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="dark" defaultTheme="system"
forcedTheme="dark"
disableTransitionOnChange disableTransitionOnChange
> >
<SocketProvider> <SocketProvider>
<PreviewProvider> <PreviewProvider>{children}</PreviewProvider>
{children}
</PreviewProvider>
</SocketProvider> </SocketProvider>
<Analytics /> <Analytics />
<Toaster position="bottom-left" richColors /> <Toaster position="bottom-left" richColors />
@ -41,4 +38,4 @@ export default function RootLayout({
</html> </html>
</ClerkProvider> </ClerkProvider>
) )
} }

View File

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

View File

@ -3,16 +3,9 @@
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog" } 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({ export default function AboutModal({
open, open,

View File

@ -1,24 +1,16 @@
"use client" "use client"
import CustomButton from "@/components/ui/customButton"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import CustomButton from "@/components/ui/customButton"
Code2,
FolderDot,
HelpCircle,
Plus,
Settings,
Users,
} from "lucide-react"
import { useEffect, useState } from "react"
import { Sandbox } from "@/lib/types" 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 DashboardProjects from "./projects"
import DashboardSharedWithMe from "./shared" 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" type TScreen = "projects" | "shared" | "settings" | "search"
@ -49,8 +41,9 @@ export default function Dashboard({
const q = searchParams.get("q") const q = searchParams.get("q")
const router = useRouter() const router = useRouter()
useEffect(() => { // update the dashboard to show a new project useEffect(() => {
router.refresh() // update the dashboard to show a new project
router.refresh()
}, []) }, [])
return ( return (
@ -102,7 +95,7 @@ export default function Dashboard({
</Button> */} </Button> */}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<a target="_blank" href="https://github.com/ishaan1013/sandbox"> <a target="_blank" href="https://github.com/jamesmurdza/sandbox">
<Button <Button
variant="ghost" variant="ghost"
className="justify-start w-full font-normal text-muted-foreground" 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 Image from "next/image"
import Link from "next/link" import Link from "next/link"
import Logo from "@/assets/logo.svg"
import DashboardNavbarSearch from "./search"
import UserButton from "../../ui/userButton" import UserButton from "../../ui/userButton"
import { User } from "@/lib/types" import DashboardNavbarSearch from "./search"
export default function DashboardNavbar({ userData }: { userData: User }) { export default function DashboardNavbar({ userData }: { userData: User }) {
return ( return (
@ -19,6 +20,7 @@ export default function DashboardNavbar({ userData }: { userData: User }) {
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<DashboardNavbarSearch /> <DashboardNavbarSearch />
<ThemeSwitcher />
<UserButton userData={userData} /> <UserButton userData={userData} />
</div> </div>
</div> </div>

View File

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

View File

@ -3,16 +3,14 @@
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import Image from "next/image"
import { useState } from "react"
import { set, z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import Image from "next/image"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod"
import { import {
Form, Form,
@ -31,13 +29,17 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { useUser } from "@clerk/nextjs"
import { createSandbox } from "@/lib/actions" import { createSandbox } from "@/lib/actions"
import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react"
import { Button } from "../ui/button"
import { projectTemplates } from "@/lib/data" import { projectTemplates } from "@/lib/data"
import { useUser } from "@clerk/nextjs"
import { ChevronLeft, ChevronRight, Loader2, Search } from "lucide-react"
import { useRouter } from "next/navigation"
import { Button } from "../ui/button"
import { cn } from "@/lib/utils"
import type { EmblaCarouselType } from "embla-carousel"
import useEmblaCarousel from "embla-carousel-react"
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
const formSchema = z.object({ const formSchema = z.object({
name: z name: z
.string() .string()
@ -57,11 +59,20 @@ export default function NewProjectModal({
open: boolean open: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
}) { }) {
const router = useRouter()
const user = useUser()
const [selected, setSelected] = useState("reactjs") const [selected, setSelected] = useState("reactjs")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const router = useRouter() const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false }, [
WheelGesturesPlugin(),
const user = useUser() ])
const {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
} = usePrevNextButtons(emblaApi)
const [search, setSearch] = useState("")
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@ -71,6 +82,26 @@ export default function NewProjectModal({
}, },
}) })
const handleTemplateClick = useCallback(
({ id, index }: { id: string; index: number }) => {
setSelected(id)
emblaApi?.scrollTo(index)
},
[emblaApi]
)
const filteredTemplates = useMemo(
() =>
projectTemplates.filter(
(item) =>
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.description.toLowerCase().includes(search.toLowerCase())
),
[search, projectTemplates]
)
const emptyTemplates = useMemo(
() => filteredTemplates.length === 0,
[filteredTemplates]
)
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
if (!user.isSignedIn) return if (!user.isSignedIn) return
@ -80,7 +111,6 @@ export default function NewProjectModal({
const id = await createSandbox(sandboxData) const id = await createSandbox(sandboxData)
router.push(`/code/${id}`) router.push(`/code/${id}`)
} }
return ( return (
<Dialog <Dialog
open={open} open={open}
@ -92,25 +122,89 @@ export default function NewProjectModal({
<DialogHeader> <DialogHeader>
<DialogTitle>Create A Sandbox</DialogTitle> <DialogTitle>Create A Sandbox</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid grid-cols-2 w-full gap-2 mt-2"> <div className="flex flex-col gap-2 max-w-full overflow-hidden">
{projectTemplates.map((item) => ( <div className="flex items-center justify-end">
<button <SearchInput
disabled={item.disabled || loading} {...{
key={item.id} value: search,
onClick={() => setSelected(item.id)} onValueChange: setSearch,
className={`${ }}
selected === item.id ? "border-foreground" : "border-border" />
} rounded-md border bg-card text-card-foreground shadow text-left p-4 flex flex-col transition-all focus-visible:outline-none focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed`} </div>
<div className="overflow-hidden relative" ref={emblaRef}>
<div
className={cn(
"grid grid-flow-col gap-x-2 min-h-[97px]",
emptyTemplates ? "auto-cols-[100%]" : "auto-cols-[200px]"
)}
> >
<div className="space-x-2 flex items-center justify-start w-full"> {filteredTemplates.map((item, i) => (
<Image alt="" src={item.icon} width={20} height={20} /> <button
<div className="font-medium">{item.name}</div> disabled={item.disabled || loading}
</div> key={item.id}
<div className="mt-2 text-muted-foreground text-sm"> onClick={handleTemplateClick.bind(null, {
{item.description} id: item.id,
</div> index: i,
</button> })}
))} className={cn(
selected === item.id
? "shadow-foreground"
: "shadow-border",
"shadow-[0_0_0_1px_inset] rounded-md border bg-card text-card-foreground text-left p-4 flex flex-col transition-all focus-visible:outline-none focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<div className="space-x-2 flex items-center justify-start w-full">
<Image alt="" src={item.icon} width={20} height={20} />
<div className="font-medium">{item.name}</div>
</div>
<div className="mt-2 text-muted-foreground text-xs line-clamp-2">
{item.description}
</div>
</button>
))}
{emptyTemplates && (
<div className="flex flex-col gap-2 items-center text-center justify-center text-muted-foreground text-sm">
<p>No templates found</p>
<Button size="xs" asChild>
<a
href="https://github.com/jamesmurdza/sandbox"
target="_blank"
>
Contribute
</a>
</Button>
</div>
)}
</div>
<div
className={cn(
"absolute transition-all opacity-100 duration-400 bg-gradient-to-r from-background via-background to-transparent w-14 pl-1 left-0 top-0 -translate-x-1 bottom-0 h-full flex items-center",
prevBtnDisabled && "opacity-0 pointer-events-none"
)}
>
<Button
size="smIcon"
className="rounded-full"
onClick={onPrevButtonClick}
>
<ChevronLeft className="size-5" />
</Button>
</div>
<div
className={cn(
"absolute transition-all opacity-100 duration-400 bg-gradient-to-l from-background via-background to-transparent w-14 pl-1 right-0 top-0 translate-x-1 bottom-0 h-full flex items-center",
nextBtnDisabled && "opacity-0 pointer-events-none"
)}
>
<Button
size="smIcon"
className="rounded-full"
onClick={onNextButtonClick}
>
<ChevronRight className="size-5" />
</Button>
</div>
</div>
</div> </div>
<Form {...form}> <Form {...form}>
@ -178,3 +272,68 @@ export default function NewProjectModal({
</Dialog> </Dialog>
) )
} }
function SearchInput({
value,
onValueChange,
}: {
value?: string
onValueChange?: (value: string) => void
}) {
const onSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault()
console.log("searching")
}, [])
return (
<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:hsl(var(--muted-foreground),50%)] h-full items-center px-2"
>
<Search className="size-4 text-[--s-color] transition-colors" />
<input
id="template-search"
type="text"
name="search"
placeholder="Search templates"
value={value}
onChange={(e) => onValueChange?.(e.target.value)}
className="bg-transparent placeholder:text-muted-foreground w-full focus:outline-none text-xs"
/>
</label>
</form>
)
}
const usePrevNextButtons = (emblaApi: EmblaCarouselType | undefined) => {
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
const onPrevButtonClick = useCallback(() => {
if (!emblaApi) return
emblaApi.scrollPrev()
}, [emblaApi])
const onNextButtonClick = useCallback(() => {
if (!emblaApi) return
emblaApi.scrollNext()
}, [emblaApi])
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
setPrevBtnDisabled(!emblaApi.canScrollPrev())
setNextBtnDisabled(!emblaApi.canScrollNext())
}, [])
useEffect(() => {
if (!emblaApi) return
onSelect(emblaApi)
emblaApi.on("reInit", onSelect).on("select", onSelect)
}, [emblaApi, onSelect])
return {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
}
}

View File

@ -1,30 +1,30 @@
"use client"; "use client"
import { Sandbox } from "@/lib/types"; import { Sandbox } from "@/lib/types"
import { Ellipsis, Globe, Lock, Trash2 } from "lucide-react"; import { Ellipsis, Globe, Lock, Trash2 } from "lucide-react"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu"
export default function ProjectCardDropdown({ export default function ProjectCardDropdown({
sandbox, sandbox,
onVisibilityChange, onVisibilityChange,
onDelete, onDelete,
}: { }: {
sandbox: Sandbox; sandbox: Sandbox
onVisibilityChange: (sandbox: Sandbox) => void; onVisibilityChange: (sandbox: Sandbox) => void
onDelete: (sandbox: Sandbox) => void; onDelete: (sandbox: Sandbox) => void
}) { }) {
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger <DropdownMenuTrigger
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); 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" 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"> <DropdownMenuContent className="w-40">
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
onVisibilityChange(sandbox); onVisibilityChange(sandbox)
}} }}
className="cursor-pointer" className="cursor-pointer"
> >
@ -52,8 +52,8 @@ export default function ProjectCardDropdown({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
onDelete(sandbox); onDelete(sandbox)
}} }}
className="!text-destructive cursor-pointer" className="!text-destructive cursor-pointer"
> >
@ -62,5 +62,5 @@ export default function ProjectCardDropdown({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); )
} }

View File

@ -1,14 +1,14 @@
"use client" "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 { AnimatePresence, motion } from "framer-motion"
import { Clock, Globe, Lock } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import ProjectCardDropdown from "./dropdown" 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({ export default function ProjectCard({
children, children,

View File

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

View File

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

View File

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

View File

@ -1,36 +1,239 @@
import React from 'react'; import { Send, StopCircle, Image as ImageIcon, Paperclip } from "lucide-react"
import { Button } from '../../ui/button'; import { Button } from "../../ui/button"
import { Send, StopCircle } from 'lucide-react'; import { useEffect } from "react"
import { TFile, TFolder } from "@/lib/types"
import { ALLOWED_FILE_TYPES } from "./types"
import { looksLikeCode } from "./lib/chatUtils"
import { ChatInputProps } from "./types"
interface ChatInputProps { export default function ChatInput({
input: string; input,
setInput: (input: string) => void; setInput,
isGenerating: boolean; isGenerating,
handleSend: () => void; handleSend,
handleStopGeneration: () => void; handleStopGeneration,
} onImageUpload,
addContextTab,
activeFileName,
editorRef,
lastCopiedRangeRef,
contextTabs,
onRemoveTab,
textareaRef,
}: ChatInputProps) {
// Auto-resize textarea as content changes
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'
}
}, [input])
// Handle keyboard events for sending messages
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
if (e.ctrlKey) {
e.preventDefault()
handleSend(true) // Send with full context
} else if (!e.shiftKey && !isGenerating) {
e.preventDefault()
handleSend(false)
}
} else if (e.key === "Backspace" && input === "" && contextTabs.length > 0) {
e.preventDefault()
// Remove the last context tab
const lastTab = contextTabs[contextTabs.length - 1]
onRemoveTab(lastTab.id)
}
}
// Handle paste events for image and code
const handlePaste = async (e: React.ClipboardEvent) => {
// Handle image paste
const items = Array.from(e.clipboardData.items);
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (!file) continue;
try {
// Convert image to base64 string for context tab title and timestamp
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result as string;
addContextTab(
"image",
`Image ${new Date().toLocaleTimeString('en-US', {
hour12: true,
hour: '2-digit',
minute: '2-digit'
}).replace(/(\d{2}):(\d{2})/, '$1:$2')}`,
base64String
);
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Error processing pasted image:', error);
}
return;
}
}
// Get text from clipboard
const text = e.clipboardData.getData('text');
// If text doesn't contain newlines or doesn't look like code, let it paste normally
if (!text || !text.includes('\n') || !looksLikeCode(text)) {
return;
}
e.preventDefault();
const editor = editorRef.current;
const currentSelection = editor?.getSelection();
const lines = text.split('\n');
// TODO: FIX THIS: even when i paste the outside code, it shows the active file name,it works when no tabs are open, just does not work when the tab is open
// If selection exists in editor, use file name and line numbers
if (currentSelection && !currentSelection.isEmpty()) {
addContextTab(
"code",
`${activeFileName} (${currentSelection.startLineNumber}-${currentSelection.endLineNumber})`,
text,
{ start: currentSelection.startLineNumber, end: currentSelection.endLineNumber }
);
return;
}
// If we have stored line range from a copy operation in the editor
if (lastCopiedRangeRef.current) {
const range = lastCopiedRangeRef.current;
addContextTab(
"code",
`${activeFileName} (${range.startLine}-${range.endLine})`,
text,
{ start: range.startLine, end: range.endLine }
);
return;
}
// For code pasted from outside the editor
addContextTab(
"code",
`Pasted Code (1-${lines.length})`,
text,
{ start: 1, end: lines.length }
);
};
// Handle image upload from local machine via input
const handleImageUpload = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) onImageUpload(file)
}
input.click()
}
// Helper function to flatten the file tree
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
return items.reduce((acc: TFile[], item) => {
if (item.type === "file") {
acc.push(item)
} else {
acc.push(...getAllFiles(item.children))
}
return acc
}, [])
}
// Handle file upload from local machine via input
const handleFileUpload = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.txt,.md,.csv,.json,.js,.ts,.html,.css,.pdf'
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
if (!(file.type in ALLOWED_FILE_TYPES)) {
alert('Unsupported file type. Please upload text, code, or PDF files.')
return
}
const reader = new FileReader()
reader.onload = () => {
addContextTab("file", file.name, reader.result as string)
}
reader.readAsText(file)
}
}
input.click()
}
export default function ChatInput({ input, setInput, isGenerating, handleSend, handleStopGeneration }: ChatInputProps) {
return ( return (
<div className="flex space-x-2 min-w-0"> <div className="space-y-2">
<input <div className="flex space-x-2 min-w-0">
type="text" <textarea
value={input} ref={textareaRef}
onChange={(e) => setInput(e.target.value)} value={input}
onKeyPress={(e) => e.key === 'Enter' && !isGenerating && handleSend()} onChange={(e) => setInput(e.target.value)}
className="flex-grow p-2 border rounded-lg min-w-0 bg-input" onKeyDown={handleKeyDown}
placeholder="Type your message..." onPaste={handlePaste}
disabled={isGenerating} className="flex-grow p-2 border rounded-lg min-w-0 bg-input resize-none overflow-hidden"
/> placeholder="Type your message..."
{isGenerating ? ( disabled={isGenerating}
<Button onClick={handleStopGeneration} variant="destructive" size="icon" className="h-10 w-10"> rows={1}
<StopCircle className="w-4 h-4" /> />
{/* Render stop generation button */}
{isGenerating ? (
<Button
onClick={handleStopGeneration}
variant="destructive"
size="icon"
className="h-10 w-10"
>
<StopCircle className="w-4 h-4" />
</Button>
) : (
<Button
onClick={() => handleSend(false)}
disabled={isGenerating}
size="icon"
className="h-10 w-10"
>
<Send className="w-4 h-4" />
</Button>
)}
</div>
<div className="flex items-center justify-end gap-2">
{/* Render file upload button */}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 sm:px-3"
onClick={handleFileUpload}
>
<Paperclip className="h-3 w-3 sm:mr-1" />
<span className="hidden sm:inline">File</span>
</Button> </Button>
) : ( {/* Render image upload button */}
<Button onClick={handleSend} disabled={isGenerating} size="icon" className="h-10 w-10"> <Button
<Send className="w-4 h-4" /> variant="ghost"
size="sm"
className="h-6 px-2 sm:px-3"
onClick={handleImageUpload}
>
<ImageIcon className="h-3 w-3 sm:mr-1" />
<span className="hidden sm:inline">Image</span>
</Button> </Button>
)} </div>
</div> </div>
); )
} }

View File

@ -1,26 +1,29 @@
import React, { useState } from 'react'; import { Check, Copy, CornerUpLeft } from "lucide-react"
import { Button } from '../../ui/button'; import React, { useState } from "react"
import { ChevronUp, ChevronDown, Copy, Check, CornerUpLeft } from 'lucide-react'; import ReactMarkdown from "react-markdown"
import ReactMarkdown from 'react-markdown'; import remarkGfm from "remark-gfm"
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Button } from "../../ui/button"
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
import remarkGfm from 'remark-gfm'; import ContextTabs from "./ContextTabs"
import { copyToClipboard, stringifyContent } from './lib/chatUtils'; import { createMarkdownComponents } from './lib/markdownComponents'
import { MessageProps } from "./types"
interface MessageProps { export default function ChatMessage({
message: { message,
role: 'user' | 'assistant'; setContext,
content: string; setIsContextExpanded,
context?: string; socket,
}; }: MessageProps) {
setContext: (context: string | null) => void;
setIsContextExpanded: (isExpanded: boolean) => void;
}
export default function ChatMessage({ message, setContext, setIsContextExpanded }: MessageProps) { // State for expanded message index
const [expandedMessageIndex, setExpandedMessageIndex] = useState<number | null>(null); const [expandedMessageIndex, setExpandedMessageIndex] = useState<
const [copiedText, setCopiedText] = useState<string | null>(null); number | null
>(null)
// State for copied text
const [copiedText, setCopiedText] = useState<string | null>(null)
// Render copy button for text content
const renderCopyButton = (text: any) => ( const renderCopyButton = (text: any) => (
<Button <Button
onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)} onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)}
@ -34,17 +37,41 @@ export default function ChatMessage({ message, setContext, setIsContextExpanded
<Copy className="w-4 h-4" /> <Copy className="w-4 h-4" />
)} )}
</Button> </Button>
); )
// Set context for code when asking about code
const askAboutCode = (code: any) => { const askAboutCode = (code: any) => {
const contextString = stringifyContent(code); const contextString = stringifyContent(code)
setContext(`Regarding this code:\n${contextString}`); const newContext = `Regarding this code:\n${contextString}`
setIsContextExpanded(false);
}; // Format timestamp to match chat message format (HH:MM PM)
const timestamp = new Date().toLocaleTimeString('en-US', {
hour12: true,
hour: '2-digit',
minute: '2-digit',
})
// Instead of replacing context, append to it
if (message.role === "assistant") {
// For assistant messages, create a new context tab with the response content and timestamp
setContext(newContext, `AI Response (${timestamp})`, {
start: 1,
end: contextString.split('\n').length
})
} else {
// For user messages, create a new context tab with the selected content and timestamp
setContext(newContext, `User Chat (${timestamp})`, {
start: 1,
end: contextString.split('\n').length
})
}
setIsContextExpanded(false)
}
// Render markdown elements for code and text
const renderMarkdownElement = (props: any) => { const renderMarkdownElement = (props: any) => {
const { node, children } = props; const { node, children } = props
const content = stringifyContent(children); const content = stringifyContent(children)
return ( return (
<div className="relative group"> <div className="relative group">
@ -59,23 +86,94 @@ export default function ChatMessage({ message, setContext, setIsContextExpanded
<CornerUpLeft className="w-4 h-4" /> <CornerUpLeft className="w-4 h-4" />
</Button> </Button>
</div> </div>
{React.createElement(node.tagName, { {/* Render markdown element */}
...props, {React.createElement(
className: `${props.className || ''} hover:bg-transparent rounded p-1 transition-colors` node.tagName,
}, children)} {
...props,
className: `${
props.className || ""
} hover:bg-transparent rounded p-1 transition-colors`,
},
children
)}
</div> </div>
); )
}; }
// Create markdown components
const components = createMarkdownComponents(
renderCopyButton,
renderMarkdownElement,
askAboutCode
)
return ( return (
<div className="text-left relative"> <div className="text-left relative">
<div className={`relative p-2 rounded-lg ${ <div
message.role === 'user' className={`relative p-2 rounded-lg ${
? 'bg-[#262626] text-white' message.role === "user"
: 'bg-transparent text-white' ? "bg-[#262626] text-white"
} max-w-full`}> : "bg-transparent text-white"
{message.role === 'user' && ( } max-w-full`}
<div className="absolute top-0 right-0 flex opacity-0 group-hover:opacity-30 transition-opacity"> >
{/* Render context tabs */}
{message.role === "user" && message.context && (
<div className="mb-2 bg-input rounded-lg">
<ContextTabs
socket={socket}
activeFileName=""
onAddFile={() => {}}
contextTabs={parseContextToTabs(message.context)}
onRemoveTab={() => {}}
isExpanded={expandedMessageIndex === 0}
onToggleExpand={() => setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)}
className="[&_div:first-child>div:first-child>div]:bg-[#0D0D0D] [&_button:first-child]:hidden [&_button:last-child]:hidden"
/>
{expandedMessageIndex === 0 && (
<div className="relative">
<div className="absolute top-0 right-0 flex p-1">
{renderCopyButton(
message.context.replace(/^Regarding this code:\n/, "")
)}
</div>
{/* Render code textarea */}
{(() => {
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, "Selected Content", {
start: 1,
end: e.target.value.split('\n').length
})
}}
className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded"
rows={code.split("\n").length}
style={{
resize: "vertical",
minHeight: "100px",
maxHeight: "400px",
}}
/>
</div>
)
})()}
</div>
)}
</div>
)}
{/* Render copy and ask about code buttons */}
{message.role === "user" && (
<div className="absolute top-0 right-0 p-1 flex opacity-40">
{renderCopyButton(message.content)} {renderCopyButton(message.content)}
<Button <Button
onClick={() => askAboutCode(message.content)} onClick={() => askAboutCode(message.content)}
@ -87,115 +185,42 @@ export default function ChatMessage({ message, setContext, setIsContextExpanded
</Button> </Button>
</div> </div>
)} )}
{message.context && ( {/* Render markdown content */}
<div className="mb-2 bg-input rounded-lg"> {message.role === "assistant" ? (
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)}
>
<span className="text-sm text-gray-300">
Context
</span>
{expandedMessageIndex === 0 ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</div>
{expandedMessageIndex === 0 && (
<div className="relative">
<div className="absolute top-0 right-0 flex p-1">
{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';
return (
<div className="pt-6">
<textarea
value={code}
onChange={(e) => {
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}
style={{
resize: 'vertical',
minHeight: '100px',
maxHeight: '400px',
}}
/>
</div>
);
})()}
</div>
)}
</div>
)}
{message.role === 'assistant' ? (
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={components}
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">
{match[1]}
</div>
<div className="absolute top-0 right-0 flex">
{renderCopyButton(children)}
<Button
onClick={() => askAboutCode(children)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
<div className="pt-6">
<SyntaxHighlighter
style={vscDarkPlus as any}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: '0.875rem',
}}
>
{stringifyContent(children)}
</SyntaxHighlighter>
</div>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
p: renderMarkdownElement,
h1: renderMarkdownElement,
h2: renderMarkdownElement,
h3: renderMarkdownElement,
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>,
}}
> >
{message.content} {message.content}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<div className="whitespace-pre-wrap group"> <div className="whitespace-pre-wrap group">{message.content}</div>
{message.content}
</div>
)} )}
</div> </div>
</div> </div>
); )
}
// Parse context to tabs for context tabs component
function parseContextToTabs(context: string) {
const sections = context.split(/(?=File |Code from )/)
return sections.map((section, index) => {
const lines = section.trim().split('\n')
const titleLine = lines[0]
let content = lines.slice(1).join('\n').trim()
// Remove code block markers for display
content = content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '')
// Determine if the context is a file or code
const isFile = titleLine.startsWith('File ')
const name = titleLine.replace(/^(File |Code from )/, '').replace(':', '')
return {
id: `context-${index}`,
type: isFile ? "file" as const : "code" as const,
name: name,
content: content
}
}).filter(tab => tab.content.length > 0)
} }

View File

@ -1,48 +0,0 @@
import React from 'react';
import { ChevronUp, ChevronDown, X } from 'lucide-react';
interface ContextDisplayProps {
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;
return (
<div className="mb-2 bg-input p-2 rounded-lg">
<div className="flex justify-between items-center">
<div
className="flex-grow cursor-pointer"
onClick={() => setIsContextExpanded(!isContextExpanded)}
>
<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)} />
) : (
<ChevronDown size={16} className="cursor-pointer" onClick={() => setIsContextExpanded(true)} />
)}
<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}`)}
className="w-full mt-2 p-2 bg-#1e1e1e text-white rounded"
rows={5}
/>
)}
</div>
);
}

View File

@ -0,0 +1,172 @@
import { Plus, X, Image as ImageIcon, FileText } from "lucide-react"
import { useState } from "react"
import { Button } from "../../ui/button"
import { TFile, TFolder } from "@/lib/types"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Input } from "@/components/ui/input"
import { ContextTab } from "./types"
import { ContextTabsProps } from "./types"
// Ignore certain folders and files from the file tree
import { ignoredFiles, ignoredFolders } from "./lib/ignored-paths"
export default function ContextTabs({
contextTabs,
onRemoveTab,
className,
files = [],
onFileSelect,
}: ContextTabsProps & { className?: string }) {
// State for preview tab
const [previewTab, setPreviewTab] = useState<ContextTab | null>(null)
const [searchQuery, setSearchQuery] = useState("")
// Allow preview for images and code selections from editor
const togglePreview = (tab: ContextTab) => {
if (!tab.lineRange && tab.type !== "image") {
return;
}
// Toggle preview for images and code selections from editor
if (previewTab?.id === tab.id) {
setPreviewTab(null)
} else {
setPreviewTab(tab)
}
}
// Remove tab from context when clicking on X
const handleRemoveTab = (id: string) => {
if (previewTab?.id === id) {
setPreviewTab(null)
}
onRemoveTab(id)
}
// Get all files from the file tree to search for context
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
return items.reduce((acc: TFile[], item) => {
// Add file if it's not ignored
if (item.type === "file" && !ignoredFiles.some((pattern: string) =>
item.name.endsWith(pattern.replace('*', '')) || item.name === pattern
)) {
acc.push(item)
// Add all files from folder if it's not ignored
} else if (item.type === "folder" && !ignoredFolders.some((folder: string) => folder === item.name)) {
acc.push(...getAllFiles(item.children))
}
return acc
}, [])
}
// Get all files from the file tree to search for context when adding context
const allFiles = getAllFiles(files)
const filteredFiles = allFiles.filter(file =>
file.name.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<div className={`border-none ${className || ''}`}>
<div className="flex flex-col">
<div className="flex items-center gap-1 overflow-hidden mb-2 flex-wrap">
{/* Add context tab button */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<Plus className="h-4 w-4" />
</Button>
</PopoverTrigger>
{/* Add context tab popover */}
<PopoverContent className="w-64 p-2">
<div className="flex gap-2 mb-2">
<Input
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
</div>
<div className="max-h-[200px] overflow-y-auto">
{filteredFiles.map((file) => (
<Button
key={file.id}
variant="ghost"
className="w-full justify-start text-sm mb-1"
onClick={() => onFileSelect?.(file)}
>
<FileText className="h-4 w-4 mr-2" />
{file.name}
</Button>
))}
</div>
</PopoverContent>
</Popover>
{/* Add context tab button */}
{contextTabs.length === 0 && (
<div className="flex items-center gap-1 px-2 rounded">
<span className="text-sm text-muted-foreground">Add Context</span>
</div>
)}
{/* Render context tabs */}
{contextTabs.map((tab) => (
<div
key={tab.id}
className="flex items-center gap-1 px-2 bg-input rounded text-sm cursor-pointer hover:bg-muted"
onClick={() => togglePreview(tab)}
>
{tab.type === "image" && <ImageIcon className="h-3 w-3" />}
<span>{tab.name}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4"
onClick={(e) => {
e.stopPropagation()
handleRemoveTab(tab.id)
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* Preview Section */}
{previewTab && (
<div className="p-2 bg-input rounded-md max-h-[200px] overflow-auto mb-2">
{previewTab.type === "image" ? (
<img
src={previewTab.content}
alt={previewTab.name}
className="max-w-full h-auto"
/>
) : previewTab.lineRange && (
<>
<div className="text-xs text-muted-foreground mt-1">
Lines {previewTab.lineRange.start}-{previewTab.lineRange.end}
</div>
<pre className="text-xs font-mono whitespace-pre-wrap">
{previewTab.content}
</pre>
</>
)}
{/* Render file context tab */}
{previewTab.type === "file" && (
<pre className="text-xs font-mono whitespace-pre-wrap">
{previewTab.content}
</pre>
)}
</div>
)}
</div>
</div>
)
}

View File

@ -1,48 +1,146 @@
import React, { useState, useEffect, useRef } from 'react'; import { X } from "lucide-react"
import LoadingDots from '../../ui/LoadingDots'; import { useEffect, useRef, useState } from "react"
import ChatMessage from './ChatMessage'; import LoadingDots from "../../ui/LoadingDots"
import ChatInput from './ChatInput'; import ChatInput from "./ChatInput"
import ContextDisplay from './ContextDisplay'; import ChatMessage from "./ChatMessage"
import { handleSend, handleStopGeneration } from './lib/chatUtils'; import ContextTabs from "./ContextTabs"
import { X } from 'lucide-react'; import { handleSend, handleStopGeneration } from "./lib/chatUtils"
import { nanoid } from 'nanoid'
import { TFile } from "@/lib/types"
import { useSocket } from "@/context/SocketContext"
import { Message, ContextTab, AIChatProps } from './types'
interface Message { export default function AIChat({
role: 'user' | 'assistant'; activeFileContent,
content: string; activeFileName,
context?: string; onClose,
} editorRef,
lastCopiedRangeRef,
files,
}: AIChatProps) {
// Initialize socket and messages
const { socket } = useSocket()
const [messages, setMessages] = useState<Message[]>([])
export default function AIChat({ activeFileContent, activeFileName, onClose }: { activeFileContent: string, activeFileName: string, onClose: () => void }) { // Initialize input and state for generating messages
const [messages, setMessages] = useState<Message[]>([]); const [input, setInput] = useState("")
const [input, setInput] = useState(''); const [isGenerating, setIsGenerating] = useState(false)
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);
// Initialize chat container ref and abort controller ref
const chatContainerRef = useRef<HTMLDivElement>(null)
const abortControllerRef = useRef<AbortController | null>(null)
// Initialize context tabs and state for expanding context
const [contextTabs, setContextTabs] = useState<ContextTab[]>([])
const [isContextExpanded, setIsContextExpanded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Initialize textarea ref
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Scroll to bottom of chat when messages change
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom()
}, [messages]); }, [messages])
// Scroll to bottom of chat when messages change
const scrollToBottom = () => { const scrollToBottom = () => {
if (chatContainerRef.current) { if (chatContainerRef.current) {
setTimeout(() => { setTimeout(() => {
chatContainerRef.current?.scrollTo({ chatContainerRef.current?.scrollTo({
top: chatContainerRef.current.scrollHeight, top: chatContainerRef.current.scrollHeight,
behavior: 'smooth' behavior: "smooth",
}); })
}, 100); }, 100)
} }
}; }
// Add context tab to context tabs
const addContextTab = (type: string, name: string, content: string, lineRange?: { start: number; end: number }) => {
const newTab = {
id: nanoid(),
type: type as "file" | "code" | "image",
name,
content,
lineRange
}
setContextTabs(prev => [...prev, newTab])
}
// Remove context tab from context tabs
const removeContextTab = (id: string) => {
setContextTabs(prev => prev.filter(tab => tab.id !== id))
}
// Add file to context tabs
const handleAddFile = (tab: ContextTab) => {
setContextTabs(prev => [...prev, tab])
}
// Format code content to remove starting and ending code block markers if they exist
const formatCodeContent = (content: string) => {
return content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '')
}
// Get combined context from context tabs
const getCombinedContext = () => {
if (contextTabs.length === 0) return ''
return contextTabs.map(tab => {
if (tab.type === 'file') {
const fileExt = tab.name.split('.').pop() || 'txt'
const cleanContent = formatCodeContent(tab.content)
return `File ${tab.name}:\n\`\`\`${fileExt}\n${cleanContent}\n\`\`\``
} else if (tab.type === 'code') {
const cleanContent = formatCodeContent(tab.content)
return `Code from ${tab.name}:\n\`\`\`typescript\n${cleanContent}\n\`\`\``
}
return `${tab.name}:\n${tab.content}`
}).join('\n\n')
}
// Handle sending message with context
const handleSendWithContext = () => {
const combinedContext = getCombinedContext()
handleSend(
input,
combinedContext,
messages,
setMessages,
setInput,
setIsContextExpanded,
setIsGenerating,
setIsLoading,
abortControllerRef,
activeFileContent
)
// Clear context tabs after sending
setContextTabs([])
}
// Set context for the chat
const setContext = (
context: string | null,
name: string,
range?: { start: number, end: number }
) => {
if (!context) {
setContextTabs([])
return
}
// Always add a new tab instead of updating existing ones
addContextTab('code', name, context, range)
}
return ( return (
<div className="flex flex-col h-screen w-full"> <div className="flex flex-col h-screen w-full">
<div className="flex justify-between items-center p-2 border-b"> <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">CHAT</span>
<div className="flex items-center h-full"> <div className="flex items-center h-full">
<span className="text-muted-foreground/50 font-medium">{activeFileName}</span> <span className="text-muted-foreground/50 font-medium">
{activeFileName}
</span>
<div className="mx-2 h-full w-px bg-muted-foreground/20"></div> <div className="mx-2 h-full w-px bg-muted-foreground/20"></div>
<button <button
onClick={onClose} onClick={onClose}
@ -53,32 +151,72 @@ export default function AIChat({ activeFileContent, activeFileName, onClose }: {
</button> </button>
</div> </div>
</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) => ( {messages.map((message, messageIndex) => (
<ChatMessage // Render chat message component for each message
key={messageIndex} <ChatMessage
message={message} key={messageIndex}
message={message}
setContext={setContext} setContext={setContext}
setIsContextExpanded={setIsContextExpanded} setIsContextExpanded={setIsContextExpanded}
socket={socket}
/> />
))} ))}
{isLoading && <LoadingDots />} {isLoading && <LoadingDots />}
</div> </div>
<div className="p-4 border-t mb-14"> <div className="p-4 border-t mb-14">
<ContextDisplay {/* Render context tabs component */}
context={context} <ContextTabs
isContextExpanded={isContextExpanded} activeFileName={activeFileName}
setIsContextExpanded={setIsContextExpanded} onAddFile={handleAddFile}
setContext={setContext} contextTabs={contextTabs}
onRemoveTab={removeContextTab}
isExpanded={isContextExpanded}
onToggleExpand={() => setIsContextExpanded(!isContextExpanded)}
files={files}
socket={socket}
onFileSelect={(file: TFile) => {
socket?.emit("getFile", { fileId: file.id }, (response: string) => {
const fileExt = file.name.split('.').pop() || 'txt'
const formattedContent = `\`\`\`${fileExt}\n${response}\n\`\`\``
addContextTab('file', file.name, formattedContent)
if (textareaRef.current) {
textareaRef.current.focus()
}
})
}}
/> />
<ChatInput {/* Render chat input component */}
<ChatInput
textareaRef={textareaRef}
addContextTab={addContextTab}
editorRef={editorRef}
input={input} input={input}
setInput={setInput} setInput={setInput}
isGenerating={isGenerating} isGenerating={isGenerating}
handleSend={() => handleSend(input, context, messages, setMessages, setInput, setIsContextExpanded, setIsGenerating, setIsLoading, abortControllerRef, activeFileContent)} handleSend={handleSendWithContext}
handleStopGeneration={() => handleStopGeneration(abortControllerRef)} handleStopGeneration={() => handleStopGeneration(abortControllerRef)}
onImageUpload={(file) => {
const reader = new FileReader()
reader.onload = (e) => {
if (e.target?.result) {
addContextTab("image", file.name, e.target.result as string)
}
}
reader.readAsDataURL(file)
}}
lastCopiedRangeRef={lastCopiedRangeRef}
activeFileName={activeFileName}
contextTabs={contextTabs.map(tab => ({
...tab,
title: tab.id
}))}
onRemoveTab={removeContextTab}
/> />
</div> </div>
</div> </div>
); )
} }

View File

@ -1,59 +1,84 @@
import React from 'react'; import React from "react"
export const stringifyContent = (content: any, seen = new WeakSet()): string => { // Stringify content for chat message component
if (typeof content === 'string') { export const stringifyContent = (
return content; content: any,
seen = new WeakSet()
): string => {
// Stringify content if it's a string
if (typeof content === "string") {
return content
} }
// Stringify content if it's null
if (content === null) { if (content === null) {
return 'null'; return "null"
} }
// Stringify content if it's undefined
if (content === undefined) { if (content === undefined) {
return 'undefined'; return "undefined"
} }
if (typeof content === 'number' || typeof content === 'boolean') { // Stringify content if it's a number or boolean
return content.toString(); if (typeof content === "number" || typeof content === "boolean") {
return content.toString()
} }
if (typeof content === 'function') { // Stringify content if it's a function
return content.toString(); if (typeof content === "function") {
return content.toString()
} }
if (typeof content === 'symbol') { // Stringify content if it's a symbol
return content.toString(); if (typeof content === "symbol") {
return content.toString()
} }
if (typeof content === 'bigint') { // Stringify content if it's a bigint
return content.toString() + 'n'; if (typeof content === "bigint") {
return content.toString() + "n"
} }
// Stringify content if it's a valid React element
if (React.isValidElement(content)) { if (React.isValidElement(content)) {
return React.Children.toArray((content as React.ReactElement).props.children) return React.Children.toArray(
.map(child => stringifyContent(child, seen)) (content as React.ReactElement).props.children
.join(''); )
.map((child) => stringifyContent(child, seen))
.join("")
} }
// Stringify content if it's an array
if (Array.isArray(content)) { if (Array.isArray(content)) {
return '[' + content.map(item => stringifyContent(item, seen)).join(', ') + ']'; return (
"[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]"
)
} }
if (typeof content === 'object') { // Stringify content if it's an object
if (typeof content === "object") {
if (seen.has(content)) { if (seen.has(content)) {
return '[Circular]'; return "[Circular]"
} }
seen.add(content); seen.add(content)
try { try {
const pairs = Object.entries(content).map( const pairs = Object.entries(content).map(
([key, value]) => `${key}: ${stringifyContent(value, seen)}` ([key, value]) => `${key}: ${stringifyContent(value, seen)}`
); )
return '{' + pairs.join(', ') + '}'; return "{" + pairs.join(", ") + "}"
} catch (error) { } catch (error) {
return Object.prototype.toString.call(content); return Object.prototype.toString.call(content)
} }
} }
return String(content); // Stringify content if it's a primitive value
}; return String(content)
}
export const copyToClipboard = (text: string, setCopiedText: (text: string | null) => void) => { // Copy to clipboard for chat message component
export const copyToClipboard = (
text: string,
setCopiedText: (text: string | null) => void
) => {
// Copy text to clipboard for chat message component
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
setCopiedText(text); setCopiedText(text)
setTimeout(() => setCopiedText(null), 2000); setTimeout(() => setCopiedText(null), 2000)
}); })
}; }
// Handle send for chat message component
export const handleSend = async ( export const handleSend = async (
input: string, input: string,
context: string | null, context: string | null,
@ -66,97 +91,145 @@ export const handleSend = async (
abortControllerRef: React.MutableRefObject<AbortController | null>, abortControllerRef: React.MutableRefObject<AbortController | null>,
activeFileContent: string activeFileContent: string
) => { ) => {
if (input.trim() === '' && !context) return; // Return if input is empty and context is null
if (input.trim() === "" && !context) return
const newMessage = { // Get timestamp for chat message component
role: 'user' as const, const timestamp = new Date().toLocaleTimeString('en-US', {
hour12: true,
hour: '2-digit',
minute: '2-digit'
}).replace(/(\d{2}):(\d{2})/, '$1:$2')
// Create user message for chat message component
const userMessage = {
role: "user" as const,
content: input, content: input,
context: context || undefined context: context || undefined,
}; timestamp: timestamp
const updatedMessages = [...messages, newMessage]; }
setMessages(updatedMessages);
setInput('');
setIsContextExpanded(false);
setIsGenerating(true);
setIsLoading(true);
abortControllerRef.current = new AbortController(); // Update messages for chat message component
const updatedMessages = [...messages, userMessage]
setMessages(updatedMessages)
setInput("")
setIsContextExpanded(false)
setIsGenerating(true)
setIsLoading(true)
abortControllerRef.current = new AbortController()
try { try {
const anthropicMessages = updatedMessages.map(msg => ({ // Create anthropic messages for chat message component
role: msg.role === 'user' ? 'human' : 'assistant', const anthropicMessages = updatedMessages.map((msg) => ({
content: msg.content role: msg.role === "user" ? "human" : "assistant",
})); content: msg.content,
}))
const response = await fetch(`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`, { // Fetch AI response for chat message component
method: 'POST', const response = await fetch(
headers: { `${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`,
'Content-Type': 'application/json', {
}, method: "POST",
body: JSON.stringify({ headers: {
messages: anthropicMessages, "Content-Type": "application/json",
context: context || undefined, },
activeFileContent: activeFileContent, body: JSON.stringify({
}), messages: anthropicMessages,
signal: abortControllerRef.current.signal, context: context || undefined,
}); activeFileContent: activeFileContent,
}),
signal: abortControllerRef.current.signal,
}
)
// Throw error if response is not ok
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to get AI response'); throw new Error("Failed to get AI response")
} }
const reader = response.body?.getReader(); // Get reader for chat message component
const decoder = new TextDecoder(); const reader = response.body?.getReader()
const assistantMessage = { role: 'assistant' as const, content: '' }; const decoder = new TextDecoder()
setMessages([...updatedMessages, assistantMessage]); const assistantMessage = { role: "assistant" as const, content: "" }
setIsLoading(false); setMessages([...updatedMessages, assistantMessage])
setIsLoading(false)
let buffer = ''; // Initialize buffer for chat message component
const updateInterval = 100; let buffer = ""
let lastUpdateTime = Date.now(); const updateInterval = 100
let lastUpdateTime = Date.now()
// Read response from reader for chat message component
if (reader) { if (reader) {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read()
if (done) break; if (done) break
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true })
const currentTime = Date.now(); const currentTime = Date.now()
if (currentTime - lastUpdateTime > updateInterval) { if (currentTime - lastUpdateTime > updateInterval) {
setMessages(prev => { setMessages((prev) => {
const updatedMessages = [...prev]; const updatedMessages = [...prev]
const lastMessage = updatedMessages[updatedMessages.length - 1]; const lastMessage = updatedMessages[updatedMessages.length - 1]
lastMessage.content = buffer; lastMessage.content = buffer
return updatedMessages; return updatedMessages
}); })
lastUpdateTime = currentTime; lastUpdateTime = currentTime
} }
} }
setMessages(prev => { // Update messages for chat message component
const updatedMessages = [...prev]; setMessages((prev) => {
const lastMessage = updatedMessages[updatedMessages.length - 1]; const updatedMessages = [...prev]
lastMessage.content = buffer; const lastMessage = updatedMessages[updatedMessages.length - 1]
return updatedMessages; lastMessage.content = buffer
}); return updatedMessages
})
} }
} catch (error: any) { } catch (error: any) {
if (error.name === 'AbortError') { // Handle abort error for chat message component
console.log('Generation aborted'); if (error.name === "AbortError") {
console.log("Generation aborted")
} else { } else {
console.error('Error fetching AI response:', error); console.error("Error fetching AI response:", error)
const errorMessage = { role: 'assistant' as const, content: 'Sorry, I encountered an error. Please try again.' }; const errorMessage = {
setMessages(prev => [...prev, errorMessage]); role: "assistant" as const,
content: "Sorry, I encountered an error. Please try again.",
}
setMessages((prev) => [...prev, errorMessage])
} }
} finally { } finally {
setIsGenerating(false); setIsGenerating(false)
setIsLoading(false); setIsLoading(false)
abortControllerRef.current = null; abortControllerRef.current = null
} }
}; }
export const handleStopGeneration = (abortControllerRef: React.MutableRefObject<AbortController | null>) => { // Handle stop generation for chat message component
export const handleStopGeneration = (
abortControllerRef: React.MutableRefObject<AbortController | null>
) => {
if (abortControllerRef.current) { if (abortControllerRef.current) {
abortControllerRef.current.abort(); abortControllerRef.current.abort()
} }
}
// Check if text looks like code for chat message component
export const looksLikeCode = (text: string): boolean => {
const codeIndicators = [
/^import\s+/m, // import statements
/^function\s+/m, // function declarations
/^class\s+/m, // class declarations
/^const\s+/m, // const declarations
/^let\s+/m, // let declarations
/^var\s+/m, // var declarations
/[{}\[\]();]/, // common code syntax
/^\s*\/\//m, // comments
/^\s*\/\*/m, // multi-line comments
/=>/, // arrow functions
/^export\s+/m, // export statements
];
return codeIndicators.some(pattern => pattern.test(text));
}; };

View File

@ -0,0 +1,102 @@
// Ignore certain folders and files from the file tree
export const ignoredFolders = [
// Package managers
'node_modules',
'venv',
'.env',
'env',
'.venv',
'virtualenv',
'pip-wheel-metadata',
// Build outputs
'.next',
'dist',
'build',
'out',
'__pycache__',
'.webpack',
'.serverless',
'storybook-static',
// Version control
'.git',
'.svn',
'.hg', // Mercurial
// Cache and temp files
'.cache',
'coverage',
'tmp',
'.temp',
'.npm',
'.pnpm',
'.yarn',
'.eslintcache',
'.stylelintcache',
// IDE specific
'.idea',
'.vscode',
'.vs',
'.sublime',
// Framework specific
'.streamlit',
'.next',
'static',
'.pytest_cache',
'.nuxt',
'.docusaurus',
'.remix',
'.parcel-cache',
'public/build', // Remix/Rails
'.turbo', // Turborepo
// Logs
'logs',
'*.log',
'npm-debug.log*',
'yarn-debug.log*',
'yarn-error.log*',
'pnpm-debug.log*',
] as const;
export const ignoredFiles = [
'.DS_Store',
'.env.local',
'.env.development',
'.env.production',
'.env.test',
'.env*.local',
'.gitignore',
'.npmrc',
'.yarnrc',
'.editorconfig',
'.prettierrc',
'.eslintrc',
'.browserslistrc',
'tsconfig.tsbuildinfo',
'*.pyc',
'*.pyo',
'*.pyd',
'*.so',
'*.dll',
'*.dylib',
'*.class',
'*.exe',
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
'composer.lock',
'poetry.lock',
'Gemfile.lock',
'*.min.js',
'*.min.css',
'*.map',
'*.chunk.*',
'*.hot-update.*',
'.vercel',
'.netlify'
] as const;

View File

@ -0,0 +1,79 @@
import { Components } from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
import { Button } from "../../../ui/button"
import { CornerUpLeft } from "lucide-react"
import { stringifyContent } from "./chatUtils"
// Create markdown components for chat message component
export const createMarkdownComponents = (
renderCopyButton: (text: any) => JSX.Element,
renderMarkdownElement: (props: any) => JSX.Element,
askAboutCode: (code: any) => void
): Components => ({
code: ({ node, className, children, ...props }: {
node?: import('hast').Element,
className?: string,
children?: React.ReactNode,
[key: string]: any,
}) => {
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">
{match[1]}
</div>
<div className="absolute top-0 right-0 flex">
{renderCopyButton(children)}
<Button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
askAboutCode(children)
}}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
<div className="pt-6">
<SyntaxHighlighter
style={vscDarkPlus as any}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
padding: "0.5rem",
fontSize: "0.875rem",
}}
>
{stringifyContent(children)}
</SyntaxHighlighter>
</div>
</div>
) : (
<code className={className} {...props}>{children}</code>
)
},
// Render markdown elements
p: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h1: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h2: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h3: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h4: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h5: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h6: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
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>
),
})

View File

@ -0,0 +1,93 @@
import * as monaco from 'monaco-editor'
import { TFile, TFolder } from "@/lib/types"
import { Socket } from 'socket.io-client';
// Allowed file types for context tabs
export const ALLOWED_FILE_TYPES = {
// Text files
'text/plain': true,
'text/markdown': true,
'text/csv': true,
// Code files
'application/json': true,
'text/javascript': true,
'text/typescript': true,
'text/html': true,
'text/css': true,
// Documents
'application/pdf': true,
// Images
'image/jpeg': true,
'image/png': true,
'image/gif': true,
'image/webp': true,
'image/svg+xml': true,
} as const;
// Message interface
export interface Message {
role: "user" | "assistant"
content: string
context?: string
}
// Context tab interface
export interface ContextTab {
id: string
type: "file" | "code" | "image"
name: string
content: string
lineRange?: { start: number; end: number }
}
// AIChat props interface
export interface AIChatProps {
activeFileContent: string
activeFileName: string
onClose: () => void
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null>
files: (TFile | TFolder)[]
}
// Chat input props interface
export interface ChatInputProps {
input: string
setInput: (input: string) => void
isGenerating: boolean
handleSend: (useFullContext?: boolean) => void
handleStopGeneration: () => void
onImageUpload: (file: File) => void
addContextTab: (type: string, title: string, content: string, lineRange?: { start: number, end: number }) => void
activeFileName?: string
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null>
contextTabs: { id: string; type: string; title: string; content: string; lineRange?: { start: number; end: number } }[]
onRemoveTab: (id: string) => void
textareaRef: React.RefObject<HTMLTextAreaElement>
}
// Chat message props interface
export interface MessageProps {
message: {
role: "user" | "assistant"
content: string
context?: string
}
setContext: (context: string | null, name: string, range?: { start: number, end: number }) => void
setIsContextExpanded: (isExpanded: boolean) => void
socket: Socket | null
}
// Context tabs props interface
export interface ContextTabsProps {
activeFileName: string
onAddFile: (tab: ContextTab) => void
contextTabs: ContextTab[]
onRemoveTab: (id: string) => void
isExpanded: boolean
onToggleExpand: () => void
files?: (TFile | TFolder)[]
onFileSelect?: (file: TFile) => void
socket: Socket | null
}

View File

@ -1,13 +1,13 @@
"use client" "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 { 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 { 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" // import monaco from "monaco-editor"
export default function GenerateInput({ export default function GenerateInput({
@ -68,10 +68,12 @@ export default function GenerateInput({
setCurrentPrompt(input) setCurrentPrompt(input)
socket.emit( socket.emit(
"generateCode", "generateCode",
data.fileName, {
data.code, fileName: data.fileName,
data.line, code: data.code,
regenerate ? currentPrompt : input, line: data.line,
instructions: regenerate ? currentPrompt : input
},
(res: { response: string; success: boolean }) => { (res: { response: string; success: boolean }) => {
console.log("Generated code", res.response, res.success) console.log("Generated code", res.response, res.success)
// if (!res.success) { // if (!res.success) {

View File

@ -1,43 +1,57 @@
"use client" "use client"
import { SetStateAction, useCallback, useEffect, useRef, useState } from "react"
import * as monaco from "monaco-editor"
import Editor, { BeforeMount, OnMount } from "@monaco-editor/react"
import { toast } from "sonner"
import { useClerk } from "@clerk/nextjs" import { useClerk } from "@clerk/nextjs"
import Editor, { BeforeMount, OnMount } from "@monaco-editor/react"
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import * as monaco from "monaco-editor"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import * as Y from "yjs" import { TypedLiveblocksProvider, useRoom, useSelf } from "@/liveblocks.config"
import LiveblocksProvider from "@liveblocks/yjs" import LiveblocksProvider from "@liveblocks/yjs"
import { MonacoBinding } from "y-monaco" import { MonacoBinding } from "y-monaco"
import { Awareness } from "y-protocols/awareness" import { Awareness } from "y-protocols/awareness"
import { TypedLiveblocksProvider, useRoom, useSelf } from "@/liveblocks.config" import * as Y from "yjs"
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import { FileJson, Loader2, Sparkles, TerminalSquare, ArrowDownToLine, ArrowRightToLine } from "lucide-react" import { PreviewProvider, usePreview } from "@/context/PreviewContext"
import Tab from "../ui/tab" import { useSocket } from "@/context/SocketContext"
import Sidebar from "./sidebar" import { parseTSConfigToMonacoOptions } from "@/lib/tsconfig"
import GenerateInput from "./generate" import { Sandbox, TFile, TFolder, TTab, User } from "@/lib/types"
import { Sandbox, User, TFile, TFolder, TTab } from "@/lib/types" import {
import { addNew, processFileType, validateName, debounce } from "@/lib/utils" addNew,
import { Cursors } from "./live/cursors" cn,
debounce,
deepMerge,
processFileType,
validateName,
} from "@/lib/utils"
import { Terminal } from "@xterm/xterm" import { Terminal } from "@xterm/xterm"
import {
ArrowDownToLine,
ArrowRightToLine,
FileJson,
Loader2,
Sparkles,
TerminalSquare,
} from "lucide-react"
import { useTheme } from "next-themes"
import React from "react"
import { ImperativePanelHandle } from "react-resizable-panels"
import { Button } from "../ui/button"
import Tab from "../ui/tab"
import AIChat from "./AIChat"
import GenerateInput from "./generate"
import { Cursors } from "./live/cursors"
import DisableAccessModal from "./live/disableModal" import DisableAccessModal from "./live/disableModal"
import Loading from "./loading" import Loading from "./loading"
import PreviewWindow from "./preview" import PreviewWindow from "./preview"
import Sidebar from "./sidebar"
import Terminals from "./terminals" import Terminals from "./terminals"
import { ImperativePanelHandle } from "react-resizable-panels"
import { PreviewProvider, usePreview } from "@/context/PreviewContext"
import { useSocket } from "@/context/SocketContext"
import { Button } from "../ui/button"
import React from "react"
import { parseTSConfigToMonacoOptions } from "@/lib/tsconfig"
import { deepMerge } from "@/lib/utils"
import AIChat from "./AIChat"
export default function CodeEditor({ export default function CodeEditor({
userData, userData,
@ -48,7 +62,8 @@ export default function CodeEditor({
}) { }) {
//SocketContext functions and effects //SocketContext functions and effects
const { socket, setUserAndSandboxId } = useSocket() const { socket, setUserAndSandboxId } = useSocket()
// theme
const { theme } = useTheme()
useEffect(() => { useEffect(() => {
// Ensure userData.id and sandboxData.id are available before attempting to connect // Ensure userData.id and sandboxData.id are available before attempting to connect
if (userData.id && sandboxData.id) { if (userData.id && sandboxData.id) {
@ -63,9 +78,9 @@ export default function CodeEditor({
// This heartbeat is critical to preventing the E2B sandbox from timing out // This heartbeat is critical to preventing the E2B sandbox from timing out
useEffect(() => { useEffect(() => {
// 10000 ms = 10 seconds // 10000 ms = 10 seconds
const interval = setInterval(() => socket?.emit("heartbeat"), 10000); const interval = setInterval(() => socket?.emit("heartbeat"), 10000)
return () => clearInterval(interval); return () => clearInterval(interval)
}, [socket]); }, [socket])
//Preview Button state //Preview Button state
const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true) const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true)
@ -75,11 +90,11 @@ export default function CodeEditor({
}) })
// Layout state // Layout state
const [isHorizontalLayout, setIsHorizontalLayout] = useState(false); const [isHorizontalLayout, setIsHorizontalLayout] = useState(false)
const [previousLayout, setPreviousLayout] = useState(false); const [previousLayout, setPreviousLayout] = useState(false)
// AI Chat state // AI Chat state
const [isAIChatOpen, setIsAIChatOpen] = useState(false); const [isAIChatOpen, setIsAIChatOpen] = useState(false)
// File state // File state
const [files, setFiles] = useState<(TFolder | TFile)[]>([]) const [files, setFiles] = useState<(TFolder | TFile)[]>([])
@ -88,7 +103,7 @@ export default function CodeEditor({
const [activeFileContent, setActiveFileContent] = useState("") const [activeFileContent, setActiveFileContent] = useState("")
const [deletingFolderId, setDeletingFolderId] = useState("") const [deletingFolderId, setDeletingFolderId] = useState("")
// Added this state to track the most recent content for each file // Added this state to track the most recent content for each file
const [fileContents, setFileContents] = useState<Record<string, string>>({}); const [fileContents, setFileContents] = useState<Record<string, string>>({})
// Editor state // Editor state
const [editorLanguage, setEditorLanguage] = useState("plaintext") const [editorLanguage, setEditorLanguage] = useState("plaintext")
@ -153,10 +168,13 @@ export default function CodeEditor({
const generateRef = useRef<HTMLDivElement>(null) const generateRef = useRef<HTMLDivElement>(null)
const suggestionRef = useRef<HTMLDivElement>(null) const suggestionRef = useRef<HTMLDivElement>(null)
const generateWidgetRef = useRef<HTMLDivElement>(null) const generateWidgetRef = useRef<HTMLDivElement>(null)
const { previewPanelRef } = usePreview(); const { previewPanelRef } = usePreview()
const editorPanelRef = useRef<ImperativePanelHandle>(null) const editorPanelRef = useRef<ImperativePanelHandle>(null)
const previewWindowRef = useRef<{ refreshIframe: () => void }>(null) const previewWindowRef = useRef<{ refreshIframe: () => void }>(null)
// Ref to store the last copied range in the editor to be used in the AIChat component
const lastCopiedRangeRef = useRef<{ startLine: number; endLine: number } | null>(null);
const debouncedSetIsSelected = useRef( const debouncedSetIsSelected = useRef(
debounce((value: boolean) => { debounce((value: boolean) => {
setIsSelected(value) setIsSelected(value)
@ -191,7 +209,7 @@ export default function CodeEditor({
) )
const fetchFileContent = (fileId: string): Promise<string> => { const fetchFileContent = (fileId: string): Promise<string> => {
return new Promise((resolve) => { return new Promise((resolve) => {
socket?.emit("getFile", fileId, (content: string) => { socket?.emit("getFile", { fileId }, (content: string) => {
resolve(content) resolve(content)
}) })
}) })
@ -241,6 +259,17 @@ export default function CodeEditor({
updatedOptions updatedOptions
) )
} }
// Store the last copied range in the editor to be used in the AIChat component
editor.onDidChangeCursorSelection((e) => {
const selection = editor.getSelection();
if (selection) {
lastCopiedRangeRef.current = {
startLine: selection.startLineNumber,
endLine: selection.endLineNumber
};
}
});
} }
// Call the function with your file structure // Call the function with your file structure
@ -470,16 +499,17 @@ export default function CodeEditor({
const model = editorRef?.getModel() const model = editorRef?.getModel()
// added this because it was giving client side exception - Illegal value for lineNumber when opening an empty file // added this because it was giving client side exception - Illegal value for lineNumber when opening an empty file
if (model) { if (model) {
const totalLines = model.getLineCount(); const totalLines = model.getLineCount()
// Check if the cursorLine is a valid number, If cursorLine is out of bounds, we fall back to 1 (the first line) as a default safe value. // Check if the cursorLine is a valid number, If cursorLine is out of bounds, we fall back to 1 (the first line) as a default safe value.
const lineNumber = cursorLine > 0 && cursorLine <= totalLines ? cursorLine : 1; // fallback to a valid line number const lineNumber =
cursorLine > 0 && cursorLine <= totalLines ? cursorLine : 1 // fallback to a valid line number
// If for some reason the content doesn't exist, we use an empty string as a fallback. // If for some reason the content doesn't exist, we use an empty string as a fallback.
const line = model.getLineContent(lineNumber) ?? ""; const line = model.getLineContent(lineNumber) ?? ""
// Check if the line is not empty or only whitespace (i.e., `.trim()` removes spaces). // Check if the line is not empty or only whitespace (i.e., `.trim()` removes spaces).
// If the line has content, we clear any decorations using the instance of the `decorations` object. // If the line has content, we clear any decorations using the instance of the `decorations` object.
// Decorations refer to editor highlights, underlines, or markers, so this clears those if conditions are met. // Decorations refer to editor highlights, underlines, or markers, so this clears those if conditions are met.
if (line.trim() !== "") { if (line.trim() !== "") {
decorations.instance?.clear(); decorations.instance?.clear()
return return
} }
} }
@ -505,40 +535,40 @@ export default function CodeEditor({
debounce((activeFileId: string | undefined) => { debounce((activeFileId: string | undefined) => {
if (activeFileId) { if (activeFileId) {
// Get the current content of the file // Get the current content of the file
const content = fileContents[activeFileId]; const content = fileContents[activeFileId]
// Mark the file as saved in the tabs // Mark the file as saved in the tabs
setTabs((prev) => setTabs((prev) =>
prev.map((tab) => prev.map((tab) =>
tab.id === activeFileId ? { ...tab, saved: true } : tab tab.id === activeFileId ? { ...tab, saved: true } : tab
) )
); )
console.log(`Saving file...${activeFileId}`); console.log(`Saving file...${activeFileId}`)
console.log(`Saving file...${content}`); 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), }, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY) || 1000),
[socket, fileContents] [socket, fileContents]
); )
// Keydown event listener to trigger file save on Ctrl+S or Cmd+S, and toggle AI chat on Ctrl+L or Cmd+L // Keydown event listener to trigger file save on Ctrl+S or Cmd+S, and toggle AI chat on Ctrl+L or Cmd+L
useEffect(() => { useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
if (e.key === "s" && (e.metaKey || e.ctrlKey)) { if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
e.preventDefault() e.preventDefault()
debouncedSaveData(activeFileId); debouncedSaveData(activeFileId)
} else if (e.key === "l" && (e.metaKey || e.ctrlKey)) { } else if (e.key === "l" && (e.metaKey || e.ctrlKey)) {
e.preventDefault() e.preventDefault()
setIsAIChatOpen(prev => !prev); setIsAIChatOpen((prev) => !prev)
} }
}; }
document.addEventListener("keydown", down); document.addEventListener("keydown", down)
// Added this line to prevent Monaco editor from handling Cmd/Ctrl+L // Added this line to prevent Monaco editor from handling Cmd/Ctrl+L
editorRef?.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL, () => { editorRef?.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL, () => {
setIsAIChatOpen(prev => !prev); setIsAIChatOpen((prev) => !prev)
}); })
return () => { return () => {
document.removeEventListener("keydown", down) document.removeEventListener("keydown", down)
@ -698,50 +728,53 @@ export default function CodeEditor({
// Debounced function to get file content // Debounced function to get file content
const debouncedGetFile = (tabId: any, callback: any) => { const debouncedGetFile = (tabId: any, callback: any) => {
socket?.emit("getFile", tabId, callback) socket?.emit("getFile", { fileId: tabId }, callback)
} // 300ms debounce delay, adjust as needed } // 300ms debounce delay, adjust as needed
const selectFile = (tab: TTab) => { const selectFile = (tab: TTab) => {
if (tab.id === activeFileId) return; if (tab.id === activeFileId) return
setGenerate((prev) => ({ ...prev, show: false })); setGenerate((prev) => ({ ...prev, show: false }))
// Check if the tab already exists in the list of open tabs // Check if the tab already exists in the list of open tabs
const exists = tabs.find((t) => t.id === tab.id); const exists = tabs.find((t) => t.id === tab.id)
setTabs((prev) => { setTabs((prev) => {
if (exists) { if (exists) {
// If the tab exists, make it the active tab // If the tab exists, make it the active tab
setActiveFileId(exists.id); setActiveFileId(exists.id)
return prev; return prev
} }
// If the tab doesn't exist, add it to the list of tabs and make it active // If the tab doesn't exist, add it to the list of tabs and make it active
return [...prev, tab]; return [...prev, tab]
}); })
// If the file's content is already cached, set it as the active content // If the file's content is already cached, set it as the active content
if (fileContents[tab.id]) { if (fileContents[tab.id]) {
setActiveFileContent(fileContents[tab.id]); setActiveFileContent(fileContents[tab.id])
} else { } else {
// Otherwise, fetch the content of the file and cache it // Otherwise, fetch the content of the file and cache it
debouncedGetFile(tab.id, (response: string) => { debouncedGetFile(tab.id, (response: string) => {
setFileContents(prev => ({ ...prev, [tab.id]: response })); setFileContents((prev) => ({ ...prev, [tab.id]: response }))
setActiveFileContent(response); setActiveFileContent(response)
}); })
} }
// Set the editor language based on the file type // Set the editor language based on the file type
setEditorLanguage(processFileType(tab.name)); setEditorLanguage(processFileType(tab.name))
// Set the active file ID to the new tab // Set the active file ID to the new tab
setActiveFileId(tab.id); setActiveFileId(tab.id)
}; }
// Added this effect to update fileContents when the editor content changes // Added this effect to update fileContents when the editor content changes
useEffect(() => { useEffect(() => {
if (activeFileId) { if (activeFileId) {
// Cache the current active file content using the file ID as the key // Cache the current active file content using the file ID as the key
setFileContents(prev => ({ ...prev, [activeFileId]: activeFileContent })); setFileContents((prev) => ({
...prev,
[activeFileId]: activeFileContent,
}))
} }
}, [activeFileContent, activeFileId]); }, [activeFileContent, activeFileId])
// Close tab and remove from tabs // Close tab and remove from tabs
const closeTab = (id: string) => { const closeTab = (id: string) => {
@ -815,7 +848,7 @@ export default function CodeEditor({
return false return false
} }
socket?.emit("renameFile", id, newName) socket?.emit("renameFile", { fileId: id, newName })
setTabs((prev) => setTabs((prev) =>
prev.map((tab) => (tab.id === id ? { ...tab, name: newName } : tab)) prev.map((tab) => (tab.id === id ? { ...tab, name: newName } : tab))
) )
@ -824,7 +857,7 @@ export default function CodeEditor({
} }
const handleDeleteFile = (file: TFile) => { const handleDeleteFile = (file: TFile) => {
socket?.emit("deleteFile", file.id, (response: (TFolder | TFile)[]) => { socket?.emit("deleteFile", { fileId: file.id }, (response: (TFolder | TFile)[]) => {
setFiles(response) setFiles(response)
}) })
closeTab(file.id) closeTab(file.id)
@ -834,11 +867,11 @@ export default function CodeEditor({
setDeletingFolderId(folder.id) setDeletingFolderId(folder.id)
console.log("deleting folder", 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) closeTabs(response)
) )
socket?.emit("deleteFolder", folder.id, (response: (TFolder | TFile)[]) => { socket?.emit("deleteFolder", { folderId: folder.id }, (response: (TFolder | TFile)[]) => {
setFiles(response) setFiles(response)
setDeletingFolderId("") setDeletingFolderId("")
}) })
@ -846,34 +879,34 @@ export default function CodeEditor({
const togglePreviewPanel = () => { const togglePreviewPanel = () => {
if (isPreviewCollapsed) { if (isPreviewCollapsed) {
previewPanelRef.current?.expand(); previewPanelRef.current?.expand()
setIsPreviewCollapsed(false); setIsPreviewCollapsed(false)
} else { } else {
previewPanelRef.current?.collapse(); previewPanelRef.current?.collapse()
setIsPreviewCollapsed(true); setIsPreviewCollapsed(true)
} }
}; }
const toggleLayout = () => { const toggleLayout = () => {
if (!isAIChatOpen) { if (!isAIChatOpen) {
setIsHorizontalLayout(prev => !prev); setIsHorizontalLayout((prev) => !prev)
} }
}; }
// Add an effect to handle layout changes when AI chat is opened/closed // Add an effect to handle layout changes when AI chat is opened/closed
useEffect(() => { useEffect(() => {
if (isAIChatOpen) { if (isAIChatOpen) {
setPreviousLayout(isHorizontalLayout); setPreviousLayout(isHorizontalLayout)
setIsHorizontalLayout(true); setIsHorizontalLayout(true)
} else { } else {
setIsHorizontalLayout(previousLayout); setIsHorizontalLayout(previousLayout)
} }
}, [isAIChatOpen]); }, [isAIChatOpen])
// Modify the toggleAIChat function // Modify the toggleAIChat function
const toggleAIChat = () => { const toggleAIChat = () => {
setIsAIChatOpen(prev => !prev); setIsAIChatOpen((prev) => !prev)
}; }
// On disabled access for shared users, show un-interactable loading placeholder + info modal // On disabled access for shared users, show un-interactable loading placeholder + info modal
if (disableAccess.isDisabled) if (disableAccess.isDisabled)
@ -910,7 +943,10 @@ export default function CodeEditor({
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
<div className="z-50 p-1" ref={generateWidgetRef}> <div
className={cn(generate.show && "z-50 p-1")}
ref={generateWidgetRef}
>
{generate.show ? ( {generate.show ? (
<GenerateInput <GenerateInput
user={userData} user={userData}
@ -1006,12 +1042,18 @@ export default function CodeEditor({
setFiles={setFiles} setFiles={setFiles}
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)} addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
deletingFolderId={deletingFolderId} deletingFolderId={deletingFolderId}
toggleAIChat={toggleAIChat}
isAIChatOpen={isAIChatOpen}
/> />
{/* Outer ResizablePanelGroup for main layout */} {/* Outer ResizablePanelGroup for main layout */}
<ResizablePanelGroup direction={isHorizontalLayout ? "horizontal" : "vertical"}> <ResizablePanelGroup
direction={isHorizontalLayout ? "horizontal" : "vertical"}
>
{/* Left side: Editor and Preview/Terminal */} {/* Left side: Editor and Preview/Terminal */}
<ResizablePanel defaultSize={isAIChatOpen ? 80 : 100} minSize={50}> <ResizablePanel defaultSize={isAIChatOpen ? 80 : 100} minSize={50}>
<ResizablePanelGroup direction={isHorizontalLayout ? "vertical" : "horizontal"}> <ResizablePanelGroup
direction={isHorizontalLayout ? "vertical" : "horizontal"}
>
<ResizablePanel <ResizablePanel
className="p-2 flex flex-col" className="p-2 flex flex-col"
maxSize={80} maxSize={80}
@ -1061,7 +1103,7 @@ export default function CodeEditor({
onChange={(value) => { onChange={(value) => {
// If the new content is different from the cached content, update it // If the new content is different from the cached content, update it
if (value !== fileContents[activeFileId]) { if (value !== fileContents[activeFileId]) {
setActiveFileContent(value ?? ""); // Update the active file content setActiveFileContent(value ?? "") // Update the active file content
// Mark the file as unsaved by setting 'saved' to false // Mark the file as unsaved by setting 'saved' to false
setTabs((prev) => setTabs((prev) =>
prev.map((tab) => prev.map((tab) =>
@ -1094,7 +1136,7 @@ export default function CodeEditor({
fixedOverflowWidgets: true, fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)", fontFamily: "var(--font-geist-mono)",
}} }}
theme="vs-dark" theme={theme === "light" ? "vs" : "vs-dark"}
value={activeFileContent} value={activeFileContent}
/> />
</> </>
@ -1108,18 +1150,23 @@ export default function CodeEditor({
</ResizablePanel> </ResizablePanel>
<ResizableHandle /> <ResizableHandle />
<ResizablePanel defaultSize={30}> <ResizablePanel defaultSize={30}>
<ResizablePanelGroup direction={ <ResizablePanelGroup
isAIChatOpen && isHorizontalLayout ? "horizontal" : direction={
isAIChatOpen ? "vertical" : isAIChatOpen && isHorizontalLayout
isHorizontalLayout ? "horizontal" : ? "horizontal"
"vertical" : isAIChatOpen
}> ? "vertical"
: isHorizontalLayout
? "horizontal"
: "vertical"
}
>
<ResizablePanel <ResizablePanel
ref={previewPanelRef} ref={previewPanelRef}
defaultSize={isPreviewCollapsed ? 4 : 20} defaultSize={isPreviewCollapsed ? 4 : 20}
minSize={25} minSize={25}
collapsedSize={isHorizontalLayout ? 20 : 4} collapsedSize={isHorizontalLayout ? 20 : 4}
className="p-2 flex flex-col" className="p-2 flex flex-col gap-2"
collapsible collapsible
onCollapse={() => setIsPreviewCollapsed(true)} onCollapse={() => setIsPreviewCollapsed(true)}
onExpand={() => setIsPreviewCollapsed(false)} onExpand={() => setIsPreviewCollapsed(false)}
@ -1131,8 +1178,12 @@ export default function CodeEditor({
variant="ghost" variant="ghost"
className="mr-2 border" className="mr-2 border"
disabled={isAIChatOpen} disabled={isAIChatOpen}
> >
{isHorizontalLayout ? <ArrowRightToLine className="w-4 h-4" /> : <ArrowDownToLine className="w-4 h-4" />} {isHorizontalLayout ? (
<ArrowRightToLine className="w-4 h-4" />
) : (
<ArrowDownToLine className="w-4 h-4" />
)}
</Button> </Button>
<PreviewWindow <PreviewWindow
open={togglePreviewPanel} open={togglePreviewPanel}
@ -1175,10 +1226,16 @@ export default function CodeEditor({
<> <>
<ResizableHandle /> <ResizableHandle />
<ResizablePanel defaultSize={30} minSize={15}> <ResizablePanel defaultSize={30} minSize={15}>
<AIChat <AIChat
activeFileContent={activeFileContent} activeFileContent={activeFileContent}
activeFileName={tabs.find(tab => tab.id === activeFileId)?.name || 'No file selected'} activeFileName={
tabs.find((tab) => tab.id === activeFileId)?.name ||
"No file selected"
}
onClose={toggleAIChat} onClose={toggleAIChat}
editorRef={{ current: editorRef }}
lastCopiedRangeRef={lastCopiedRangeRef}
files={files}
/> />
</ResizablePanel> </ResizablePanel>
</> </>

View File

@ -1,6 +1,6 @@
"use client"; "use client"
import { useOthers } from "@/liveblocks.config"; import { useOthers } from "@/liveblocks.config"
const classNames = { 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", 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: 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", "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", 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() { export function Avatars() {
const users = useOthers(); const users = useOthers()
return ( return (
<> <>
@ -30,12 +30,12 @@ export function Avatars() {
.slice(0, 2) .slice(0, 2)
.map((letter) => letter[0].toUpperCase())} .map((letter) => letter[0].toUpperCase())}
</div> </div>
); )
})} })}
</div> </div>
{users.length > 0 ? ( {users.length > 0 ? (
<div className="h-full w-[1px] bg-border mx-2" /> <div className="h-full w-[1px] bg-border mx-2" />
) : null} ) : null}
</> </>
); )
} }

View File

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

View File

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

View File

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

View File

@ -1,9 +1,6 @@
"use client" "use client"
import Image from "next/image"
import Logo from "@/assets/logo.svg" import Logo from "@/assets/logo.svg"
import { Skeleton } from "@/components/ui/skeleton"
import { Loader2, X } from "lucide-react"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -11,6 +8,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } 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" import { useEffect, useState } from "react"
export default function Loading({ export default function Loading({
@ -84,8 +84,10 @@ export default function Loading({
</div> </div>
</div> </div>
<div className="w-full mt-1 flex flex-col"> <div className="w-full mt-1 flex flex-col">
<div className="w-full flex justify-center"> <div className="w-full flex flex-col justify-center">
<Loader2 className="w-4 h-4 animate-spin" /> {new Array(6).fill(0).map((_, i) => (
<Skeleton key={i} className="h-[1.625rem] mb-0.5 rounded-sm" />
))}
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -1,33 +1,34 @@
"use client"; "use client"
import Image from "next/image"; import Logo from "@/assets/logo.svg"
import Logo from "@/assets/logo.svg"; import { Button } from "@/components/ui/button"
import { Pencil, Users } from "lucide-react"; import { ThemeSwitcher } from "@/components/ui/theme-switcher"
import Link from "next/link"; import UserButton from "@/components/ui/userButton"
import { Sandbox, User } from "@/lib/types"; import { Sandbox, User } from "@/lib/types"
import UserButton from "@/components/ui/userButton"; import { Pencil, Users } from "lucide-react"
import { Button } from "@/components/ui/button"; import Image from "next/image"
import { useState } from "react"; import Link from "next/link"
import EditSandboxModal from "./edit"; import { useState } from "react"
import ShareSandboxModal from "./share"; import { Avatars } from "../live/avatars"
import { Avatars } from "../live/avatars"; import DeployButtonModal from "./deploy"
import RunButtonModal from "./run"; import EditSandboxModal from "./edit"
import DeployButtonModal from "./deploy"; import RunButtonModal from "./run"
import ShareSandboxModal from "./share"
export default function Navbar({ export default function Navbar({
userData, userData,
sandboxData, sandboxData,
shared, shared,
}: { }: {
userData: User; userData: User
sandboxData: Sandbox; sandboxData: Sandbox
shared: { id: string; name: string }[]; shared: { id: string; name: string }[]
}) { }) {
const [isEditOpen, setIsEditOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false)
const [isShareOpen, setIsShareOpen] = useState(false); const [isShareOpen, setIsShareOpen] = useState(false)
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false)
const isOwner = sandboxData.userId === userData.id;; const isOwner = sandboxData.userId === userData.id
return ( return (
<> <>
@ -72,19 +73,17 @@ export default function Navbar({
{isOwner ? ( {isOwner ? (
<> <>
<DeployButtonModal <DeployButtonModal data={sandboxData} userData={userData} />
data={sandboxData} <Button variant="outline" onClick={() => setIsShareOpen(true)}>
userData={userData} <Users className="w-4 h-4 mr-2" />
/> Share
<Button variant="outline" onClick={() => setIsShareOpen(true)}> </Button>
<Users className="w-4 h-4 mr-2" />
Share
</Button>
</> </>
) : null} ) : null}
<ThemeSwitcher />
<UserButton userData={userData} /> <UserButton userData={userData} />
</div> </div>
</div> </div>
</> </>
); )
} }

View File

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

View File

@ -6,10 +6,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { import {
Form, Form,
FormControl, FormControl,
@ -18,14 +19,13 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form" } from "@/components/ui/form"
import { Input } from "@/components/ui/input" 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 { 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 { toast } from "sonner"
import SharedUser from "./sharedUser" import SharedUser from "./sharedUser"
import { DialogDescription } from "@radix-ui/react-dialog"
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email(), email: z.string().email(),

View File

@ -1,66 +1,69 @@
"use client" "use client"
import { Link, RotateCw, UnfoldVertical } from "lucide-react"
import { import {
Link, forwardRef,
RotateCw, useEffect,
TerminalSquare, useImperativeHandle,
UnfoldVertical, useRef,
} from "lucide-react" useState,
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from "react" } from "react"
import { toast } from "sonner" import { toast } from "sonner"
export default forwardRef(function PreviewWindow({ export default forwardRef(function PreviewWindow(
collapsed, {
open, collapsed,
src open,
}: { src,
collapsed: boolean }: {
open: () => void collapsed: boolean
src: string open: () => void
}, src: string
ref: React.Ref<{ },
refreshIframe: () => void ref: React.Ref<{
}>) { refreshIframe: () => void
}>
) {
const frameRef = useRef<HTMLIFrameElement>(null) const frameRef = useRef<HTMLIFrameElement>(null)
const [iframeKey, setIframeKey] = useState(0) const [iframeKey, setIframeKey] = useState(0)
const refreshIframe = () => { 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]) useEffect(refreshIframe, [src])
// Expose refreshIframe method to the parent. // Expose refreshIframe method to the parent.
useImperativeHandle(ref, () => ({ refreshIframe })) useImperativeHandle(ref, () => ({ refreshIframe }))
return ( return (
<> <>
<div className="h-8 rounded-md px-3 bg-secondary flex items-center w-full justify-between"> <div className="h-8 rounded-md px-3 bg-secondary flex items-center w-full justify-between">
<div className="text-xs">Preview</div> <div className="text-xs">Preview</div>
<div className="flex space-x-1 translate-x-1"> <div className="flex space-x-1 translate-x-1">
{collapsed ? ( {collapsed ? (
<PreviewButton onClick={open}>
<UnfoldVertical className="w-4 h-4" />
</PreviewButton>
) : (
<>
<PreviewButton onClick={open}> <PreviewButton onClick={open}>
<UnfoldVertical className="w-4 h-4" /> <UnfoldVertical className="w-4 h-4" />
</PreviewButton> </PreviewButton>
) : (
<>
<PreviewButton onClick={open}>
<UnfoldVertical className="w-4 h-4" />
</PreviewButton>
<PreviewButton <PreviewButton
onClick={() => { onClick={() => {
navigator.clipboard.writeText(src) navigator.clipboard.writeText(src)
toast.info("Copied preview link to clipboard") toast.info("Copied preview link to clipboard")
}} }}
> >
<Link className="w-4 h-4" /> <Link className="w-4 h-4" />
</PreviewButton> </PreviewButton>
<PreviewButton onClick={refreshIframe}> <PreviewButton onClick={refreshIframe}>
<RotateCw className="w-3 h-3" /> <RotateCw className="w-3 h-3" />
</PreviewButton> </PreviewButton>
</> </>
)} )}
</div>
</div> </div>
</div>
</> </>
) )
}) })
@ -76,8 +79,9 @@ function PreviewButton({
}) { }) {
return ( return (
<div <div
className={`${disabled ? "pointer-events-none opacity-50" : "" className={`${
} 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`} 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} onClick={onClick}
> >
{children} {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 { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } from "@/components/ui/context-menu"
import { Loader2, Pencil, Trash2 } from "lucide-react"; 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({ export default function SidebarFile({
data, data,
@ -22,36 +22,36 @@ export default function SidebarFile({
movingId, movingId,
deletingFolderId, deletingFolderId,
}: { }: {
data: TFile; data: TFile
selectFile: (file: TTab) => void; selectFile: (file: TTab) => void
handleRename: ( handleRename: (
id: string, id: string,
newName: string, newName: string,
oldName: string, oldName: string,
type: "file" | "folder" type: "file" | "folder"
) => boolean; ) => boolean
handleDeleteFile: (file: TFile) => void; handleDeleteFile: (file: TFile) => void
movingId: string; movingId: string
deletingFolderId: string; deletingFolderId: string
}) { }) {
const isMoving = movingId === data.id; const isMoving = movingId === data.id
const isDeleting = const isDeleting =
deletingFolderId.length > 0 && data.id.startsWith(deletingFolderId); deletingFolderId.length > 0 && data.id.startsWith(deletingFolderId)
const ref = useRef(null); // for draggable const ref = useRef(null) // for draggable
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null)
const [imgSrc, setImgSrc] = useState(`/icons/${getIconForFile(data.name)}`); const [imgSrc, setImgSrc] = useState(`/icons/${getIconForFile(data.name)}`)
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false)
const [pendingDelete, setPendingDelete] = useState(isDeleting); const [pendingDelete, setPendingDelete] = useState(isDeleting)
useEffect(() => { useEffect(() => {
setPendingDelete(isDeleting); setPendingDelete(isDeleting)
}, [isDeleting]); }, [isDeleting])
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current
if (el) if (el)
return draggable({ return draggable({
@ -59,14 +59,14 @@ export default function SidebarFile({
onDragStart: () => setDragging(true), onDragStart: () => setDragging(true),
onDrop: () => setDragging(false), onDrop: () => setDragging(false),
getInitialData: () => ({ id: data.id }), getInitialData: () => ({ id: data.id }),
}); })
}, []); }, [])
useEffect(() => { useEffect(() => {
if (editing) { if (editing) {
setTimeout(() => inputRef.current?.focus(), 0); setTimeout(() => inputRef.current?.focus(), 0)
} }
}, [editing, inputRef.current]); }, [editing, inputRef.current])
const renameFile = () => { const renameFile = () => {
const renamed = handleRename( const renamed = handleRename(
@ -74,12 +74,12 @@ export default function SidebarFile({
inputRef.current?.value ?? data.name, inputRef.current?.value ?? data.name,
data.name, data.name,
"file" "file"
); )
if (!renamed && inputRef.current) { if (!renamed && inputRef.current) {
inputRef.current.value = data.name; inputRef.current.value = data.name
} }
setEditing(false); setEditing(false)
}; }
return ( return (
<ContextMenu> <ContextMenu>
@ -88,7 +88,7 @@ export default function SidebarFile({
disabled={pendingDelete || dragging || isMoving} disabled={pendingDelete || dragging || isMoving}
onClick={() => { onClick={() => {
if (!editing && !pendingDelete && !isMoving) if (!editing && !pendingDelete && !isMoving)
selectFile({ ...data, saved: true }); selectFile({ ...data, saved: true })
}} }}
onDoubleClick={() => { onDoubleClick={() => {
setEditing(true) setEditing(true)
@ -119,8 +119,8 @@ export default function SidebarFile({
) : ( ) : (
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault()
renameFile(); renameFile()
}} }}
> >
<input <input
@ -138,8 +138,8 @@ export default function SidebarFile({
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem <ContextMenuItem
onClick={() => { onClick={() => {
console.log("rename"); console.log("rename")
setEditing(true); setEditing(true)
}} }}
> >
<Pencil className="w-4 h-4 mr-2" /> <Pencil className="w-4 h-4 mr-2" />
@ -148,9 +148,9 @@ export default function SidebarFile({
<ContextMenuItem <ContextMenuItem
disabled={pendingDelete} disabled={pendingDelete}
onClick={() => { onClick={() => {
console.log("delete"); console.log("delete")
setPendingDelete(true); setPendingDelete(true)
handleDeleteFile(data); handleDeleteFile(data)
}} }}
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
@ -158,5 +158,5 @@ export default function SidebarFile({
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
); )
} }

View File

@ -1,20 +1,20 @@
"use client" "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 { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
import { ChevronRight, Loader2, Pencil, Trash2 } from "lucide-react" import { TFile, TFolder, TTab } from "@/lib/types"
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { cn } from "@/lib/utils" 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 // Note: Renaming has not been implemented in the backend yet, so UI relating to renaming is commented out

View File

@ -1,25 +1,21 @@
"use client"; "use client"
import { import { Sandbox, TFile, TFolder, TTab } from "@/lib/types"
FilePlus, import { FilePlus, FolderPlus, MessageSquareMore, Sparkles } from "lucide-react"
FolderPlus, import { useEffect, useMemo, useRef, useState } from "react"
Loader2, import { Socket } from "socket.io-client"
Sparkles, import SidebarFile from "./file"
MessageSquareMore, import SidebarFolder from "./folder"
} from "lucide-react"; import New from "./new"
import SidebarFile from "./file";
import SidebarFolder from "./folder";
import { Sandbox, TFile, TFolder, TTab } from "@/lib/types";
import { useEffect, useRef, useState } from "react";
import New from "./new";
import { Socket } from "socket.io-client";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { cn, sortFileExplorer } from "@/lib/utils"
import { import {
dropTargetForElements, dropTargetForElements,
monitorForElements, monitorForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
export default function Sidebar({ export default function Sidebar({
sandboxData, sandboxData,
files, files,
@ -31,76 +27,82 @@ export default function Sidebar({
setFiles, setFiles,
addNew, addNew,
deletingFolderId, deletingFolderId,
toggleAIChat,
isAIChatOpen,
}: { }: {
sandboxData: Sandbox; sandboxData: Sandbox
files: (TFile | TFolder)[]; files: (TFile | TFolder)[]
selectFile: (tab: TTab) => void; selectFile: (tab: TTab) => void
handleRename: ( handleRename: (
id: string, id: string,
newName: string, newName: string,
oldName: string, oldName: string,
type: "file" | "folder" type: "file" | "folder"
) => boolean; ) => boolean
handleDeleteFile: (file: TFile) => void; handleDeleteFile: (file: TFile) => void
handleDeleteFolder: (folder: TFolder) => void; handleDeleteFolder: (folder: TFolder) => void
socket: Socket; socket: Socket
setFiles: (files: (TFile | TFolder)[]) => void; setFiles: (files: (TFile | TFolder)[]) => void
addNew: (name: string, type: "file" | "folder") => void; addNew: (name: string, type: "file" | "folder") => void
deletingFolderId: string; deletingFolderId: string
toggleAIChat: () => void
isAIChatOpen: boolean
}) { }) {
const ref = useRef(null); // drop target const ref = useRef(null) // drop target
const [creatingNew, setCreatingNew] = useState<"file" | "folder" | null>(
null
);
const [movingId, setMovingId] = useState("");
const [creatingNew, setCreatingNew] = useState<"file" | "folder" | null>(null)
const [movingId, setMovingId] = useState("")
const sortedFiles = useMemo(() => {
return sortFileExplorer(files)
}, [files])
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current
if (el) { if (el) {
return dropTargetForElements({ return dropTargetForElements({
element: el, element: el,
getData: () => ({ id: `projects/${sandboxData.id}` }), getData: () => ({ id: `projects/${sandboxData.id}` }),
canDrop: ({ source }) => { canDrop: ({ source }) => {
const file = files.find((child) => child.id === source.data.id); const file = files.find((child) => child.id === source.data.id)
return !file; return !file
}, },
}); })
} }
}, [files]); }, [files])
useEffect(() => { useEffect(() => {
return monitorForElements({ return monitorForElements({
onDrop({ source, location }) { onDrop({ source, location }) {
const destination = location.current.dropTargets[0]; const destination = location.current.dropTargets[0]
if (!destination) { if (!destination) {
return; return
} }
const fileId = source.data.id as string; const fileId = source.data.id as string
const folderId = destination.data.id as string; const folderId = destination.data.id as string
const fileFolder = fileId.split("/").slice(0, -1).join("/"); const fileFolder = fileId.split("/").slice(0, -1).join("/")
if (fileFolder === folderId) { if (fileFolder === folderId) {
return; return
} }
console.log("move file", fileId, "to folder", folderId); console.log("move file", fileId, "to folder", folderId)
setMovingId(fileId); setMovingId(fileId)
socket.emit( socket.emit(
"moveFile", "moveFile",
fileId, {
folderId, fileId,
folderId
},
(response: (TFolder | TFile)[]) => { (response: (TFolder | TFile)[]) => {
setFiles(response); setFiles(response)
setMovingId(""); setMovingId("")
} }
); )
}, },
}); })
}, []); }, [])
return ( return (
<div className="h-full w-56 select-none flex flex-col text-sm"> <div className="h-full w-56 select-none flex flex-col text-sm">
@ -135,13 +137,15 @@ export default function Sidebar({
isDraggedOver ? "bg-secondary/50" : "" isDraggedOver ? "bg-secondary/50" : ""
} rounded-sm w-full mt-1 flex flex-col`} } rounded-sm w-full mt-1 flex flex-col`}
> */} > */}
{files.length === 0 ? ( {sortedFiles.length === 0 ? (
<div className="w-full flex justify-center"> <div className="w-full flex flex-col justify-center">
<Loader2 className="w-4 h-4 animate-spin" /> {new Array(6).fill(0).map((_, i) => (
<Skeleton key={i} className="h-[1.625rem] mb-0.5 rounded-sm" />
))}
</div> </div>
) : ( ) : (
<> <>
{files.map((child) => {sortedFiles.map((child) =>
child.type === "file" ? ( child.type === "file" ? (
<SidebarFile <SidebarFile
key={child.id} key={child.id}
@ -170,7 +174,7 @@ export default function Sidebar({
socket={socket} socket={socket}
type={creatingNew} type={creatingNew}
stopEditing={() => { stopEditing={() => {
setCreatingNew(null); setCreatingNew(null)
}} }}
addNew={addNew} addNew={addNew}
/> />
@ -180,25 +184,49 @@ export default function Sidebar({
</div> </div>
</div> </div>
<div className="fixed bottom-0 w-48 flex flex-col p-2 bg-background"> <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}}> <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" /> <Sparkles className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
Copilot AI Editor
<div className="ml-auto"> <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"> <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 <span className="text-xs"></span>G
</kbd> </kbd>
</div> </div>
</Button> </Button>
<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 }}> <Button
<MessageSquareMore className="h-4 w-4 mr-2 text-indigo-500 opacity-70" /> variant="ghost"
AI Chat 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"> <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"> <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 <span className="text-xs"></span>L
</kbd> </kbd>
</div> </div>
</Button> </Button>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,9 +1,9 @@
"use client"; "use client"
import { validateName } from "@/lib/utils"; import { validateName } from "@/lib/utils"
import Image from "next/image"; import Image from "next/image"
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react"
import { Socket } from "socket.io-client"; import { Socket } from "socket.io-client"
export default function New({ export default function New({
socket, socket,
@ -11,42 +11,42 @@ export default function New({
stopEditing, stopEditing,
addNew, addNew,
}: { }: {
socket: Socket; socket: Socket
type: "file" | "folder"; type: "file" | "folder"
stopEditing: () => void; stopEditing: () => void
addNew: (name: string, type: "file" | "folder") => void; addNew: (name: string, type: "file" | "folder") => void
}) { }) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null)
function createNew() { function createNew() {
const name = inputRef.current?.value; const name = inputRef.current?.value
if (name) { if (name) {
const valid = validateName(name, "", type); const valid = validateName(name, "", type)
if (valid.status) { if (valid.status) {
if (type === "file") { if (type === "file") {
socket.emit( socket.emit(
"createFile", "createFile",
name, { name },
({ success }: { success: boolean }) => { ({ success }: { success: boolean }) => {
if (success) { if (success) {
addNew(name, type); addNew(name, type)
} }
} }
); )
} else { } else {
socket.emit("createFolder", name, () => { socket.emit("createFolder", { name }, () => {
addNew(name, type); addNew(name, type)
}); })
} }
} }
} }
stopEditing(); stopEditing()
} }
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus()
}, []); }, [])
return ( 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"> <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 <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault()
createNew(); createNew()
}} }}
> >
<input <input
@ -74,5 +74,5 @@ export default function New({
/> />
</form> </form>
</div> </div>
); )
} }

View File

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

View File

@ -1,13 +1,14 @@
"use client"; "use client"
import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"
import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"
import "./xterm.css"; import "./xterm.css"
import { 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({ export default function EditorTerminal({
socket, socket,
id, id,
@ -15,82 +16,112 @@ export default function EditorTerminal({
setTerm, setTerm,
visible, visible,
}: { }: {
socket: Socket; socket: Socket
id: string; id: string
term: Terminal | null; term: Terminal | null
setTerm: (term: Terminal) => void; setTerm: (term: Terminal) => void
visible: boolean; visible: boolean
}) { }) {
const terminalRef = useRef(null); const { theme } = useTheme()
const terminalContainerRef = useRef<ElementRef<"div">>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
useEffect(() => { useEffect(() => {
if (!terminalRef.current) return; if (!terminalContainerRef.current) return
// console.log("new terminal", id, term ? "reusing" : "creating"); // console.log("new terminal", id, term ? "reusing" : "creating");
const terminal = new Terminal({ const terminal = new Terminal({
cursorBlink: true, cursorBlink: true,
theme: { theme: theme === "light" ? lightTheme : darkTheme,
background: "#262626",
},
fontFamily: "var(--font-geist-mono)", fontFamily: "var(--font-geist-mono)",
fontSize: 14, fontSize: 14,
lineHeight: 1.5, lineHeight: 1.5,
letterSpacing: 0, letterSpacing: 0,
}); })
setTerm(terminal); setTerm(terminal)
const dispose = () => {
return () => { terminal.dispose()
if (terminal) terminal.dispose(); }
}; return dispose
}, []); }, [])
useEffect(() => { useEffect(() => {
if (!term) return; if (term) {
term.options.theme = theme === "light" ? lightTheme : darkTheme
}
}, [theme])
if (!terminalRef.current) return; useEffect(() => {
const fitAddon = new FitAddon(); if (!term) return
term.loadAddon(fitAddon);
term.open(terminalRef.current); if (!terminalContainerRef.current) return
fitAddon.fit(); if (!fitAddonRef.current) {
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(terminalContainerRef.current)
fitAddon.fit()
fitAddonRef.current = fitAddon
}
const disposableOnData = term.onData((data) => { const disposableOnData = term.onData((data) => {
socket.emit("terminalData", id, data); socket.emit("terminalData", { id, data })
}); })
const disposableOnResize = term.onResize((dimensions) => { const disposableOnResize = term.onResize((dimensions) => {
// const terminal_size = { fitAddonRef.current?.fit()
// width: dimensions.cols, socket.emit("terminalResize", { dimensions })
// height: dimensions.rows, })
// }; const resizeObserver = new ResizeObserver(
fitAddon.fit(); debounce((entries) => {
socket.emit("terminalResize", dimensions); if (!fitAddonRef.current || !terminalContainerRef.current) return
});
const entry = entries[0]
if (!entry) return
const { width, height } = entry.contentRect
// Only call fit if the size has actually changed
if (
width !== terminalContainerRef.current.offsetWidth ||
height !== terminalContainerRef.current.offsetHeight
) {
try {
fitAddonRef.current.fit()
} catch (err) {
console.error("Error during fit:", err)
}
}
}, 50) // Debounce for 50ms
)
// start observing for resize
resizeObserver.observe(terminalContainerRef.current)
return () => { return () => {
disposableOnData.dispose(); disposableOnData.dispose()
disposableOnResize.dispose(); disposableOnResize.dispose()
}; resizeObserver.disconnect()
}, [term, terminalRef.current]); }
}, [term, terminalContainerRef.current])
useEffect(() => { useEffect(() => {
if (!term) return; if (!term) return
const handleTerminalResponse = (response: { id: string; data: string }) => { const handleTerminalResponse = (response: { id: string; data: string }) => {
if (response.id === id) { if (response.id === id) {
term.write(response.data); term.write(response.data)
} }
}; }
socket.on("terminalResponse", handleTerminalResponse); socket.on("terminalResponse", handleTerminalResponse)
return () => { return () => {
socket.off("terminalResponse", handleTerminalResponse); socket.off("terminalResponse", handleTerminalResponse)
}; }
}, [term, id, socket]); }, [term, id, socket])
return ( return (
<> <>
<div <div
ref={terminalRef} ref={terminalContainerRef}
style={{ display: visible ? "block" : "none" }} style={{ display: visible ? "block" : "none" }}
className="w-full h-full text-left" className="w-full h-full text-left"
> >
@ -102,5 +133,58 @@ export default function EditorTerminal({
) : null} ) : null}
</div> </div>
</> </>
); )
}
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 * Default styles for xterm.js
*/ */
.xterm { .xterm {
cursor: text; cursor: text;
position: relative; position: relative;
user-select: none; user-select: none;
@ -80,7 +80,7 @@
.xterm .composition-view { .xterm .composition-view {
/* TODO: Composition position got messed up somewhere */ /* TODO: Composition position got messed up somewhere */
background: transparent; background: transparent;
color: #FFF; color: #fff;
display: none; display: none;
position: absolute; position: absolute;
white-space: nowrap; white-space: nowrap;
@ -154,12 +154,12 @@
} }
.xterm .xterm-accessibility-tree:not(.debug) *::selection { .xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent; color: transparent;
} }
.xterm .xterm-accessibility-tree { .xterm .xterm-accessibility-tree {
user-select: text; user-select: text;
white-space: pre; white-space: pre;
} }
.xterm .live-region { .xterm .live-region {
@ -176,33 +176,55 @@ white-space: pre;
opacity: 1 !important; opacity: 1 !important;
} }
.xterm-underline-1 { text-decoration: underline; } .xterm-underline-1 {
.xterm-underline-2 { text-decoration: double underline; } text-decoration: underline;
.xterm-underline-3 { text-decoration: wavy underline; } }
.xterm-underline-4 { text-decoration: dotted underline; } .xterm-underline-2 {
.xterm-underline-5 { text-decoration: dashed underline; } 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 { .xterm-overline {
text-decoration: overline; text-decoration: overline;
} }
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } .xterm-overline.xterm-underline-1 {
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } text-decoration: overline 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-2 {
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } 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 { .xterm-strikethrough {
text-decoration: line-through; text-decoration: line-through;
} }
.xterm-screen .xterm-decoration-container .xterm-decoration { .xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6; z-index: 6;
position: absolute; position: absolute;
} }
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { .xterm-screen
z-index: 7; .xterm-decoration-container
.xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
} }
.xterm-decoration-overview-ruler { .xterm-decoration-overview-ruler {
@ -216,4 +238,4 @@ z-index: 7;
.xterm-decoration-top { .xterm-decoration-top {
z-index: 2; z-index: 2;
position: relative; position: relative;
} }

View File

@ -1,9 +1,10 @@
import Image from "next/image"
import Logo from "@/assets/logo.svg" import Logo from "@/assets/logo.svg"
import XLogo from "@/assets/x.svg" import CustomButton from "@/components/ui/customButton"
import Button from "@/components/ui/customButton"
import { ChevronRight } from "lucide-react" import { ChevronRight } from "lucide-react"
import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { Button } from "../ui/button"
import { ThemeSwitcher } from "../ui/theme-switcher"
export default function Landing() { export default function Landing() {
return ( return (
@ -20,24 +21,40 @@ export default function Landing() {
/> />
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<a href="https://www.x.com/ishaandey_" target="_blank"> <Button variant="outline" size="icon" asChild>
<Image src={XLogo} alt="X Logo" width={18} height={18} /> <a href="https://x.com/gitwitdev" target="_blank">
</a> <svg
width="1200"
height="1227"
viewBox="0 0 1200 1227"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-[1.125rem] text-muted-foreground"
>
<path
d="M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z"
fill="currentColor"
/>
</svg>
</a>
</Button>
<ThemeSwitcher />
</div> </div>
</div> </div>
<h1 className="text-2xl font-medium text-center mt-16"> <h1 className="text-2xl font-medium text-center mt-16">
A Collaborative + AI-Powered Code Environment A Collaborative + AI-Powered Code Environment
</h1> </h1>
<div className="text-muted-foreground mt-4 text-center "> <p className="text-muted-foreground mt-4 text-center ">
Sandbox is an open-source cloud-based code editing environment with Sandbox is an open-source cloud-based code editing environment with
custom AI code autocompletion and real-time collaboration. custom AI code autocompletion and real-time collaboration.
</div> </p>
<div className="mt-8 flex space-x-4"> <div className="mt-8 flex space-x-4">
<Link href="/sign-up"> <Link href="/sign-up">
<Button>Go To App</Button> <CustomButton>Go To App</CustomButton>
</Link> </Link>
<a <a
href="https://github.com/ishaan1013/sandbox" href="https://github.com/jamesmurdza/sandbox"
target="_blank" 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" 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 = () => { const LoadingDots: React.FC = () => {
return ( return (
@ -9,24 +9,35 @@ const LoadingDots: React.FC = () => {
<style jsx>{` <style jsx>{`
.loading-dots { .loading-dots {
display: inline-block; display: inline-block;
font-size: 24px; font-size: 24px;
} }
.dot { .dot {
opacity: 0; opacity: 0;
animation: showHideDot 1.5s ease-in-out infinite; animation: showHideDot 1.5s ease-in-out infinite;
} }
.dot:nth-child(1) { animation-delay: 0s; } .dot:nth-child(1) {
.dot:nth-child(2) { animation-delay: 0.5s; } animation-delay: 0s;
.dot:nth-child(3) { animation-delay: 1s; } }
.dot:nth-child(2) {
animation-delay: 0.5s;
}
.dot:nth-child(3) {
animation-delay: 1s;
}
@keyframes showHideDot { @keyframes showHideDot {
0% { opacity: 0; } 0% {
50% { opacity: 1; } opacity: 0;
100% { opacity: 0; } }
50% {
opacity: 1;
}
100% {
opacity: 0;
}
} }
`}</style> `}</style>
</span> </span>
); )
}; }
export default LoadingDots;
export default LoadingDots

View File

@ -1,10 +1,10 @@
"use client" "use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 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 { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
const AlertDialog = AlertDialogPrimitive.Root const AlertDialog = AlertDialogPrimitive.Root
@ -128,14 +128,14 @@ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export { export {
AlertDialog, AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, 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 { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"

View File

@ -73,4 +73,4 @@ const CardFooter = React.forwardRef<
)) ))
CardFooter.displayName = "CardFooter" 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" "use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { import {
CheckIcon, CheckIcon,
ChevronRightIcon, ChevronRightIcon,
DotFilledIcon, DotFilledIcon,
} from "@radix-ui/react-icons" } from "@radix-ui/react-icons"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -187,18 +187,18 @@ ContextMenuShortcut.displayName = "ContextMenuShortcut"
export { export {
ContextMenu, ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem, ContextMenuCheckboxItem,
ContextMenuRadioItem, ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel, ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuShortcut, ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub, ContextMenuSub,
ContextMenuSubContent, ContextMenuSubContent,
ContextMenuSubTrigger, ContextMenuSubTrigger,
ContextMenuRadioGroup, ContextMenuTrigger,
} }

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