Merge branch 'main' into feat/profile-page

This commit is contained in:
Hamzat Victor Oluwabori
2024-11-25 23:10:21 +01:00
committed by GitHub
79 changed files with 2716 additions and 5837 deletions

View File

@ -18,11 +18,38 @@ export default function AboutModal({
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>About this project</DialogTitle>
<DialogTitle>Help & Support</DialogTitle>
</DialogHeader>
<div className="text-sm text-muted-foreground">
Sandbox is an open-source cloud-based code editing environment with
custom AI code autocompletion and real-time collaboration.
<div className="space-y-4">
{/* <div className="text-sm text-muted-foreground">
Sandbox is an open-source cloud-based code editing environment with
custom AI code autocompletion and real-time collaboration.
</div> */}
<div className="text-sm text-muted-foreground">
Get help and support through our Discord community or by creating issues on GitHub:
</div>
<div className="space-y-2">
<div className="text-sm">
<a
href="https://discord.gitwit.dev/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Join our Discord community
</a>
</div>
<div className="text-sm">
<a
href="https://github.com/jamesmurdza/sandbox/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Report issues on GitHub
</a>
</div>
</div>
</div>
</DialogContent>
</Dialog>

View File

@ -25,6 +25,7 @@ export default function Dashboard({
type: "react" | "node"
author: string
sharedOn: Date
authorAvatarUrl?: string
}[]
}) {
const [screen, setScreen] = useState<TScreen>("projects")
@ -77,14 +78,14 @@ export default function Dashboard({
<FolderDot className="w-4 h-4 mr-2" />
My Projects
</Button>
<Button
{/* <Button
variant="ghost"
onClick={() => setScreen("shared")}
className={activeScreen("shared")}
>
<Users className="w-4 h-4 mr-2" />
Shared With Me
</Button>
</Button> */}
{/* <Button
variant="ghost"
onClick={() => setScreen("settings")}
@ -110,7 +111,7 @@ export default function Dashboard({
className="justify-start font-normal text-muted-foreground"
>
<HelpCircle className="w-4 h-4 mr-2" />
About
Help
</Button>
</div>
</div>
@ -121,7 +122,12 @@ export default function Dashboard({
) : null}
</>
) : screen === "shared" ? (
<DashboardSharedWithMe shared={shared} />
<DashboardSharedWithMe
shared={shared.map((item) => ({
...item,
authorAvatarUrl: item.authorAvatarUrl || "",
}))}
/>
) : screen === "settings" ? null : null}
</div>
</>

View File

@ -6,6 +6,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { projectTemplates } from "@/lib/data"
import { ChevronRight } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
@ -18,7 +19,7 @@ export default function DashboardSharedWithMe({
shared: {
id: string
name: string
type: "react" | "node"
type: string
author: string
authorAvatarUrl: string
sharedOn: Date
@ -46,9 +47,8 @@ export default function DashboardSharedWithMe({
<Image
alt=""
src={
sandbox.type === "react"
? "/project-icons/react.svg"
: "/project-icons/node.svg"
projectTemplates.find((p) => p.id === sandbox.type)
?.icon ?? "/project-icons/node.svg"
}
width={20}
height={20}
@ -59,7 +59,7 @@ export default function DashboardSharedWithMe({
</TableCell>
<TableCell>
<div className="flex items-center">
<Avatar
<Avatar
name={sandbox.author}
avatarUrl={sandbox.authorAvatarUrl}
className="mr-2"

View File

@ -1,10 +1,9 @@
import { Send, StopCircle, Image as ImageIcon, Paperclip } from "lucide-react"
import { Button } from "../../ui/button"
import { useEffect } from "react"
import { TFile, TFolder } from "@/lib/types"
import { ALLOWED_FILE_TYPES } from "./types"
import { Image as ImageIcon, Paperclip, Send, StopCircle } from "lucide-react"
import { useEffect } from "react"
import { Button } from "../../ui/button"
import { looksLikeCode } from "./lib/chatUtils"
import { ChatInputProps } from "./types"
import { ALLOWED_FILE_TYPES, ChatInputProps } from "./types"
export default function ChatInput({
input,
@ -21,12 +20,11 @@ export default function ChatInput({
onRemoveTab,
textareaRef,
}: ChatInputProps) {
// Auto-resize textarea as content changes
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'
textareaRef.current.style.height = "auto"
textareaRef.current.style.height = textareaRef.current.scrollHeight + "px"
}
}, [input])
@ -40,7 +38,11 @@ export default function ChatInput({
e.preventDefault()
handleSend(false)
}
} else if (e.key === "Backspace" && input === "" && contextTabs.length > 0) {
} else if (
e.key === "Backspace" &&
input === "" &&
contextTabs.length > 0
) {
e.preventDefault()
// Remove the last context tab
const lastTab = contextTabs[contextTabs.length - 1]
@ -51,89 +53,92 @@ export default function ChatInput({
// Handle paste events for image and code
const handlePaste = async (e: React.ClipboardEvent) => {
// Handle image paste
const items = Array.from(e.clipboardData.items);
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;
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();
const reader = new FileReader()
reader.onload = () => {
const base64String = reader.result as string;
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')}`,
`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);
)
}
reader.readAsDataURL(file)
} catch (error) {
console.error('Error processing pasted image:', error);
console.error("Error processing pasted image:", error)
}
return;
return
}
}
// Get text from clipboard
const text = e.clipboardData.getData('text');
// 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;
if (!text || !text.includes("\n") || !looksLikeCode(text)) {
return
}
e.preventDefault();
const editor = editorRef.current;
const currentSelection = editor?.getSelection();
const lines = text.split('\n');
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;
{
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;
const range = lastCopiedRangeRef.current
addContextTab(
"code",
`${activeFileName} (${range.startLine}-${range.endLine})`,
text,
{ start: range.startLine, end: range.endLine }
);
return;
)
return
}
// For code pasted from outside the editor
addContextTab(
"code",
`Pasted Code (1-${lines.length})`,
text,
{ start: 1, end: lines.length }
);
};
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/*'
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)
@ -155,14 +160,16 @@ export default function ChatInput({
// 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'
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.')
alert(
"Unsupported file type. Please upload text, code, or PDF files."
)
return
}
@ -223,17 +230,16 @@ export default function ChatInput({
<span className="hidden sm:inline">File</span>
</Button>
{/* Render image upload button */}
<Button
variant="ghost"
<Button
variant="ghost"
size="sm"
className="h-6 px-2 sm:px-3"
onClick={handleImageUpload}
>
<ImageIcon className="h-3 w-3 sm:mr-1" />
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>
)
}

View File

@ -3,9 +3,9 @@ import React, { useState } from "react"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { Button } from "../../ui/button"
import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
import ContextTabs from "./ContextTabs"
import { createMarkdownComponents } from './lib/markdownComponents'
import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
import { createMarkdownComponents } from "./lib/markdownComponents"
import { MessageProps } from "./types"
export default function ChatMessage({
@ -14,7 +14,6 @@ export default function ChatMessage({
setIsContextExpanded,
socket,
}: MessageProps) {
// State for expanded message index
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
number | null
@ -23,7 +22,7 @@ export default function ChatMessage({
// State for copied text
const [copiedText, setCopiedText] = useState<string | null>(null)
// Render copy button for text content
// Render copy button for text content
const renderCopyButton = (text: any) => (
<Button
onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)}
@ -43,26 +42,26 @@ export default function ChatMessage({
const askAboutCode = (code: any) => {
const contextString = stringifyContent(code)
const newContext = `Regarding this code:\n${contextString}`
// Format timestamp to match chat message format (HH:MM PM)
const timestamp = new Date().toLocaleTimeString('en-US', {
const timestamp = new Date().toLocaleTimeString("en-US", {
hour12: true,
hour: '2-digit',
minute: '2-digit',
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
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
end: contextString.split("\n").length,
})
}
setIsContextExpanded(false)
@ -127,7 +126,9 @@ export default function ChatMessage({
contextTabs={parseContextToTabs(message.context)}
onRemoveTab={() => {}}
isExpanded={expandedMessageIndex === 0}
onToggleExpand={() => setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)}
onToggleExpand={() =>
setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)
}
className="[&_div:first-child>div:first-child>div]:bg-[#0D0D0D] [&_button:first-child]:hidden [&_button:last-child]:hidden"
/>
{expandedMessageIndex === 0 && (
@ -153,7 +154,7 @@ export default function ChatMessage({
const updatedContext = `Regarding this code:\n${e.target.value}`
setContext(updatedContext, "Selected Content", {
start: 1,
end: e.target.value.split('\n').length
end: e.target.value.split("\n").length,
})
}}
className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded"
@ -187,10 +188,7 @@ export default function ChatMessage({
)}
{/* Render markdown content */}
{message.role === "assistant" ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={components}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{message.content}
</ReactMarkdown>
) : (
@ -201,26 +199,28 @@ export default function ChatMessage({
)
}
// Parse context to tabs for context tabs component
// 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)
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,16 +1,15 @@
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 { Input } from "@/components/ui/input"
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 { TFile, TFolder } from "@/lib/types"
import { FileText, Image as ImageIcon, Plus, X } from "lucide-react"
import { useState } from "react"
import { Button } from "../../ui/button"
import { ContextTab, ContextTabsProps } from "./types"
// Ignore certain folders and files from the file tree
import { ignoredFiles, ignoredFolders } from "./lib/ignored-paths"
export default function ContextTabs({
@ -20,7 +19,6 @@ export default function ContextTabs({
files = [],
onFileSelect,
}: ContextTabsProps & { className?: string }) {
// State for preview tab
const [previewTab, setPreviewTab] = useState<ContextTab | null>(null)
const [searchQuery, setSearchQuery] = useState("")
@ -28,9 +26,9 @@ export default function ContextTabs({
// Allow preview for images and code selections from editor
const togglePreview = (tab: ContextTab) => {
if (!tab.lineRange && tab.type !== "image") {
return;
return
}
// Toggle preview for images and code selections from editor
if (previewTab?.id === tab.id) {
setPreviewTab(null)
@ -50,13 +48,21 @@ export default function ContextTabs({
// 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
)) {
// 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)) {
// 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
@ -65,22 +71,18 @@ export default function ContextTabs({
// Get all files from the file tree to search for context when adding context
const allFiles = getAllFiles(files)
const filteredFiles = allFiles.filter(file =>
const filteredFiles = allFiles.filter((file) =>
file.name.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<div className={`border-none ${className || ''}`}>
<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"
>
<Button variant="ghost" size="icon" className="h-6 w-6">
<Plus className="h-4 w-4" />
</Button>
</PopoverTrigger>
@ -143,20 +145,23 @@ export default function ContextTabs({
{previewTab && (
<div className="p-2 bg-input rounded-md max-h-[200px] overflow-auto mb-2">
{previewTab.type === "image" ? (
<img
src={previewTab.content}
<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>
</>
) : (
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" && (
@ -169,4 +174,4 @@ export default function ContextTabs({
</div>
</div>
)
}
}

View File

@ -1,14 +1,14 @@
import { X } from "lucide-react"
import { useSocket } from "@/context/SocketContext"
import { TFile } from "@/lib/types"
import { X, ChevronDown } from "lucide-react"
import { nanoid } from "nanoid"
import { useEffect, useRef, useState } from "react"
import LoadingDots from "../../ui/LoadingDots"
import ChatInput from "./ChatInput"
import ChatMessage from "./ChatMessage"
import ContextTabs from "./ContextTabs"
import { handleSend, handleStopGeneration } from "./lib/chatUtils"
import { nanoid } from 'nanoid'
import { TFile } from "@/lib/types"
import { useSocket } from "@/context/SocketContext"
import { Message, ContextTab, AIChatProps } from './types'
import { AIChatProps, ContextTab, Message } from "./types"
export default function AIChat({
activeFileContent,
@ -17,6 +17,7 @@ export default function AIChat({
editorRef,
lastCopiedRangeRef,
files,
templateType,
}: AIChatProps) {
// Initialize socket and messages
const { socket } = useSocket()
@ -38,65 +39,96 @@ export default function AIChat({
// Initialize textarea ref
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Scroll to bottom of chat when messages change
useEffect(() => {
scrollToBottom()
}, [messages])
// state variables for auto scroll and scroll button
const [autoScroll, setAutoScroll] = useState(true)
const [showScrollButton, setShowScrollButton] = useState(false)
// Scroll to bottom of chat when messages change
const scrollToBottom = () => {
if (chatContainerRef.current) {
setTimeout(() => {
chatContainerRef.current?.scrollTo({
top: chatContainerRef.current.scrollHeight,
behavior: "smooth",
})
}, 100)
// scroll to bottom of chat when messages change
useEffect(() => {
if (autoScroll) {
scrollToBottom()
}
}, [messages, autoScroll])
// scroll to bottom of chat when messages change
const scrollToBottom = (force: boolean = false) => {
if (!chatContainerRef.current || (!autoScroll && !force)) return
chatContainerRef.current.scrollTo({
top: chatContainerRef.current.scrollHeight,
behavior: force ? "smooth" : "auto",
})
}
// function to handle scroll events
const handleScroll = () => {
if (!chatContainerRef.current) return
const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 50
setAutoScroll(isAtBottom)
setShowScrollButton(!isAtBottom)
}
// scroll event listener
useEffect(() => {
const container = chatContainerRef.current
if (container) {
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}
}, [])
// Add context tab to context tabs
const addContextTab = (type: string, name: string, content: string, lineRange?: { start: number; end: number }) => {
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
lineRange,
}
setContextTabs(prev => [...prev, newTab])
setContextTabs((prev) => [...prev, newTab])
}
// Remove context tab from context tabs
const removeContextTab = (id: string) => {
setContextTabs(prev => prev.filter(tab => tab.id !== id))
setContextTabs((prev) => prev.filter((tab) => tab.id !== id))
}
// Add file to context tabs
const handleAddFile = (tab: ContextTab) => {
setContextTabs(prev => [...prev, tab])
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```$/, '')
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')
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
@ -112,7 +144,9 @@ export default function AIChat({
setIsGenerating,
setIsLoading,
abortControllerRef,
activeFileContent
activeFileContent,
false,
templateType
)
// Clear context tabs after sending
setContextTabs([])
@ -120,9 +154,9 @@ export default function AIChat({
// Set context for the chat
const setContext = (
context: string | null,
name: string,
range?: { start: number, end: number }
context: string | null,
name: string,
range?: { start: number; end: number }
) => {
if (!context) {
setContextTabs([])
@ -130,7 +164,7 @@ export default function AIChat({
}
// Always add a new tab instead of updating existing ones
addContextTab('code', name, context, range)
addContextTab("code", name, context, range)
}
return (
@ -153,10 +187,10 @@ export default function AIChat({
</div>
<div
ref={chatContainerRef}
className="flex-grow overflow-y-auto p-4 space-y-4"
className="flex-grow overflow-y-auto p-4 space-y-4 relative"
>
{messages.map((message, messageIndex) => (
// Render chat message component for each message
// Render chat message component for each message
<ChatMessage
key={messageIndex}
message={message}
@ -166,6 +200,17 @@ export default function AIChat({
/>
))}
{isLoading && <LoadingDots />}
{/* Add scroll to bottom button */}
{showScrollButton && (
<button
onClick={() => scrollToBottom(true)}
className="fixed bottom-36 right-6 bg-primary text-primary-foreground rounded-md border border-primary p-0.5 shadow-lg hover:bg-primary/90 transition-all"
aria-label="Scroll to bottom"
>
<ChevronDown className="h-5 w-5" />
</button>
)}
</div>
<div className="p-4 border-t mb-14">
{/* Render context tabs component */}
@ -180,9 +225,9 @@ export default function AIChat({
socket={socket}
onFileSelect={(file: TFile) => {
socket?.emit("getFile", { fileId: file.id }, (response: string) => {
const fileExt = file.name.split('.').pop() || 'txt'
const fileExt = file.name.split(".").pop() || "txt"
const formattedContent = `\`\`\`${fileExt}\n${response}\n\`\`\``
addContextTab('file', file.name, formattedContent)
addContextTab("file", file.name, formattedContent)
if (textareaRef.current) {
textareaRef.current.focus()
}
@ -210,9 +255,9 @@ export default function AIChat({
}}
lastCopiedRangeRef={lastCopiedRangeRef}
activeFileName={activeFileName}
contextTabs={contextTabs.map(tab => ({
contextTabs={contextTabs.map((tab) => ({
...tab,
title: tab.id
title: tab.id,
}))}
onRemoveTab={removeContextTab}
/>

View File

@ -1,6 +1,6 @@
import React from "react"
// Stringify content for chat message component
// Stringify content for chat message component
export const stringifyContent = (
content: any,
seen = new WeakSet()
@ -66,19 +66,19 @@ export const stringifyContent = (
return String(content)
}
// Copy to clipboard for chat message component
// Copy to clipboard for chat message component
export const copyToClipboard = (
text: string,
setCopiedText: (text: string | null) => void
) => {
// Copy text to clipboard for chat message component
// Copy text to clipboard for chat message component
navigator.clipboard.writeText(text).then(() => {
setCopiedText(text)
setTimeout(() => setCopiedText(null), 2000)
})
}
// Handle send for chat message component
// Handle send for chat message component
export const handleSend = async (
input: string,
context: string | null,
@ -89,27 +89,31 @@ export const handleSend = async (
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>,
abortControllerRef: React.MutableRefObject<AbortController | null>,
activeFileContent: string
activeFileContent: string,
isEditMode: boolean = false,
templateType: string
) => {
// Return if input is empty and context is null
if (input.trim() === "" && !context) return
if (input.trim() === "" && !context) return
// 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')
// 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
// Create user message for chat message component
const userMessage = {
role: "user" as const,
content: input,
context: context || undefined,
timestamp: timestamp
timestamp: timestamp,
}
// Update messages for chat message component
// Update messages for chat message component
const updatedMessages = [...messages, userMessage]
setMessages(updatedMessages)
setInput("")
@ -120,24 +124,25 @@ export const handleSend = async (
abortControllerRef.current = new AbortController()
try {
// Create anthropic messages for chat message component
// Create anthropic messages for chat message component
const anthropicMessages = updatedMessages.map((msg) => ({
role: msg.role === "user" ? "human" : "assistant",
content: msg.content,
}))
// Fetch AI response for chat message component
const response = await fetch(
`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// Fetch AI response for chat message component
const response = await fetch("/api/ai",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: anthropicMessages,
context: context || undefined,
activeFileContent: activeFileContent,
isEditMode: isEditMode,
templateType: templateType,
}),
signal: abortControllerRef.current.signal,
}
@ -145,22 +150,23 @@ export const handleSend = async (
// Throw error if response is not ok
if (!response.ok) {
throw new Error("Failed to get AI response")
const error = await response.text()
throw new Error(error)
}
// Get reader for chat message component
// Get reader for chat message component
const reader = response.body?.getReader()
const decoder = new TextDecoder()
const assistantMessage = { role: "assistant" as const, content: "" }
setMessages([...updatedMessages, assistantMessage])
setIsLoading(false)
// Initialize buffer for chat message component
// Initialize buffer for chat message component
let buffer = ""
const updateInterval = 100
let lastUpdateTime = Date.now()
// Read response from reader for chat message component
// Read response from reader for chat message component
if (reader) {
while (true) {
const { done, value } = await reader.read()
@ -179,7 +185,7 @@ export const handleSend = async (
}
}
// Update messages for chat message component
// Update messages for chat message component
setMessages((prev) => {
const updatedMessages = [...prev]
const lastMessage = updatedMessages[updatedMessages.length - 1]
@ -188,14 +194,14 @@ export const handleSend = async (
})
}
} catch (error: any) {
// Handle abort error for chat message component
// Handle abort error for chat message component
if (error.name === "AbortError") {
console.log("Generation aborted")
} else {
console.error("Error fetching AI response:", error)
const errorMessage = {
role: "assistant" as const,
content: "Sorry, I encountered an error. Please try again.",
content: error.message || "Sorry, I encountered an error. Please try again.",
}
setMessages((prev) => [...prev, errorMessage])
}
@ -206,7 +212,7 @@ export const handleSend = async (
}
}
// Handle stop generation for chat message component
// Handle stop generation for chat message component
export const handleStopGeneration = (
abortControllerRef: React.MutableRefObject<AbortController | null>
) => {
@ -215,21 +221,21 @@ export const handleStopGeneration = (
}
}
// Check if text looks like code for chat message component
// 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
];
/^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));
};
return codeIndicators.some((pattern) => pattern.test(text))
}

View File

@ -1,102 +1,102 @@
// Ignore certain folders and files from the file tree
// 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;
// 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

@ -1,21 +1,26 @@
import { CornerUpLeft } from "lucide-react"
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
// 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,
code: ({
node,
className,
children,
...props
}: {
node?: import("hast").Element
className?: string
children?: React.ReactNode
[key: string]: any
}) => {
const match = /language-(\w+)/.exec(className || "")
@ -55,25 +60,30 @@ export const createMarkdownComponents = (
</div>
</div>
) : (
<code className={className} {...props}>{children}</code>
<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 }),
// 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>
<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>
<ol className="list-decimal pl-6 mb-4 space-y-2">{props.children}</ol>
),
})

View File

@ -1,28 +1,29 @@
import * as monaco from 'monaco-editor'
import { TemplateConfig } from "@/lib/templates"
import { TFile, TFolder } from "@/lib/types"
import { Socket } from 'socket.io-client';
import * as monaco from "monaco-editor"
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,
"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,
"application/json": true,
"text/javascript": true,
"text/typescript": true,
"text/html": true,
"text/css": true,
// Documents
'application/pdf': true,
"application/pdf": true,
// Images
'image/jpeg': true,
'image/png': true,
'image/gif': true,
'image/webp': true,
'image/svg+xml': true,
} as const;
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"image/svg+xml": true,
} as const
// Message interface
export interface Message {
@ -45,9 +46,16 @@ export interface AIChatProps {
activeFileContent: string
activeFileName: string
onClose: () => void
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null>
editorRef: React.MutableRefObject<
monaco.editor.IStandaloneCodeEditor | undefined
>
lastCopiedRangeRef: React.MutableRefObject<{
startLine: number
endLine: number
} | null>
files: (TFile | TFolder)[]
templateType: string
templateConfig?: TemplateConfig
}
// Chat input props interface
@ -58,11 +66,27 @@ export interface ChatInputProps {
handleSend: (useFullContext?: boolean) => void
handleStopGeneration: () => void
onImageUpload: (file: File) => void
addContextTab: (type: string, title: string, content: string, lineRange?: { start: number, end: number }) => 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 } }[]
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>
}
@ -74,7 +98,11 @@ export interface MessageProps {
content: string
context?: string
}
setContext: (context: string | null, name: string, range?: { start: number, end: number }) => void
setContext: (
context: string | null,
name: string,
range?: { start: number; end: number }
) => void
setIsContextExpanded: (isExpanded: boolean) => void
socket: Socket | null
}

View File

@ -5,14 +5,12 @@ import { Editor } from "@monaco-editor/react"
import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"
import { usePathname, useRouter } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react"
import { Socket } from "socket.io-client"
import { toast } from "sonner"
import { Button } from "../ui/button"
// import monaco from "monaco-editor"
export default function GenerateInput({
user,
socket,
width,
data,
editor,
@ -21,7 +19,6 @@ export default function GenerateInput({
onClose,
}: {
user: User
socket: Socket
width: number
data: {
fileName: string
@ -59,32 +56,54 @@ export default function GenerateInput({
}: {
regenerate?: boolean
}) => {
if (user.generations >= 1000) {
toast.error("You reached the maximum # of generations.")
return
}
try {
setLoading({ generate: !regenerate, regenerate })
setCurrentPrompt(input)
setLoading({ generate: !regenerate, regenerate })
setCurrentPrompt(input)
socket.emit(
"generateCode",
{
fileName: data.fileName,
code: data.code,
line: data.line,
instructions: regenerate ? currentPrompt : input
},
(res: { response: string; success: boolean }) => {
console.log("Generated code", res.response, res.success)
// if (!res.success) {
// toast.error("Failed to generate code.");
// return;
// }
const response = await fetch("/api/ai", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: [{
role: "user",
content: regenerate ? currentPrompt : input
}],
context: null,
activeFileContent: data.code,
isEditMode: true,
fileName: data.fileName,
line: data.line
}),
})
setCode(res.response)
router.refresh()
if (!response.ok) {
const error = await response.text()
toast.error(error)
return
}
)
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let result = ""
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value, { stream: true })
}
}
setCode(result.trim())
router.refresh()
} catch (error) {
console.error("Generation error:", error)
toast.error("Failed to generate code")
} finally {
setLoading({ generate: false, regenerate: false })
}
}
const handleGenerateForm = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {

View File

@ -7,11 +7,11 @@ import * as monaco from "monaco-editor"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { TypedLiveblocksProvider, useRoom, useSelf } from "@/liveblocks.config"
import LiveblocksProvider from "@liveblocks/yjs"
import { MonacoBinding } from "y-monaco"
import { Awareness } from "y-protocols/awareness"
import * as Y from "yjs"
// import { TypedLiveblocksProvider, useRoom, useSelf } from "@/liveblocks.config"
// import LiveblocksProvider from "@liveblocks/yjs"
// import { MonacoBinding } from "y-monaco"
// import { Awareness } from "y-protocols/awareness"
// import * as Y from "yjs"
import {
ResizableHandle,
@ -23,7 +23,6 @@ import { useSocket } from "@/context/SocketContext"
import { parseTSConfigToMonacoOptions } from "@/lib/tsconfig"
import { Sandbox, TFile, TFolder, TTab, User } from "@/lib/types"
import {
addNew,
cn,
debounce,
deepMerge,
@ -46,7 +45,7 @@ import { Button } from "../ui/button"
import Tab from "../ui/tab"
import AIChat from "./AIChat"
import GenerateInput from "./generate"
import { Cursors } from "./live/cursors"
// import { Cursors } from "./live/cursors"
import DisableAccessModal from "./live/disableModal"
import Loading from "./loading"
import PreviewWindow from "./preview"
@ -147,20 +146,20 @@ export default function CodeEditor({
const isOwner = sandboxData.userId === userData.id
const clerk = useClerk()
// Liveblocks hooks
const room = useRoom()
const [provider, setProvider] = useState<TypedLiveblocksProvider>()
const userInfo = useSelf((me) => me.info)
// // Liveblocks hooks
// const room = useRoom()
// const [provider, setProvider] = useState<TypedLiveblocksProvider>()
// const userInfo = useSelf((me) => me.info)
// Liveblocks providers map to prevent reinitializing providers
type ProviderData = {
provider: LiveblocksProvider<never, never, never, never>
yDoc: Y.Doc
yText: Y.Text
binding?: MonacoBinding
onSync: (isSynced: boolean) => void
}
const providersMap = useRef(new Map<string, ProviderData>())
// // Liveblocks providers map to prevent reinitializing providers
// type ProviderData = {
// provider: LiveblocksProvider<never, never, never, never>
// yDoc: Y.Doc
// yText: Y.Text
// binding?: MonacoBinding
// onSync: (isSynced: boolean) => void
// }
// const providersMap = useRef(new Map<string, ProviderData>())
// Refs for libraries / features
const editorContainerRef = useRef<HTMLDivElement>(null)
@ -173,7 +172,10 @@ export default function CodeEditor({
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 lastCopiedRangeRef = useRef<{
startLine: number
endLine: number
} | null>(null)
const debouncedSetIsSelected = useRef(
debounce((value: boolean) => {
@ -260,14 +262,14 @@ export default function CodeEditor({
// Store the last copied range in the editor to be used in the AIChat component
editor.onDidChangeCursorSelection((e) => {
const selection = editor.getSelection();
const selection = editor.getSelection()
if (selection) {
lastCopiedRangeRef.current = {
startLine: selection.startLineNumber,
endLine: selection.endLineNumber
};
endLine: selection.endLineNumber,
}
}
});
})
}
// Call the function with your file structure
@ -571,82 +573,82 @@ export default function CodeEditor({
}
}, [activeFileId, tabs, debouncedSaveData, setIsAIChatOpen, editorRef])
// Liveblocks live collaboration setup effect
useEffect(() => {
const tab = tabs.find((t) => t.id === activeFileId)
const model = editorRef?.getModel()
// // Liveblocks live collaboration setup effect
// useEffect(() => {
// const tab = tabs.find((t) => t.id === activeFileId)
// const model = editorRef?.getModel()
if (!editorRef || !tab || !model) return
// if (!editorRef || !tab || !model) return
let providerData: ProviderData
// let providerData: ProviderData
// When a file is opened for the first time, create a new provider and store in providersMap.
if (!providersMap.current.has(tab.id)) {
const yDoc = new Y.Doc()
const yText = yDoc.getText(tab.id)
const yProvider = new LiveblocksProvider(room, yDoc)
// // When a file is opened for the first time, create a new provider and store in providersMap.
// if (!providersMap.current.has(tab.id)) {
// const yDoc = new Y.Doc()
// const yText = yDoc.getText(tab.id)
// const yProvider = new LiveblocksProvider(room, yDoc)
// Inserts the file content into the editor once when the tab is changed.
const onSync = (isSynced: boolean) => {
if (isSynced) {
const text = yText.toString()
if (text === "") {
if (activeFileContent) {
yText.insert(0, activeFileContent)
} else {
setTimeout(() => {
yText.insert(0, editorRef.getValue())
}, 0)
}
}
}
}
// // Inserts the file content into the editor once when the tab is changed.
// const onSync = (isSynced: boolean) => {
// if (isSynced) {
// const text = yText.toString()
// if (text === "") {
// if (activeFileContent) {
// yText.insert(0, activeFileContent)
// } else {
// setTimeout(() => {
// yText.insert(0, editorRef.getValue())
// }, 0)
// }
// }
// }
// }
yProvider.on("sync", onSync)
// yProvider.on("sync", onSync)
// Save the provider to the map.
providerData = { provider: yProvider, yDoc, yText, onSync }
providersMap.current.set(tab.id, providerData)
} else {
// When a tab is opened that has been open before, reuse the existing provider.
providerData = providersMap.current.get(tab.id)!
}
// // Save the provider to the map.
// providerData = { provider: yProvider, yDoc, yText, onSync }
// providersMap.current.set(tab.id, providerData)
// } else {
// // When a tab is opened that has been open before, reuse the existing provider.
// providerData = providersMap.current.get(tab.id)!
// }
const binding = new MonacoBinding(
providerData.yText,
model,
new Set([editorRef]),
providerData.provider.awareness as unknown as Awareness
)
// const binding = new MonacoBinding(
// providerData.yText,
// model,
// new Set([editorRef]),
// providerData.provider.awareness as unknown as Awareness
// )
providerData.binding = binding
setProvider(providerData.provider)
// providerData.binding = binding
// setProvider(providerData.provider)
return () => {
// Cleanup logic
if (binding) {
binding.destroy()
}
if (providerData.binding) {
providerData.binding = undefined
}
}
}, [room, activeFileContent])
// return () => {
// // Cleanup logic
// if (binding) {
// binding.destroy()
// }
// if (providerData.binding) {
// providerData.binding = undefined
// }
// }
// }, [room, activeFileContent])
// Added this effect to clean up when the component unmounts
useEffect(() => {
return () => {
// Clean up all providers when the component unmounts
providersMap.current.forEach((data) => {
if (data.binding) {
data.binding.destroy()
}
data.provider.disconnect()
data.yDoc.destroy()
})
providersMap.current.clear()
}
}, [])
// // Added this effect to clean up when the component unmounts
// useEffect(() => {
// return () => {
// // Clean up all providers when the component unmounts
// providersMap.current.forEach((data) => {
// if (data.binding) {
// data.binding.destroy()
// }
// data.provider.disconnect()
// data.yDoc.destroy()
// })
// providersMap.current.clear()
// }
// }, [])
// Connection/disconnection effect
useEffect(() => {
@ -658,7 +660,7 @@ export default function CodeEditor({
// Socket event listener effect
useEffect(() => {
const onConnect = () => { }
const onConnect = () => {}
const onDisconnect = () => {
setTerminals([])
@ -786,8 +788,8 @@ export default function CodeEditor({
? numTabs === 1
? null
: index < numTabs - 1
? tabs[index + 1].id
: tabs[index - 1].id
? tabs[index + 1].id
: tabs[index - 1].id
: activeFileId
setTabs((prev) => prev.filter((t) => t.id !== id))
@ -853,9 +855,7 @@ export default function CodeEditor({
}
const handleDeleteFile = (file: TFile) => {
socket?.emit("deleteFile", { fileId: file.id }, (response: (TFolder | TFile)[]) => {
setFiles(response)
})
socket?.emit("deleteFile", { fileId: file.id })
closeTab(file.id)
}
@ -867,10 +867,13 @@ export default function CodeEditor({
closeTabs(response)
)
socket?.emit("deleteFolder", { folderId: folder.id }, (response: (TFolder | TFile)[]) => {
setFiles(response)
setDeletingFolderId("")
})
socket?.emit(
"deleteFolder",
{ folderId: folder.id },
(response: (TFolder | TFile)[]) => {
setDeletingFolderId("")
}
)
}
const togglePreviewPanel = () => {
@ -911,7 +914,7 @@ export default function CodeEditor({
<DisableAccessModal
message={disableAccess.message}
open={disableAccess.isDisabled}
setOpen={() => { }}
setOpen={() => {}}
/>
<Loading />
</>
@ -946,15 +949,14 @@ export default function CodeEditor({
{generate.show ? (
<GenerateInput
user={userData}
socket={socket!}
width={generate.width - 90}
data={{
fileName: tabs.find((t) => t.id === activeFileId)?.name ?? "",
code:
(isSelected && editorRef?.getSelection()
? editorRef
?.getModel()
?.getValueInRange(editorRef?.getSelection()!)
?.getModel()
?.getValueInRange(editorRef?.getSelection()!)
: editorRef?.getValue()) ?? "",
line: generate.line,
}}
@ -1036,7 +1038,6 @@ export default function CodeEditor({
handleDeleteFolder={handleDeleteFolder}
socket={socket!}
setFiles={setFiles}
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
deletingFolderId={deletingFolderId}
toggleAIChat={toggleAIChat}
isAIChatOpen={isAIChatOpen}
@ -1088,9 +1089,9 @@ export default function CodeEditor({
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
clerk.loaded ? (
<>
{provider && userInfo ? (
{/* {provider && userInfo ? (
<Cursors yProvider={provider} userInfo={userInfo} />
) : null}
) : null} */}
<Editor
height="100%"
language={editorLanguage}
@ -1151,10 +1152,10 @@ export default function CodeEditor({
isAIChatOpen && isHorizontalLayout
? "horizontal"
: isAIChatOpen
? "vertical"
: isHorizontalLayout
? "horizontal"
: "vertical"
? "vertical"
: isHorizontalLayout
? "horizontal"
: "vertical"
}
>
<ResizablePanel
@ -1232,6 +1233,7 @@ export default function CodeEditor({
editorRef={{ current: editorRef }}
lastCopiedRangeRef={lastCopiedRangeRef}
files={files}
templateType={sandboxData.type}
/>
</ResizablePanel>
</>

View File

@ -1,29 +1,36 @@
import JSZip from 'jszip'
import { useSocket } from "@/context/SocketContext"
// React component for download button
import { Button } from "@/components/ui/button"
import { useSocket } from "@/context/SocketContext"
import { Download } from "lucide-react"
export default function DownloadButton({ name }: { name: string }) {
const { socket } = useSocket()
const handleDownload = async () => {
socket?.emit("downloadFiles", {}, async (response: {files: {path: string, content: string}[]}) => {
const zip = new JSZip()
response.files.forEach(file => {
zip.file(file.path, file.content)
})
const blob = await zip.generateAsync({type: "blob"})
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name}.zip`
a.click()
window.URL.revokeObjectURL(url)
})
}
socket?.emit(
"downloadFiles",
{ timestamp: Date.now() },
async (response: { zipBlob: string }) => {
const { zipBlob } = response
// Decode Base64 back to binary data
const binary = atob(zipBlob)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
const blob = new Blob([bytes], { type: "application/zip" })
// Create URL and download
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${name}.zip`
a.click()
window.URL.revokeObjectURL(url)
}
)
}
return (
<Button variant="outline" onClick={handleDownload}>

View File

@ -9,12 +9,12 @@ import { Pencil, Users } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { useState } from "react"
import { Avatars } from "../live/avatars"
// import { Avatars } from "../live/avatars"
import DeployButtonModal from "./deploy"
import DownloadButton from "./downloadButton"
import EditSandboxModal from "./edit"
import RunButtonModal from "./run"
import ShareSandboxModal from "./share"
import DownloadButton from "./downloadButton"
export default function Navbar({
userData,
@ -70,15 +70,15 @@ export default function Navbar({
sandboxData={sandboxData}
/>
<div className="flex items-center h-full space-x-4">
<Avatars />
{/* <Avatars /> */}
{isOwner ? (
<>
<DeployButtonModal data={sandboxData} userData={userData} />
<Button variant="outline" onClick={() => setIsShareOpen(true)}>
{/* <Button variant="outline" onClick={() => setIsShareOpen(true)}>
<Users className="w-4 h-4 mr-2" />
Share
</Button>
</Button> */}
<DownloadButton name={sandboxData.name} /></>
) : null}
<ThemeSwitcher />

View File

@ -7,6 +7,7 @@ import { Sandbox } from "@/lib/types"
import { Play, StopCircle } from "lucide-react"
import { useEffect, useRef } from "react"
import { toast } from "sonner"
import { templateConfigs } from "@/lib/templates"
export default function RunButtonModal({
isRunning,
@ -34,7 +35,12 @@ export default function RunButtonModal({
}
}
}, [terminals, isRunning])
// commands to run in the terminal
const COMMANDS = {
streamlit: "./venv/bin/streamlit run main.py --server.runOnSave true",
php: "echo http://localhost:80 && npx vite",
default: "npm run dev",
} as const
const handleRun = async () => {
if (isRunning && lastCreatedTerminalRef.current) {
await closeTerminal(lastCreatedTerminalRef.current)
@ -42,10 +48,7 @@ export default function RunButtonModal({
setIsPreviewCollapsed(true)
previewPanelRef.current?.collapse()
} else if (!isRunning && terminals.length < 4) {
const command =
sandboxData.type === "streamlit"
? "./venv/bin/streamlit run main.py --server.runOnSave true"
: "npm run dev"
const command = templateConfigs[sandboxData.type]?.runCommand || "npm run dev"
try {
// Create a new terminal with the appropriate command

View File

@ -143,11 +143,7 @@ export default function ShareSandboxModal({
</DialogHeader>
<div className="space-y-2">
{shared.map((user) => (
<SharedUser
key={user.id}
user={user}
sandboxId={data.id}
/>
<SharedUser key={user.id} user={user} sandboxId={data.id} />
))}
</div>
</div>

View File

@ -25,7 +25,6 @@ export default function Sidebar({
handleDeleteFolder,
socket,
setFiles,
addNew,
deletingFolderId,
toggleAIChat,
isAIChatOpen,
@ -43,7 +42,6 @@ export default function Sidebar({
handleDeleteFolder: (folder: TFolder) => void
socket: Socket
setFiles: (files: (TFile | TFolder)[]) => void
addNew: (name: string, type: "file" | "folder") => void
deletingFolderId: string
toggleAIChat: () => void
isAIChatOpen: boolean
@ -93,7 +91,7 @@ export default function Sidebar({
"moveFile",
{
fileId,
folderId
folderId,
},
(response: (TFolder | TFile)[]) => {
setFiles(response)
@ -176,7 +174,6 @@ export default function Sidebar({
stopEditing={() => {
setCreatingNew(null)
}}
addNew={addNew}
/>
) : null}
</>
@ -203,20 +200,18 @@ export default function Sidebar({
variant="ghost"
className={cn(
"w-full justify-start text-sm font-normal h-8 px-2 mb-2 border-t",
isAIChatOpen
? "bg-muted-foreground/25 text-foreground"
isAIChatOpen
? "bg-muted-foreground/25 text-foreground"
: "text-muted-foreground"
)}
onClick={toggleAIChat}
aria-disabled={false}
style={{ opacity: 1 }}
>
<MessageSquareMore
<MessageSquareMore
className={cn(
"h-4 w-4 mr-2",
isAIChatOpen
? "text-indigo-500"
: "text-indigo-500 opacity-70"
isAIChatOpen ? "text-indigo-500" : "text-indigo-500 opacity-70"
)}
/>
AI Chat

View File

@ -9,12 +9,10 @@ export default function New({
socket,
type,
stopEditing,
addNew,
}: {
socket: Socket
type: "file" | "folder"
stopEditing: () => void
addNew: (name: string, type: "file" | "folder") => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
@ -25,19 +23,9 @@ export default function New({
const valid = validateName(name, "", type)
if (valid.status) {
if (type === "file") {
socket.emit(
"createFile",
{ name },
({ success }: { success: boolean }) => {
if (success) {
addNew(name, type)
}
}
)
socket.emit("createFile", { name })
} else {
socket.emit("createFolder", { name }, () => {
addNew(name, type)
})
socket.emit("createFolder", { name })
}
}
}

View File

@ -694,4 +694,4 @@
}
],
"encodedTokensColors": []
}
}

View File

@ -45,9 +45,14 @@ export default function Landing() {
<h1 className="text-2xl font-medium text-center mt-16">
A Collaborative + AI-Powered Code Environment
</h1>
<p className="text-muted-foreground mt-4 text-center ">
{/* <p className="text-muted-foreground mt-4 text-center ">
Sandbox is an open-source cloud-based code editing environment with
custom AI code autocompletion and real-time collaboration.
</p> */}
<p className="text-muted-foreground mt-4 text-center ">
A cloud-based code editor featuring real-time collaboration,
intelligent code autocompletion, and an AI assistant to help you code
faster and smarter.
</p>
<div className="mt-8 flex space-x-4">
<Link href="/sign-up">

View File

@ -1,5 +1,5 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
@ -56,4 +56,4 @@ const AlertDescription = React.forwardRef<
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertDescription, AlertTitle }

View File

@ -6,4 +6,3 @@ import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"
import { cn } from "@/lib/utils"
@ -29,4 +29,4 @@ const TooltipContent = React.forwardRef<
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@ -11,27 +11,85 @@ import { MAX_FREE_GENERATION } from "@/lib/constant"
import { User } from "@/lib/types"
import { useClerk } from "@clerk/nextjs"
import {
Crown,
LayoutDashboard,
LogOut,
Sparkles,
User as UserIcon,
} from "lucide-react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import Avatar from "./avatar"
import { Button } from "./button"
import { TIERS } from "@/lib/tiers"
export default function UserButton({ userData }: { userData: User }) {
if (!userData) return null
// TODO: Remove this once we have a proper tier system
const TIER_INFO = {
FREE: {
color: "text-gray-500",
icon: Sparkles,
limit: TIERS.FREE.generations,
},
PRO: {
color: "text-blue-500",
icon: Crown,
limit: TIERS.PRO.generations,
},
ENTERPRISE: {
color: "text-purple-500",
icon: Crown,
limit: TIERS.ENTERPRISE.generations,
},
} as const
export default function UserButton({ userData: initialUserData }: { userData: User }) {
const [userData, setUserData] = useState<User>(initialUserData)
const [isOpen, setIsOpen] = useState(false)
const { signOut } = useClerk()
const router = useRouter()
const fetchUserData = async () => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${userData.id}`,
{
headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
},
cache: 'no-store'
}
)
if (res.ok) {
const updatedUserData = await res.json()
setUserData(updatedUserData)
}
} catch (error) {
console.error("Failed to fetch user data:", error)
}
}
useEffect(() => {
if (isOpen) {
fetchUserData()
}
}, [isOpen])
const tierInfo = TIER_INFO[userData.tier as keyof typeof TIER_INFO] || TIER_INFO.FREE
const TierIcon = tierInfo.icon
const usagePercentage = Math.floor((userData.generations || 0) * 100 / tierInfo.limit)
const handleUpgrade = async () => {
router.push('/upgrade')
}
return (
<DropdownMenu>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger>
<Avatar name={userData.name} avatarUrl={userData.avatarUrl} />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" align="end">
<DropdownMenuContent className="w-64" align="end">
<div className="py-1.5 px-2 w-full">
<div className="font-medium">{userData.name}</div>
<div className="text-sm w-full overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground">
@ -40,20 +98,8 @@ export default function UserButton({ userData }: { userData: User }) {
</div>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Sparkles className="size-4 mr-2 text-indigo-500" />
<div className="w-full flex flex-col items-start text-sm">
<span className="text-sm">{`AI Usage: ${userData.generations}/${MAX_FREE_GENERATION}`}</span>
<div className="rounded-full w-full mt-1 h-1.5 overflow-hidden bg-secondary border border-muted-foreground">
<div
className="h-full bg-indigo-500 rounded-full"
style={{
width: `${(userData.generations * 100) / 1000}%`,
}}
/>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer" asChild>
<Link href={"/dashboard"}>
<LayoutDashboard className="mr-2 size-4" />
@ -68,6 +114,52 @@ export default function UserButton({ userData }: { userData: User }) {
<DropdownMenuSeparator />
</Link>
</DropdownMenuItem>
<div className="py-1.5 px-2 w-full">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<TierIcon className={`h-4 w-4 ${tierInfo.color}`} />
<span className="text-sm font-medium">{userData.tier || "FREE"} Plan</span>
</div>
{/* {(userData.tier === "FREE" || userData.tier === "PRO") && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs border-b hover:border-b-foreground"
onClick={handleUpgrade}
>
Upgrade
</Button>
)} */}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Sparkles className="size-4 mr-2 text-indigo-500" />
<div className="w-full">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>AI Usage</span>
<span>{userData.generations}/{tierInfo.limit}</span>
</div>
<div className="rounded-full w-full h-2 overflow-hidden bg-secondary">
<div
className={`h-full rounded-full transition-all duration-300 ${
usagePercentage > 90 ? 'bg-red-500' :
usagePercentage > 75 ? 'bg-yellow-500' :
tierInfo.color.replace('text-', 'bg-')
}`}
style={{
width: `${Math.min(usagePercentage, 100)}%`,
}}
/>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* <DropdownMenuItem className="cursor-pointer">
<Pencil className="mr-2 size-4" />
<span>Edit Profile</span>
@ -83,3 +175,4 @@ export default function UserButton({ userData }: { userData: User }) {
</DropdownMenu>
)
}