Compare commits
13 Commits
deployment
...
file-save
Author | SHA1 | Date | |
---|---|---|---|
0117e197bb | |||
37ebe41526 | |||
ed367ed27c | |||
9666401625 | |||
22d638a090 | |||
0dd99cbc77 | |||
093a4e9758 | |||
6a60f4d286 | |||
260110724e | |||
ee51ae7a33 | |||
0d0eed34b2 | |||
4e1c5cac27 | |||
0ae89341d2 |
19
README.md
19
README.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Sandbox is an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration and AI chat.
|
Sandbox is an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration, and AI chat.
|
||||||
|
|
||||||
For the latest updates, join our Discord server: [discord.gitwit.dev](https://discord.gitwit.dev/).
|
For the latest updates, join our Discord server: [discord.gitwit.dev](https://discord.gitwit.dev/).
|
||||||
|
|
||||||
@ -10,11 +10,11 @@ For the latest updates, join our Discord server: [discord.gitwit.dev](https://di
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Double check that whatever you change "SUPERDUPERSECRET" to, it's the same in all config files.
|
- Double-check that whatever you change "SUPERDUPERSECRET" to, it's the same in all config files.
|
||||||
|
|
||||||
### 0. Requirements
|
### 0. Requirements
|
||||||
|
|
||||||
The application uses NodeJS for the backend, NextJS for the frontend and Cloudflare workers for additional backend tasks.
|
The application uses NodeJS for the backend, NextJS for the frontend, and Cloudflare workers for additional backend tasks.
|
||||||
|
|
||||||
Needed accounts to set up:
|
Needed accounts to set up:
|
||||||
|
|
||||||
@ -42,7 +42,6 @@ Run `npm install` in:
|
|||||||
/backend/database
|
/backend/database
|
||||||
/backend/storage
|
/backend/storage
|
||||||
/backend/server
|
/backend/server
|
||||||
/backend/ai
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Adding Clerk
|
### 2. Adding Clerk
|
||||||
@ -175,6 +174,15 @@ Setting up deployments first requires a separate domain (such as gitwit.app, whi
|
|||||||
|
|
||||||
We then deploy Dokku on a separate server, according to this guide: https://dev.to/jamesmurdza/host-your-own-paas-platform-as-a-service-on-amazon-web-services-3f0d
|
We then deploy Dokku on a separate server, according to this guide: https://dev.to/jamesmurdza/host-your-own-paas-platform-as-a-service-on-amazon-web-services-3f0d
|
||||||
|
|
||||||
|
And we install [dokku-daemon](https://github.com/dokku/dokku-daemon) with the following commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/dokku/dokku-daemon
|
||||||
|
cd dokku-daemon
|
||||||
|
sudo make install
|
||||||
|
systemctl start dokku-daemon
|
||||||
|
```
|
||||||
|
|
||||||
The Sandbox platform connects to the Dokku server via SSH, using SSH keys specifically generated for this connection. The SSH key is stored on the Sandbox server, and the following environment variables are set in /backend/server/.env:
|
The Sandbox platform connects to the Dokku server via SSH, using SSH keys specifically generated for this connection. The SSH key is stored on the Sandbox server, and the following environment variables are set in /backend/server/.env:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -248,8 +256,7 @@ backend/
|
|||||||
├── database/
|
├── database/
|
||||||
│ ├── src
|
│ ├── src
|
||||||
│ └── drizzle
|
│ └── drizzle
|
||||||
├── storage
|
└── storage
|
||||||
└── ai
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Path | Description |
|
| Path | Description |
|
||||||
|
@ -7,7 +7,6 @@ PORT=4000
|
|||||||
WORKERS_KEY=
|
WORKERS_KEY=
|
||||||
DATABASE_WORKER_URL=
|
DATABASE_WORKER_URL=
|
||||||
STORAGE_WORKER_URL=
|
STORAGE_WORKER_URL=
|
||||||
AI_WORKER_URL=
|
|
||||||
E2B_API_KEY=
|
E2B_API_KEY=
|
||||||
DOKKU_HOST=
|
DOKKU_HOST=
|
||||||
DOKKU_USERNAME=
|
DOKKU_USERNAME=
|
||||||
|
@ -92,7 +92,11 @@ export class FileManager {
|
|||||||
|
|
||||||
// Convert local file path to remote path
|
// Convert local file path to remote path
|
||||||
private getRemoteFileId(localId: string): string {
|
private getRemoteFileId(localId: string): string {
|
||||||
return `projects/${this.sandboxId}${localId}`
|
return [
|
||||||
|
"projects",
|
||||||
|
this.sandboxId,
|
||||||
|
localId.startsWith("/") ? localId : localId,
|
||||||
|
].join("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert remote file path to local file path
|
// Convert remote file path to local file path
|
||||||
@ -326,13 +330,15 @@ export class FileManager {
|
|||||||
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
|
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
|
||||||
throw new Error("File size too large. Please reduce the file size.")
|
throw new Error("File size too large. Please reduce the file size.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save to remote storage
|
||||||
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
|
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
|
||||||
|
|
||||||
|
// Update local file data cache
|
||||||
let file = this.fileData.find((f) => f.id === fileId)
|
let file = this.fileData.find((f) => f.id === fileId)
|
||||||
if (file) {
|
if (file) {
|
||||||
file.data = body
|
file.data = body
|
||||||
} else {
|
} else {
|
||||||
// If the file wasn't in our cache, add it
|
|
||||||
file = {
|
file = {
|
||||||
id: fileId,
|
id: fileId,
|
||||||
data: body,
|
data: body,
|
||||||
@ -340,7 +346,49 @@ export class FileManager {
|
|||||||
this.fileData.push(file)
|
this.fileData.push(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sandbox.files.write(path.posix.join(this.dirName, fileId), body)
|
// Save to sandbox filesystem
|
||||||
|
const filePath = path.posix.join(this.dirName, fileId)
|
||||||
|
await this.sandbox.files.write(filePath, body)
|
||||||
|
|
||||||
|
// Instead of updating the entire file structure, just ensure this file exists in it
|
||||||
|
const parts = fileId.split('/').filter(Boolean)
|
||||||
|
let current = this.files
|
||||||
|
let currentPath = ''
|
||||||
|
|
||||||
|
// Navigate/create the path to the file
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
currentPath += '/' + parts[i]
|
||||||
|
let folder = current.find(
|
||||||
|
(f) => f.type === 'folder' && f.name === parts[i]
|
||||||
|
) as TFolder
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
folder = {
|
||||||
|
id: currentPath,
|
||||||
|
type: 'folder',
|
||||||
|
name: parts[i],
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
current.push(folder)
|
||||||
|
}
|
||||||
|
current = folder.children
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add/update the file in the structure if it doesn't exist
|
||||||
|
const fileName = parts[parts.length - 1]
|
||||||
|
const existingFile = current.find(
|
||||||
|
(f) => f.type === 'file' && f.name === fileName
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!existingFile) {
|
||||||
|
current.push({
|
||||||
|
id: fileId,
|
||||||
|
type: 'file',
|
||||||
|
name: fileName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshFileList?.(this.files)
|
||||||
this.fixPermissions()
|
this.fixPermissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,7 +13,10 @@ export class SSHSocketClient {
|
|||||||
private conn: Client
|
private conn: Client
|
||||||
private config: SSHConfig
|
private config: SSHConfig
|
||||||
private socketPath: string
|
private socketPath: string
|
||||||
private isConnected: boolean = false
|
private _isConnected: boolean = false
|
||||||
|
public get isConnected(): boolean {
|
||||||
|
return this._isConnected
|
||||||
|
}
|
||||||
|
|
||||||
// Constructor initializes the SSH client and sets up configuration
|
// Constructor initializes the SSH client and sets up configuration
|
||||||
constructor(config: SSHConfig, socketPath: string) {
|
constructor(config: SSHConfig, socketPath: string) {
|
||||||
@ -34,7 +37,7 @@ export class SSHSocketClient {
|
|||||||
private closeConnection() {
|
private closeConnection() {
|
||||||
console.log("Closing SSH connection...")
|
console.log("Closing SSH connection...")
|
||||||
this.conn.end()
|
this.conn.end()
|
||||||
this.isConnected = false
|
this._isConnected = false
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,17 +47,17 @@ export class SSHSocketClient {
|
|||||||
this.conn
|
this.conn
|
||||||
.on("ready", () => {
|
.on("ready", () => {
|
||||||
console.log("SSH connection established")
|
console.log("SSH connection established")
|
||||||
this.isConnected = true
|
this._isConnected = true
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
.on("error", (err) => {
|
.on("error", (err) => {
|
||||||
console.error("SSH connection error:", err)
|
console.error("SSH connection error:", err)
|
||||||
this.isConnected = false
|
this._isConnected = false
|
||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
.on("close", () => {
|
.on("close", () => {
|
||||||
console.log("SSH connection closed")
|
console.log("SSH connection closed")
|
||||||
this.isConnected = false
|
this._isConnected = false
|
||||||
})
|
})
|
||||||
.connect(this.config)
|
.connect(this.config)
|
||||||
})
|
})
|
||||||
@ -86,10 +89,13 @@ export class SSHSocketClient {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.on("data", (data: Buffer) => {
|
.on("data", (data: Buffer) => {
|
||||||
|
// Netcat remains open until it is closed, so we close the connection once we receive data.
|
||||||
resolve(data.toString())
|
resolve(data.toString())
|
||||||
|
stream.close()
|
||||||
})
|
})
|
||||||
.stderr.on("data", (data: Buffer) => {
|
.stderr.on("data", (data: Buffer) => {
|
||||||
reject(new Error(data.toString()))
|
reject(new Error(data.toString()))
|
||||||
|
stream.close()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -170,8 +170,20 @@ export class Sandbox {
|
|||||||
|
|
||||||
// Handle checking if an app exists
|
// Handle checking if an app exists
|
||||||
const handleAppExists: SocketHandler = async ({ appName }) => {
|
const handleAppExists: SocketHandler = async ({ appName }) => {
|
||||||
if (!this.dokkuClient)
|
if (!this.dokkuClient) {
|
||||||
throw new Error("Failed to check app existence: No Dokku client")
|
console.log("Failed to check app existence: No Dokku client")
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.dokkuClient.isConnected) {
|
||||||
|
console.log(
|
||||||
|
"Failed to check app existence: The Dokku client is not connected"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
exists: await this.dokkuClient.appExists(appName),
|
exists: await this.dokkuClient.appExists(appName),
|
||||||
|
@ -3,18 +3,21 @@ CLERK_SECRET_KEY=
|
|||||||
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
|
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
|
||||||
LIVEBLOCKS_SECRET_KEY=
|
LIVEBLOCKS_SECRET_KEY=
|
||||||
|
|
||||||
NEXT_PUBLIC_SERVER_URL=http://localhost:4000
|
NEXT_PUBLIC_SERVER_PORT=4000
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||||
|
NEXT_PUBLIC_SERVER_URL=http://localhost:4000
|
||||||
|
|
||||||
# Set WORKER_URLs after deploying the workers.
|
# Set WORKER_URLs after deploying the workers.
|
||||||
# Set NEXT_PUBLIC_WORKERS_KEY to be the same as KEY in /backend/storage/wrangler.toml.
|
# Set NEXT_PUBLIC_WORKERS_KEY to be the same as KEY in /backend/storage/wrangler.toml.
|
||||||
# These URLs should begin with https:// in production
|
NEXT_PUBLIC_DATABASE_WORKER_URL=https://database.your-worker.workers.dev
|
||||||
NEXT_PUBLIC_DATABASE_WORKER_URL=
|
NEXT_PUBLIC_STORAGE_WORKER_URL=https://storage.your-worker.workers.dev
|
||||||
NEXT_PUBLIC_STORAGE_WORKER_URL=
|
NEXT_PUBLIC_WORKERS_KEY=SUPERDUPERSECRET
|
||||||
NEXT_PUBLIC_AI_WORKER_URL=
|
|
||||||
NEXT_PUBLIC_WORKERS_KEY=
|
|
||||||
|
|
||||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
|
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
|
||||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
|
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
@ -1,16 +1,22 @@
|
|||||||
import { currentUser } from "@clerk/nextjs"
|
import {
|
||||||
import { Anthropic } from "@anthropic-ai/sdk"
|
ignoredFiles,
|
||||||
import { TIERS } from "@/lib/tiers"
|
ignoredFolders,
|
||||||
|
} from "@/components/editor/AIChat/lib/ignored-paths"
|
||||||
import { templateConfigs } from "@/lib/templates"
|
import { templateConfigs } from "@/lib/templates"
|
||||||
|
import { TIERS } from "@/lib/tiers"
|
||||||
import { TFile, TFolder } from "@/lib/types"
|
import { TFile, TFolder } from "@/lib/types"
|
||||||
import { ignoredFiles, ignoredFolders } from "@/components/editor/AIChat/lib/ignored-paths"
|
import { Anthropic } from "@anthropic-ai/sdk"
|
||||||
|
import { currentUser } from "@clerk/nextjs"
|
||||||
|
|
||||||
const anthropic = new Anthropic({
|
const anthropic = new Anthropic({
|
||||||
apiKey: process.env.ANTHROPIC_API_KEY!,
|
apiKey: process.env.ANTHROPIC_API_KEY!,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Format file structure for context
|
// Format file structure for context
|
||||||
function formatFileStructure(items: (TFile | TFolder)[] | undefined, prefix = ""): string {
|
function formatFileStructure(
|
||||||
|
items: (TFile | TFolder)[] | undefined,
|
||||||
|
prefix = ""
|
||||||
|
): string {
|
||||||
if (!items || !Array.isArray(items)) {
|
if (!items || !Array.isArray(items)) {
|
||||||
return "No files available"
|
return "No files available"
|
||||||
}
|
}
|
||||||
@ -23,15 +29,23 @@ function formatFileStructure(items: (TFile | TFolder)[] | undefined, prefix = ""
|
|||||||
|
|
||||||
return sortedItems
|
return sortedItems
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (item.type === "file" && !ignoredFiles.some(
|
if (
|
||||||
(pattern) => item.name.endsWith(pattern.replace("*", "")) || item.name === pattern
|
item.type === "file" &&
|
||||||
)) {
|
!ignoredFiles.some(
|
||||||
|
(pattern) =>
|
||||||
|
item.name.endsWith(pattern.replace("*", "")) ||
|
||||||
|
item.name === pattern
|
||||||
|
)
|
||||||
|
) {
|
||||||
return `${prefix}├── ${item.name}`
|
return `${prefix}├── ${item.name}`
|
||||||
} else if (
|
} else if (
|
||||||
item.type === "folder" &&
|
item.type === "folder" &&
|
||||||
!ignoredFolders.some((folder) => folder === item.name)
|
!ignoredFolders.some((folder) => folder === item.name)
|
||||||
) {
|
) {
|
||||||
const folderContent = formatFileStructure(item.children, `${prefix}│ `)
|
const folderContent = formatFileStructure(
|
||||||
|
item.children,
|
||||||
|
`${prefix}│ `
|
||||||
|
)
|
||||||
return `${prefix}├── ${item.name}/\n${folderContent}`
|
return `${prefix}├── ${item.name}/\n${folderContent}`
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@ -94,6 +108,7 @@ export async function POST(request: Request) {
|
|||||||
line,
|
line,
|
||||||
templateType,
|
templateType,
|
||||||
files,
|
files,
|
||||||
|
projectName,
|
||||||
} = await request.json()
|
} = await request.json()
|
||||||
|
|
||||||
// Get template configuration
|
// Get template configuration
|
||||||
@ -138,13 +153,23 @@ Instructions: ${messages[0].content}
|
|||||||
|
|
||||||
Respond only with the modified code that can directly replace the existing code.`
|
Respond only with the modified code that can directly replace the existing code.`
|
||||||
} else {
|
} else {
|
||||||
systemMessage = `You are an intelligent programming assistant for a ${templateType} project. Please respond to the following request concisely. If your response includes code, please format it using triple backticks (\`\`\`) with the appropriate language identifier. For example:
|
systemMessage = `You are an intelligent programming assistant for a ${templateType} project. Please respond to the following request concisely. When providing code:
|
||||||
|
|
||||||
\`\`\`python
|
1. Format it using triple backticks (\`\`\`) with the appropriate language identifier.
|
||||||
print("Hello, World!")
|
2. Always specify the complete file path in the format:
|
||||||
\`\`\`
|
${projectName}/filepath/to/file.ext
|
||||||
|
|
||||||
Provide a clear and concise explanation along with any code snippets. Keep your response brief and to the point.
|
3. If creating a new file, specify the path as:
|
||||||
|
${projectName}/filepath/to/file.ext (new file)
|
||||||
|
|
||||||
|
4. Format your code blocks as:
|
||||||
|
|
||||||
|
${projectName}/filepath/to/file.ext
|
||||||
|
\`\`\`language
|
||||||
|
code here
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
If multiple files are involved, repeat the format for each file. Provide a clear and concise explanation along with any code snippets. Keep your response brief and to the point.
|
||||||
|
|
||||||
This is the project template:
|
This is the project template:
|
||||||
${templateContext}
|
${templateContext}
|
||||||
|
@ -19,7 +19,9 @@ export async function POST(request: Request) {
|
|||||||
- Explanations or comments
|
- Explanations or comments
|
||||||
- Markdown formatting
|
- Markdown formatting
|
||||||
|
|
||||||
The output should be the exact code that will replace the existing code, nothing more and nothing less.`
|
The output should be the exact code that will replace the existing code, nothing more and nothing less.
|
||||||
|
|
||||||
|
Important: When merging, preserve the original code structure as much as possible. Only make necessary changes to integrate the new code while maintaining the original code's organization and style.`
|
||||||
|
|
||||||
const mergedCode = `Original file (${fileName}):\n${originalCode}\n\nNew code to merge:\n${newCode}`
|
const mergedCode = `Original file (${fileName}):\n${originalCode}\n\nNew code to merge:\n${newCode}`
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ interface ApplyButtonProps {
|
|||||||
activeFileName: string
|
activeFileName: string
|
||||||
activeFileContent: string
|
activeFileContent: string
|
||||||
editorRef: { current: any }
|
editorRef: { current: any }
|
||||||
onApply: (mergedCode: string) => void
|
onApply: (mergedCode: string, originalCode: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ApplyButton({
|
export default function ApplyButton({
|
||||||
@ -48,7 +48,7 @@ export default function ApplyButton({
|
|||||||
mergedCode += decoder.decode(value, { stream: true })
|
mergedCode += decoder.decode(value, { stream: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onApply(mergedCode.trim())
|
onApply(mergedCode.trim(), activeFileContent)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error applying code:", error)
|
console.error("Error applying code:", error)
|
||||||
toast.error(
|
toast.error(
|
||||||
|
@ -19,6 +19,7 @@ export default function ChatMessage({
|
|||||||
editorRef,
|
editorRef,
|
||||||
mergeDecorationsCollection,
|
mergeDecorationsCollection,
|
||||||
setMergeDecorationsCollection,
|
setMergeDecorationsCollection,
|
||||||
|
selectFile,
|
||||||
}: MessageProps) {
|
}: MessageProps) {
|
||||||
// State for expanded message index
|
// State for expanded message index
|
||||||
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
|
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
|
||||||
@ -115,8 +116,9 @@ export default function ChatMessage({
|
|||||||
activeFileContent,
|
activeFileContent,
|
||||||
editorRef,
|
editorRef,
|
||||||
handleApplyCode,
|
handleApplyCode,
|
||||||
|
selectFile,
|
||||||
mergeDecorationsCollection,
|
mergeDecorationsCollection,
|
||||||
setMergeDecorationsCollection
|
setMergeDecorationsCollection,
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -19,8 +19,10 @@ export default function AIChat({
|
|||||||
files,
|
files,
|
||||||
templateType,
|
templateType,
|
||||||
handleApplyCode,
|
handleApplyCode,
|
||||||
|
selectFile,
|
||||||
mergeDecorationsCollection,
|
mergeDecorationsCollection,
|
||||||
setMergeDecorationsCollection,
|
setMergeDecorationsCollection,
|
||||||
|
projectName,
|
||||||
}: AIChatProps) {
|
}: AIChatProps) {
|
||||||
// Initialize socket and messages
|
// Initialize socket and messages
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
@ -152,7 +154,8 @@ export default function AIChat({
|
|||||||
activeFileContent,
|
activeFileContent,
|
||||||
false,
|
false,
|
||||||
templateType,
|
templateType,
|
||||||
files
|
files,
|
||||||
|
projectName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,6 +226,7 @@ export default function AIChat({
|
|||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
mergeDecorationsCollection={mergeDecorationsCollection}
|
mergeDecorationsCollection={mergeDecorationsCollection}
|
||||||
setMergeDecorationsCollection={setMergeDecorationsCollection}
|
setMergeDecorationsCollection={setMergeDecorationsCollection}
|
||||||
|
selectFile={selectFile}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isLoading && <LoadingDots />}
|
{isLoading && <LoadingDots />}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TFolder, TFile } from "@/lib/types"
|
import { TFile, TFolder } from "@/lib/types"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
// Stringify content for chat message component
|
// Stringify content for chat message component
|
||||||
@ -93,7 +93,8 @@ export const handleSend = async (
|
|||||||
activeFileContent: string,
|
activeFileContent: string,
|
||||||
isEditMode: boolean = false,
|
isEditMode: boolean = false,
|
||||||
templateType: string,
|
templateType: string,
|
||||||
files: (TFile | TFolder)[]
|
files: (TFile | TFolder)[],
|
||||||
|
projectName: string
|
||||||
) => {
|
) => {
|
||||||
// Return if input is empty and context is null
|
// Return if input is empty and context is null
|
||||||
if (input.trim() === "" && !context) return
|
if (input.trim() === "" && !context) return
|
||||||
@ -145,6 +146,7 @@ export const handleSend = async (
|
|||||||
isEditMode: isEditMode,
|
isEditMode: isEditMode,
|
||||||
templateType: templateType,
|
templateType: templateType,
|
||||||
files: files,
|
files: files,
|
||||||
|
projectName: projectName,
|
||||||
}),
|
}),
|
||||||
signal: abortControllerRef.current.signal,
|
signal: abortControllerRef.current.signal,
|
||||||
})
|
})
|
||||||
@ -241,3 +243,11 @@ export const looksLikeCode = (text: string): boolean => {
|
|||||||
|
|
||||||
return codeIndicators.some((pattern) => pattern.test(text))
|
return codeIndicators.some((pattern) => pattern.test(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add this new function after looksLikeCode function
|
||||||
|
export const isFilePath = (text: string): boolean => {
|
||||||
|
// Match patterns like next/styles/SignIn.module.css or path/to/file.ext (new file)
|
||||||
|
const pattern =
|
||||||
|
/^(?:[a-zA-Z0-9_.-]+\/)*[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+(\s+\(new file\))?$/
|
||||||
|
return pattern.test(text)
|
||||||
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { Check, CornerUpLeft, X } from "lucide-react"
|
import { useSocket } from "@/context/SocketContext"
|
||||||
|
import { TTab } from "@/lib/types"
|
||||||
|
import { Check, CornerUpLeft, FileText, X } from "lucide-react"
|
||||||
import monaco from "monaco-editor"
|
import monaco from "monaco-editor"
|
||||||
import { Components } from "react-markdown"
|
import { Components } from "react-markdown"
|
||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
||||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
|
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
|
||||||
import { Button } from "../../../ui/button"
|
import { Button } from "../../../ui/button"
|
||||||
import ApplyButton from "../ApplyButton"
|
import ApplyButton from "../ApplyButton"
|
||||||
import { stringifyContent } from "./chatUtils"
|
import { isFilePath, stringifyContent } from "./chatUtils"
|
||||||
|
|
||||||
// Create markdown components for chat message component
|
// Create markdown components for chat message component
|
||||||
export const createMarkdownComponents = (
|
export const createMarkdownComponents = (
|
||||||
@ -15,7 +17,8 @@ export const createMarkdownComponents = (
|
|||||||
activeFileName: string,
|
activeFileName: string,
|
||||||
activeFileContent: string,
|
activeFileContent: string,
|
||||||
editorRef: any,
|
editorRef: any,
|
||||||
handleApplyCode: (mergedCode: string) => void,
|
handleApplyCode: (mergedCode: string, originalCode: string) => void,
|
||||||
|
selectFile: (tab: TTab) => void,
|
||||||
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection,
|
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection,
|
||||||
setMergeDecorationsCollection?: (collection: undefined) => void
|
setMergeDecorationsCollection?: (collection: undefined) => void
|
||||||
): Components => ({
|
): Components => ({
|
||||||
@ -58,7 +61,7 @@ export const createMarkdownComponents = (
|
|||||||
mergeDecorationsCollection &&
|
mergeDecorationsCollection &&
|
||||||
editorRef?.current
|
editorRef?.current
|
||||||
) {
|
) {
|
||||||
mergeDecorationsCollection.clear()
|
mergeDecorationsCollection?.clear()
|
||||||
setMergeDecorationsCollection(undefined)
|
setMergeDecorationsCollection(undefined)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -72,14 +75,15 @@ export const createMarkdownComponents = (
|
|||||||
<div className="w-px bg-input"></div>
|
<div className="w-px bg-input"></div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (editorRef?.current && mergeDecorationsCollection) {
|
||||||
setMergeDecorationsCollection &&
|
const model = editorRef.current.getModel()
|
||||||
mergeDecorationsCollection &&
|
if (model && (model as any).originalContent) {
|
||||||
editorRef?.current
|
editorRef.current?.setValue(
|
||||||
) {
|
(model as any).originalContent
|
||||||
editorRef.current.getModel()?.setValue(activeFileContent)
|
)
|
||||||
mergeDecorationsCollection.clear()
|
mergeDecorationsCollection.clear()
|
||||||
setMergeDecorationsCollection(undefined)
|
setMergeDecorationsCollection?.(undefined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -126,8 +130,62 @@ export const createMarkdownComponents = (
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Render markdown elements
|
// Render markdown elements
|
||||||
p: ({ node, children, ...props }) =>
|
p: ({ node, children, ...props }) => {
|
||||||
renderMarkdownElement({ node, children, ...props }),
|
const content = stringifyContent(children)
|
||||||
|
const { socket } = useSocket()
|
||||||
|
|
||||||
|
if (isFilePath(content)) {
|
||||||
|
const isNewFile = content.endsWith("(new file)")
|
||||||
|
const filePath = (
|
||||||
|
isNewFile ? content.replace(" (new file)", "") : content
|
||||||
|
)
|
||||||
|
.split("/")
|
||||||
|
.filter((part, index) => index !== 0)
|
||||||
|
.join("/")
|
||||||
|
|
||||||
|
const handleFileClick = () => {
|
||||||
|
if (isNewFile) {
|
||||||
|
socket?.emit(
|
||||||
|
"createFile",
|
||||||
|
{
|
||||||
|
name: filePath,
|
||||||
|
},
|
||||||
|
(response: any) => {
|
||||||
|
if (response.success) {
|
||||||
|
const tab: TTab = {
|
||||||
|
id: filePath,
|
||||||
|
name: filePath.split("/").pop() || "",
|
||||||
|
saved: true,
|
||||||
|
type: "file",
|
||||||
|
}
|
||||||
|
selectFile(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const tab: TTab = {
|
||||||
|
id: filePath,
|
||||||
|
name: filePath.split("/").pop() || "",
|
||||||
|
saved: true,
|
||||||
|
type: "file",
|
||||||
|
}
|
||||||
|
selectFile(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleFileClick}
|
||||||
|
className="group flex items-center gap-2 px-2 py-1 bg-secondary/50 rounded-md my-2 text-xs hover:bg-secondary cursor-pointer w-fit"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span className="font-mono group-hover:underline">{content}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderMarkdownElement({ node, children, ...props })
|
||||||
|
},
|
||||||
h1: ({ node, children, ...props }) =>
|
h1: ({ node, children, ...props }) =>
|
||||||
renderMarkdownElement({ node, children, ...props }),
|
renderMarkdownElement({ node, children, ...props }),
|
||||||
h2: ({ node, children, ...props }) =>
|
h2: ({ node, children, ...props }) =>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { TemplateConfig } from "@/lib/templates"
|
import { TemplateConfig } from "@/lib/templates"
|
||||||
import { TFile, TFolder } from "@/lib/types"
|
import { TFile, TFolder, TTab } from "@/lib/types"
|
||||||
import * as monaco from "monaco-editor"
|
import * as monaco from "monaco-editor"
|
||||||
import { Socket } from "socket.io-client"
|
import { Socket } from "socket.io-client"
|
||||||
|
|
||||||
@ -56,9 +56,11 @@ export interface AIChatProps {
|
|||||||
files: (TFile | TFolder)[]
|
files: (TFile | TFolder)[]
|
||||||
templateType: string
|
templateType: string
|
||||||
templateConfig?: TemplateConfig
|
templateConfig?: TemplateConfig
|
||||||
handleApplyCode: (mergedCode: string) => void
|
projectName: string
|
||||||
|
handleApplyCode: (mergedCode: string, originalCode: string) => void
|
||||||
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
|
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
|
||||||
setMergeDecorationsCollection?: (collection: undefined) => void
|
setMergeDecorationsCollection?: (collection: undefined) => void
|
||||||
|
selectFile: (tab: TTab) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat input props interface
|
// Chat input props interface
|
||||||
@ -108,12 +110,13 @@ export interface MessageProps {
|
|||||||
) => void
|
) => void
|
||||||
setIsContextExpanded: (isExpanded: boolean) => void
|
setIsContextExpanded: (isExpanded: boolean) => void
|
||||||
socket: Socket | null
|
socket: Socket | null
|
||||||
handleApplyCode: (mergedCode: string) => void
|
handleApplyCode: (mergedCode: string, originalCode: string) => void
|
||||||
activeFileName: string
|
activeFileName: string
|
||||||
activeFileContent: string
|
activeFileContent: string
|
||||||
editorRef: any
|
editorRef: any
|
||||||
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
|
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
|
||||||
setMergeDecorationsCollection?: (collection: undefined) => void
|
setMergeDecorationsCollection?: (collection: undefined) => void
|
||||||
|
selectFile: (tab: TTab) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context tabs props interface
|
// Context tabs props interface
|
||||||
|
@ -384,12 +384,19 @@ export default function CodeEditor({
|
|||||||
|
|
||||||
// handle apply code
|
// handle apply code
|
||||||
const handleApplyCode = useCallback(
|
const handleApplyCode = useCallback(
|
||||||
(mergedCode: string) => {
|
(mergedCode: string, originalCode: string) => {
|
||||||
if (!editorRef) return
|
if (!editorRef) return
|
||||||
|
|
||||||
const originalLines = activeFileContent.split("\n")
|
const model = editorRef.getModel()
|
||||||
const mergedLines = mergedCode.split("\n")
|
if (!model) return // Store original content
|
||||||
|
;(model as any).originalContent = originalCode
|
||||||
|
|
||||||
|
// Calculate the full range of the document
|
||||||
|
const fullRange = model.getFullModelRange()
|
||||||
|
|
||||||
|
// Create decorations before applying the edit
|
||||||
|
const originalLines = originalCode.split("\n")
|
||||||
|
const mergedLines = mergedCode.split("\n")
|
||||||
const decorations: monaco.editor.IModelDeltaDecoration[] = []
|
const decorations: monaco.editor.IModelDeltaDecoration[] = []
|
||||||
|
|
||||||
for (
|
for (
|
||||||
@ -397,32 +404,33 @@ export default function CodeEditor({
|
|||||||
i < Math.max(originalLines.length, mergedLines.length);
|
i < Math.max(originalLines.length, mergedLines.length);
|
||||||
i++
|
i++
|
||||||
) {
|
) {
|
||||||
if (originalLines[i] !== mergedLines[i]) {
|
// Only highlight new lines (green highlights)
|
||||||
|
if (i >= originalLines.length || originalLines[i] !== mergedLines[i]) {
|
||||||
decorations.push({
|
decorations.push({
|
||||||
range: new monaco.Range(i + 1, 1, i + 1, 1),
|
range: new monaco.Range(i + 1, 1, i + 1, 1),
|
||||||
options: {
|
options: {
|
||||||
isWholeLine: true,
|
isWholeLine: true,
|
||||||
className:
|
className: "added-line-decoration",
|
||||||
i < originalLines.length
|
glyphMarginClassName: "added-line-glyph",
|
||||||
? "removed-line-decoration"
|
|
||||||
: "added-line-decoration",
|
|
||||||
glyphMarginClassName:
|
|
||||||
i < originalLines.length
|
|
||||||
? "removed-line-glyph"
|
|
||||||
: "added-line-glyph",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update editor content
|
// Execute the edit operation
|
||||||
editorRef.setValue(mergedCode)
|
editorRef.executeEdits("apply-code", [
|
||||||
|
{
|
||||||
|
range: fullRange,
|
||||||
|
text: mergedCode,
|
||||||
|
forceMoveMarkers: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
// Apply decorations
|
// Apply decorations after the edit
|
||||||
const newDecorations = editorRef.createDecorationsCollection(decorations)
|
const newDecorations = editorRef.createDecorationsCollection(decorations)
|
||||||
setMergeDecorationsCollection(newDecorations)
|
setMergeDecorationsCollection(newDecorations)
|
||||||
},
|
},
|
||||||
[editorRef, activeFileContent]
|
[editorRef]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generate widget effect
|
// Generate widget effect
|
||||||
@ -785,31 +793,32 @@ export default function CodeEditor({
|
|||||||
setGenerate((prev) => ({ ...prev, show: false }))
|
setGenerate((prev) => ({ ...prev, show: false }))
|
||||||
|
|
||||||
// Check if the tab already exists in the list of open tabs
|
// Check if the tab already exists in the list of open tabs
|
||||||
const exists = tabs.find((t) => t.id === tab.id)
|
const existingTab = tabs.find((t) => t.id === tab.id)
|
||||||
setTabs((prev) => {
|
|
||||||
if (exists) {
|
|
||||||
// If the tab exists, make it the active tab
|
|
||||||
setActiveFileId(exists.id)
|
|
||||||
return prev
|
|
||||||
}
|
|
||||||
// If the tab doesn't exist, add it to the list of tabs and make it active
|
|
||||||
return [...prev, tab]
|
|
||||||
})
|
|
||||||
|
|
||||||
// If the file's content is already cached, set it as the active content
|
if (existingTab) {
|
||||||
if (fileContents[tab.id]) {
|
// If the tab exists, just make it active
|
||||||
setActiveFileContent(fileContents[tab.id])
|
setActiveFileId(existingTab.id)
|
||||||
|
if (fileContents[existingTab.id]) {
|
||||||
|
setActiveFileContent(fileContents[existingTab.id])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, fetch the content of the file and cache it
|
// If the tab doesn't exist, add it to the list and make it active
|
||||||
|
setTabs((prev) => [...prev, tab])
|
||||||
|
|
||||||
|
// Fetch content if not cached
|
||||||
|
if (!fileContents[tab.id]) {
|
||||||
debouncedGetFile(tab.id, (response: string) => {
|
debouncedGetFile(tab.id, (response: string) => {
|
||||||
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
|
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
|
||||||
setActiveFileContent(response)
|
setActiveFileContent(response)
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
setActiveFileContent(fileContents[tab.id])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the editor language based on the file type
|
// Set the editor language based on the file type
|
||||||
setEditorLanguage(processFileType(tab.name))
|
setEditorLanguage(processFileType(tab.name))
|
||||||
// Set the active file ID to the new tab
|
// Set the active file ID
|
||||||
setActiveFileId(tab.id)
|
setActiveFileId(tab.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1284,9 +1293,11 @@ export default function CodeEditor({
|
|||||||
lastCopiedRangeRef={lastCopiedRangeRef}
|
lastCopiedRangeRef={lastCopiedRangeRef}
|
||||||
files={files}
|
files={files}
|
||||||
templateType={sandboxData.type}
|
templateType={sandboxData.type}
|
||||||
|
projectName={sandboxData.name}
|
||||||
handleApplyCode={handleApplyCode}
|
handleApplyCode={handleApplyCode}
|
||||||
mergeDecorationsCollection={mergeDecorationsCollection}
|
mergeDecorationsCollection={mergeDecorationsCollection}
|
||||||
setMergeDecorationsCollection={setMergeDecorationsCollection}
|
setMergeDecorationsCollection={setMergeDecorationsCollection}
|
||||||
|
selectFile={selectFile}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</>
|
</>
|
||||||
|
@ -21,12 +21,14 @@ export default function DeployButtonModal({
|
|||||||
const { deploy, getAppExists } = useTerminal()
|
const { deploy, getAppExists } = useTerminal()
|
||||||
const [isDeploying, setIsDeploying] = useState(false)
|
const [isDeploying, setIsDeploying] = useState(false)
|
||||||
const [isDeployed, setIsDeployed] = useState(false)
|
const [isDeployed, setIsDeployed] = useState(false)
|
||||||
|
const [deployButtonVisible, setDeployButtonEnabled] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkDeployment = async () => {
|
const checkDeployment = async () => {
|
||||||
if (getAppExists) {
|
if (getAppExists) {
|
||||||
const exists = await getAppExists(data.id)
|
const exists = await getAppExists(data.id)
|
||||||
setIsDeployed(exists)
|
setDeployButtonEnabled(exists.success)
|
||||||
|
setIsDeployed((exists.success && exists.exists) ?? false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkDeployment()
|
checkDeployment()
|
||||||
@ -50,10 +52,12 @@ export default function DeployButtonModal({
|
|||||||
<>
|
<>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
{deployButtonVisible && (
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Globe className="w-4 h-4 mr-2" />
|
<Globe className="w-4 h-4 mr-2" />
|
||||||
Deploy
|
Deploy
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="p-4 w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl rounded-lg shadow-lg"
|
className="p-4 w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl rounded-lg shadow-lg"
|
||||||
|
@ -20,7 +20,9 @@ interface TerminalContextType {
|
|||||||
createNewTerminal: (command?: string) => Promise<void>
|
createNewTerminal: (command?: string) => Promise<void>
|
||||||
closeTerminal: (id: string) => void
|
closeTerminal: (id: string) => void
|
||||||
deploy: (callback: () => void) => void
|
deploy: (callback: () => void) => void
|
||||||
getAppExists: ((appName: string) => Promise<boolean>) | null
|
getAppExists:
|
||||||
|
| ((appName: string) => Promise<{ success: boolean; exists?: boolean }>)
|
||||||
|
| null
|
||||||
}
|
}
|
||||||
|
|
||||||
const TerminalContext = createContext<TerminalContextType | undefined>(
|
const TerminalContext = createContext<TerminalContextType | undefined>(
|
||||||
@ -92,16 +94,18 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAppExists = async (appName: string): Promise<boolean> => {
|
const getAppExists = async (
|
||||||
|
appName: string
|
||||||
|
): Promise<{ success: boolean; exists?: boolean }> => {
|
||||||
console.log("Is there a socket: " + !!socket)
|
console.log("Is there a socket: " + !!socket)
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
console.error("Couldn't check if app exists: No socket")
|
console.error("Couldn't check if app exists: No socket")
|
||||||
return false
|
return { success: false }
|
||||||
}
|
}
|
||||||
const response: { exists: boolean } = await new Promise((resolve) =>
|
const response: { success: boolean; exists?: boolean } = await new Promise(
|
||||||
socket.emit("getAppExists", { appName }, resolve)
|
(resolve) => socket.emit("getAppExists", { appName }, resolve)
|
||||||
)
|
)
|
||||||
return response.exists
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
|
Reference in New Issue
Block a user