1232 lines
39 KiB
TypeScript
Raw Normal View History

2024-05-26 12:18:09 -07:00
"use client"
2024-04-06 19:03:04 -04:00
2024-05-26 17:28:52 -07:00
import { useClerk } from "@clerk/nextjs"
2024-10-23 10:51:50 +01:00
import Editor, { BeforeMount, OnMount } from "@monaco-editor/react"
2024-09-06 20:41:45 +01:00
import { AnimatePresence, motion } from "framer-motion"
2024-10-23 10:51:50 +01:00
import * as monaco from "monaco-editor"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
2024-05-03 13:53:21 -07:00
2024-10-23 10:51:50 +01:00
import { TypedLiveblocksProvider, useRoom, useSelf } from "@/liveblocks.config"
2024-05-26 17:28:52 -07:00
import LiveblocksProvider from "@liveblocks/yjs"
import { MonacoBinding } from "y-monaco"
import { Awareness } from "y-protocols/awareness"
2024-10-23 10:51:50 +01:00
import * as Y from "yjs"
2024-04-06 19:03:04 -04:00
2024-05-26 17:28:52 -07:00
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
2024-10-23 10:51:50 +01:00
import { PreviewProvider, usePreview } from "@/context/PreviewContext"
import { useSocket } from "@/context/SocketContext"
import { parseTSConfigToMonacoOptions } from "@/lib/tsconfig"
import { Sandbox, TFile, TFolder, TTab, User } from "@/lib/types"
import {
addNew,
cn,
debounce,
deepMerge,
processFileType,
validateName,
} from "@/lib/utils"
import { Terminal } from "@xterm/xterm"
import {
ArrowDownToLine,
ArrowRightToLine,
FileJson,
Loader2,
Sparkles,
TerminalSquare,
} from "lucide-react"
import { useTheme } from "next-themes"
import React from "react"
import { ImperativePanelHandle } from "react-resizable-panels"
import { Button } from "../ui/button"
2024-05-26 17:28:52 -07:00
import Tab from "../ui/tab"
2024-10-23 10:51:50 +01:00
import AIChat from "./AIChat"
2024-05-26 17:28:52 -07:00
import GenerateInput from "./generate"
import { Cursors } from "./live/cursors"
import DisableAccessModal from "./live/disableModal"
import Loading from "./loading"
import PreviewWindow from "./preview"
2024-10-23 10:51:50 +01:00
import Sidebar from "./sidebar"
2024-05-26 17:28:52 -07:00
import Terminals from "./terminals"
export default function CodeEditor({
2024-05-01 01:53:49 -04:00
userData,
2024-05-05 00:06:10 -07:00
sandboxData,
}: {
2024-05-26 12:18:09 -07:00
userData: User
sandboxData: Sandbox
2024-04-26 22:34:56 -04:00
}) {
//SocketContext functions and effects
2024-09-06 20:41:45 +01:00
const { socket, setUserAndSandboxId } = useSocket()
2024-10-23 10:51:50 +01:00
// theme
const { theme } = useTheme()
2024-07-23 17:30:35 -04:00
useEffect(() => {
// Ensure userData.id and sandboxData.id are available before attempting to connect
if (userData.id && sandboxData.id) {
// Check if the socket is not initialized or not connected
if (!socket || (socket && !socket.connected)) {
// Initialize socket connection
2024-09-06 20:41:45 +01:00
setUserAndSandboxId(userData.id, sandboxData.id)
}
}
2024-09-06 20:41:45 +01:00
}, [socket, userData.id, sandboxData.id, setUserAndSandboxId])
2024-07-23 17:30:35 -04:00
// This heartbeat is critical to preventing the E2B sandbox from timing out
useEffect(() => {
// 10000 ms = 10 seconds
const interval = setInterval(() => socket?.emit("heartbeat"), 10000)
return () => clearInterval(interval)
}, [socket])
2024-07-23 17:30:35 -04:00
//Preview Button state
2024-05-26 17:28:52 -07:00
const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true)
const [disableAccess, setDisableAccess] = useState({
isDisabled: false,
message: "",
})
// Layout state
2024-10-23 10:51:50 +01:00
const [isHorizontalLayout, setIsHorizontalLayout] = useState(false)
2024-10-21 13:57:45 -06:00
const [previousLayout, setPreviousLayout] = useState(false)
// AI Chat state
2024-10-23 10:51:50 +01:00
const [isAIChatOpen, setIsAIChatOpen] = useState(false)
2024-05-26 17:28:52 -07:00
// File state
const [files, setFiles] = useState<(TFolder | TFile)[]>([])
const [tabs, setTabs] = useState<TTab[]>([])
const [activeFileId, setActiveFileId] = useState<string>("")
const [activeFileContent, setActiveFileContent] = useState("")
const [deletingFolderId, setDeletingFolderId] = useState("")
// Added this state to track the most recent content for each file
const [fileContents, setFileContents] = useState<Record<string, string>>({})
2024-05-26 17:28:52 -07:00
// Editor state
const [editorLanguage, setEditorLanguage] = useState("plaintext")
console.log("editor language: ", editorLanguage)
2024-05-26 17:28:52 -07:00
const [cursorLine, setCursorLine] = useState(0)
const [editorRef, setEditorRef] =
useState<monaco.editor.IStandaloneCodeEditor>()
// AI Copilot state
const [generate, setGenerate] = useState<{
show: boolean
id: string
line: number
widget: monaco.editor.IContentWidget | undefined
pref: monaco.editor.ContentWidgetPositionPreference[]
width: number
}>({ show: false, line: 0, id: "", widget: undefined, pref: [], width: 0 })
const [decorations, setDecorations] = useState<{
options: monaco.editor.IModelDeltaDecoration[]
instance: monaco.editor.IEditorDecorationsCollection | undefined
}>({ options: [], instance: undefined })
2024-09-06 20:41:45 +01:00
const [isSelected, setIsSelected] = useState(false)
const [showSuggestion, setShowSuggestion] = useState(false)
2024-05-26 17:28:52 -07:00
// Terminal state
const [terminals, setTerminals] = useState<
{
id: string
terminal: Terminal | null
}[]
>([])
2024-05-26 12:18:09 -07:00
// Preview state
2024-09-06 20:41:45 +01:00
const [previewURL, setPreviewURL] = useState<string>("")
const loadPreviewURL = (url: string) => {
// This will cause a reload if previewURL changed.
2024-09-06 20:41:45 +01:00
setPreviewURL(url)
// If the URL didn't change, still reload the preview.
2024-09-06 20:41:45 +01:00
previewWindowRef.current?.refreshIframe()
}
2024-05-26 17:28:52 -07:00
const isOwner = sandboxData.userId === userData.id
const clerk = useClerk()
2024-05-26 17:28:52 -07:00
// Liveblocks hooks
const room = useRoom()
const [provider, setProvider] = useState<TypedLiveblocksProvider>()
2024-05-26 18:04:43 -07:00
const userInfo = useSelf((me) => me.info)
2024-07-15 14:56:37 -04:00
// Liveblocks providers map to prevent reinitializing providers
type ProviderData = {
2024-09-06 20:41:45 +01:00
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>())
2024-05-26 17:28:52 -07:00
// Refs for libraries / features
const editorContainerRef = useRef<HTMLDivElement>(null)
const monacoRef = useRef<typeof monaco | null>(null)
const generateRef = useRef<HTMLDivElement>(null)
2024-09-06 20:41:45 +01:00
const suggestionRef = useRef<HTMLDivElement>(null)
2024-05-26 17:28:52 -07:00
const generateWidgetRef = useRef<HTMLDivElement>(null)
2024-10-23 10:51:50 +01:00
const { previewPanelRef } = usePreview()
2024-05-26 17:28:52 -07:00
const editorPanelRef = useRef<ImperativePanelHandle>(null)
const previewWindowRef = useRef<{ refreshIframe: () => void }>(null)
2024-05-26 17:28:52 -07:00
2024-09-06 20:41:45 +01:00
const debouncedSetIsSelected = useRef(
debounce((value: boolean) => {
setIsSelected(value)
}, 800) //
).current
2024-05-26 17:28:52 -07:00
// Pre-mount editor keybindings
const handleEditorWillMount: BeforeMount = (monaco) => {
monaco.editor.addKeybindingRules([
{
keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyG,
command: "null",
},
])
}
// Post-mount editor keybindings and actions
const handleEditorMount: OnMount = async (editor, monaco) => {
2024-05-26 17:28:52 -07:00
setEditorRef(editor)
monacoRef.current = monaco
/**
* Sync all the models to the worker eagerly.
* This enables intelliSense for all files without needing an `addExtraLib` call.
*/
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true)
monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true)
monaco.languages.typescript.typescriptDefaults.setCompilerOptions(
defaultCompilerOptions
)
monaco.languages.typescript.javascriptDefaults.setCompilerOptions(
defaultCompilerOptions
)
const fetchFileContent = (fileId: string): Promise<string> => {
return new Promise((resolve) => {
socket?.emit("getFile", { fileId }, (content: string) => {
resolve(content)
})
})
}
const loadTSConfig = async (files: (TFolder | TFile)[]) => {
const tsconfigFiles = files.filter((file) =>
file.name.endsWith("tsconfig.json")
)
let mergedConfig: any = { compilerOptions: {} }
for (const file of tsconfigFiles) {
const containerId = file.id.split("/").slice(0, 2).join("/")
const content = await fetchFileContent(file.id)
try {
let tsConfig = JSON.parse(content)
// Handle references
if (tsConfig.references) {
for (const ref of tsConfig.references) {
const path = ref.path.replace("./", "")
const fileId = `${containerId}/${path}`
const refContent = await fetchFileContent(fileId)
const referenceTsConfig = JSON.parse(refContent)
// Merge configurations
mergedConfig = deepMerge(mergedConfig, referenceTsConfig)
}
}
// Merge current file's config
mergedConfig = deepMerge(mergedConfig, tsConfig)
} catch (error) {
console.error("Error parsing TSConfig:", error)
}
}
// Apply merged compiler options
if (mergedConfig.compilerOptions) {
const updatedOptions = parseTSConfigToMonacoOptions({
...defaultCompilerOptions,
...mergedConfig.compilerOptions,
})
monaco.languages.typescript.typescriptDefaults.setCompilerOptions(
updatedOptions
)
monaco.languages.typescript.javascriptDefaults.setCompilerOptions(
updatedOptions
)
}
}
// Call the function with your file structure
await loadTSConfig(files)
2024-05-26 17:28:52 -07:00
editor.onDidChangeCursorPosition((e) => {
2024-09-06 20:41:45 +01:00
setIsSelected(false)
const selection = editor.getSelection()
if (selection !== null) {
const hasSelection = !selection.isEmpty()
debouncedSetIsSelected(hasSelection)
setShowSuggestion(hasSelection)
}
2024-05-26 17:28:52 -07:00
const { column, lineNumber } = e.position
if (lineNumber === cursorLine) return
setCursorLine(lineNumber)
const model = editor.getModel()
const endColumn = model?.getLineContent(lineNumber).length || 0
setDecorations((prev) => {
return {
...prev,
options: [
{
range: new monaco.Range(
lineNumber,
column,
lineNumber,
endColumn
),
options: {
afterContentClassName: "inline-decoration",
},
},
],
}
})
})
editor.onDidBlurEditorText((e) => {
setDecorations((prev) => {
return {
...prev,
options: [],
}
})
})
editor.addAction({
id: "generate",
label: "Generate",
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyG],
precondition:
"editorTextFocus && !suggestWidgetVisible && !renameInputVisible && !inSnippetMode && !quickFixWidgetVisible",
run: () => {
setGenerate((prev) => {
return {
...prev,
show: !prev.show,
pref: [monaco.editor.ContentWidgetPositionPreference.BELOW],
}
})
},
})
}
2024-09-06 20:41:45 +01:00
const handleAiEdit = React.useCallback(() => {
if (!editorRef) return
const selection = editorRef.getSelection()
if (!selection) return
const pos = selection.getPosition()
const start = selection.getStartPosition()
const end = selection.getEndPosition()
let pref: monaco.editor.ContentWidgetPositionPreference
let id = ""
const isMultiline = start.lineNumber !== end.lineNumber
if (isMultiline) {
if (pos.lineNumber <= start.lineNumber) {
pref = monaco.editor.ContentWidgetPositionPreference.ABOVE
} else {
pref = monaco.editor.ContentWidgetPositionPreference.BELOW
}
} else {
pref = monaco.editor.ContentWidgetPositionPreference.ABOVE
}
editorRef.changeViewZones(function (changeAccessor) {
if (!generateRef.current) return
if (pref === monaco.editor.ContentWidgetPositionPreference.ABOVE) {
id = changeAccessor.addZone({
afterLineNumber: start.lineNumber - 1,
heightInLines: 2,
domNode: generateRef.current,
})
}
})
setGenerate((prev) => {
return {
...prev,
show: true,
pref: [pref],
id,
}
})
}, [editorRef])
2024-05-26 17:28:52 -07:00
// Generate widget effect
useEffect(() => {
2024-05-26 17:28:52 -07:00
if (generate.show) {
2024-09-06 20:41:45 +01:00
setShowSuggestion(false)
2024-05-26 17:28:52 -07:00
editorRef?.changeViewZones(function (changeAccessor) {
if (!generateRef.current) return
2024-09-06 20:41:45 +01:00
if (!generate.id) {
const id = changeAccessor.addZone({
afterLineNumber: cursorLine,
heightInLines: 3,
domNode: generateRef.current,
})
setGenerate((prev) => {
return { ...prev, id, line: cursorLine }
})
}
2024-05-26 17:28:52 -07:00
setGenerate((prev) => {
2024-09-06 20:41:45 +01:00
return { ...prev, line: cursorLine }
2024-05-26 17:28:52 -07:00
})
})
2024-05-23 01:35:08 -07:00
2024-05-26 17:28:52 -07:00
if (!generateWidgetRef.current) return
const widgetElement = generateWidgetRef.current
const contentWidget = {
getDomNode: () => {
return widgetElement
},
getId: () => {
return "generate.widget"
},
getPosition: () => {
return {
position: {
lineNumber: cursorLine,
column: 1,
},
preference: generate.pref,
}
},
}
// window width - sidebar width, times the percentage of the editor panel
const width = editorPanelRef.current
? (editorPanelRef.current.getSize() / 100) * (window.innerWidth - 224)
: 400 //fallback
setGenerate((prev) => {
return {
...prev,
widget: contentWidget,
width,
}
})
editorRef?.addContentWidget(contentWidget)
if (generateRef.current && generateWidgetRef.current) {
editorRef?.applyFontInfo(generateRef.current)
editorRef?.applyFontInfo(generateWidgetRef.current)
}
} else {
editorRef?.changeViewZones(function (changeAccessor) {
changeAccessor.removeZone(generate.id)
setGenerate((prev) => {
return { ...prev, id: "" }
})
})
if (!generate.widget) return
editorRef?.removeContentWidget(generate.widget)
setGenerate((prev) => {
return {
...prev,
widget: undefined,
}
})
}
}, [generate.show])
2024-09-06 20:41:45 +01:00
// Suggestion widget effect
useEffect(() => {
if (!suggestionRef.current || !editorRef) return
const widgetElement = suggestionRef.current
const suggestionWidget: monaco.editor.IContentWidget = {
getDomNode: () => {
return widgetElement
},
getId: () => {
return "suggestion.widget"
},
getPosition: () => {
const selection = editorRef?.getSelection()
const column = Math.max(3, selection?.positionColumn ?? 1)
let lineNumber = selection?.positionLineNumber ?? 1
let pref = monaco.editor.ContentWidgetPositionPreference.ABOVE
if (lineNumber <= 3) {
pref = monaco.editor.ContentWidgetPositionPreference.BELOW
}
return {
preference: [pref],
position: {
lineNumber,
column,
},
}
},
}
if (isSelected) {
editorRef?.addContentWidget(suggestionWidget)
editorRef?.applyFontInfo(suggestionRef.current)
} else {
editorRef?.removeContentWidget(suggestionWidget)
}
}, [isSelected])
2024-05-26 17:28:52 -07:00
// Decorations effect for generate widget tips
useEffect(() => {
if (decorations.options.length === 0) {
decorations.instance?.clear()
}
2024-05-26 18:04:43 -07:00
const model = editorRef?.getModel()
// added this because it was giving client side exception - Illegal value for lineNumber when opening an empty file
if (model) {
const totalLines = model.getLineCount()
// Check if the cursorLine is a valid number, If cursorLine is out of bounds, we fall back to 1 (the first line) as a default safe value.
const lineNumber =
cursorLine > 0 && cursorLine <= totalLines ? cursorLine : 1 // fallback to a valid line number
// If for some reason the content doesn't exist, we use an empty string as a fallback.
const line = model.getLineContent(lineNumber) ?? ""
// Check if the line is not empty or only whitespace (i.e., `.trim()` removes spaces).
// If the line has content, we clear any decorations using the instance of the `decorations` object.
// Decorations refer to editor highlights, underlines, or markers, so this clears those if conditions are met.
if (line.trim() !== "") {
decorations.instance?.clear()
return
}
2024-05-26 18:04:43 -07:00
}
2024-05-26 17:28:52 -07:00
if (decorations.instance) {
decorations.instance.set(decorations.options)
} else {
const instance = editorRef?.createDecorationsCollection()
instance?.set(decorations.options)
setDecorations((prev) => {
return {
...prev,
instance,
}
})
}
}, [decorations.options])
// Save file keybinding logic effect
// Function to save the file content after a debounce period
const debouncedSaveData = useCallback(
debounce((activeFileId: string | undefined) => {
if (activeFileId) {
// Get the current content of the file
const content = fileContents[activeFileId]
// Mark the file as saved in the tabs
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId ? { ...tab, saved: true } : tab
)
)
console.log(`Saving file...${activeFileId}`)
console.log(`Saving file...${content}`)
socket?.emit("saveFile", { fileId: activeFileId, body: content })
}
2024-07-23 17:30:35 -04:00
}, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY) || 1000),
[socket, fileContents]
)
// Keydown event listener to trigger file save on Ctrl+S or Cmd+S, and toggle AI chat on Ctrl+L or Cmd+L
2024-05-26 17:28:52 -07:00
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
2024-09-06 20:41:45 +01:00
e.preventDefault()
2024-10-23 10:51:50 +01:00
debouncedSaveData(activeFileId)
} else if (e.key === "l" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
2024-10-23 10:51:50 +01:00
setIsAIChatOpen((prev) => !prev)
2024-05-26 17:28:52 -07:00
}
2024-09-06 20:41:45 +01:00
}
2024-09-06 20:41:45 +01:00
document.addEventListener("keydown", down)
2024-10-23 10:51:50 +01:00
// Added this line to prevent Monaco editor from handling Cmd/Ctrl+L
editorRef?.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL, () => {
2024-10-21 13:57:45 -06:00
setIsAIChatOpen((prev) => !prev)
})
2024-05-26 17:28:52 -07:00
return () => {
2024-09-06 20:41:45 +01:00
document.removeEventListener("keydown", down)
}
}, [activeFileId, tabs, debouncedSaveData, setIsAIChatOpen, editorRef])
2024-05-26 17:28:52 -07:00
// Liveblocks live collaboration setup effect
useEffect(() => {
const tab = tabs.find((t) => t.id === activeFileId)
const model = editorRef?.getModel()
if (!editorRef || !tab || !model) return
2024-09-06 20:41:45 +01:00
let providerData: ProviderData
2024-07-23 17:30:35 -04:00
2024-07-15 14:56:37 -04:00
// When a file is opened for the first time, create a new provider and store in providersMap.
if (!providersMap.current.has(tab.id)) {
2024-09-06 20:41:45 +01:00
const yDoc = new Y.Doc()
const yText = yDoc.getText(tab.id)
const yProvider = new LiveblocksProvider(room, yDoc)
2024-07-15 14:56:37 -04:00
// 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)
}
2024-05-26 17:28:52 -07:00
}
}
}
2024-07-15 14:56:37 -04:00
yProvider.on("sync", onSync)
2024-05-26 17:28:52 -07:00
2024-07-15 14:56:37 -04:00
// Save the provider to the map.
2024-09-06 20:41:45 +01:00
providerData = { provider: yProvider, yDoc, yText, onSync }
providersMap.current.set(tab.id, providerData)
2024-07-15 14:56:37 -04:00
} else {
// When a tab is opened that has been open before, reuse the existing provider.
2024-09-06 20:41:45 +01:00
providerData = providersMap.current.get(tab.id)!
2024-07-15 14:56:37 -04:00
}
2024-05-26 17:28:52 -07:00
const binding = new MonacoBinding(
2024-07-15 14:56:37 -04:00
providerData.yText,
2024-05-26 17:28:52 -07:00
model,
new Set([editorRef]),
2024-07-15 14:56:37 -04:00
providerData.provider.awareness as unknown as Awareness
2024-09-06 20:41:45 +01:00
)
2024-07-15 14:56:37 -04:00
2024-09-06 20:41:45 +01:00
providerData.binding = binding
setProvider(providerData.provider)
2024-05-26 17:28:52 -07:00
return () => {
2024-07-15 14:56:37 -04:00
// Cleanup logic
if (binding) {
2024-09-06 20:41:45 +01:00
binding.destroy()
2024-07-15 14:56:37 -04:00
}
if (providerData.binding) {
2024-09-06 20:41:45 +01:00
providerData.binding = undefined
2024-07-15 14:56:37 -04:00
}
2024-09-06 20:41:45 +01:00
}
}, [room, activeFileContent])
2024-07-15 14:56:37 -04:00
2024-07-23 17:30:35 -04:00
// 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) {
2024-09-06 20:41:45 +01:00
data.binding.destroy()
2024-07-23 17:30:35 -04:00
}
2024-09-06 20:41:45 +01:00
data.provider.disconnect()
data.yDoc.destroy()
})
providersMap.current.clear()
}
}, [])
2024-05-26 17:28:52 -07:00
// Connection/disconnection effect
useEffect(() => {
socket?.connect()
2024-05-26 17:28:52 -07:00
return () => {
socket?.disconnect()
2024-05-26 17:28:52 -07:00
}
2024-09-01 22:04:56 -04:00
}, [socket])
2024-05-26 17:28:52 -07:00
// Socket event listener effect
useEffect(() => {
const onConnect = () => { }
2024-05-26 17:28:52 -07:00
const onDisconnect = () => {
setTerminals([])
}
const onLoadedEvent = (files: (TFolder | TFile)[]) => {
setFiles(files)
}
const onError = (message: string) => {
2024-05-26 17:28:52 -07:00
toast.error(message)
}
const onTerminalResponse = (response: { id: string; data: string }) => {
const term = terminals.find((t) => t.id === response.id)
if (term && term.terminal) {
term.terminal.write(response.data)
}
}
const onDisableAccess = (message: string) => {
if (!isOwner)
setDisableAccess({
isDisabled: true,
message,
})
}
socket?.on("connect", onConnect)
socket?.on("disconnect", onDisconnect)
socket?.on("loaded", onLoadedEvent)
socket?.on("error", onError)
socket?.on("terminalResponse", onTerminalResponse)
socket?.on("disableAccess", onDisableAccess)
socket?.on("previewURL", loadPreviewURL)
2024-05-26 17:28:52 -07:00
return () => {
socket?.off("connect", onConnect)
socket?.off("disconnect", onDisconnect)
socket?.off("loaded", onLoadedEvent)
socket?.off("error", onError)
socket?.off("terminalResponse", onTerminalResponse)
socket?.off("disableAccess", onDisableAccess)
socket?.off("previewURL", loadPreviewURL)
2024-05-26 17:28:52 -07:00
}
2024-09-06 20:41:45 +01:00
}, [
socket,
terminals,
setTerminals,
setFiles,
toast,
setDisableAccess,
isOwner,
loadPreviewURL,
])
2024-05-26 17:28:52 -07:00
// Helper functions for tabs:
// Select file and load content
// Initialize debounced function once
2024-09-06 20:41:45 +01:00
const fileCache = useRef(new Map())
// Debounced function to get file content
2024-09-06 20:41:45 +01:00
const debouncedGetFile = (tabId: any, callback: any) => {
socket?.emit("getFile", { fileId: tabId }, callback)
} // 300ms debounce delay, adjust as needed
const selectFile = (tab: TTab) => {
if (tab.id === activeFileId) return
setGenerate((prev) => ({ ...prev, show: false }))
// Check if the tab already exists in the list of open tabs
const exists = tabs.find((t) => t.id === tab.id)
2024-05-26 17:28:52 -07:00
setTabs((prev) => {
if (exists) {
// If the tab exists, make it the active tab
setActiveFileId(exists.id)
return prev
2024-05-26 17:28:52 -07:00
}
// If the tab doesn't exist, add it to the list of tabs and make it active
return [...prev, tab]
})
// If the file's content is already cached, set it as the active content
if (fileContents[tab.id]) {
setActiveFileContent(fileContents[tab.id])
} else {
// Otherwise, fetch the content of the file and cache it
debouncedGetFile(tab.id, (response: string) => {
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
setActiveFileContent(response)
})
}
// Set the editor language based on the file type
setEditorLanguage(processFileType(tab.name))
// Set the active file ID to the new tab
setActiveFileId(tab.id)
}
// Added this effect to update fileContents when the editor content changes
useEffect(() => {
if (activeFileId) {
// Cache the current active file content using the file ID as the key
setFileContents((prev) => ({
...prev,
[activeFileId]: activeFileContent,
}))
}
}, [activeFileContent, activeFileId])
2024-05-26 17:28:52 -07:00
// Close tab and remove from tabs
const closeTab = (id: string) => {
const numTabs = tabs.length
const index = tabs.findIndex((t) => t.id === id)
console.log("closing tab", id, index)
if (index === -1) return
const nextId =
activeFileId === id
? numTabs === 1
? null
: index < numTabs - 1
? tabs[index + 1].id
: tabs[index - 1].id
2024-05-26 17:28:52 -07:00
: activeFileId
setTabs((prev) => prev.filter((t) => t.id !== id))
if (!nextId) {
setActiveFileId("")
} else {
const nextTab = tabs.find((t) => t.id === nextId)
if (nextTab) {
selectFile(nextTab)
}
}
}
const closeTabs = (ids: string[]) => {
const numTabs = tabs.length
if (numTabs === 0) return
const allIndexes = ids.map((id) => tabs.findIndex((t) => t.id === id))
const indexes = allIndexes.filter((index) => index !== -1)
if (indexes.length === 0) return
console.log("closing tabs", ids, indexes)
const activeIndex = tabs.findIndex((t) => t.id === activeFileId)
const newTabs = tabs.filter((t) => !ids.includes(t.id))
setTabs(newTabs)
if (indexes.length === numTabs) {
setActiveFileId("")
} else {
const nextTab =
newTabs.length > activeIndex
? newTabs[activeIndex]
: newTabs[newTabs.length - 1]
if (nextTab) {
selectFile(nextTab)
}
}
}
const handleRename = (
id: string,
newName: string,
oldName: string,
type: "file" | "folder"
) => {
const valid = validateName(newName, oldName, type)
if (!valid.status) {
if (valid.message) toast.error("Invalid file name.")
return false
}
socket?.emit("renameFile", { fileId: id, newName })
2024-05-26 17:28:52 -07:00
setTabs((prev) =>
prev.map((tab) => (tab.id === id ? { ...tab, name: newName } : tab))
)
return true
}
const handleDeleteFile = (file: TFile) => {
socket?.emit("deleteFile", { fileId: file.id }, (response: (TFolder | TFile)[]) => {
2024-05-26 17:28:52 -07:00
setFiles(response)
})
closeTab(file.id)
}
const handleDeleteFolder = (folder: TFolder) => {
setDeletingFolderId(folder.id)
console.log("deleting folder", folder.id)
socket?.emit("getFolder", { folderId: folder.id }, (response: string[]) =>
2024-05-26 17:28:52 -07:00
closeTabs(response)
)
socket?.emit("deleteFolder", { folderId: folder.id }, (response: (TFolder | TFile)[]) => {
2024-05-26 17:28:52 -07:00
setFiles(response)
setDeletingFolderId("")
})
}
2024-10-12 17:55:49 -04:00
const togglePreviewPanel = () => {
if (isPreviewCollapsed) {
2024-10-23 10:51:50 +01:00
previewPanelRef.current?.expand()
setIsPreviewCollapsed(false)
2024-10-12 17:55:49 -04:00
} else {
2024-10-23 10:51:50 +01:00
previewPanelRef.current?.collapse()
setIsPreviewCollapsed(true)
2024-10-12 17:55:49 -04:00
}
2024-10-23 10:51:50 +01:00
}
2024-10-12 17:55:49 -04:00
const toggleLayout = () => {
if (!isAIChatOpen) {
2024-10-21 13:57:45 -06:00
setIsHorizontalLayout((prev) => !prev)
}
2024-10-21 13:57:45 -06:00
}
// Add an effect to handle layout changes when AI chat is opened/closed
useEffect(() => {
if (isAIChatOpen) {
2024-10-21 13:57:45 -06:00
setPreviousLayout(isHorizontalLayout)
setIsHorizontalLayout(true)
} else {
2024-10-21 13:57:45 -06:00
setIsHorizontalLayout(previousLayout)
}
2024-10-21 13:57:45 -06:00
}, [isAIChatOpen])
// Modify the toggleAIChat function
const toggleAIChat = () => {
2024-10-21 13:57:45 -06:00
setIsAIChatOpen((prev) => !prev)
2024-10-23 10:51:50 +01:00
}
2024-05-26 17:28:52 -07:00
// On disabled access for shared users, show un-interactable loading placeholder + info modal
if (disableAccess.isDisabled)
2024-05-25 20:13:31 -07:00
return (
2024-05-26 17:28:52 -07:00
<>
<DisableAccessModal
message={disableAccess.message}
open={disableAccess.isDisabled}
setOpen={() => { }}
2024-05-26 17:28:52 -07:00
/>
<Loading />
</>
2024-05-26 12:18:09 -07:00
)
2024-05-07 21:19:32 -07:00
2024-05-23 01:35:08 -07:00
return (
2024-05-26 17:28:52 -07:00
<>
{/* Copilot DOM elements */}
2024-07-23 17:30:35 -04:00
<PreviewProvider>
<div ref={generateRef} />
2024-09-06 20:41:45 +01:00
<div ref={suggestionRef} className="absolute">
<AnimatePresence>
2024-09-06 18:14:54 -07:00
{isSelected && showSuggestion && (
2024-09-06 20:41:45 +01:00
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease: "easeOut", duration: 0.2 }}
>
<Button size="xs" type="submit" onClick={handleAiEdit}>
<Sparkles className="h-3 w-3 mr-1" />
Edit Code
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
<div
className={cn(generate.show && "z-50 p-1")}
ref={generateWidgetRef}
>
2024-09-06 18:14:54 -07:00
{generate.show ? (
2024-07-23 17:30:35 -04:00
<GenerateInput
user={userData}
socket={socket!}
2024-07-23 17:30:35 -04:00
width={generate.width - 90}
data={{
fileName: tabs.find((t) => t.id === activeFileId)?.name ?? "",
2024-09-06 20:41:45 +01:00
code:
(isSelected && editorRef?.getSelection()
? editorRef
?.getModel()
?.getValueInRange(editorRef?.getSelection()!)
2024-09-06 20:41:45 +01:00
: editorRef?.getValue()) ?? "",
2024-07-23 17:30:35 -04:00
line: generate.line,
}}
editor={{
language: editorLanguage,
}}
onExpand={() => {
2024-09-06 20:41:45 +01:00
const line = generate.line
2024-07-23 17:30:35 -04:00
editorRef?.changeViewZones(function (changeAccessor) {
changeAccessor.removeZone(generate.id)
if (!generateRef.current) return
2024-09-06 20:41:45 +01:00
let id = ""
if (isSelected) {
const selection = editorRef?.getSelection()
if (!selection) return
const isAbove =
generate.pref?.[0] ===
monaco.editor.ContentWidgetPositionPreference.ABOVE
const afterLineNumber = isAbove ? line - 1 : line
id = changeAccessor.addZone({
afterLineNumber,
heightInLines: isAbove ? 11 : 12,
2024-09-06 20:41:45 +01:00
domNode: generateRef.current,
})
const contentWidget = generate.widget
if (contentWidget) {
2024-09-06 20:41:45 +01:00
editorRef?.layoutContentWidget(contentWidget)
}
} else {
id = changeAccessor.addZone({
afterLineNumber: cursorLine,
heightInLines: 12,
domNode: generateRef.current,
})
}
2024-07-23 17:30:35 -04:00
setGenerate((prev) => {
return { ...prev, id }
})
2024-05-26 17:28:52 -07:00
})
2024-07-23 17:30:35 -04:00
}}
onAccept={(code: string) => {
const line = generate.line
2024-05-26 17:28:52 -07:00
setGenerate((prev) => {
2024-07-23 17:30:35 -04:00
return {
...prev,
show: !prev.show,
}
2024-05-26 17:28:52 -07:00
})
2024-09-06 20:41:45 +01:00
const selection = editorRef?.getSelection()
const range =
isSelected && selection
? selection
: new monaco.Range(line, 1, line, 1)
editorRef?.executeEdits("ai-generation", [
{ range, text: code, forceMoveMarkers: true },
])
2024-07-23 17:30:35 -04:00
}}
onClose={() => {
setGenerate((prev) => {
return {
...prev,
show: !prev.show,
}
})
}}
/>
) : null}
</div>
{/* Main editor components */}
<Sidebar
sandboxData={sandboxData}
files={files}
selectFile={selectFile}
handleRename={handleRename}
handleDeleteFile={handleDeleteFile}
handleDeleteFolder={handleDeleteFolder}
socket={socket!}
2024-07-23 17:30:35 -04:00
setFiles={setFiles}
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
deletingFolderId={deletingFolderId}
/>
{/* Outer ResizablePanelGroup for main layout */}
2024-10-21 13:57:45 -06:00
<ResizablePanelGroup
direction={isHorizontalLayout ? "horizontal" : "vertical"}
>
{/* Left side: Editor and Preview/Terminal */}
<ResizablePanel defaultSize={isAIChatOpen ? 80 : 100} minSize={50}>
2024-10-23 10:51:50 +01:00
<ResizablePanelGroup
direction={isHorizontalLayout ? "vertical" : "horizontal"}
>
2024-07-23 17:30:35 -04:00
<ResizablePanel
className="p-2 flex flex-col"
maxSize={80}
minSize={30}
defaultSize={70}
ref={editorPanelRef}
2024-07-23 17:30:35 -04:00
>
<div className="h-10 w-full flex gap-2 overflow-auto tab-scroll">
{/* File tabs */}
{tabs.map((tab) => (
<Tab
key={tab.id}
saved={tab.saved}
selected={activeFileId === tab.id}
onClick={(e) => {
selectFile(tab)
}}
onClose={() => closeTab(tab.id)}
>
{tab.name}
</Tab>
))}
</div>
{/* Monaco editor */}
<div
ref={editorContainerRef}
className="grow w-full overflow-hidden rounded-md relative"
>
{!activeFileId ? (
<>
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<FileJson className="w-6 h-6 mr-3" />
No file selected.
</div>
</>
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
clerk.loaded ? (
<>
{provider && userInfo ? (
<Cursors yProvider={provider} userInfo={userInfo} />
) : null}
<Editor
height="100%"
language={editorLanguage}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
onChange={(value) => {
// If the new content is different from the cached content, update it
if (value !== fileContents[activeFileId]) {
setActiveFileContent(value ?? "") // Update the active file content
// Mark the file as unsaved by setting 'saved' to false
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: false }
: tab
)
)
} else {
// If the content matches the cached content, mark the file as saved
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: true }
: tab
)
)
}
}}
options={{
tabSize: 2,
minimap: {
enabled: false,
},
padding: {
bottom: 4,
top: 4,
},
scrollBeyondLastLine: false,
fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)",
}}
theme={theme === "light" ? "vs" : "vs-dark"}
value={activeFileContent}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<Loader2 className="animate-spin w-6 h-6 mr-3" />
Waiting for Clerk to load...
</div>
)}
</div>
2024-07-23 17:30:35 -04:00
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={30}>
2024-10-23 10:51:50 +01:00
<ResizablePanelGroup
direction={
isAIChatOpen && isHorizontalLayout
? "horizontal"
: isAIChatOpen
? "vertical"
: isHorizontalLayout
? "horizontal"
: "vertical"
2024-10-23 10:51:50 +01:00
}
>
<ResizablePanel
ref={previewPanelRef}
defaultSize={isPreviewCollapsed ? 4 : 20}
minSize={25}
collapsedSize={isHorizontalLayout ? 20 : 4}
2024-10-23 10:51:50 +01:00
className="p-2 flex flex-col gap-2"
collapsible
onCollapse={() => setIsPreviewCollapsed(true)}
onExpand={() => setIsPreviewCollapsed(false)}
>
<div className="flex items-center justify-between">
2024-10-23 10:51:50 +01:00
<Button
onClick={toggleLayout}
size="sm"
variant="ghost"
className="mr-2 border"
disabled={isAIChatOpen}
2024-10-23 10:51:50 +01:00
>
{isHorizontalLayout ? (
<ArrowRightToLine className="w-4 h-4" />
) : (
<ArrowDownToLine className="w-4 h-4" />
)}
</Button>
<PreviewWindow
open={togglePreviewPanel}
collapsed={isPreviewCollapsed}
src={previewURL}
ref={previewWindowRef}
/>
</div>
{!isPreviewCollapsed && (
<div className="w-full grow rounded-md overflow-hidden bg-foreground mt-2">
<iframe
width={"100%"}
height={"100%"}
src={previewURL}
/>
</div>
)}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
defaultSize={50}
minSize={20}
className="p-2 flex flex-col"
>
{isOwner ? (
<Terminals />
) : (
<div className="w-full h-full flex items-center justify-center text-lg font-medium text-muted-foreground/50 select-none">
<TerminalSquare className="w-4 h-4 mr-2" />
No terminal access.
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
2024-07-23 17:30:35 -04:00
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
{/* Right side: AIChat (if open) */}
{isAIChatOpen && (
<>
<ResizableHandle />
<ResizablePanel defaultSize={30} minSize={15}>
2024-10-23 10:51:50 +01:00
<AIChat
activeFileContent={activeFileContent}
activeFileName={
tabs.find((tab) => tab.id === activeFileId)?.name ||
"No file selected"
}
onClose={toggleAIChat}
2024-10-23 10:51:50 +01:00
/>
</ResizablePanel>
</>
)}
2024-07-23 17:30:35 -04:00
</ResizablePanelGroup>
</PreviewProvider>
2024-05-26 17:28:52 -07:00
</>
2024-05-26 12:18:09 -07:00
)
2024-04-06 19:03:04 -04:00
}