Compare commits

...

37 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
32 changed files with 3295 additions and 817 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -95,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,13 +1,10 @@
import { Send, StopCircle } from "lucide-react" import { Send, StopCircle, Image as ImageIcon, Paperclip } from "lucide-react"
import { Button } from "../../ui/button" import { Button } from "../../ui/button"
import { useEffect } from "react"
interface ChatInputProps { import { TFile, TFolder } from "@/lib/types"
input: string import { ALLOWED_FILE_TYPES } from "./types"
setInput: (input: string) => void import { looksLikeCode } from "./lib/chatUtils"
isGenerating: boolean import { ChatInputProps } from "./types"
handleSend: () => void
handleStopGeneration: () => void
}
export default function ChatInput({ export default function ChatInput({
input, input,
@ -15,37 +12,228 @@ export default function ChatInput({
isGenerating, isGenerating,
handleSend, handleSend,
handleStopGeneration, handleStopGeneration,
onImageUpload,
addContextTab,
activeFileName,
editorRef,
lastCopiedRangeRef,
contextTabs,
onRemoveTab,
textareaRef,
}: ChatInputProps) { }: 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()
}
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 ? (
<Button
onClick={handleStopGeneration}
variant="destructive"
size="icon"
className="h-10 w-10"
>
<StopCircle className="w-4 h-4" />
</Button>
) : (
<Button
onClick={handleSend}
disabled={isGenerating} disabled={isGenerating}
size="icon" rows={1}
className="h-10 w-10" />
{/* 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}
> >
<Send className="w-4 h-4" /> <Paperclip className="h-3 w-3 sm:mr-1" />
<span className="hidden sm:inline">File</span>
</Button> </Button>
)} {/* Render image upload button */}
<Button
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>
</div>
</div> </div>
) )
} }

View File

@ -1,32 +1,29 @@
import { Check, ChevronDown, ChevronUp, Copy, CornerUpLeft } from "lucide-react" import { Check, Copy, CornerUpLeft } from "lucide-react"
import React, { useState } from "react" import React, { useState } from "react"
import ReactMarkdown from "react-markdown" 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 remarkGfm from "remark-gfm"
import { Button } from "../../ui/button" import { Button } from "../../ui/button"
import { copyToClipboard, stringifyContent } from "./lib/chatUtils" import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
import ContextTabs from "./ContextTabs"
interface MessageProps { import { createMarkdownComponents } from './lib/markdownComponents'
message: { import { MessageProps } from "./types"
role: "user" | "assistant"
content: string
context?: string
}
setContext: (context: string | null) => void
setIsContextExpanded: (isExpanded: boolean) => void
}
export default function ChatMessage({ export default function ChatMessage({
message, message,
setContext, setContext,
setIsContextExpanded, setIsContextExpanded,
socket,
}: MessageProps) { }: MessageProps) {
// State for expanded message index
const [expandedMessageIndex, setExpandedMessageIndex] = useState< const [expandedMessageIndex, setExpandedMessageIndex] = useState<
number | null number | null
>(null) >(null)
// State for copied text
const [copiedText, setCopiedText] = useState<string | null>(null) 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)}
@ -42,12 +39,36 @@ export default function ChatMessage({
</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}`
// 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) 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)
@ -65,6 +86,7 @@ export default function ChatMessage({
<CornerUpLeft className="w-4 h-4" /> <CornerUpLeft className="w-4 h-4" />
</Button> </Button>
</div> </div>
{/* Render markdown element */}
{React.createElement( {React.createElement(
node.tagName, node.tagName,
{ {
@ -79,6 +101,13 @@ export default function ChatMessage({
) )
} }
// Create markdown components
const components = createMarkdownComponents(
renderCopyButton,
renderMarkdownElement,
askAboutCode
)
return ( return (
<div className="text-left relative"> <div className="text-left relative">
<div <div
@ -88,34 +117,19 @@ export default function ChatMessage({
: "bg-transparent text-white" : "bg-transparent text-white"
} max-w-full`} } max-w-full`}
> >
{message.role === "user" && ( {/* Render context tabs */}
<div className="absolute top-0 right-0 flex opacity-0 group-hover:opacity-30 transition-opacity"> {message.role === "user" && message.context && (
{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="mb-2 bg-input rounded-lg">
<div <ContextTabs
className="flex justify-between items-center cursor-pointer" socket={socket}
onClick={() => activeFileName=""
setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0) onAddFile={() => {}}
} contextTabs={parseContextToTabs(message.context)}
> onRemoveTab={() => {}}
<span className="text-sm text-gray-300">Context</span> isExpanded={expandedMessageIndex === 0}
{expandedMessageIndex === 0 ? ( onToggleExpand={() => setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)}
<ChevronUp size={16} /> className="[&_div:first-child>div:first-child>div]:bg-[#0D0D0D] [&_button:first-child]:hidden [&_button:last-child]:hidden"
) : ( />
<ChevronDown size={16} />
)}
</div>
{expandedMessageIndex === 0 && ( {expandedMessageIndex === 0 && (
<div className="relative"> <div className="relative">
<div className="absolute top-0 right-0 flex p-1"> <div className="absolute top-0 right-0 flex p-1">
@ -123,6 +137,7 @@ export default function ChatMessage({
message.context.replace(/^Regarding this code:\n/, "") message.context.replace(/^Regarding this code:\n/, "")
)} )}
</div> </div>
{/* Render code textarea */}
{(() => { {(() => {
const code = message.context.replace( const code = message.context.replace(
/^Regarding this code:\n/, /^Regarding this code:\n/,
@ -136,7 +151,10 @@ export default function ChatMessage({
value={code} value={code}
onChange={(e) => { onChange={(e) => {
const updatedContext = `Regarding this code:\n${e.target.value}` const updatedContext = `Regarding this code:\n${e.target.value}`
setContext(updatedContext) 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" className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded"
rows={code.split("\n").length} rows={code.split("\n").length}
@ -153,67 +171,25 @@ export default function ChatMessage({
)} )}
</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)}
<Button
onClick={() => askAboutCode(message.content)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
)}
{/* Render markdown content */}
{message.role === "assistant" ? ( {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>
@ -224,3 +200,27 @@ export default function ChatMessage({
</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,60 +0,0 @@
import { ChevronDown, ChevronUp, 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

@ -3,37 +3,47 @@ import { useEffect, useRef, useState } from "react"
import LoadingDots from "../../ui/LoadingDots" import LoadingDots from "../../ui/LoadingDots"
import ChatInput from "./ChatInput" import ChatInput from "./ChatInput"
import ChatMessage from "./ChatMessage" import ChatMessage from "./ChatMessage"
import ContextDisplay from "./ContextDisplay" import ContextTabs from "./ContextTabs"
import { handleSend, handleStopGeneration } from "./lib/chatUtils" import { handleSend, handleStopGeneration } from "./lib/chatUtils"
import { nanoid } from 'nanoid'
interface Message { import { TFile } from "@/lib/types"
role: "user" | "assistant" import { useSocket } from "@/context/SocketContext"
content: string import { Message, ContextTab, AIChatProps } from './types'
context?: string
}
export default function AIChat({ export default function AIChat({
activeFileContent, activeFileContent,
activeFileName, activeFileName,
onClose, onClose,
}: { editorRef,
activeFileContent: string lastCopiedRangeRef,
activeFileName: string files,
onClose: () => void }: AIChatProps) {
}) { // Initialize socket and messages
const { socket } = useSocket()
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
// Initialize input and state for generating messages
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
// Initialize chat container ref and abort controller ref
const chatContainerRef = useRef<HTMLDivElement>(null) const chatContainerRef = useRef<HTMLDivElement>(null)
const abortControllerRef = useRef<AbortController | null>(null) const abortControllerRef = useRef<AbortController | null>(null)
const [context, setContext] = useState<string | null>(null)
// Initialize context tabs and state for expanding context
const [contextTabs, setContextTabs] = useState<ContextTab[]>([])
const [isContextExpanded, setIsContextExpanded] = useState(false) const [isContextExpanded, setIsContextExpanded] = useState(false)
const [isLoading, setIsLoading] = 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(() => {
@ -45,6 +55,84 @@ export default function AIChat({
} }
} }
// 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">
@ -68,41 +156,65 @@ export default function AIChat({
className="flex-grow overflow-y-auto p-4 space-y-4" className="flex-grow overflow-y-auto p-4 space-y-4"
> >
{messages.map((message, messageIndex) => ( {messages.map((message, messageIndex) => (
// Render chat message component for each message
<ChatMessage <ChatMessage
key={messageIndex} key={messageIndex}
message={message} 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()
}
})
}}
/> />
{/* Render chat input component */}
<ChatInput <ChatInput
textareaRef={textareaRef}
addContextTab={addContextTab}
editorRef={editorRef}
input={input} input={input}
setInput={setInput} setInput={setInput}
isGenerating={isGenerating} isGenerating={isGenerating}
handleSend={() => handleSend={handleSendWithContext}
handleSend(
input,
context,
messages,
setMessages,
setInput,
setIsContextExpanded,
setIsGenerating,
setIsLoading,
abortControllerRef,
activeFileContent
)
}
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,30 +1,39 @@
import React from "react" import React from "react"
// Stringify content for chat message component
export const stringifyContent = ( export const stringifyContent = (
content: any, content: any,
seen = new WeakSet() seen = new WeakSet()
): string => { ): string => {
// Stringify content if it's a string
if (typeof content === "string") { if (typeof content === "string") {
return content 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"
} }
// Stringify content if it's a number or boolean
if (typeof content === "number" || typeof content === "boolean") { if (typeof content === "number" || typeof content === "boolean") {
return content.toString() return content.toString()
} }
// Stringify content if it's a function
if (typeof content === "function") { if (typeof content === "function") {
return content.toString() return content.toString()
} }
// Stringify content if it's a symbol
if (typeof content === "symbol") { if (typeof content === "symbol") {
return content.toString() return content.toString()
} }
// Stringify content if it's a bigint
if (typeof content === "bigint") { if (typeof content === "bigint") {
return content.toString() + "n" 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( return React.Children.toArray(
(content as React.ReactElement).props.children (content as React.ReactElement).props.children
@ -32,11 +41,13 @@ export const stringifyContent = (
.map((child) => stringifyContent(child, seen)) .map((child) => stringifyContent(child, seen))
.join("") .join("")
} }
// Stringify content if it's an array
if (Array.isArray(content)) { if (Array.isArray(content)) {
return ( return (
"[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]" "[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]"
) )
} }
// Stringify content if it's an object
if (typeof content === "object") { if (typeof content === "object") {
if (seen.has(content)) { if (seen.has(content)) {
return "[Circular]" return "[Circular]"
@ -51,19 +62,23 @@ export const stringifyContent = (
return Object.prototype.toString.call(content) return Object.prototype.toString.call(content)
} }
} }
// Stringify content if it's a primitive value
return String(content) return String(content)
} }
// Copy to clipboard for chat message component
export const copyToClipboard = ( export const copyToClipboard = (
text: string, text: string,
setCopiedText: (text: string | null) => void 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,
@ -76,14 +91,26 @@ export const handleSend = async (
abortControllerRef: React.MutableRefObject<AbortController | null>, abortControllerRef: React.MutableRefObject<AbortController | null>,
activeFileContent: string activeFileContent: string
) => { ) => {
// Return if input is empty and context is null
if (input.trim() === "" && !context) return if (input.trim() === "" && !context) return
const newMessage = { // Get timestamp for chat message component
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, role: "user" as const,
content: input, content: input,
context: context || undefined, context: context || undefined,
timestamp: timestamp
} }
const updatedMessages = [...messages, newMessage]
// Update messages for chat message component
const updatedMessages = [...messages, userMessage]
setMessages(updatedMessages) setMessages(updatedMessages)
setInput("") setInput("")
setIsContextExpanded(false) setIsContextExpanded(false)
@ -93,11 +120,13 @@ export const handleSend = async (
abortControllerRef.current = new AbortController() abortControllerRef.current = new AbortController()
try { try {
// Create anthropic messages for chat message component
const anthropicMessages = updatedMessages.map((msg) => ({ const anthropicMessages = updatedMessages.map((msg) => ({
role: msg.role === "user" ? "human" : "assistant", role: msg.role === "user" ? "human" : "assistant",
content: msg.content, content: msg.content,
})) }))
// Fetch AI response for chat message component
const response = await fetch( const response = await fetch(
`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`, `${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`,
{ {
@ -114,20 +143,24 @@ export const handleSend = async (
} }
) )
// 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")
} }
// Get reader for chat message component
const reader = response.body?.getReader() const reader = response.body?.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
const assistantMessage = { role: "assistant" as const, content: "" } const assistantMessage = { role: "assistant" as const, content: "" }
setMessages([...updatedMessages, assistantMessage]) setMessages([...updatedMessages, assistantMessage])
setIsLoading(false) setIsLoading(false)
// Initialize buffer for chat message component
let buffer = "" let buffer = ""
const updateInterval = 100 const updateInterval = 100
let lastUpdateTime = Date.now() 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()
@ -146,6 +179,7 @@ export const handleSend = async (
} }
} }
// Update messages for chat message component
setMessages((prev) => { setMessages((prev) => {
const updatedMessages = [...prev] const updatedMessages = [...prev]
const lastMessage = updatedMessages[updatedMessages.length - 1] const lastMessage = updatedMessages[updatedMessages.length - 1]
@ -154,6 +188,7 @@ export const handleSend = async (
}) })
} }
} catch (error: any) { } catch (error: any) {
// Handle abort error for chat message component
if (error.name === "AbortError") { if (error.name === "AbortError") {
console.log("Generation aborted") console.log("Generation aborted")
} else { } else {
@ -171,6 +206,7 @@ export const handleSend = async (
} }
} }
// Handle stop generation for chat message component
export const handleStopGeneration = ( export const handleStopGeneration = (
abortControllerRef: React.MutableRefObject<AbortController | null> abortControllerRef: React.MutableRefObject<AbortController | null>
) => { ) => {
@ -178,3 +214,22 @@ export const handleStopGeneration = (
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

@ -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

@ -107,7 +107,6 @@ export default function CodeEditor({
// Editor state // Editor state
const [editorLanguage, setEditorLanguage] = useState("plaintext") const [editorLanguage, setEditorLanguage] = useState("plaintext")
console.log("editor language: ",editorLanguage)
const [cursorLine, setCursorLine] = useState(0) const [cursorLine, setCursorLine] = useState(0)
const [editorRef, setEditorRef] = const [editorRef, setEditorRef] =
useState<monaco.editor.IStandaloneCodeEditor>() useState<monaco.editor.IStandaloneCodeEditor>()
@ -173,6 +172,9 @@ export default function CodeEditor({
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)
@ -207,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)
}) })
}) })
@ -257,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
@ -532,7 +545,7 @@ export default function CodeEditor({
) )
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]
@ -649,7 +662,7 @@ export default function CodeEditor({
// Socket event listener effect // Socket event listener effect
useEffect(() => { useEffect(() => {
const onConnect = () => {} const onConnect = () => { }
const onDisconnect = () => { const onDisconnect = () => {
setTerminals([]) setTerminals([])
@ -715,7 +728,7 @@ 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) => {
@ -777,8 +790,8 @@ export default function CodeEditor({
? numTabs === 1 ? numTabs === 1
? null ? null
: index < numTabs - 1 : index < numTabs - 1
? tabs[index + 1].id ? tabs[index + 1].id
: tabs[index - 1].id : tabs[index - 1].id
: activeFileId : activeFileId
setTabs((prev) => prev.filter((t) => t.id !== id)) setTabs((prev) => prev.filter((t) => t.id !== id))
@ -835,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))
) )
@ -844,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)
@ -854,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("")
}) })
@ -902,7 +915,7 @@ export default function CodeEditor({
<DisableAccessModal <DisableAccessModal
message={disableAccess.message} message={disableAccess.message}
open={disableAccess.isDisabled} open={disableAccess.isDisabled}
setOpen={() => {}} setOpen={() => { }}
/> />
<Loading /> <Loading />
</> </>
@ -944,8 +957,8 @@ export default function CodeEditor({
code: code:
(isSelected && editorRef?.getSelection() (isSelected && editorRef?.getSelection()
? editorRef ? editorRef
?.getModel() ?.getModel()
?.getValueInRange(editorRef?.getSelection()!) ?.getValueInRange(editorRef?.getSelection()!)
: editorRef?.getValue()) ?? "", : editorRef?.getValue()) ?? "",
line: generate.line, line: generate.line,
}} }}
@ -1029,6 +1042,8 @@ 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 <ResizablePanelGroup
@ -1075,62 +1090,62 @@ export default function CodeEditor({
</div> </div>
</> </>
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643 ) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
clerk.loaded ? ( clerk.loaded ? (
<> <>
{provider && userInfo ? ( {provider && userInfo ? (
<Cursors yProvider={provider} userInfo={userInfo} /> <Cursors yProvider={provider} userInfo={userInfo} />
) : null} ) : null}
<Editor <Editor
height="100%" height="100%"
language={editorLanguage} language={editorLanguage}
beforeMount={handleEditorWillMount} beforeMount={handleEditorWillMount}
onMount={handleEditorMount} onMount={handleEditorMount}
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) =>
tab.id === activeFileId tab.id === activeFileId
? { ...tab, saved: false } ? { ...tab, saved: false }
: tab : tab
)
) )
) } else {
} else { // If the content matches the cached content, mark the file as saved
// If the content matches the cached content, mark the file as saved setTabs((prev) =>
setTabs((prev) => prev.map((tab) =>
prev.map((tab) => tab.id === activeFileId
tab.id === activeFileId ? { ...tab, saved: true }
? { ...tab, saved: true } : tab
: tab )
) )
) }
} }}
}} options={{
options={{ tabSize: 2,
tabSize: 2, minimap: {
minimap: { enabled: false,
enabled: false, },
}, padding: {
padding: { bottom: 4,
bottom: 4, top: 4,
top: 4, },
}, scrollBeyondLastLine: false,
scrollBeyondLastLine: false, fixedOverflowWidgets: true,
fixedOverflowWidgets: true, fontFamily: "var(--font-geist-mono)",
fontFamily: "var(--font-geist-mono)", }}
}} theme={theme === "light" ? "vs" : "vs-dark"}
theme={theme === "light" ? "vs" : "vs-dark"} value={activeFileContent}
value={activeFileContent} />
/> </>
</> ) : (
) : ( <div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<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" />
<Loader2 className="animate-spin w-6 h-6 mr-3" /> Waiting for Clerk to load...
Waiting for Clerk to load... </div>
</div> )}
)}
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle /> <ResizableHandle />
@ -1140,10 +1155,10 @@ export default function CodeEditor({
isAIChatOpen && isHorizontalLayout isAIChatOpen && isHorizontalLayout
? "horizontal" ? "horizontal"
: isAIChatOpen : isAIChatOpen
? "vertical" ? "vertical"
: isHorizontalLayout : isHorizontalLayout
? "horizontal" ? "horizontal"
: "vertical" : "vertical"
} }
> >
<ResizablePanel <ResizablePanel
@ -1218,6 +1233,9 @@ export default function CodeEditor({
"No file selected" "No file selected"
} }
onClose={toggleAIChat} onClose={toggleAIChat}
editorRef={{ current: editorRef }}
lastCopiedRangeRef={lastCopiedRangeRef}
files={files}
/> />
</ResizablePanel> </ResizablePanel>
</> </>

View File

@ -10,7 +10,7 @@ import New from "./new"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { sortFileExplorer } from "@/lib/utils" import { cn, sortFileExplorer } from "@/lib/utils"
import { import {
dropTargetForElements, dropTargetForElements,
monitorForElements, monitorForElements,
@ -27,6 +27,8 @@ export default function Sidebar({
setFiles, setFiles,
addNew, addNew,
deletingFolderId, deletingFolderId,
toggleAIChat,
isAIChatOpen,
}: { }: {
sandboxData: Sandbox sandboxData: Sandbox
files: (TFile | TFolder)[] files: (TFile | TFolder)[]
@ -43,6 +45,8 @@ export default function Sidebar({
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
@ -87,8 +91,10 @@ export default function Sidebar({
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("")
@ -186,7 +192,7 @@ export default function Sidebar({
style={{ opacity: 1 }} 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
@ -195,12 +201,24 @@ export default function Sidebar({
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-start text-sm text-muted-foreground font-normal h-8 px-2 mb-2" className={cn(
disabled "w-full justify-start text-sm font-normal h-8 px-2 mb-2 border-t",
aria-disabled="true" isAIChatOpen
? "bg-muted-foreground/25 text-foreground"
: "text-muted-foreground"
)}
onClick={toggleAIChat}
aria-disabled={false}
style={{ opacity: 1 }} style={{ opacity: 1 }}
> >
<MessageSquareMore className="h-4 w-4 mr-2 text-indigo-500 opacity-70" /> <MessageSquareMore
className={cn(
"h-4 w-4 mr-2",
isAIChatOpen
? "text-indigo-500"
: "text-indigo-500 opacity-70"
)}
/>
AI Chat 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">

View File

@ -27,7 +27,7 @@ export default function New({
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)
@ -35,7 +35,7 @@ export default function New({
} }
) )
} else { } else {
socket.emit("createFolder", name, () => { socket.emit("createFolder", { name }, () => {
addNew(name, type) addNew(name, type)
}) })
} }

View File

@ -65,12 +65,12 @@ export default function EditorTerminal({
} }
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) => {
fitAddonRef.current?.fit() fitAddonRef.current?.fit()
socket.emit("terminalResize", dimensions) socket.emit("terminalResize", { dimensions })
}) })
const resizeObserver = new ResizeObserver( const resizeObserver = new ResizeObserver(
debounce((entries) => { debounce((entries) => {

View File

@ -22,7 +22,7 @@ export default function Landing() {
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Button variant="outline" size="icon" asChild> <Button variant="outline" size="icon" asChild>
<a href="https://www.x.com/ishaandey_" target="_blank"> <a href="https://x.com/gitwitdev" target="_blank">
<svg <svg
width="1200" width="1200"
height="1227" height="1227"
@ -54,7 +54,7 @@ export default function Landing() {
<CustomButton>Go To App</CustomButton> <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

@ -63,7 +63,7 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
terminals, terminals,
setTerminals, setTerminals,
setActiveTerminalId, setActiveTerminalId,
setClosingTerminal: () => {}, setClosingTerminal: () => { },
socket, socket,
activeTerminalId, activeTerminalId,
}) })
@ -73,7 +73,7 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
const deploy = (callback: () => void) => { const deploy = (callback: () => void) => {
if (!socket) console.error("Couldn't deploy: No socket") if (!socket) console.error("Couldn't deploy: No socket")
console.log("Deploying...") console.log("Deploying...")
socket?.emit("deploy", () => { socket?.emit("deploy", {}, () => {
callback() callback()
}) })
} }

View File

@ -32,9 +32,9 @@ export const createTerminal = ({
setActiveTerminalId(id) setActiveTerminalId(id)
setTimeout(() => { setTimeout(() => {
socket.emit("createTerminal", id, () => { socket.emit("createTerminal", { id }, () => {
setCreatingTerminal(false) setCreatingTerminal(false)
if (command) socket.emit("terminalData", id, command + "\n") if (command) socket.emit("terminalData", { id, data: command + "\n" })
}) })
}, 1000) }, 1000)
} }
@ -75,7 +75,7 @@ export const closeTerminal = ({
setClosingTerminal(term.id) setClosingTerminal(term.id)
socket.emit("closeTerminal", term.id, () => { socket.emit("closeTerminal", { id: term.id }, () => {
setClosingTerminal("") setClosingTerminal("")
const nextId = const nextId =
@ -83,8 +83,8 @@ export const closeTerminal = ({
? numTerminals === 1 ? numTerminals === 1
? null ? null
: index < numTerminals - 1 : index < numTerminals - 1
? terminals[index + 1].id ? terminals[index + 1].id
: terminals[index - 1].id : terminals[index - 1].id
: activeTerminalId : activeTerminalId
setTerminals((prev) => prev.filter((t) => t.id !== term.id)) setTerminals((prev) => prev.filter((t) => t.id !== term.id))

View File

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

View File

@ -37,6 +37,7 @@
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"embla-carousel": "^8.3.0",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"embla-carousel-wheel-gestures": "^8.0.1", "embla-carousel-wheel-gestures": "^8.0.1",
"framer-motion": "^11.2.3", "framer-motion": "^11.2.3",
@ -3128,12 +3129,14 @@
"node_modules/embla-carousel": { "node_modules/embla-carousel": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.3.0.tgz", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.3.0.tgz",
"integrity": "sha512-Ve8dhI4w28qBqR8J+aMtv7rLK89r1ZA5HocwFz6uMB/i5EiC7bGI7y+AM80yAVUJw3qqaZYK7clmZMUR8kM3UA==" "integrity": "sha512-Ve8dhI4w28qBqR8J+aMtv7rLK89r1ZA5HocwFz6uMB/i5EiC7bGI7y+AM80yAVUJw3qqaZYK7clmZMUR8kM3UA==",
"license": "MIT"
}, },
"node_modules/embla-carousel-react": { "node_modules/embla-carousel-react": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.3.0.tgz", "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.3.0.tgz",
"integrity": "sha512-P1FlinFDcIvggcErRjNuVqnUR8anyo8vLMIH8Rthgofw7Nj8qTguCa2QjFAbzxAUTQTPNNjNL7yt0BGGinVdFw==", "integrity": "sha512-P1FlinFDcIvggcErRjNuVqnUR8anyo8vLMIH8Rthgofw7Nj8qTguCa2QjFAbzxAUTQTPNNjNL7yt0BGGinVdFw==",
"license": "MIT",
"dependencies": { "dependencies": {
"embla-carousel": "8.3.0", "embla-carousel": "8.3.0",
"embla-carousel-reactive-utils": "8.3.0" "embla-carousel-reactive-utils": "8.3.0"
@ -3154,6 +3157,7 @@
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-8.0.1.tgz", "resolved": "https://registry.npmjs.org/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-8.0.1.tgz",
"integrity": "sha512-LMAnruDqDmsjL6UoQD65aLotpmfO49Fsr3H0bMi7I+BH6jbv9OJiE61kN56daKsVtCQEt0SU1MrJslbhtgF3yQ==", "integrity": "sha512-LMAnruDqDmsjL6UoQD65aLotpmfO49Fsr3H0bMi7I+BH6jbv9OJiE61kN56daKsVtCQEt0SU1MrJslbhtgF3yQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"wheel-gestures": "^2.2.5" "wheel-gestures": "^2.2.5"
}, },

View File

@ -38,6 +38,7 @@
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"embla-carousel": "^8.3.0",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"embla-carousel-wheel-gestures": "^8.0.1", "embla-carousel-wheel-gestures": "^8.0.1",
"framer-motion": "^11.2.3", "framer-motion": "^11.2.3",

1669
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,8 @@
{ {
"dependencies": { "dependencies": {
"@radix-ui/react-popover": "^1.1.1" "@radix-ui/react-popover": "^1.1.1"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.15"
} }
} }

View File

@ -1,6 +1,6 @@
// Import necessary modules // Import necessary modules
import { io, Socket } from "socket.io-client";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { io, Socket } from "socket.io-client";
dotenv.config(); dotenv.config();
@ -21,7 +21,7 @@ socketRef.on("connect", async () => {
console.log("Connected to the server"); console.log("Connected to the server");
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
socketRef.emit("list", (response: CallbackResponse) => { socketRef.emit("list", {}, (response: CallbackResponse) => {
if (response.success) { if (response.success) {
console.log("List of apps:", response.apps); console.log("List of apps:", response.apps);
} else { } else {
@ -29,7 +29,7 @@ socketRef.on("connect", async () => {
} }
}); });
socketRef.emit("deploy", (response: CallbackResponse) => { socketRef.emit("deploy", {}, (response: CallbackResponse) => {
if (response.success) { if (response.success) {
console.log("It worked!"); console.log("It worked!");
} else { } else {