Compare commits

...

13 Commits

Author SHA1 Message Date
0117e197bb fix: prevent duplicate tabs when clicking file paths
Modify selectFile function to check for existing tabs before creating new ones.
When clicking file paths in AIChat, it will now switch to the existing tab
instead of creating duplicates.
2025-01-01 22:31:23 -05:00
37ebe41526 fix: ctrl/cmd + z with applied code 2025-01-01 08:15:53 -05:00
ed367ed27c fix: close the SSH channel when a Dokku command is finished 2025-01-01 07:58:44 -05:00
9666401625 chore: add dokku-daemon instructions to README 2025-01-01 07:58:25 -05:00
22d638a090 feat: hide the deploy button when the Dokku server is not connected 2025-01-01 07:58:08 -05:00
0dd99cbc77 fix: apply button file save 2024-12-31 08:03:19 -05:00
093a4e9758 fix: correctly generate remoteFileId for paths without a leading slash 2024-12-09 13:59:47 -08:00
6a60f4d286 Update README.md 2024-12-06 14:32:17 -05:00
260110724e chore: update .env.example 2024-12-06 14:24:59 -05:00
ee51ae7a33 feat: correctly show whether a project has been deployed 2024-12-01 20:07:01 -08:00
0d0eed34b2 fix: apply code- discard button 2024-12-01 22:15:16 -05:00
4e1c5cac27 feat: Add clickable file paths in AI chat responses
- Detect file paths with dots in directory names (e.g. next/styles/SignIn.module.css)
- Create new files when path ends with "(new file)"
- Use existing socket connection and file management system
2024-12-01 18:52:28 -05:00
0ae89341d2 feat: file path above each code snippet 2024-12-01 14:29:23 -05:00
19 changed files with 414 additions and 123 deletions

View File

@ -2,7 +2,7 @@
![2024-10-2307 17 42-ezgif com-resize](https://github.com/user-attachments/assets/a4057129-81a7-4a31-a093-c8bc8189ae72) ![2024-10-2307 17 42-ezgif com-resize](https://github.com/user-attachments/assets/a4057129-81a7-4a31-a093-c8bc8189ae72)
Sandbox is an open-source cloud-based code editing environment with custom AI code 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 |

View File

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

View File

@ -31,9 +31,30 @@ export class DokkuClient extends SSHSocketClient {
// List all deployed Dokku apps // List all deployed Dokku apps
async listApps(): Promise<string[]> { async listApps(): Promise<string[]> {
const response = await this.sendCommand("apps:list") const response = await this.sendCommand("--quiet apps:list")
// Split the output by newline and remove the header return response.output.split("\n")
return response.output.split("\n").slice(1) }
// Get the creation timestamp of an app
async getAppCreatedAt(appName: string): Promise<number> {
const response = await this.sendCommand(
`apps:report --app-created-at ${appName}`
)
const createdAt = parseInt(response.output.trim(), 10)
if (isNaN(createdAt)) {
throw new Error(
`Failed to retrieve creation timestamp for app ${appName}`
)
}
return createdAt
}
// Check if an app exists
async appExists(appName: string): Promise<boolean> {
const response = await this.sendCommand(`apps:exists ${appName}`)
return response.output.includes("App") === false
} }
} }

View File

@ -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()
} }

View File

@ -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()
}) })
} }
) )

View File

@ -156,6 +156,40 @@ export class Sandbox {
return { success: true, apps: await this.dokkuClient.listApps() } return { success: true, apps: await this.dokkuClient.listApps() }
} }
// Handle getting app creation timestamp
const handleGetAppCreatedAt: SocketHandler = async ({ appName }) => {
if (!this.dokkuClient)
throw new Error(
"Failed to retrieve app creation timestamp: No Dokku client"
)
return {
success: true,
createdAt: await this.dokkuClient.getAppCreatedAt(appName),
}
}
// Handle checking if an app exists
const handleAppExists: SocketHandler = async ({ appName }) => {
if (!this.dokkuClient) {
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 {
success: true,
exists: await this.dokkuClient.appExists(appName),
}
}
// Handle deploying code // Handle deploying code
const handleDeploy: SocketHandler = async (_: any) => { const handleDeploy: SocketHandler = async (_: any) => {
if (!this.gitClient) throw Error("No git client") if (!this.gitClient) throw Error("No git client")
@ -253,7 +287,9 @@ export class Sandbox {
getFolder: handleGetFolder, getFolder: handleGetFolder,
saveFile: handleSaveFile, saveFile: handleSaveFile,
moveFile: handleMoveFile, moveFile: handleMoveFile,
list: handleListApps, listApps: handleListApps,
getAppCreatedAt: handleGetAppCreatedAt,
getAppExists: handleAppExists,
deploy: handleDeploy, deploy: handleDeploy,
createFile: handleCreateFile, createFile: handleCreateFile,
createFolder: handleCreateFolder, createFolder: handleCreateFolder,

View File

@ -146,6 +146,8 @@ io.on("connection", async (socket) => {
) )
}) })
socket.emit("ready")
// Handle disconnection event // Handle disconnection event
socket.on("disconnect", async () => { socket.on("disconnect", async () => {
try { try {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }) =>

View File

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

View File

@ -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
debouncedGetFile(tab.id, (response: string) => { setTabs((prev) => [...prev, tab])
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
setActiveFileContent(response) // Fetch content if not cached
}) if (!fileContents[tab.id]) {
debouncedGetFile(tab.id, (response: string) => {
setFileContents((prev) => ({ ...prev, [tab.id]: 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>
</> </>

View File

@ -9,7 +9,7 @@ import {
import { useTerminal } from "@/context/TerminalContext" import { useTerminal } from "@/context/TerminalContext"
import { Sandbox, User } from "@/lib/types" import { Sandbox, User } from "@/lib/types"
import { Globe } from "lucide-react" import { Globe } from "lucide-react"
import { useState } from "react" import { useEffect, useState } from "react"
export default function DeployButtonModal({ export default function DeployButtonModal({
userData, userData,
@ -18,8 +18,21 @@ export default function DeployButtonModal({
userData: User userData: User
data: Sandbox data: Sandbox
}) { }) {
const { deploy } = useTerminal() const { deploy, getAppExists } = useTerminal()
const [isDeploying, setIsDeploying] = useState(false) const [isDeploying, setIsDeploying] = useState(false)
const [isDeployed, setIsDeployed] = useState(false)
const [deployButtonVisible, setDeployButtonEnabled] = useState(false)
useEffect(() => {
const checkDeployment = async () => {
if (getAppExists) {
const exists = await getAppExists(data.id)
setDeployButtonEnabled(exists.success)
setIsDeployed((exists.success && exists.exists) ?? false)
}
}
checkDeployment()
}, [data.id, getAppExists])
const handleDeploy = () => { const handleDeploy = () => {
if (isDeploying) { if (isDeploying) {
@ -30,6 +43,7 @@ export default function DeployButtonModal({
setIsDeploying(true) setIsDeploying(true)
deploy(() => { deploy(() => {
setIsDeploying(false) setIsDeploying(false)
setIsDeployed(true)
}) })
} }
} }
@ -38,10 +52,12 @@ export default function DeployButtonModal({
<> <>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline"> {deployButtonVisible && (
<Globe className="w-4 h-4 mr-2" /> <Button variant="outline">
Deploy <Globe className="w-4 h-4 mr-2" />
</Button> Deploy
</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"
@ -52,8 +68,9 @@ export default function DeployButtonModal({
<DeploymentOption <DeploymentOption
icon={<Globe className="text-gray-500 w-5 h-5" />} icon={<Globe className="text-gray-500 w-5 h-5" />}
domain={`${data.id}.gitwit.app`} domain={`${data.id}.gitwit.app`}
timestamp="Deployed 1h ago" timestamp="Deployed 1m ago"
user={userData.name} user={userData.name}
isDeployed={isDeployed}
/> />
</div> </div>
<Button <Button
@ -61,7 +78,7 @@ export default function DeployButtonModal({
className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]" className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]"
onClick={handleDeploy} onClick={handleDeploy}
> >
{isDeploying ? "Deploying..." : "Update"} {isDeploying ? "Deploying..." : isDeployed ? "Update" : "Deploy"}
</Button> </Button>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -74,27 +91,33 @@ function DeploymentOption({
domain, domain,
timestamp, timestamp,
user, user,
isDeployed,
}: { }: {
icon: React.ReactNode icon: React.ReactNode
domain: string domain: string
timestamp: string timestamp: string
user: string user: string
isDeployed: boolean
}) { }) {
return ( return (
<div className="flex flex-col gap-2 w-full text-left p-2 rounded-md border border-gray-700 bg-gray-900"> <div className="flex flex-col gap-2 w-full text-left p-2 rounded-md border border-gray-700 bg-gray-900">
<div className="flex items-start gap-2 relative"> <div className="flex items-start gap-2 relative">
<div className="flex-shrink-0">{icon}</div> <div className="flex-shrink-0">{icon}</div>
<a {isDeployed ? (
href={`https://${domain}`} <a
target="_blank" href={`https://${domain}`}
rel="noopener noreferrer" target="_blank"
className="font-semibold text-gray-300 hover:underline" rel="noopener noreferrer"
> className="font-semibold text-gray-300 hover:underline"
{domain} >
</a> {domain}
</a>
) : (
<span className="font-semibold text-gray-300">{domain}</span>
)}
</div> </div>
<p className="text-sm text-gray-400 mt-0 ml-7"> <p className="text-sm text-gray-400 mt-0 ml-7">
{timestamp} {user} {isDeployed ? `${timestamp}${user}` : "Never deployed"}
</p> </p>
</div> </div>
) )

View File

@ -20,6 +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<{ success: boolean; exists?: boolean }>)
| null
} }
const TerminalContext = createContext<TerminalContextType | undefined>( const TerminalContext = createContext<TerminalContextType | undefined>(
@ -35,6 +38,19 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
>([]) >([])
const [activeTerminalId, setActiveTerminalId] = useState<string>("") const [activeTerminalId, setActiveTerminalId] = useState<string>("")
const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false) const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false)
const [isSocketReady, setIsSocketReady] = useState<boolean>(false)
// Listen for the "ready" signal from the socket
React.useEffect(() => {
if (socket) {
socket.on("ready", () => {
setIsSocketReady(true)
})
}
return () => {
if (socket) socket.off("ready")
}
}, [socket])
const createNewTerminal = async (command?: string): Promise<void> => { const createNewTerminal = async (command?: string): Promise<void> => {
if (!socket) return if (!socket) return
@ -78,6 +94,20 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
}) })
} }
const getAppExists = async (
appName: string
): Promise<{ success: boolean; exists?: boolean }> => {
console.log("Is there a socket: " + !!socket)
if (!socket) {
console.error("Couldn't check if app exists: No socket")
return { success: false }
}
const response: { success: boolean; exists?: boolean } = await new Promise(
(resolve) => socket.emit("getAppExists", { appName }, resolve)
)
return response
}
const value = { const value = {
terminals, terminals,
setTerminals, setTerminals,
@ -88,6 +118,7 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
createNewTerminal, createNewTerminal,
closeTerminal, closeTerminal,
deploy, deploy,
getAppExists: isSocketReady ? getAppExists : null,
} }
return ( return (