Compare commits

..

42 Commits

Author SHA1 Message Date
7951221310 fix: global buttons and indicators
- cmd/ctrl + L works globally now
- added the copilot and ai chat button indicators
- when aichat is open, the preview/terminal column becomes horizontal
2024-10-20 23:23:04 -04:00
fae09d2b6d fix: "Edit Code" widget code generation 2024-10-20 18:29:08 -04:00
9e13db2020 chore: update env variable for ai worker 2024-10-17 22:28:25 -04:00
751d9a3005 feat: ai chat now has context of the active tab 2024-10-14 23:01:25 -04:00
cc4a5307cd feat: ai chat now has its own context
This commit includes refactoring and dividing the AI chat files to ensure better readability.
2024-10-14 22:34:26 -04:00
ab7ee17145 fix: update fetch url with env and model to sonnet 2024-10-14 17:11:54 -04:00
bfc687a3e6 fix: aichat and preview/terminal layout 2024-10-14 13:51:17 -04:00
1365fecb08 feat: optimized agent response time 2024-10-13 23:04:16 -04:00
dd59608d73 feature: add AI chat
features:

1. Real-time message display
2. User input handling
3. AI response generation
4. Markdown rendering for AI responses
5. Syntax highlighting for code blocks
6. Copy to clipboard functionality for messages and code blocks
7. Context handling (setting, displaying, and removing context)
8. Expandable/collapsible context display
9. Ability to ask about specific code snippets
10. Auto-scrolling to the latest message
11. Loading indicator during AI response generation
12. Stop generation functionality
13. Error handling for failed API requests
14. Responsive design (flex layout)
15. Custom styling for user and AI messages
16. Support for various Markdown elements (paragraphs, lists, code blocks)
17. Language detection and display for code blocks
18. Animated text generation effect for AI responses
19. Input field placeholder changes based on context presence
20. Disable input during message generation
21. Send message on Enter key press
22. Expandable/collapsible message context for each message
23. Editable context in expanded view
24. Icons for various actions (send, stop, copy, expand/collapse)
25. Visual feedback for copied text (checkmark icon)
26. Abortable fetch requests for AI responses
27. Custom button components
28. Custom loading dots component
29. Truncated display of long messages with expand/collapse functionality
2024-10-13 22:47:47 -04:00
62e282da63 feat: added AI chat
backend implementation remaining
2024-10-13 01:41:48 -04:00
f192d9f3ab chore: default terminal column size 2024-10-12 22:33:09 -04:00
b6569550fc feature: add terminal/preview layout button 2024-10-12 19:46:32 -04:00
f863f2f763 feat: add preview panel button 2024-10-12 17:55:49 -04:00
6ea86afc70 chore: fix file paths 2024-10-12 14:54:43 -04:00
41dbd4a1da feature: enable file renaming
Users can now rename a file by double-clicking on it.
2024-10-12 14:54:21 -04:00
08fccdd506 Merge pull request #8 from jamesmurdza/fix/editor-file-cache
Fix buggy editor behavior related to file cache
2024-10-03 06:40:12 -07:00
cf6888e3d3 chore: remove unnecessary code 2024-10-03 06:30:28 -07:00
229b489c1e fix: filecontent update while switching tabs, empty file crash
# Conflicts:
#	backend/server/src/index.ts
2024-10-03 06:29:57 -07:00
8ae166fef4 fix: close the terminal opened with run button 2024-10-03 06:29:21 -07:00
645ff5b119 Merge branch 'refs/heads/sync-container-files'
# Conflicts:
#	backend/server/src/index.ts
2024-10-02 13:47:45 -07:00
7e48faa1b5 fix: prevent the file sync from timing out after the default timeout 2024-10-02 13:44:55 -07:00
9d06808137 feat: keep containers alive for 60s of inactivity instead of killing them on disconnect 2024-10-02 05:22:37 -07:00
63f3b082d5 fix: don't limit the number of terminals on the backend 2024-10-02 05:20:18 -07:00
8e3a6d1aa6 fix: recreate timed out E2B sandboxes on page load 2024-10-02 05:20:14 -07:00
023b3bdc5e fix: add missing await keywords 2024-09-30 04:20:14 -07:00
01fb3ab921 feat: keep containers alive for 60s of inactivity instead of killing them on disconnect 2024-09-30 04:15:26 -07:00
13be78dee8 fix: don't exit the script when exceptions occur 2024-09-30 02:55:30 -07:00
7a00d24ab9 feat: sync changes to the filesystem 2024-09-30 02:55:28 -07:00
69b1287349 fix: handle errors when fixing permissions 2024-09-29 17:40:09 -07:00
09b3cf1862 fix: don't limit the number of terminals on the backend 2024-09-29 17:23:31 -07:00
f4c79bbb07 fix: recreate timed out E2B sandboxes on page load 2024-09-26 05:34:14 -07:00
55fde2f648 Merge pull request #7 from Code-Victor/feat/editor-fix-n-ui-updates
Feat/editor fix n UI updates
2024-09-26 05:32:19 -07:00
0f619ccb7d feat: update project icon for each template type 2024-09-24 14:10:56 +01:00
b7230f1bc4 fix: new project modal scrolls when it overflows(instead of clipping content) 2024-09-24 14:01:51 +01:00
af45df28d5 feat(ui): improve folder structure UI 2024-09-24 13:57:40 +01:00
c2a23fcbcb fix: remove editor red squiggly lines
by dynamically loading project's tsconfig file and adding nice defaults
2024-09-24 13:00:49 +01:00
0f7eb9a856 chore: change path.join to path.posix.join 2024-09-16 15:46:55 -07:00
0a99eda5ec chore: split up default terminal commands 2024-09-16 15:43:41 -07:00
c5b197f41c chore: add missing await 2024-09-16 15:43:41 -07:00
70cfb5dc3f fix: remove unneeded pty.wait 2024-09-16 15:43:41 -07:00
c94678c430 feat: watch container for file changes 2024-09-15 13:11:59 -07:00
585dcb469e fix: skip creating a directory in the container when it already exists 2024-09-15 10:47:00 -07:00
22 changed files with 3523 additions and 397 deletions

View File

@ -1,4 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"; import { Anthropic } from "@anthropic-ai/sdk";
import { MessageParam } from "@anthropic-ai/sdk/src/resources/messages.js";
export interface Env { export interface Env {
ANTHROPIC_API_KEY: string; ANTHROPIC_API_KEY: string;
@ -6,69 +7,112 @@ export interface Env {
export default { export default {
async fetch(request: Request, env: Env): Promise<Response> { async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "GET") { // Handle CORS preflight requests
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}
if (request.method !== "GET" && request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 }); return new Response("Method Not Allowed", { status: 405 });
} }
const url = new URL(request.url); let body;
// const fileName = url.searchParams.get("fileName"); let isEditCodeWidget = false;
// const line = url.searchParams.get("line"); if (request.method === "POST") {
const instructions = url.searchParams.get("instructions"); body = await request.json() as { messages: unknown; context: unknown; activeFileContent: string };
const code = url.searchParams.get("code"); } else {
const url = new URL(request.url);
const fileName = url.searchParams.get("fileName") || "";
const code = url.searchParams.get("code") || "";
const line = url.searchParams.get("line") || "";
const instructions = url.searchParams.get("instructions") || "";
const prompt = ` body = {
Make the following changes to the code below: messages: [{ role: "human", content: instructions }],
- ${instructions} context: `File: ${fileName}\nLine: ${line}\nCode:\n${code}`,
activeFileContent: code,
};
isEditCodeWidget = true;
}
Return the complete code chunk. Do not refer to other code files. Do not add code before or after the chunk. Start your reponse with \`\`\`, and end with \`\`\`. Do not include any other text. const messages = body.messages;
const context = body.context;
const activeFileContent = body.activeFileContent;
if (!Array.isArray(messages) || messages.length === 0) {
return new Response("Invalid or empty messages", { status: 400 });
}
let systemMessage;
if (isEditCodeWidget) {
systemMessage = `You are an AI code editor. Your task is to modify the given code based on the user's instructions. Only output the modified code, without any explanations or markdown formatting. The code should be a direct replacement for the existing code.
Context:
${context}
Active File Content:
${activeFileContent}
Instructions: ${messages[0].content}
Respond only with the modified code that can directly replace the existing code.`;
} else {
systemMessage = `You are an intelligent programming assistant. Please respond to the following request concisely. If your response includes code, please format it using triple backticks (\`\`\`) with the appropriate language identifier. For example:
\`\`\`python
print("Hello, World!")
\`\`\` \`\`\`
${code}
\`\`\` Provide a clear and concise explanation along with any code snippets. Keep your response brief and to the point.
`;
console.log(prompt); ${context ? `Context:\n${context}\n` : ''}
${activeFileContent ? `Active File Content:\n${activeFileContent}\n` : ''}`;
}
const anthropicMessages = messages.map(msg => ({
role: msg.role === 'human' ? 'user' : 'assistant',
content: msg.content
})) as MessageParam[];
try { try {
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
interface TextBlock { const stream = await anthropic.messages.create({
type: "text";
text: string;
}
interface ToolUseBlock {
type: "tool_use";
tool_use: {
// Add properties if needed
};
}
type ContentBlock = TextBlock | ToolUseBlock;
function getTextContent(content: ContentBlock[]): string {
for (const block of content) {
if (block.type === "text") {
return block.text;
}
}
return "No text content found";
}
const response = await anthropic.messages.create({
model: "claude-3-5-sonnet-20240620", model: "claude-3-5-sonnet-20240620",
max_tokens: 1024, max_tokens: 1024,
messages: [{ role: "user", content: prompt }], system: systemMessage,
messages: anthropicMessages,
stream: true,
}); });
const message = response.content as ContentBlock[]; const encoder = new TextEncoder();
const textBlockContent = getTextContent(message);
const pattern = /```[a-zA-Z]*\n([\s\S]*?)\n```/; const streamResponse = new ReadableStream({
const match = textBlockContent.match(pattern); async start(controller) {
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
const bytes = encoder.encode(chunk.delta.text);
controller.enqueue(bytes);
}
}
controller.close();
},
});
const codeContent = match ? match[1] : "Error: Could not extract code."; return new Response(streamResponse, {
headers: {
return new Response(JSON.stringify({ "response": codeContent })) "Content-Type": "text/plain; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"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

@ -6,10 +6,15 @@ import { createServer } from "http";
import { Server } from "socket.io"; import { Server } from "socket.io";
import { DokkuClient } from "./DokkuClient"; import { DokkuClient } from "./DokkuClient";
import { SecureGitClient, FileData } from "./SecureGitClient"; import { SecureGitClient, FileData } from "./SecureGitClient";
import fs from "fs"; import fs, { readFile } from "fs";
import { z } from "zod"; import { z } from "zod";
import { User } from "./types"; import {
TFile,
TFileData,
TFolder,
User
} from "./types";
import { import {
createFile, createFile,
deleteFile, deleteFile,
@ -21,7 +26,7 @@ import {
} from "./fileoperations"; } from "./fileoperations";
import { LockManager } from "./utils"; import { LockManager } from "./utils";
import { Sandbox, Filesystem } from "e2b"; import { Sandbox, Filesystem, FilesystemEvent, EntryInfo, WatchHandle } from "e2b";
import { Terminal } from "./Terminal" import { Terminal } from "./Terminal"
@ -34,6 +39,21 @@ import {
saveFileRL, saveFileRL,
} from "./ratelimit"; } from "./ratelimit";
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
// Do not exit the process
// You can add additional logging or recovery logic here
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Do not exit the process
// You can also handle the rejected promise here if needed
});
// The amount of time in ms that a container will stay alive without a hearbeat.
const CONTAINER_TIMEOUT = 60_000;
dotenv.config(); dotenv.config();
const app: Express = express(); const app: Express = express();
@ -55,14 +75,14 @@ const terminals: Record<string, Terminal> = {};
const dirName = "/home/user"; const dirName = "/home/user";
const moveFile = async ( const moveFile = async (filesystem: Filesystem, filePath: string, newFilePath: string) => {
filesystem: Filesystem, try {
filePath: string, const fileContents = await filesystem.read(filePath);
newFilePath: string await filesystem.write(newFilePath, fileContents);
) => { await filesystem.remove(filePath);
const fileContents = await filesystem.read(filePath); } catch (e) {
await filesystem.write(newFilePath, fileContents); console.error(`Error moving file from ${filePath} to ${newFilePath}:`, e);
await filesystem.remove(filePath); }
}; };
io.use(async (socket, next) => { io.use(async (socket, next) => {
@ -157,12 +177,13 @@ io.on("connection", async (socket) => {
} }
} }
await lockManager.acquireLock(data.sandboxId, async () => { const createdContainer = await lockManager.acquireLock(data.sandboxId, async () => {
try { try {
// Start a new container if the container doesn't exist or it timed out. // Start a new container if the container doesn't exist or it timed out.
if (!containers[data.sandboxId] || !(await containers[data.sandboxId].isRunning())) { if (!containers[data.sandboxId] || !(await containers[data.sandboxId].isRunning())) {
containers[data.sandboxId] = await Sandbox.create({ timeoutMs: 1200_000 }); containers[data.sandboxId] = await Sandbox.create({ timeoutMs: CONTAINER_TIMEOUT });
console.log("Created container ", data.sandboxId); console.log("Created container ", data.sandboxId);
return true;
} }
} catch (e: any) { } catch (e: any) {
console.error(`Error creating container ${data.sandboxId}:`, e); console.error(`Error creating container ${data.sandboxId}:`, e);
@ -170,34 +191,189 @@ io.on("connection", async (socket) => {
} }
}); });
const sandboxFiles = await getSandboxFiles(data.sandboxId);
const projectDirectory = path.posix.join(dirName, "projects", data.sandboxId);
const containerFiles = containers[data.sandboxId].files;
const fileWatchers: WatchHandle[] = [];
// Change the owner of the project directory to user // Change the owner of the project directory to user
const fixPermissions = async () => { const fixPermissions = async (projectDirectory: string) => {
await containers[data.sandboxId].commands.run( try {
`sudo chown -R user "${path.posix.join(dirName, "projects", data.sandboxId)}"` await containers[data.sandboxId].commands.run(
); `sudo chown -R user "${projectDirectory}"`
);
} catch (e: any) {
console.log("Failed to fix permissions: " + e);
}
}; };
// Copy all files from the project to the container // Check if the given path is a directory
const sandboxFiles = await getSandboxFiles(data.sandboxId); const isDirectory = async (projectDirectory: string): Promise<boolean> => {
const containerFiles = containers[data.sandboxId].files;
const promises = sandboxFiles.fileData.map(async (file) => {
try { try {
const filePath = path.posix.join(dirName, file.id); const result = await containers[data.sandboxId].commands.run(
const parentDirectory = path.dirname(filePath); `[ -d "${projectDirectory}" ] && echo "true" || echo "false"`
if (!containerFiles.exists(parentDirectory)) { );
await containerFiles.makeDir(parentDirectory); return result.stdout.trim() === "true";
}
await containerFiles.write(filePath, file.data);
} catch (e: any) { } catch (e: any) {
console.log("Failed to create file: " + e); console.log("Failed to check if directory: " + e);
return false;
} }
}); };
await Promise.all(promises);
fixPermissions(); // Only continue to container setup if a new container was created
if (createdContainer) {
// Copy all files from the project to the container
const promises = sandboxFiles.fileData.map(async (file) => {
try {
const filePath = path.posix.join(dirName, file.id);
const parentDirectory = path.dirname(filePath);
if (!containerFiles.exists(parentDirectory)) {
await containerFiles.makeDir(parentDirectory);
}
await containerFiles.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
fixPermissions(projectDirectory);
}
// Start filesystem watcher for the project directory
const watchDirectory = async (directory: string): Promise<WatchHandle | undefined> => {
try {
return await containerFiles.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 home directory
const sandboxFilePath = removeDirName(containerFilePath, dirName + "/");
// This is the directory being watched relative to the home directory
const sandboxDirectory = removeDirName(directory, 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);
}
// A new file or directory was created.
if (event.type === "create") {
const folder = findFolderById(sandboxFiles.files, sandboxDirectory) as TFolder;
const isDir = await 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
sandboxFiles.files.push(newItem);
}
if (!isDir) {
const fileData = await containers[data.sandboxId].files.read(containerFilePath);
const fileContents = typeof fileData === "string" ? fileData : "";
sandboxFiles.fileData.push({ id: sandboxFilePath, data: fileContents });
}
console.log(`Create ${sandboxFilePath}`);
}
// A file or directory was removed or renamed.
else if (event.type === "remove" || event.type == "rename") {
const folder = findFolderById(sandboxFiles.files, sandboxDirectory) as TFolder;
const isDir = await 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
sandboxFiles.files = sandboxFiles.files.filter((file: TFolder | TFile) => !isFileMatch(file));
}
// Also remove any corresponding file data
sandboxFiles.fileData = sandboxFiles.fileData.filter((file: TFileData) => !isFileMatch(file));
console.log(`Removed: ${sandboxFilePath}`);
}
// The contents of a file were changed.
else if (event.type === "write") {
const folder = findFolderById(sandboxFiles.files, sandboxDirectory) as TFolder;
const fileToWrite = sandboxFiles.fileData.find(file => file.id === sandboxFilePath);
if (fileToWrite) {
fileToWrite.data = await containers[data.sandboxId].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 containers[data.sandboxId].files.read(containerFilePath);
const fileContents = typeof fileData === "string" ? fileData : "";
sandboxFiles.fileData.push({ id: sandboxFilePath, data: fileContents });
console.log(`Write to ${sandboxFilePath}`);
}
}
}
// Tell the client to reload the file list
socket.emit("loaded", sandboxFiles.files);
} catch (error) {
console.error(`Error handling ${event.type} event for ${event.name}:`, error);
}
}, { "timeout": 0 } )
} catch (error) {
console.error(`Error watching filesystem:`, error);
}
};
// Watch the project directory
const handle = await watchDirectory(projectDirectory);
// Keep track of watch handlers to close later
if (handle) fileWatchers.push(handle);
// Watch all subdirectories of the project directory, but not deeper
// This also means directories created after the container is created won't be watched
const dirContent = await containerFiles.list(projectDirectory);
await Promise.all(dirContent.map(async (item : EntryInfo) => {
if (item.type === "dir") {
console.log("Watching " + item.path);
// Keep track of watch handlers to close later
const handle = await watchDirectory(item.path);
if (handle) fileWatchers.push(handle);
}
}))
socket.emit("loaded", sandboxFiles.files); socket.emit("loaded", sandboxFiles.files);
socket.on("heartbeat", async () => {
try {
// This keeps the container alive for another CONTAINER_TIMEOUT seconds.
// The E2B docs are unclear, but the timeout is relative to the time of this method call.
await containers[data.sandboxId].setTimeout(CONTAINER_TIMEOUT);
} catch (e: any) {
console.error("Error setting timeout:", e);
io.emit("error", `Error: set timeout. ${e.message ?? e}`);
}
});
socket.on("getFile", (fileId: string, callback) => { socket.on("getFile", (fileId: string, callback) => {
console.log(fileId); console.log(fileId);
try { try {
@ -249,7 +425,7 @@ io.on("connection", async (socket) => {
path.posix.join(dirName, file.id), path.posix.join(dirName, file.id),
body body
); );
fixPermissions(); fixPermissions(projectDirectory);
} catch (e: any) { } catch (e: any) {
console.error("Error saving file:", e); console.error("Error saving file:", e);
io.emit("error", `Error: file saving. ${e.message ?? e}`); io.emit("error", `Error: file saving. ${e.message ?? e}`);
@ -271,7 +447,7 @@ io.on("connection", async (socket) => {
path.posix.join(dirName, fileId), path.posix.join(dirName, fileId),
path.posix.join(dirName, newFileId) path.posix.join(dirName, newFileId)
); );
fixPermissions(); fixPermissions(projectDirectory);
file.id = newFileId; file.id = newFileId;
@ -364,7 +540,7 @@ io.on("connection", async (socket) => {
path.posix.join(dirName, id), path.posix.join(dirName, id),
"" ""
); );
fixPermissions(); fixPermissions(projectDirectory);
sandboxFiles.files.push({ sandboxFiles.files.push({
id, id,
@ -430,7 +606,7 @@ io.on("connection", async (socket) => {
path.posix.join(dirName, fileId), path.posix.join(dirName, fileId),
path.posix.join(dirName, newFileId) path.posix.join(dirName, newFileId)
); );
fixPermissions(); fixPermissions(projectDirectory);
await renameFile(fileId, newFileId, file.data); await renameFile(fileId, newFileId, file.data);
} catch (e: any) { } catch (e: any) {
console.error("Error renaming folder:", e); console.error("Error renaming folder:", e);
@ -499,7 +675,8 @@ io.on("connection", async (socket) => {
socket.on("createTerminal", async (id: string, callback) => { socket.on("createTerminal", async (id: string, callback) => {
try { try {
if (terminals[id] || Object.keys(terminals).length >= 4) { // Note: The number of terminals per window is limited on the frontend, but not backend
if (terminals[id]) {
return; return;
} }
@ -638,12 +815,28 @@ io.on("connection", async (socket) => {
generateCodePromise, generateCodePromise,
]); ]);
const json = await generateCodeResponse.json(); if (!generateCodeResponse.ok) {
throw new Error(`HTTP error! status: ${generateCodeResponse.status}`);
}
callback({ response: json.response, success: true }); 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
callback({ response: result.trim(), success: true });
} catch (e: any) { } catch (e: any) {
console.error("Error generating code:", e); console.error("Error generating code:", e);
io.emit("error", `Error: code generation. ${e.message ?? e}`); io.emit("error", `Error: code generation. ${e.message ?? e}`);
callback({ response: "Error generating code. Please try again.", success: false });
} }
} }
); );
@ -654,26 +847,12 @@ io.on("connection", async (socket) => {
connections[data.sandboxId]--; connections[data.sandboxId]--;
} }
// Stop watching file changes in the container
Promise.all(fileWatchers.map(async (handle : WatchHandle) => {
await handle.close();
}));
if (data.isOwner && connections[data.sandboxId] <= 0) { if (data.isOwner && connections[data.sandboxId] <= 0) {
await Promise.all(
Object.entries(terminals).map(async ([key, terminal]) => {
await terminal.close();
delete terminals[key];
})
);
await lockManager.acquireLock(data.sandboxId, async () => {
try {
if (containers[data.sandboxId]) {
await containers[data.sandboxId].kill();
delete containers[data.sandboxId];
console.log("Closed container", data.sandboxId);
}
} catch (error) {
console.error("Error closing container ", data.sandboxId, error);
}
});
socket.broadcast.emit( socket.broadcast.emit(
"disableAccess", "disableAccess",
"The sandbox owner has disconnected." "The sandbox owner has disconnected."

View File

@ -36,43 +36,7 @@ import { createSandbox } from "@/lib/actions"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { Button } from "../ui/button" import { Button } from "../ui/button"
import { projectTemplates } from "@/lib/data"
const data: {
id: string
name: string
icon: string
description: string
disabled: boolean
}[] = [
{
id: "reactjs",
name: "React",
icon: "/project-icons/react.svg",
description: "A JavaScript library for building user interfaces",
disabled: false,
},
{
id: "vanillajs",
name: "HTML/JS",
icon: "/project-icons/more.svg",
description: "More coming soon, feel free to contribute on GitHub",
disabled: false,
},
{
id: "nextjs",
name: "NextJS",
icon: "/project-icons/node.svg",
description: "A JavaScript runtime built on the V8 JavaScript engine",
disabled: false,
},
{
id: "streamlit",
name: "Streamlit",
icon: "/project-icons/python.svg",
description: "A JavaScript runtime built on the V8 JavaScript engine",
disabled: false,
}
]
const formSchema = z.object({ const formSchema = z.object({
name: z name: z
@ -124,12 +88,12 @@ export default function NewProjectModal({
if (!loading) setOpen(open) if (!loading) setOpen(open)
}} }}
> >
<DialogContent> <DialogContent className="max-h-[95vh] overflow-y-auto">
<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="grid grid-cols-2 w-full gap-2 mt-2">
{data.map((item) => ( {projectTemplates.map((item) => (
<button <button
disabled={item.disabled || loading} disabled={item.disabled || loading}
key={item.id} key={item.id}

View File

@ -8,6 +8,7 @@ import { Clock, Globe, Lock } from "lucide-react"
import { Sandbox } from "@/lib/types" import { Sandbox } from "@/lib/types"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { projectTemplates } from "@/lib/data"
export default function ProjectCard({ export default function ProjectCard({
children, children,
@ -43,7 +44,9 @@ export default function ProjectCard({
setDate(`${Math.floor(diffInMinutes / 1440)}d ago`) setDate(`${Math.floor(diffInMinutes / 1440)}d ago`)
} }
}, [sandbox]) }, [sandbox])
const projectIcon =
projectTemplates.find((p) => p.id === sandbox.type)?.icon ??
"/project-icons/node.svg"
return ( return (
<Card <Card
tabIndex={0} tabIndex={0}
@ -65,16 +68,7 @@ export default function ProjectCard({
</AnimatePresence> </AnimatePresence>
<div className="space-x-2 flex items-center justify-start w-full z-10"> <div className="space-x-2 flex items-center justify-start w-full z-10">
<Image <Image alt="" src={projectIcon} width={20} height={20} />
alt=""
src={
sandbox.type === "react"
? "/project-icons/react.svg"
: "/project-icons/node.svg"
}
width={20}
height={20}
/>
<div className="font-medium static whitespace-nowrap w-full text-ellipsis overflow-hidden"> <div className="font-medium static whitespace-nowrap w-full text-ellipsis overflow-hidden">
{sandbox.name} {sandbox.name}
</div> </div>

View File

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

View File

@ -0,0 +1,201 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { ChevronUp, ChevronDown, Copy, Check, CornerUpLeft } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import remarkGfm from 'remark-gfm';
import { copyToClipboard, stringifyContent } from './lib/chatUtils';
interface MessageProps {
message: {
role: 'user' | 'assistant';
content: string;
context?: string;
};
setContext: (context: string | null) => void;
setIsContextExpanded: (isExpanded: boolean) => void;
}
export default function ChatMessage({ message, setContext, setIsContextExpanded }: MessageProps) {
const [expandedMessageIndex, setExpandedMessageIndex] = useState<number | null>(null);
const [copiedText, setCopiedText] = useState<string | null>(null);
const renderCopyButton = (text: any) => (
<Button
onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
{copiedText === stringifyContent(text) ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
);
const askAboutCode = (code: any) => {
const contextString = stringifyContent(code);
setContext(`Regarding this code:\n${contextString}`);
setIsContextExpanded(false);
};
const renderMarkdownElement = (props: any) => {
const { node, children } = props;
const content = stringifyContent(children);
return (
<div className="relative group">
<div className="absolute top-0 right-0 flex opacity-0 group-hover:opacity-30 transition-opacity">
{renderCopyButton(content)}
<Button
onClick={() => askAboutCode(content)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
{React.createElement(node.tagName, {
...props,
className: `${props.className || ''} hover:bg-transparent rounded p-1 transition-colors`
}, children)}
</div>
);
};
return (
<div className="text-left relative">
<div className={`relative p-2 rounded-lg ${
message.role === 'user'
? 'bg-[#262626] text-white'
: 'bg-transparent text-white'
} max-w-full`}>
{message.role === 'user' && (
<div className="absolute top-0 right-0 flex opacity-0 group-hover:opacity-30 transition-opacity">
{renderCopyButton(message.content)}
<Button
onClick={() => askAboutCode(message.content)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
)}
{message.context && (
<div className="mb-2 bg-input rounded-lg">
<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
remarkPlugins={[remarkGfm]}
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}
</ReactMarkdown>
) : (
<div className="whitespace-pre-wrap group">
{message.content}
</div>
)}
</div>
</div>
);
}

View File

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

View File

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

View File

@ -18,7 +18,7 @@ import {
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import { FileJson, Loader2, Sparkles, TerminalSquare } from "lucide-react" import { FileJson, Loader2, Sparkles, TerminalSquare, ArrowDownToLine, ArrowRightToLine } from "lucide-react"
import Tab from "../ui/tab" import Tab from "../ui/tab"
import Sidebar from "./sidebar" import Sidebar from "./sidebar"
import GenerateInput from "./generate" import GenerateInput from "./generate"
@ -35,6 +35,9 @@ import { PreviewProvider, usePreview } from "@/context/PreviewContext"
import { useSocket } from "@/context/SocketContext" import { useSocket } from "@/context/SocketContext"
import { Button } from "../ui/button" import { Button } from "../ui/button"
import React from "react" 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,
@ -57,6 +60,13 @@ export default function CodeEditor({
} }
}, [socket, userData.id, sandboxData.id, setUserAndSandboxId]) }, [socket, userData.id, sandboxData.id, setUserAndSandboxId])
// This heartbeat is critical to preventing the E2B sandbox from timing out
useEffect(() => {
// 10000 ms = 10 seconds
const interval = setInterval(() => socket?.emit("heartbeat"), 10000);
return () => clearInterval(interval);
}, [socket]);
//Preview Button state //Preview Button state
const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true) const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true)
const [disableAccess, setDisableAccess] = useState({ const [disableAccess, setDisableAccess] = useState({
@ -64,6 +74,13 @@ export default function CodeEditor({
message: "", message: "",
}) })
// Layout state
const [isHorizontalLayout, setIsHorizontalLayout] = useState(false);
const [previousLayout, setPreviousLayout] = useState(false);
// AI Chat state
const [isAIChatOpen, setIsAIChatOpen] = useState(false);
// File state // File state
const [files, setFiles] = useState<(TFolder | TFile)[]>([]) const [files, setFiles] = useState<(TFolder | TFile)[]>([])
const [tabs, setTabs] = useState<TTab[]>([]) const [tabs, setTabs] = useState<TTab[]>([])
@ -136,7 +153,7 @@ 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 = useRef<ImperativePanelHandle>(null) 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)
@ -156,9 +173,78 @@ export default function CodeEditor({
} }
// Post-mount editor keybindings and actions // Post-mount editor keybindings and actions
const handleEditorMount: OnMount = (editor, monaco) => { const handleEditorMount: OnMount = async (editor, monaco) => {
setEditorRef(editor) setEditorRef(editor)
monacoRef.current = monaco monacoRef.current = monaco
/**
* Sync all the models to the worker eagerly.
* This enables intelliSense for all files without needing an `addExtraLib` call.
*/
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true)
monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true)
monaco.languages.typescript.typescriptDefaults.setCompilerOptions(
defaultCompilerOptions
)
monaco.languages.typescript.javascriptDefaults.setCompilerOptions(
defaultCompilerOptions
)
const fetchFileContent = (fileId: string): Promise<string> => {
return new Promise((resolve) => {
socket?.emit("getFile", fileId, (content: string) => {
resolve(content)
})
})
}
const loadTSConfig = async (files: (TFolder | TFile)[]) => {
const tsconfigFiles = files.filter((file) =>
file.name.endsWith("tsconfig.json")
)
let mergedConfig: any = { compilerOptions: {} }
for (const file of tsconfigFiles) {
const containerId = file.id.split("/").slice(0, 2).join("/")
const content = await fetchFileContent(file.id)
try {
let tsConfig = JSON.parse(content)
// Handle references
if (tsConfig.references) {
for (const ref of tsConfig.references) {
const path = ref.path.replace("./", "")
const fileId = `${containerId}/${path}`
const refContent = await fetchFileContent(fileId)
const referenceTsConfig = JSON.parse(refContent)
// Merge configurations
mergedConfig = deepMerge(mergedConfig, referenceTsConfig)
}
}
// Merge current file's config
mergedConfig = deepMerge(mergedConfig, tsConfig)
} catch (error) {
console.error("Error parsing TSConfig:", error)
}
}
// Apply merged compiler options
if (mergedConfig.compilerOptions) {
const updatedOptions = parseTSConfigToMonacoOptions({
...defaultCompilerOptions,
...mergedConfig.compilerOptions,
})
monaco.languages.typescript.typescriptDefaults.setCompilerOptions(
updatedOptions
)
monaco.languages.typescript.javascriptDefaults.setCompilerOptions(
updatedOptions
)
}
}
// Call the function with your file structure
await loadTSConfig(files)
editor.onDidChangeCursorPosition((e) => { editor.onDidChangeCursorPosition((e) => {
setIsSelected(false) setIsSelected(false)
@ -435,20 +521,29 @@ export default function CodeEditor({
[socket, fileContents] [socket, fileContents]
); );
// Keydown event listener to trigger file save on Ctrl+S or Cmd+S // 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)) {
e.preventDefault()
setIsAIChatOpen(prev => !prev);
} }
} };
document.addEventListener("keydown", down)
document.addEventListener("keydown", down);
// Added this line to prevent Monaco editor from handling Cmd/Ctrl+L
editorRef?.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL, () => {
setIsAIChatOpen(prev => !prev);
});
return () => { return () => {
document.removeEventListener("keydown", down) document.removeEventListener("keydown", down)
} }
}, [activeFileId, tabs, debouncedSaveData]) }, [activeFileId, tabs, debouncedSaveData, setIsAIChatOpen, editorRef])
// Liveblocks live collaboration setup effect // Liveblocks live collaboration setup effect
useEffect(() => { useEffect(() => {
@ -749,6 +844,37 @@ export default function CodeEditor({
}) })
} }
const togglePreviewPanel = () => {
if (isPreviewCollapsed) {
previewPanelRef.current?.expand();
setIsPreviewCollapsed(false);
} else {
previewPanelRef.current?.collapse();
setIsPreviewCollapsed(true);
}
};
const toggleLayout = () => {
if (!isAIChatOpen) {
setIsHorizontalLayout(prev => !prev);
}
};
// Add an effect to handle layout changes when AI chat is opened/closed
useEffect(() => {
if (isAIChatOpen) {
setPreviousLayout(isHorizontalLayout);
setIsHorizontalLayout(true);
} else {
setIsHorizontalLayout(previousLayout);
}
}, [isAIChatOpen]);
// Modify the toggleAIChat function
const toggleAIChat = () => {
setIsAIChatOpen(prev => !prev);
};
// On disabled access for shared users, show un-interactable loading placeholder + info modal // On disabled access for shared users, show un-interactable loading placeholder + info modal
if (disableAccess.isDisabled) if (disableAccess.isDisabled)
return ( return (
@ -868,7 +994,6 @@ export default function CodeEditor({
/> />
) : null} ) : null}
</div> </div>
{/* Main editor components */} {/* Main editor components */}
<Sidebar <Sidebar
sandboxData={sandboxData} sandboxData={sandboxData}
@ -882,145 +1007,199 @@ export default function CodeEditor({
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)} addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
deletingFolderId={deletingFolderId} deletingFolderId={deletingFolderId}
/> />
{/* Outer ResizablePanelGroup for main layout */}
{/* Shadcn resizeable panels: https://ui.shadcn.com/docs/components/resizable */} <ResizablePanelGroup direction={isHorizontalLayout ? "horizontal" : "vertical"}>
<ResizablePanelGroup direction="horizontal"> {/* Left side: Editor and Preview/Terminal */}
<ResizablePanel <ResizablePanel defaultSize={isAIChatOpen ? 80 : 100} minSize={50}>
className="p-2 flex flex-col" <ResizablePanelGroup direction={isHorizontalLayout ? "vertical" : "horizontal"}>
maxSize={80}
minSize={30}
defaultSize={60}
ref={editorPanelRef}
>
<div className="h-10 w-full flex gap-2 overflow-auto tab-scroll">
{/* File tabs */}
{tabs.map((tab) => (
<Tab
key={tab.id}
saved={tab.saved}
selected={activeFileId === tab.id}
onClick={(e) => {
selectFile(tab)
}}
onClose={() => closeTab(tab.id)}
>
{tab.name}
</Tab>
))}
</div>
{/* Monaco editor */}
<div
ref={editorContainerRef}
className="grow w-full overflow-hidden rounded-md relative"
>
{!activeFileId ? (
<>
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<FileJson className="w-6 h-6 mr-3" />
No file selected.
</div>
</>
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
clerk.loaded ? (
<>
{provider && userInfo ? (
<Cursors yProvider={provider} userInfo={userInfo} />
) : null}
<Editor
height="100%"
language={editorLanguage}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
onChange={(value) => {
// If the new content is different from the cached content, update it
if (value !== fileContents[activeFileId]) {
setActiveFileContent(value ?? ""); // Update the active file content
// Mark the file as unsaved by setting 'saved' to false
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: false }
: tab
)
)
} else {
// If the content matches the cached content, mark the file as saved
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: true }
: tab
)
)
}
}}
options={{
tabSize: 2,
minimap: {
enabled: false,
},
padding: {
bottom: 4,
top: 4,
},
scrollBeyondLastLine: false,
fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)",
}}
theme="vs-dark"
value={activeFileContent}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<Loader2 className="animate-spin w-6 h-6 mr-3" />
Waiting for Clerk to load...
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={40}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel <ResizablePanel
ref={usePreview().previewPanelRef}
defaultSize={4}
collapsedSize={4}
minSize={25}
collapsible
className="p-2 flex flex-col" className="p-2 flex flex-col"
onCollapse={() => setIsPreviewCollapsed(true)} maxSize={80}
onExpand={() => setIsPreviewCollapsed(false)} minSize={30}
defaultSize={70}
ref={editorPanelRef}
> >
<PreviewWindow <div className="h-10 w-full flex gap-2 overflow-auto tab-scroll">
open={() => { {/* File tabs */}
usePreview().previewPanelRef.current?.expand() {tabs.map((tab) => (
setIsPreviewCollapsed(false) <Tab
}} key={tab.id}
collapsed={isPreviewCollapsed} saved={tab.saved}
src={previewURL} selected={activeFileId === tab.id}
ref={previewWindowRef} onClick={(e) => {
/> selectFile(tab)
}}
onClose={() => closeTab(tab.id)}
>
{tab.name}
</Tab>
))}
</div>
{/* Monaco editor */}
<div
ref={editorContainerRef}
className="grow w-full overflow-hidden rounded-md relative"
>
{!activeFileId ? (
<>
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<FileJson className="w-6 h-6 mr-3" />
No file selected.
</div>
</>
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
clerk.loaded ? (
<>
{provider && userInfo ? (
<Cursors yProvider={provider} userInfo={userInfo} />
) : null}
<Editor
height="100%"
language={editorLanguage}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
onChange={(value) => {
// If the new content is different from the cached content, update it
if (value !== fileContents[activeFileId]) {
setActiveFileContent(value ?? ""); // Update the active file content
// Mark the file as unsaved by setting 'saved' to false
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: false }
: tab
)
)
} else {
// If the content matches the cached content, mark the file as saved
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: true }
: tab
)
)
}
}}
options={{
tabSize: 2,
minimap: {
enabled: false,
},
padding: {
bottom: 4,
top: 4,
},
scrollBeyondLastLine: false,
fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)",
}}
theme="vs-dark"
value={activeFileContent}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<Loader2 className="animate-spin w-6 h-6 mr-3" />
Waiting for Clerk to load...
</div>
)}
</div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle /> <ResizableHandle />
<ResizablePanel <ResizablePanel defaultSize={30}>
defaultSize={50} <ResizablePanelGroup direction={
minSize={20} isAIChatOpen && isHorizontalLayout ? "horizontal" :
className="p-2 flex flex-col" isAIChatOpen ? "vertical" :
> isHorizontalLayout ? "horizontal" :
{isOwner ? ( "vertical"
<Terminals /> }>
) : ( <ResizablePanel
<div className="w-full h-full flex items-center justify-center text-lg font-medium text-muted-foreground/50 select-none"> ref={previewPanelRef}
<TerminalSquare className="w-4 h-4 mr-2" /> defaultSize={isPreviewCollapsed ? 4 : 20}
No terminal access. minSize={25}
</div> collapsedSize={isHorizontalLayout ? 20 : 4}
)} className="p-2 flex flex-col"
collapsible
onCollapse={() => setIsPreviewCollapsed(true)}
onExpand={() => setIsPreviewCollapsed(false)}
>
<div className="flex items-center justify-between">
<Button
onClick={toggleLayout}
size="sm"
variant="ghost"
className="mr-2 border"
disabled={isAIChatOpen}
>
{isHorizontalLayout ? <ArrowRightToLine className="w-4 h-4" /> : <ArrowDownToLine className="w-4 h-4" />}
</Button>
<PreviewWindow
open={togglePreviewPanel}
collapsed={isPreviewCollapsed}
src={previewURL}
ref={previewWindowRef}
/>
</div>
{!isPreviewCollapsed && (
<div className="w-full grow rounded-md overflow-hidden bg-foreground mt-2">
<iframe
width={"100%"}
height={"100%"}
src={previewURL}
/>
</div>
)}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
defaultSize={50}
minSize={20}
className="p-2 flex flex-col"
>
{isOwner ? (
<Terminals />
) : (
<div className="w-full h-full flex items-center justify-center text-lg font-medium text-muted-foreground/50 select-none">
<TerminalSquare className="w-4 h-4 mr-2" />
No terminal access.
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
{/* Right side: AIChat (if open) */}
{isAIChatOpen && (
<>
<ResizableHandle />
<ResizablePanel defaultSize={30} minSize={15}>
<AIChat
activeFileContent={activeFileContent}
activeFileName={tabs.find(tab => tab.id === activeFileId)?.name || 'No file selected'}
onClose={toggleAIChat}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup> </ResizablePanelGroup>
</PreviewProvider> </PreviewProvider>
</> </>
) )
} }
/**
* Configure the typescript compiler to detect JSX and load type definitions
*/
const defaultCompilerOptions: monaco.languages.typescript.CompilerOptions = {
allowJs: true,
allowSyntheticDefaultImports: true,
allowNonTsExtensions: true,
resolveJsonModule: true,
jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
module: monaco.languages.typescript.ModuleKind.ESNext,
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
target: monaco.languages.typescript.ScriptTarget.ESNext,
}

View File

@ -4,6 +4,7 @@ import {
Link, Link,
RotateCw, RotateCw,
TerminalSquare, TerminalSquare,
UnfoldVertical,
} from "lucide-react" } from "lucide-react"
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from "react" import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from "react"
import { toast } from "sonner" import { toast } from "sonner"
@ -32,24 +33,18 @@ ref: React.Ref<{
return ( return (
<> <>
<div
className={`${collapsed ? "h-full" : "h-10"
} select-none w-full flex gap-2`}
>
<div className="h-8 rounded-md px-3 bg-secondary flex items-center w-full justify-between"> <div className="h-8 rounded-md px-3 bg-secondary flex items-center w-full justify-between">
<div className="text-xs">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 disabled onClick={() => { }}> <PreviewButton onClick={open}>
<TerminalSquare className="w-4 h-4" /> <UnfoldVertical className="w-4 h-4" />
</PreviewButton> </PreviewButton>
) : ( ) : (
<> <>
{/* Removed the unfoldvertical button since we have the same thing via the run button.
<PreviewButton onClick={open}> <PreviewButton onClick={open}>
<UnfoldVertical className="w-4 h-4" /> <UnfoldVertical className="w-4 h-4" />
</PreviewButton> */} </PreviewButton>
<PreviewButton <PreviewButton
onClick={() => { onClick={() => {
@ -66,18 +61,6 @@ ref: React.Ref<{
)} )}
</div> </div>
</div> </div>
</div>
{collapsed ? null : (
<div className="w-full grow rounded-md overflow-hidden bg-foreground">
<iframe
key={iframeKey}
ref={frameRef}
width={"100%"}
height={"100%"}
src={src}
/>
</div>
)}
</> </>
) )
}) })

View File

@ -90,9 +90,9 @@ export default function SidebarFile({
if (!editing && !pendingDelete && !isMoving) if (!editing && !pendingDelete && !isMoving)
selectFile({ ...data, saved: true }); selectFile({ ...data, saved: true });
}} }}
// onDoubleClick={() => { onDoubleClick={() => {
// setEditing(true) setEditing(true)
// }} }}
className={`${ className={`${
dragging ? "opacity-50 hover:!bg-background" : "" dragging ? "opacity-50 hover:!bg-background" : ""
} data-[state=open]:bg-secondary/50 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`} } data-[state=open]:bg-secondary/50 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`}

View File

@ -1,18 +1,20 @@
"use client"; "use client"
import Image from "next/image"; import Image from "next/image"
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react"
import { getIconForFolder, getIconForOpenFolder } from "vscode-icons-js"; import { getIconForFolder, getIconForOpenFolder } from "vscode-icons-js"
import { TFile, TFolder, TTab } from "@/lib/types"; import { TFile, TFolder, TTab } from "@/lib/types"
import SidebarFile from "./file"; 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 { Loader2, Pencil, Trash2 } from "lucide-react"; import { ChevronRight, Loader2, Pencil, Trash2 } from "lucide-react"
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { cn } from "@/lib/utils"
import { motion, AnimatePresence } from "framer-motion"
// 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
@ -25,27 +27,27 @@ export default function SidebarFolder({
movingId, movingId,
deletingFolderId, deletingFolderId,
}: { }: {
data: TFolder; data: TFolder
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
handleDeleteFolder: (folder: TFolder) => void; handleDeleteFolder: (folder: TFolder) => void
movingId: string; movingId: string
deletingFolderId: string; deletingFolderId: string
}) { }) {
const ref = useRef(null); // drop target const ref = useRef(null) // drop target
const [isDraggedOver, setIsDraggedOver] = useState(false); const [isDraggedOver, setIsDraggedOver] = useState(false)
const isDeleting = const isDeleting =
deletingFolderId.length > 0 && data.id.startsWith(deletingFolderId); deletingFolderId.length > 0 && data.id.startsWith(deletingFolderId)
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current
if (el) if (el)
return dropTargetForElements({ return dropTargetForElements({
@ -67,17 +69,17 @@ export default function SidebarFolder({
// no dropping while awaiting move // no dropping while awaiting move
canDrop: () => { canDrop: () => {
return !movingId; return !movingId
}, },
}); })
}, []); }, [])
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false)
const folder = isOpen const folder = isOpen
? getIconForOpenFolder(data.name) ? getIconForOpenFolder(data.name)
: getIconForFolder(data.name); : getIconForFolder(data.name)
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null)
// const [editing, setEditing] = useState(false); // const [editing, setEditing] = useState(false);
// useEffect(() => { // useEffect(() => {
@ -96,6 +98,12 @@ export default function SidebarFolder({
isDraggedOver ? "bg-secondary/50 rounded-t-sm" : "rounded-sm" isDraggedOver ? "bg-secondary/50 rounded-t-sm" : "rounded-sm"
} w-full flex items-center h-7 px-1 transition-colors hover:bg-secondary cursor-pointer`} } w-full flex items-center h-7 px-1 transition-colors hover:bg-secondary cursor-pointer`}
> >
<ChevronRight
className={cn(
"min-w-3 min-h-3 mr-1 ml-auto transition-all duration-300",
isOpen ? "transform rotate-90" : ""
)}
/>
<Image <Image
src={`/icons/${folder}`} src={`/icons/${folder}`}
alt="Folder icon" alt="Folder icon"
@ -149,48 +157,65 @@ export default function SidebarFolder({
<ContextMenuItem <ContextMenuItem
disabled={isDeleting} disabled={isDeleting}
onClick={() => { onClick={() => {
handleDeleteFolder(data); handleDeleteFolder(data)
}} }}
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Delete Delete
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
{isOpen ? ( <AnimatePresence>
<div {isOpen ? (
className={`flex w-full items-stretch ${ <motion.div
isDraggedOver ? "rounded-b-sm bg-secondary/50" : "" className="overflow-y-hidden"
}`} initial={{
> height: 0,
<div className="w-[1px] bg-border mx-2 h-full"></div> opacity: 0,
<div className="flex flex-col grow"> }}
{data.children.map((child) => animate={{
child.type === "file" ? ( height: "auto",
<SidebarFile opacity: 1,
key={child.id} }}
data={child} exit={{
selectFile={selectFile} height: 0,
handleRename={handleRename} opacity: 0,
handleDeleteFile={handleDeleteFile} }}
movingId={movingId} >
deletingFolderId={deletingFolderId} <div
/> className={cn(
) : ( isDraggedOver ? "rounded-b-sm bg-secondary/50" : ""
<SidebarFolder )}
key={child.id} >
data={child} <div className="flex flex-col grow ml-2 pl-2 border-l border-border">
selectFile={selectFile} {data.children.map((child) =>
handleRename={handleRename} child.type === "file" ? (
handleDeleteFile={handleDeleteFile} <SidebarFile
handleDeleteFolder={handleDeleteFolder} key={child.id}
movingId={movingId} data={child}
deletingFolderId={deletingFolderId} selectFile={selectFile}
/> handleRename={handleRename}
) handleDeleteFile={handleDeleteFile}
)} movingId={movingId}
</div> deletingFolderId={deletingFolderId}
</div> />
) : null} ) : (
<SidebarFolder
key={child.id}
data={child}
selectFile={selectFile}
handleRename={handleRename}
handleDeleteFile={handleDeleteFile}
handleDeleteFolder={handleDeleteFolder}
movingId={movingId}
deletingFolderId={deletingFolderId}
/>
)
)}
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
</ContextMenu> </ContextMenu>
); )
} }

View File

@ -4,9 +4,8 @@ import {
FilePlus, FilePlus,
FolderPlus, FolderPlus,
Loader2, Loader2,
MonitorPlay,
Search,
Sparkles, Sparkles,
MessageSquareMore,
} from "lucide-react"; } from "lucide-react";
import SidebarFile from "./file"; import SidebarFile from "./file";
import SidebarFolder from "./folder"; import SidebarFolder from "./folder";
@ -14,13 +13,12 @@ import { Sandbox, TFile, TFolder, TTab } from "@/lib/types";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import New from "./new"; import New from "./new";
import { Socket } from "socket.io-client"; import { Socket } from "socket.io-client";
import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button";
import { import {
dropTargetForElements, dropTargetForElements,
monitorForElements, monitorForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import Button from "@/components/ui/customButton";
export default function Sidebar({ export default function Sidebar({
sandboxData, sandboxData,
@ -105,9 +103,9 @@ export default function Sidebar({
}, []); }, []);
return ( return (
<div className="h-full w-56 select-none flex flex-col text-sm items-start justify-between p-2"> <div className="h-full w-56 select-none flex flex-col text-sm">
<div className="w-full flex flex-col items-start"> <div className="flex-grow overflow-auto p-2 pb-[84px]">
<div className="flex w-full items-center justify-between h-8 mb-1 "> <div className="flex w-full items-center justify-between h-8 mb-1">
<div className="text-muted-foreground">Explorer</div> <div className="text-muted-foreground">Explorer</div>
<div className="flex space-x-1"> <div className="flex space-x-1">
<button <button
@ -181,10 +179,25 @@ export default function Sidebar({
)} )}
</div> </div>
</div> </div>
<div className="w-full space-y-4"> <div className="fixed bottom-0 w-48 flex flex-col p-2 bg-background">
{/* <Button className="w-full"> <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}}>
<MonitorPlay className="w-4 h-4 mr-2" /> Run <Sparkles className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
</Button> */} Copilot
<div className="ml-auto">
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
<span className="text-xs"></span>G
</kbd>
</div>
</Button>
<Button variant="ghost" className="w-full justify-start text-sm text-muted-foreground font-normal h-8 px-2 mb-2" disabled aria-disabled="true" style={{ opacity: 1 }}>
<MessageSquareMore className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
AI Chat
<div className="ml-auto">
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
<span className="text-xs"></span>L
</kbd>
</div>
</Button>
</div> </div>
</div> </div>
); );

View File

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

View File

@ -0,0 +1,36 @@
export const projectTemplates: {
id: string
name: string
icon: string
description: string
disabled: boolean
}[] = [
{
id: "reactjs",
name: "React",
icon: "/project-icons/react.svg",
description: "A JavaScript library for building user interfaces",
disabled: false,
},
{
id: "vanillajs",
name: "HTML/JS",
icon: "/project-icons/more.svg",
description: "More coming soon, feel free to contribute on GitHub",
disabled: false,
},
{
id: "nextjs",
name: "NextJS",
icon: "/project-icons/node.svg",
description: "A JavaScript runtime built on the V8 JavaScript engine",
disabled: false,
},
{
id: "streamlit",
name: "Streamlit",
icon: "/project-icons/python.svg",
description: "A JavaScript runtime built on the V8 JavaScript engine",
disabled: false,
},
]

99
frontend/lib/tsconfig.ts Normal file
View File

@ -0,0 +1,99 @@
import * as monaco from "monaco-editor"
export function parseTSConfigToMonacoOptions(
tsconfig: any
): monaco.languages.typescript.CompilerOptions {
const compilerOptions: monaco.languages.typescript.CompilerOptions = {}
// Map tsconfig options to Monaco CompilerOptions
if (tsconfig.strict) compilerOptions.strict = tsconfig.strict
if (tsconfig.target) compilerOptions.target = mapScriptTarget(tsconfig.target)
if (tsconfig.module) compilerOptions.module = mapModule(tsconfig.module)
if (tsconfig.lib) compilerOptions.lib = tsconfig.lib
if (tsconfig.allowJs) compilerOptions.allowJs = tsconfig.allowJs
if (tsconfig.checkJs) compilerOptions.checkJs = tsconfig.checkJs
if (tsconfig.jsx) compilerOptions.jsx = mapJSX(tsconfig.jsx)
if (tsconfig.declaration) compilerOptions.declaration = tsconfig.declaration
if (tsconfig.declarationMap)
compilerOptions.declarationMap = tsconfig.declarationMap
if (tsconfig.sourceMap) compilerOptions.sourceMap = tsconfig.sourceMap
if (tsconfig.outFile) compilerOptions.outFile = tsconfig.outFile
if (tsconfig.outDir) compilerOptions.outDir = tsconfig.outDir
if (tsconfig.removeComments)
compilerOptions.removeComments = tsconfig.removeComments
if (tsconfig.noEmit) compilerOptions.noEmit = tsconfig.noEmit
if (tsconfig.noEmitOnError)
compilerOptions.noEmitOnError = tsconfig.noEmitOnError
return compilerOptions
}
function mapScriptTarget(
target: string
): monaco.languages.typescript.ScriptTarget {
const targetMap: { [key: string]: monaco.languages.typescript.ScriptTarget } =
{
es3: monaco.languages.typescript.ScriptTarget.ES3,
es5: monaco.languages.typescript.ScriptTarget.ES5,
es6: monaco.languages.typescript.ScriptTarget.ES2015,
es2015: monaco.languages.typescript.ScriptTarget.ES2015,
es2016: monaco.languages.typescript.ScriptTarget.ES2016,
es2017: monaco.languages.typescript.ScriptTarget.ES2017,
es2018: monaco.languages.typescript.ScriptTarget.ES2018,
es2019: monaco.languages.typescript.ScriptTarget.ES2019,
es2020: monaco.languages.typescript.ScriptTarget.ES2020,
esnext: monaco.languages.typescript.ScriptTarget.ESNext,
}
if (typeof target !== "string") {
return monaco.languages.typescript.ScriptTarget.Latest
}
return (
targetMap[target?.toLowerCase()] ||
monaco.languages.typescript.ScriptTarget.Latest
)
}
function mapModule(module: string): monaco.languages.typescript.ModuleKind {
const moduleMap: { [key: string]: monaco.languages.typescript.ModuleKind } = {
none: monaco.languages.typescript.ModuleKind.None,
commonjs: monaco.languages.typescript.ModuleKind.CommonJS,
amd: monaco.languages.typescript.ModuleKind.AMD,
umd: monaco.languages.typescript.ModuleKind.UMD,
system: monaco.languages.typescript.ModuleKind.System,
es6: monaco.languages.typescript.ModuleKind.ES2015,
es2015: monaco.languages.typescript.ModuleKind.ES2015,
esnext: monaco.languages.typescript.ModuleKind.ESNext,
}
if (typeof module !== "string") {
return monaco.languages.typescript.ModuleKind.ESNext
}
return (
moduleMap[module.toLowerCase()] ||
monaco.languages.typescript.ModuleKind.ESNext
)
}
function mapJSX(jsx: string): monaco.languages.typescript.JsxEmit {
const jsxMap: { [key: string]: monaco.languages.typescript.JsxEmit } = {
preserve: monaco.languages.typescript.JsxEmit.Preserve,
react: monaco.languages.typescript.JsxEmit.React,
"react-native": monaco.languages.typescript.JsxEmit.ReactNative,
}
return jsxMap[jsx.toLowerCase()] || monaco.languages.typescript.JsxEmit.React
}
// Example usage:
const tsconfigJSON = {
compilerOptions: {
strict: true,
target: "ES2020",
module: "ESNext",
lib: ["DOM", "ES2020"],
jsx: "react",
sourceMap: true,
outDir: "./dist",
},
}
const monacoOptions = parseTSConfigToMonacoOptions(tsconfigJSON.compilerOptions)
console.log(monacoOptions)

View File

@ -75,3 +75,26 @@ export function debounce<T extends (...args: any[]) => void>(
timeout = setTimeout(() => func(...args), wait) timeout = setTimeout(() => func(...args), wait)
} as T } as T
} }
// Deep merge utility function
export const deepMerge = (target: any, source: any) => {
const output = { ...target }
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
if (isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] })
} else {
output[key] = deepMerge(target[key], source[key])
}
} else {
Object.assign(output, { [key]: source[key] })
}
})
}
return output
}
const isObject = (item: any) => {
return item && typeof item === "object" && !Array.isArray(item)
}

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.1.7", "@atlaskit/pragmatic-drag-and-drop": "^1.1.7",
"@clerk/nextjs": "^4.29.12", "@clerk/nextjs": "^4.29.12",
"@clerk/themes": "^1.7.12", "@clerk/themes": "^1.7.12",
"@codemirror/lang-javascript": "^6.2.2",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
"@liveblocks/client": "^1.12.0", "@liveblocks/client": "^1.12.0",
"@liveblocks/node": "^1.12.0", "@liveblocks/node": "^1.12.0",
@ -30,6 +31,8 @@
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@react-three/fiber": "^8.16.6", "@react-three/fiber": "^8.16.6",
"@uiw/codemirror-theme-vscode": "^4.23.5",
"@uiw/react-codemirror": "^4.23.5",
"@vercel/analytics": "^1.2.2", "@vercel/analytics": "^1.2.2",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
@ -46,7 +49,10 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.51.3", "react-hook-form": "^7.51.3",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.0.16", "react-resizable-panels": "^2.0.16",
"react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^4.0.0",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"sonner": "^1.4.41", "sonner": "^1.4.41",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
@ -59,9 +65,11 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/estree": "^1.0.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/three": "^0.164.0", "@types/three": "^0.164.0",
"autoprefixer": "^10.0.1", "autoprefixer": "^10.0.1",
"postcss": "^8", "postcss": "^8",

View File

@ -0,0 +1,3 @@
declare module 'react-syntax-highlighter';
declare module 'react-syntax-highlighter/dist/esm/styles/prism';

View File

@ -1,6 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"types": ["node"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,