"use client" import { useClerk } from "@clerk/nextjs" import Editor, { BeforeMount, OnMount } from "@monaco-editor/react" import { AnimatePresence, motion } from "framer-motion" 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 { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable" 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" import Tab from "../ui/tab" import AIChat from "./AIChat" import GenerateInput from "./generate" import { Cursors } from "./live/cursors" import DisableAccessModal from "./live/disableModal" import Loading from "./loading" import PreviewWindow from "./preview" import Sidebar from "./sidebar" import Terminals from "./terminals" export default function CodeEditor({ userData, sandboxData, }: { userData: User sandboxData: Sandbox }) { //SocketContext functions and effects const { socket, setUserAndSandboxId } = useSocket() // theme const { theme } = useTheme() 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 setUserAndSandboxId(userData.id, sandboxData.id) } } }, [socket, userData.id, sandboxData.id, setUserAndSandboxId]) // 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]) //Preview Button state const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true) const [disableAccess, setDisableAccess] = useState({ isDisabled: false, message: "", }) // Layout state const [isHorizontalLayout, setIsHorizontalLayout] = useState(false) const [previousLayout, setPreviousLayout] = useState(false) // AI Chat state const [isAIChatOpen, setIsAIChatOpen] = useState(false) // File state const [files, setFiles] = useState<(TFolder | TFile)[]>([]) const [tabs, setTabs] = useState([]) const [activeFileId, setActiveFileId] = useState("") const [activeFileContent, setActiveFileContent] = useState("") const [deletingFolderId, setDeletingFolderId] = useState("") // Added this state to track the most recent content for each file const [fileContents, setFileContents] = useState>({}) // Editor state const [editorLanguage, setEditorLanguage] = useState("plaintext") const [cursorLine, setCursorLine] = useState(0) const [editorRef, setEditorRef] = useState() // 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 }) const [isSelected, setIsSelected] = useState(false) const [showSuggestion, setShowSuggestion] = useState(false) // Terminal state const [terminals, setTerminals] = useState< { id: string terminal: Terminal | null }[] >([]) // Preview state const [previewURL, setPreviewURL] = useState("") const loadPreviewURL = (url: string) => { // This will cause a reload if previewURL changed. setPreviewURL(url) // If the URL didn't change, still reload the preview. previewWindowRef.current?.refreshIframe() } const isOwner = sandboxData.userId === userData.id const clerk = useClerk() // Liveblocks hooks const room = useRoom() const [provider, setProvider] = useState() const userInfo = useSelf((me) => me.info) // Liveblocks providers map to prevent reinitializing providers type ProviderData = { provider: LiveblocksProvider yDoc: Y.Doc yText: Y.Text binding?: MonacoBinding onSync: (isSynced: boolean) => void } const providersMap = useRef(new Map()) // Refs for libraries / features const editorContainerRef = useRef(null) const monacoRef = useRef(null) const generateRef = useRef(null) const suggestionRef = useRef(null) const generateWidgetRef = useRef(null) const { previewPanelRef } = usePreview() const editorPanelRef = useRef(null) 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 debouncedSetIsSelected = useRef( debounce((value: boolean) => { setIsSelected(value) }, 800) // ).current // 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) => { 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 => { 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 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 refContent = await fetchFileContent(path) 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 ) } // Store the last copied range in the editor to be used in the AIChat component editor.onDidChangeCursorSelection((e) => { const selection = editor.getSelection(); if (selection) { lastCopiedRangeRef.current = { startLine: selection.startLineNumber, endLine: selection.endLineNumber }; } }); } // Call the function with your file structure await loadTSConfig(files) editor.onDidChangeCursorPosition((e) => { setIsSelected(false) const selection = editor.getSelection() if (selection !== null) { const hasSelection = !selection.isEmpty() debouncedSetIsSelected(hasSelection) setShowSuggestion(hasSelection) } 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], } }) }, }) } 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]) // Generate widget effect useEffect(() => { if (generate.show) { setShowSuggestion(false) editorRef?.changeViewZones(function (changeAccessor) { if (!generateRef.current) return if (!generate.id) { const id = changeAccessor.addZone({ afterLineNumber: cursorLine, heightInLines: 3, domNode: generateRef.current, }) setGenerate((prev) => { return { ...prev, id, line: cursorLine } }) } setGenerate((prev) => { return { ...prev, line: cursorLine } }) }) 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]) // 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]) // Decorations effect for generate widget tips useEffect(() => { if (decorations.options.length === 0) { decorations.instance?.clear() } 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 } } 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 }) } }, 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 useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "s" && (e.metaKey || e.ctrlKey)) { e.preventDefault() debouncedSaveData(activeFileId) } else if (e.key === "l" && (e.metaKey || e.ctrlKey)) { e.preventDefault() setIsAIChatOpen((prev) => !prev) } } document.addEventListener("keydown", down) // Added this line to prevent Monaco editor from handling Cmd/Ctrl+L editorRef?.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL, () => { setIsAIChatOpen((prev) => !prev) }) return () => { document.removeEventListener("keydown", down) } }, [activeFileId, tabs, debouncedSaveData, setIsAIChatOpen, editorRef]) // Liveblocks live collaboration setup effect useEffect(() => { const tab = tabs.find((t) => t.id === activeFileId) const model = editorRef?.getModel() if (!editorRef || !tab || !model) return 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) // 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) // 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 ) providerData.binding = binding setProvider(providerData.provider) 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() } }, []) // Connection/disconnection effect useEffect(() => { socket?.connect() return () => { socket?.disconnect() } }, [socket]) // Socket event listener effect useEffect(() => { const onConnect = () => { } const onDisconnect = () => { setTerminals([]) } const onLoadedEvent = (files: (TFolder | TFile)[]) => { setFiles(files) } const onError = (message: string) => { 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) 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) } }, [ socket, terminals, setTerminals, setFiles, toast, setDisableAccess, isOwner, loadPreviewURL, ]) // Helper functions for tabs: // Select file and load content // Initialize debounced function once const fileCache = useRef(new Map()) // Debounced function to get file content 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) setTabs((prev) => { if (exists) { // If the tab exists, make it the active tab setActiveFileId(exists.id) return prev } // If the tab doesn't exist, add it to the list of tabs and make it active return [...prev, tab] }) // If the file's content is already cached, set it as the active content if (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]) // 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 : 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 }) 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)[]) => { 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[]) => closeTabs(response) ) socket?.emit("deleteFolder", { folderId: folder.id }, (response: (TFolder | TFile)[]) => { setFiles(response) setDeletingFolderId("") }) } const togglePreviewPanel = () => { if (isPreviewCollapsed) { previewPanelRef.current?.expand() setIsPreviewCollapsed(false) } else { previewPanelRef.current?.collapse() setIsPreviewCollapsed(true) } } const toggleLayout = () => { if (!isAIChatOpen) { setIsHorizontalLayout((prev) => !prev) } } // Add an effect to handle layout changes when AI chat is opened/closed useEffect(() => { if (isAIChatOpen) { setPreviousLayout(isHorizontalLayout) setIsHorizontalLayout(true) } else { setIsHorizontalLayout(previousLayout) } }, [isAIChatOpen]) // Modify the toggleAIChat function const toggleAIChat = () => { setIsAIChatOpen((prev) => !prev) } // On disabled access for shared users, show un-interactable loading placeholder + info modal if (disableAccess.isDisabled) return ( <> { }} /> ) return ( <> {/* Copilot DOM elements */}
{isSelected && showSuggestion && ( )}
{generate.show ? ( t.id === activeFileId)?.name ?? "", code: (isSelected && editorRef?.getSelection() ? editorRef ?.getModel() ?.getValueInRange(editorRef?.getSelection()!) : editorRef?.getValue()) ?? "", line: generate.line, }} editor={{ language: editorLanguage, }} onExpand={() => { const line = generate.line editorRef?.changeViewZones(function (changeAccessor) { changeAccessor.removeZone(generate.id) if (!generateRef.current) return 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, domNode: generateRef.current, }) const contentWidget = generate.widget if (contentWidget) { editorRef?.layoutContentWidget(contentWidget) } } else { id = changeAccessor.addZone({ afterLineNumber: cursorLine, heightInLines: 12, domNode: generateRef.current, }) } setGenerate((prev) => { return { ...prev, id } }) }) }} onAccept={(code: string) => { const line = generate.line setGenerate((prev) => { return { ...prev, show: !prev.show, } }) 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 }, ]) }} onClose={() => { setGenerate((prev) => { return { ...prev, show: !prev.show, } }) }} /> ) : null}
{/* Main editor components */} addNew(name, type, setFiles, sandboxData)} deletingFolderId={deletingFolderId} toggleAIChat={toggleAIChat} isAIChatOpen={isAIChatOpen} /> {/* Outer ResizablePanelGroup for main layout */} {/* Left side: Editor and Preview/Terminal */}
{/* File tabs */} {tabs.map((tab) => ( { selectFile(tab) }} onClose={() => closeTab(tab.id)} > {tab.name} ))}
{/* Monaco editor */}
{!activeFileId ? ( <>
No file selected.
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643 clerk.loaded ? ( <> {provider && userInfo ? ( ) : null} { // 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} /> ) : (
Waiting for Clerk to load...
)}
setIsPreviewCollapsed(true)} onExpand={() => setIsPreviewCollapsed(false)} >
{!isPreviewCollapsed && (