re-organize files + setup more orchestration logic

This commit is contained in:
Ishaan Dey 2024-05-12 02:10:31 -07:00
parent e699028096
commit 18aca540cc
9 changed files with 848 additions and 780 deletions

View File

@ -19,8 +19,7 @@ app.use(express.json())
dotenv.config()
const corsOptions = {
origin: 'http://localhost:3000',
// origin: 'https://s.ishaand.com',
origin: ['http://localhost:3000', 'https://s.ishaand.com', 'http://localhost:4000'],
}
const kubeconfig = new KubeConfig()

View File

@ -1,10 +0,0 @@
import { Server } from "socket.io";
import { DefaultEventsMap } from "socket.io/dist/typed-events";
export function checkForInactivity(io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>) {
io.fetchSockets().then(sockets => {
if (sockets.length === 0) {
console.log("No users have been connected for 15 seconds.");
}
});
}

View File

@ -18,6 +18,7 @@ import {
getSandboxFiles,
renameFile,
saveFile,
stopServer,
} from "./utils"
import { IDisposable, IPty, spawn } from "node-pty"
import {
@ -437,10 +438,12 @@ io.on("connection", async (socket) => {
inactivityTimeout = setTimeout(() => {
io.fetchSockets().then(sockets => {
if (sockets.length === 0) {
console.log("No users have been connected for 15 seconds.");
// close server
console.log("Closing server due to inactivity.");
stopServer(data.sandboxId, data.userId)
}
});
}, 15000);
}, 60000);
}
})

View File

@ -196,3 +196,16 @@ ${code}`,
}
)
}
export const stopServer = async (sandboxId: string, userId: string) => {
await fetch("http://localhost:4001/stop", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
sandboxId,
userId
}),
})
}

View File

@ -2,15 +2,8 @@ import Navbar from "@/components/editor/navbar";
import { Room } from "@/components/editor/live/room";
import { Sandbox, User, UsersToSandboxes } from "@/lib/types";
import { currentUser } from "@clerk/nextjs";
import dynamic from "next/dynamic";
import { notFound, redirect } from "next/navigation";
import Loading from "@/components/editor/loading";
import { Suspense } from "react";
const CodeEditor = dynamic(() => import("@/components/editor"), {
ssr: false,
loading: () => <Loading />,
});
import Editor from "@/components/editor";
const getUserData = async (id: string) => {
const userRes = await fetch(
@ -67,11 +60,7 @@ export default async function CodePage({ params }: { params: { id: string } }) {
<Room id={sandboxId}>
<Navbar userData={userData} sandboxData={sandboxData} shared={shared} />
<div className="w-screen flex grow">
<CodeEditor
userData={userData}
sandboxData={sandboxData}
isSharedUser={isSharedUser}
/>
<Editor userData={userData} sandboxData={sandboxData} />
</div>
</Room>
{/* </Suspense> */}

View File

@ -0,0 +1,763 @@
"use client";
import { useEffect, useRef, useState } from "react";
import monaco from "monaco-editor";
import Editor, { BeforeMount, OnMount } from "@monaco-editor/react";
import { io } from "socket.io-client";
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 } 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 } 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";
export default function CodeEditor({
userData,
sandboxData,
}: {
userData: User;
sandboxData: Sandbox;
}) {
const socket = io(
`http://localhost:4000?userId=${userData.id}&sandboxId=${sandboxData.id}`
);
const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(
sandboxData.type !== "react"
);
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;
}[]
>([]);
const [activeTerminalId, setActiveTerminalId] = useState("");
const [creatingTerminal, setCreatingTerminal] = useState(false);
const [closingTerminal, setClosingTerminal] = useState("");
const activeTerminal = terminals.find((t) => t.id === activeTerminalId);
const isOwner = sandboxData.userId === userData.id;
const clerk = useClerk();
// Liveblocks hooks
const room = useRoom();
const [provider, setProvider] = useState<TypedLiveblocksProvider>();
// 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);
// Resize observer tracks editor width for generate widget
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect;
setGenerate((prev) => {
return { ...prev, width };
});
}
});
// 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,
};
},
};
setGenerate((prev) => {
return { ...prev, widget: contentWidget };
});
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;
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
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId ? { ...tab, saved: true } : tab
)
);
socket.emit("saveFile", activeFileId, editorRef?.getValue());
}
};
document.addEventListener("keydown", down);
return () => {
document.removeEventListener("keydown", down);
};
}, [tabs, activeFileId]);
// Liveblocks live collaboration setup effect
useEffect(() => {
const tab = tabs.find((t) => t.id === activeFileId);
const model = editorRef?.getModel();
if (!editorRef || !tab || !model) return;
const yDoc = new Y.Doc();
const yText = yDoc.getText(tab.id);
const yProvider: any = new LiveblocksProvider(room, yDoc);
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);
setProvider(yProvider);
const binding = new MonacoBinding(
yText,
model,
new Set([editorRef]),
yProvider.awareness as Awareness
);
return () => {
yDoc.destroy();
yProvider.destroy();
binding.destroy();
yProvider.off("sync", onSync);
};
}, [editorRef, room, activeFileContent]);
// Connection/disconnection effect + resizeobserver
useEffect(() => {
socket.connect();
if (editorContainerRef.current) {
resizeObserver.observe(editorContainerRef.current);
}
return () => {
socket.disconnect();
resizeObserver.disconnect();
};
}, []);
// Socket event listener effect
useEffect(() => {
const onConnect = () => {};
const onDisconnect = () => {
setTerminals([]);
};
const onLoadedEvent = (files: (TFolder | TFile)[]) => {
setFiles(files);
};
const onRateLimit = (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("rateLimit", onRateLimit);
socket.on("terminalResponse", onTerminalResponse);
socket.on("disableAccess", onDisableAccess);
return () => {
socket.off("connect", onConnect);
socket.off("disconnect", onDisconnect);
socket.off("loaded", onLoadedEvent);
socket.off("rateLimit", onRateLimit);
socket.off("terminalResponse", onTerminalResponse);
socket.off("disableAccess", onDisableAccess);
};
// }, []);
}, [terminals]);
// Helper functions for tabs:
// Select file and load content
const selectFile = (tab: TTab) => {
if (tab.id === activeFileId) return;
const exists = tabs.find((t) => t.id === tab.id);
setTabs((prev) => {
if (exists) {
setActiveFileId(exists.id);
return prev;
}
return [...prev, tab];
});
socket.emit("getFile", tab.id, (response: string) => {
setActiveFileContent(response);
});
setEditorLanguage(processFileType(tab.name));
setActiveFileId(tab.id);
};
// 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("");
});
setTimeout(() => {
setDeletingFolderId("");
}, 3000);
};
// 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 */}
<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);
}}
/>
) : 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}
>
<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 ? <Cursors yProvider={provider} /> : 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={{
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={previewPanelRef}
defaultSize={4}
collapsedSize={4}
minSize={25}
collapsible
className="p-2 flex flex-col"
onCollapse={() => setIsPreviewCollapsed(true)}
onExpand={() => setIsPreviewCollapsed(false)}
>
<PreviewWindow
collapsed={isPreviewCollapsed}
open={() => {
previewPanelRef.current?.expand();
setIsPreviewCollapsed(false);
}}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
defaultSize={50}
minSize={20}
className="p-2 flex flex-col"
>
{isOwner ? (
<Terminals
terminals={terminals}
setTerminals={setTerminals}
activeTerminalId={activeTerminalId}
setActiveTerminalId={setActiveTerminalId}
socket={socket}
activeTerminal={activeTerminal}
creatingTerminal={creatingTerminal}
setCreatingTerminal={setCreatingTerminal}
closingTerminal={closingTerminal}
setClosingTerminal={setClosingTerminal}
/>
) : (
<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>
</>
);
}

View File

@ -1,765 +1,40 @@
"use client";
import { useEffect, useRef, useState } from "react";
import monaco from "monaco-editor";
import Editor, { BeforeMount, OnMount } from "@monaco-editor/react";
import { io } from "socket.io-client";
import dynamic from "next/dynamic";
import Loading from "@/components/editor/loading";
import { Sandbox, User } from "@/lib/types";
import { useEffect, useState } from "react";
import { startServer } from "@/lib/utils";
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 } from "@/liveblocks.config";
const CodeEditor = dynamic(() => import("@/components/editor/editor"), {
ssr: false,
loading: () => <Loading />,
});
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 } 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";
export default function CodeEditor({
export default function Editor({
userData,
sandboxData,
}: // isSharedUser,
{
}: {
userData: User;
sandboxData: Sandbox;
isSharedUser: boolean;
}) {
const socket = io(
`http://localhost:4000?userId=${userData.id}&sandboxId=${sandboxData.id}`
);
const [isServerRunning, setIsServerRunning] = useState(false);
const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(
sandboxData.type !== "react"
);
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;
}[]
>([]);
const [activeTerminalId, setActiveTerminalId] = useState("");
const [creatingTerminal, setCreatingTerminal] = useState(false);
const [closingTerminal, setClosingTerminal] = useState("");
const activeTerminal = terminals.find((t) => t.id === activeTerminalId);
const isOwner = sandboxData.userId === userData.id;
const clerk = useClerk();
// Liveblocks hooks
const room = useRoom();
const [provider, setProvider] = useState<TypedLiveblocksProvider>();
// 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);
// Resize observer tracks editor width for generate widget
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect;
setGenerate((prev) => {
return { ...prev, width };
});
}
});
// 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,
};
},
};
setGenerate((prev) => {
return { ...prev, widget: contentWidget };
});
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;
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
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId ? { ...tab, saved: true } : tab
)
);
socket.emit("saveFile", activeFileId, editorRef?.getValue());
}
};
document.addEventListener("keydown", down);
return () => {
document.removeEventListener("keydown", down);
};
}, [tabs, activeFileId]);
// Liveblocks live collaboration setup effect
useEffect(() => {
const tab = tabs.find((t) => t.id === activeFileId);
const model = editorRef?.getModel();
if (!editorRef || !tab || !model) return;
const yDoc = new Y.Doc();
const yText = yDoc.getText(tab.id);
const yProvider: any = new LiveblocksProvider(room, yDoc);
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);
setProvider(yProvider);
const binding = new MonacoBinding(
yText,
model,
new Set([editorRef]),
yProvider.awareness as Awareness
);
return () => {
yDoc.destroy();
yProvider.destroy();
binding.destroy();
yProvider.off("sync", onSync);
};
}, [editorRef, room, activeFileContent]);
// Connection/disconnection effect + resizeobserver
useEffect(() => {
socket.connect();
if (editorContainerRef.current) {
resizeObserver.observe(editorContainerRef.current);
}
return () => {
socket.disconnect();
resizeObserver.disconnect();
};
}, []);
// Socket event listener effect
useEffect(() => {
const onConnect = () => {};
const onDisconnect = () => {
setTerminals([]);
};
const onLoadedEvent = (files: (TFolder | TFile)[]) => {
setFiles(files);
};
const onRateLimit = (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("rateLimit", onRateLimit);
socket.on("terminalResponse", onTerminalResponse);
socket.on("disableAccess", onDisableAccess);
return () => {
socket.off("connect", onConnect);
socket.off("disconnect", onDisconnect);
socket.off("loaded", onLoadedEvent);
socket.off("rateLimit", onRateLimit);
socket.off("terminalResponse", onTerminalResponse);
socket.off("disableAccess", onDisableAccess);
};
// useEffect(() => {
// startServer(sandboxData.id, userData.id, (success: boolean) => {
// if (!success) {
// toast.error("Failed to start server.");
// return;
// }
// setIsServerRunning(true);
// });
// }, []);
}, [terminals]);
// Helper functions for tabs:
// Select file and load content
const selectFile = (tab: TTab) => {
if (tab.id === activeFileId) return;
const exists = tabs.find((t) => t.id === tab.id);
setTabs((prev) => {
if (exists) {
setActiveFileId(exists.id);
return prev;
}
return [...prev, tab];
});
socket.emit("getFile", tab.id, (response: string) => {
setActiveFileContent(response);
});
setEditorLanguage(processFileType(tab.name));
setActiveFileId(tab.id);
};
// 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("");
});
setTimeout(() => {
setDeletingFolderId("");
}, 3000);
};
// On disabled access for shared users, show un-interactable loading placeholder + info modal
if (disableAccess.isDisabled)
if (!isServerRunning)
return (
<>
<DisableAccessModal
message={disableAccess.message}
open={disableAccess.isDisabled}
setOpen={() => {}}
/>
<Loading />
</>
<Loading text="Creating code editing environment, this could take a while." />
);
return (
<>
{/* Copilot DOM elements */}
<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);
}}
/>
) : 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}
>
<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 ? <Cursors yProvider={provider} /> : 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={{
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={previewPanelRef}
defaultSize={4}
collapsedSize={4}
minSize={25}
collapsible
className="p-2 flex flex-col"
onCollapse={() => setIsPreviewCollapsed(true)}
onExpand={() => setIsPreviewCollapsed(false)}
>
<PreviewWindow
collapsed={isPreviewCollapsed}
open={() => {
previewPanelRef.current?.expand();
setIsPreviewCollapsed(false);
}}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
defaultSize={50}
minSize={20}
className="p-2 flex flex-col"
>
{isOwner ? (
<Terminals
terminals={terminals}
setTerminals={setTerminals}
activeTerminalId={activeTerminalId}
setActiveTerminalId={setActiveTerminalId}
socket={socket}
activeTerminal={activeTerminal}
creatingTerminal={creatingTerminal}
setCreatingTerminal={setCreatingTerminal}
closingTerminal={closingTerminal}
setClosingTerminal={setClosingTerminal}
/>
) : (
<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>
</>
);
return <CodeEditor userData={userData} sandboxData={sandboxData} />;
}

View File

@ -3,9 +3,22 @@ import Logo from "@/assets/logo.svg";
import { Skeleton } from "../ui/skeleton";
import { Loader, Loader2 } from "lucide-react";
export default function Loading({ withNav = false }: { withNav?: boolean }) {
export default function Loading({
withNav = false,
text = "",
}: {
withNav?: boolean;
text?: string;
}) {
return (
<div className="overflow-hidden overscroll-none w-screen flex flex-col h-screen bg-background">
<div className="overflow-hidden overscroll-none w-screen flex flex-col justify-center items-center z-0 h-screen bg-background relative">
{text ? (
<div className="text-lg font-medium flex items-center z-50 shadow-xl shadow-red-500">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
{text}
</div>
) : null}
{withNav ? (
<div className="h-14 px-2 w-full flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-4">

View File

@ -48,3 +48,26 @@ export function addNew(name: string, type: "file" | "folder", setFiles: React.Di
setFiles(prev => [...prev, { id: `projects/${sandboxData.id}/${name}`, name, type: "folder", children: [] }])
}
}
export async function startServer(sandboxId: string, userId: string, callback: (success: boolean) => void) {
try {
await fetch("http://localhost:4001/start", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
sandboxId,
userId
}),
})
callback(true)
} catch (error) {
console.error("Failed to start server", error)
callback(false)
}
}