Akhileshrangani4 f683ff6480 fix: files not loading when creating a new project
This push contains console logs at various places where the server is emitting the event and the client is receiving the event. Please remove those before merging with production.
2024-08-31 20:31:20 -04:00

858 lines
26 KiB
TypeScript

"use client"
import { SetStateAction, useCallback, useEffect, useRef, useState } from "react"
import monaco from "monaco-editor"
import Editor, { BeforeMount, OnMount } from "@monaco-editor/react"
import { toast } from "sonner"
import { useClerk } from "@clerk/nextjs"
import * as Y from "yjs"
import LiveblocksProvider from "@liveblocks/yjs"
import { MonacoBinding } from "y-monaco"
import { Awareness } from "y-protocols/awareness"
import { TypedLiveblocksProvider, useRoom, useSelf } from "@/liveblocks.config"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { FileJson, Loader2, TerminalSquare } from "lucide-react"
import Tab from "../ui/tab"
import Sidebar from "./sidebar"
import GenerateInput from "./generate"
import { Sandbox, User, TFile, TFolder, TTab } from "@/lib/types"
import { addNew, processFileType, validateName, debounce } from "@/lib/utils"
import { Cursors } from "./live/cursors"
import { Terminal } from "@xterm/xterm"
import DisableAccessModal from "./live/disableModal"
import Loading from "./loading"
import PreviewWindow from "./preview"
import Terminals from "./terminals"
import { ImperativePanelHandle } from "react-resizable-panels"
import { PreviewProvider, usePreview } from '@/context/PreviewContext';
import { useSocket } from "@/context/SocketContext"
export default function CodeEditor({
userData,
sandboxData,
}: {
userData: User
sandboxData: Sandbox
}) {
//SocketContext functions and effects
const { socket, setUserAndSandboxId } = useSocket();
useEffect(() => {
console.log('Effect triggered:', { socket, userData, sandboxData });
// 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
console.log('Initializing socket...');
setUserAndSandboxId(userData.id, sandboxData.id);
}
}
}, [socket, userData.id, sandboxData.id, setUserAndSandboxId]);
//Preview Button state
const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true)
const [disableAccess, setDisableAccess] = useState({
isDisabled: false,
message: "",
})
// 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("")
// Editor state
const [editorLanguage, setEditorLanguage] = useState("plaintext")
const [cursorLine, setCursorLine] = useState(0)
const [editorRef, setEditorRef] =
useState<monaco.editor.IStandaloneCodeEditor>()
// AI Copilot state
const [ai, setAi] = useState(false)
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 })
// Terminal state
const [terminals, setTerminals] = useState<
{
id: string
terminal: Terminal | null
}[]
>([])
// Preview state
const [previewURL, setPreviewURL] = useState<string>("");
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<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>());
// Refs for libraries / features
const editorContainerRef = useRef<HTMLDivElement>(null)
const monacoRef = useRef<typeof monaco | null>(null)
const generateRef = useRef<HTMLDivElement>(null)
const generateWidgetRef = useRef<HTMLDivElement>(null)
const previewPanelRef = useRef<ImperativePanelHandle>(null)
const editorPanelRef = useRef<ImperativePanelHandle>(null)
const previewWindowRef = useRef<{ refreshIframe: () => void }>(null)
// 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 = (editor, monaco) => {
setEditorRef(editor)
monacoRef.current = monaco
editor.onDidChangeCursorPosition((e) => {
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],
}
})
},
})
}
// Generate widget effect
useEffect(() => {
if (!ai) {
setGenerate((prev) => {
return {
...prev,
show: false,
}
})
return
}
if (generate.show) {
editorRef?.changeViewZones(function (changeAccessor) {
if (!generateRef.current) return
const id = changeAccessor.addZone({
afterLineNumber: cursorLine,
heightInLines: 3,
domNode: generateRef.current,
})
setGenerate((prev) => {
return { ...prev, id, 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])
// Decorations effect for generate widget tips
useEffect(() => {
if (decorations.options.length === 0) {
decorations.instance?.clear()
}
if (!ai) return
const model = editorRef?.getModel()
const line = model?.getLineContent(cursorLine)
if (line === undefined || 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
const debouncedSaveData = useCallback(
debounce((value: string | undefined, activeFileId: string | undefined) => {
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId ? { ...tab, saved: true } : tab
)
);
console.log(`Saving file...${activeFileId}`);
console.log(`Saving file...${value}`);
socket?.emit("saveFile", activeFileId, value);
}, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY) || 1000),
[socket]
);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
debouncedSaveData(editorRef?.getValue(), activeFileId);
}
};
document.addEventListener("keydown", down);
return () => {
document.removeEventListener("keydown", down);
};
}, [activeFileId, tabs, debouncedSaveData]);
// 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 event listener effect
useEffect(() => {
const onConnect = () => { }
const onDisconnect = () => {
setTerminals([])
}
const onLoadedEvent = (files: (TFolder | TFile)[]) => {
setFiles(files)
console.log("loaded", 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 = useCallback(
debounce((tabId, callback) => {
socket?.emit('getFile', tabId, callback);
}, 300), // 300ms debounce delay, adjust as needed
[]
);
const selectFile = useCallback((tab: TTab) => {
if (tab.id === activeFileId) return;
setGenerate((prev) => ({ ...prev, show: false }));
const exists = tabs.find((t) => t.id === tab.id);
setTabs((prev) => {
if (exists) {
setActiveFileId(exists.id);
return prev;
}
return [...prev, tab];
});
if (fileCache.current.has(tab.id)) {
setActiveFileContent(fileCache.current.get(tab.id));
} else {
debouncedGetFile(tab.id, (response: SetStateAction<string>) => {
fileCache.current.set(tab.id, response);
setActiveFileContent(response);
});
}
setEditorLanguage(processFileType(tab.name));
setActiveFileId(tab.id);
}, [activeFileId, tabs, debouncedGetFile]);
// 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", id, newName)
setTabs((prev) =>
prev.map((tab) => (tab.id === id ? { ...tab, name: newName } : tab))
)
return true
}
const handleDeleteFile = (file: TFile) => {
socket?.emit("deleteFile", 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", folder.id, (response: string[]) =>
closeTabs(response)
)
socket?.emit("deleteFolder", folder.id, (response: (TFolder | TFile)[]) => {
setFiles(response)
setDeletingFolderId("")
})
}
// On disabled access for shared users, show un-interactable loading placeholder + info modal
if (disableAccess.isDisabled)
return (
<>
<DisableAccessModal
message={disableAccess.message}
open={disableAccess.isDisabled}
setOpen={() => { }}
/>
<Loading />
</>
)
return (
<>
{/* Copilot DOM elements */}
<PreviewProvider>
<div ref={generateRef} />
<div className="z-50 p-1" ref={generateWidgetRef}>
{generate.show && ai ? (
<GenerateInput
user={userData}
socket={socket!}
width={generate.width - 90}
data={{
fileName: tabs.find((t) => t.id === activeFileId)?.name ?? "",
code: editorRef?.getValue() ?? "",
line: generate.line,
}}
editor={{
language: editorLanguage,
}}
onExpand={() => {
editorRef?.changeViewZones(function (changeAccessor) {
changeAccessor.removeZone(generate.id)
if (!generateRef.current) return
const 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 file = editorRef?.getValue()
const lines = file?.split("\n") || []
lines.splice(line - 1, 0, code)
const updatedFile = lines.join("\n")
editorRef?.setValue(updatedFile)
}}
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!}
setFiles={setFiles}
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
deletingFolderId={deletingFolderId}
// AI Copilot Toggle
ai={ai}
setAi={setAi}
/>
{/* Shadcn resizeable panels: https://ui.shadcn.com/docs/components/resizable */}
<ResizablePanelGroup direction="horizontal">
<ResizablePanel
className="p-2 flex flex-col"
maxSize={80}
minSize={30}
defaultSize={60}
ref={editorPanelRef}
>
<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 (value === activeFileContent) {
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: true }
: tab
)
)
} else {
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: false }
: tab
)
)
}
}}
options={{
tabSize: 2,
minimap: {
enabled: false,
},
padding: {
bottom: 4,
top: 4,
},
scrollBeyondLastLine: false,
fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)",
}}
theme="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>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={40}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel
ref={usePreview().previewPanelRef}
defaultSize={4}
collapsedSize={4}
minSize={25}
collapsible
className="p-2 flex flex-col"
onCollapse={() => setIsPreviewCollapsed(true)}
onExpand={() => setIsPreviewCollapsed(false)}
>
<PreviewWindow
open={() => {
usePreview().previewPanelRef.current?.expand()
setIsPreviewCollapsed(false)
}} collapsed={isPreviewCollapsed} src={previewURL} ref={previewWindowRef} />
</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>
</ResizablePanel>
</ResizablePanelGroup>
</PreviewProvider>
</>
)
}