Merge branch 'main' into feat/profile-page
This commit is contained in:
@ -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>
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
),
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>) => {
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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}>
|
||||
|
@ -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 />
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -694,4 +694,4 @@
|
||||
}
|
||||
],
|
||||
"encodedTokensColors": []
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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 }
|
||||
|
@ -6,4 +6,3 @@ import { type ThemeProviderProps } from "next-themes/dist/types"
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
|
@ -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 }
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user