diff --git a/apps/server/src/error.ts b/apps/server/src/error.ts new file mode 100644 index 0000000..e4b2cd8 --- /dev/null +++ b/apps/server/src/error.ts @@ -0,0 +1,8 @@ +export class HTTPError extends Error { + constructor( + public status: number, + public message: string, + ) { + super(message); + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ced288c..454a570 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,6 +3,7 @@ import { Elysia } from "elysia"; import { helmet } from "elysia-helmet"; import { msgpack } from "elysia-msgpack"; import env from "./env"; +import { HTTPError } from "./error"; import { authController } from "./modules/auth/auth.controller"; import authMiddleware from "./modules/auth/auth.middleware"; import { usersController } from "./modules/users/users.controller"; @@ -22,6 +23,16 @@ const app = new Elysia({ }, }) .use(msgpack({ moreTypes: true })) + .error({ + HTTPError, + }) + .onError(({ code, error, set }) => { + switch (code) { + case "HTTPError": + set.status = error.status; + throw new Error(error.message); + } + }) .use(helmet()) .use(authMiddleware) .use(authController) diff --git a/apps/server/src/modules/auth/auth.controller.ts b/apps/server/src/modules/auth/auth.controller.ts index 516b8c3..548f37e 100644 --- a/apps/server/src/modules/auth/auth.controller.ts +++ b/apps/server/src/modules/auth/auth.controller.ts @@ -15,10 +15,6 @@ export const authController = new Elysia({ prefix: "/auth" }) async ({ body: { email }, cookie: { session: sessionCookie } }) => { const result = await authService.resendEmail({ email }); - if (result.error) { - return result; - } - sessionCookie.set({ value: result.value, expires: result.attributes.expires, @@ -52,17 +48,11 @@ export const authController = new Elysia({ prefix: "/auth" }) cookie: { session: sessionCookie }, set, }) => { - const result = await authService.login({ + const { user, cookie } = await authService.login({ email, password, }); - if (result.error) { - return result; - } - - const { user, cookie } = result; - sessionCookie.set({ value: cookie.value, expires: cookie.attributes.expires, diff --git a/apps/server/src/modules/auth/auth.service.ts b/apps/server/src/modules/auth/auth.service.ts index 4f344f4..f7a20f7 100644 --- a/apps/server/src/modules/auth/auth.service.ts +++ b/apps/server/src/modules/auth/auth.service.ts @@ -12,6 +12,7 @@ import { usersTable, } from "../../db/schema"; import env from "../../env"; +import { HTTPError } from "../../error"; import EmailsService, { templates } from "../email/emails.service"; import { lucia } from "./auth.utils"; @@ -74,11 +75,11 @@ export default class AuthService { }); if (!user) { - return error(404, "User not found"); + throw new HTTPError(404, "User not found"); } if (user.emailVerified) { - return error(400, "Email already confirmed"); + throw new HTTPError(400, "Email already confirmed"); } return await this.sendEmailVerificationCode(email, user.id); @@ -86,7 +87,7 @@ export default class AuthService { async confirmEmail({ user, code }: { user: User | null; code: string }) { if (user === null) { - return error(401, "Unauthorized"); + throw new HTTPError(401, "Unauthorized"); } const databaseCode = await db.query.emailVerificationCodeTable.findFirst({ @@ -99,7 +100,7 @@ export default class AuthService { databaseCode.code !== code || databaseCode.email !== user.email ) { - return error(400, "Invalid code"); + throw new HTTPError(400, "Invalid code"); } await db @@ -107,7 +108,7 @@ export default class AuthService { .where(eq(emailVerificationCodeTable.userId, user.id)); if (!isWithinExpirationDate(new Date(databaseCode.expiresAt))) { - return error(400, "Code expired"); + throw new HTTPError(400, "Code expired"); } await db @@ -124,11 +125,11 @@ export default class AuthService { }); if (!user) { - return error(401, "Invalid email or password"); + throw new HTTPError(401, "Invalid email or password"); } if (!user.emailVerified) { - return error(401, "Email not confirmed"); + throw new HTTPError(401, "Email not confirmed"); } if ( @@ -137,7 +138,7 @@ export default class AuthService { user.password, )) === false ) { - return error(401, "Invalid email or password"); + throw new HTTPError(401, "Invalid email or password"); } const session = await lucia.createSession(user.id, {}); @@ -152,7 +153,7 @@ export default class AuthService { }); if (!user) { - return error(404, "User not found"); + throw new HTTPError(404, "User not found"); } const token = await this.createPasswordResetToken(email); @@ -180,11 +181,11 @@ export default class AuthService { ); if (!passwordResetToken) { - return error(400, "Invalid token"); + throw new HTTPError(400, "Invalid token"); } if (!isWithinExpirationDate(new Date(passwordResetToken.expiresAt))) { - return error(400, "Token expired"); + throw new HTTPError(400, "Token expired"); } await db diff --git a/apps/server/src/modules/email/emails.service.ts b/apps/server/src/modules/email/emails.service.ts index 590e3d4..9ec9a46 100644 --- a/apps/server/src/modules/email/emails.service.ts +++ b/apps/server/src/modules/email/emails.service.ts @@ -4,6 +4,7 @@ import { Resend } from "resend"; import EmailConfirmation from "transactional/emails/EmailConfirmation"; import ResetPassword from "transactional/emails/ResetPassword"; import env from "../../env"; +import { HTTPError } from "../../error"; export default class EmailsService { private resend = new Resend(env.RESEND_API_KEY); @@ -17,7 +18,7 @@ export default class EmailsService { }); if (error) { - throw new InternalServerError("Failed to send email"); + throw new HTTPError(500, "Failed to send email"); } } } diff --git a/apps/web/index.html b/apps/web/index.html index d113c57..39e93cd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -11,6 +11,7 @@ +
diff --git a/apps/web/package.json b/apps/web/package.json index 74912a3..0540f07 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,11 +27,13 @@ "@xterm/xterm": "^5.5.0", "clsx": "^2.1.1", "codemirror": "^6.0.1", + "file-extension-icon-js": "^1.1.6", "goober": "^2.1.14", "immer": "^10.1.1", "loro-crdt": "^0.16.7", "million": "^3.1.0", "msgpackr": "^1.10.2", + "path-browserify": "^1.0.1", "randomcolor": "^0.6.2", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", @@ -40,6 +42,7 @@ "react-hook-form": "^7.51.4", "react-hot-toast": "^2.4.1", "react-resizable-panels": "^2.0.19", + "rrweb": "^2.0.0-alpha.4", "server": "workspace:*", "tailwind-merge": "^2.3.0", "tailwind-variants": "^0.2.1", @@ -50,6 +53,7 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@types/path-browserify": "^1.0.3", "@types/randomcolor": "^0.5.9", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 914f5e1..a64ce19 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -3,7 +3,6 @@ import { keyframes } from "goober"; import { Suspense, useEffect } from "react"; import { Toaster, resolveValue } from "react-hot-toast"; import { Redirect, Route, Switch, useLocation } from "wouter"; - import importedRoutes from "~react-pages"; import LoadingIndicator from "./components/LoadingIndicator"; import cn from "./utils/cn"; diff --git a/apps/web/src/components/Editor/index.tsx b/apps/web/src/components/Editor/index.tsx index e3ca269..a95eeef 100644 --- a/apps/web/src/components/Editor/index.tsx +++ b/apps/web/src/components/Editor/index.tsx @@ -1,16 +1,12 @@ import { nord } from "@uiw/codemirror-theme-nord"; import CodeMirror, { type Extension } from "@uiw/react-codemirror"; -const Editor: React.FC<{ extensions: Extension[]; initialValue: string }> = ({ - extensions, - initialValue, -}) => { +const Editor: React.FC<{ extensions: Extension[] }> = ({ extensions }) => { return ( ); diff --git a/apps/web/src/components/Editor/plugins/collab/cursors.ts b/apps/web/src/components/Editor/plugins/collab/cursors.ts index c48af38..63ba82e 100644 --- a/apps/web/src/components/Editor/plugins/collab/cursors.ts +++ b/apps/web/src/components/Editor/plugins/collab/cursors.ts @@ -10,8 +10,8 @@ import { type ViewUpdate, WidgetType, } from "@codemirror/view"; -import type { Loro } from "loro-crdt"; import randomColor from "randomcolor"; +import type Codelabs from "../../../../core"; import useStore from "../../../../utils/store"; const cursorsTheme = EditorView.baseTheme({ @@ -133,8 +133,11 @@ class RemoteCaretWidget extends WidgetType { } } -export default function collabCursorsPlugin(doc: Loro, currentTab: string) { - const presence = doc.getMap("presence"); +export default function collabCursorsPlugin( + codelabs: Codelabs, + currentTab: string, +) { + const presence = codelabs.doc.getMap("presence"); const user = useStore.getState().user; const userId = user?.id ?? ""; const userName = `${user?.firstName} ${user?.lastName}`; @@ -258,7 +261,7 @@ export default function collabCursorsPlugin(doc: Loro, currentTab: string) { (presence.get(userId) as { color: string } | undefined)?.color ?? randomColor({ luminosity: "light" }), }); - doc.commit(); + codelabs.doc.commit(); } } diff --git a/apps/web/src/components/Editor/plugins/collab/index.ts b/apps/web/src/components/Editor/plugins/collab/index.ts index a615eda..2023c1f 100644 --- a/apps/web/src/components/Editor/plugins/collab/index.ts +++ b/apps/web/src/components/Editor/plugins/collab/index.ts @@ -1,16 +1,11 @@ -import { type Loro, LoroText } from "loro-crdt"; +import type Codelabs from "../../../../core"; import collabCursorsPlugin from "./cursors"; import collabTextPlugin from "./loro"; -const collab = (doc: Loro, path: string, userId: string) => { - const docText = doc - .getTree("fileTree") - .getNodeByID(path as `${number}@${number}`) - .data.getOrCreateContainer("content", new LoroText()); - +const collab = (codelabs: Codelabs, path: string, userId: string) => { return [ - collabTextPlugin(doc, docText, userId), - collabCursorsPlugin(doc, path), + collabTextPlugin(codelabs, path, userId), + collabCursorsPlugin(codelabs, path), ]; }; diff --git a/apps/web/src/components/Editor/plugins/collab/loro.ts b/apps/web/src/components/Editor/plugins/collab/loro.ts index 45a605e..1b25036 100644 --- a/apps/web/src/components/Editor/plugins/collab/loro.ts +++ b/apps/web/src/components/Editor/plugins/collab/loro.ts @@ -5,18 +5,24 @@ import { ViewPlugin, type ViewUpdate, } from "@codemirror/view"; -import type { Loro, LoroText } from "loro-crdt"; +import { LoroText } from "loro-crdt"; +import type Codelabs from "../../../../core"; export default function collabTextPlugin( - loroDoc: Loro, - loroText: LoroText, + codelabs: Codelabs, + path: string, userId: string, ) { + const loroText = codelabs.docTree + .getNodeByID(path as `${number}@${number}`) + .data.getOrCreateContainer("content", new LoroText()); + class LoroPluginClass implements PluginValue { private annotation = Annotation.define(); + private loroSubscription: number; constructor(view: EditorView) { - loroText.subscribe(({ by, events }) => { + this.loroSubscription = loroText.subscribe(({ by, events }) => { if (by !== "local") { for (const event of events) { if (event.diff.type === "text") { @@ -42,8 +48,25 @@ export default function collabTextPlugin( } } } + } else { + const currentText = loroText.toString(); + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: currentText, + }, + annotations: [this.annotation.of(userId)], + }); } }); + setTimeout(() => { + const currentText = loroText.toString(); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: currentText }, + annotations: [this.annotation.of(userId)], + }); + }, 100); } update(update: ViewUpdate) { @@ -64,9 +87,13 @@ export default function collabTextPlugin( } adj += insertText.length - (toA - fromA); }); - loroDoc.commit(); + codelabs.doc.commit(`editor-${path}`); } } + + destroy() { + loroText.unsubscribe(this.loroSubscription); + } } return [ViewPlugin.fromClass(LoroPluginClass)]; diff --git a/apps/web/src/components/FileTree/dataProvider.ts b/apps/web/src/components/FileTree/dataProvider.ts index de44c44..3bc65c3 100644 --- a/apps/web/src/components/FileTree/dataProvider.ts +++ b/apps/web/src/components/FileTree/dataProvider.ts @@ -1,5 +1,4 @@ import { - type Loro, LoroList, LoroText, type LoroTree, @@ -11,19 +10,20 @@ import type { TreeItem, TreeItemIndex, } from "react-complex-tree"; +import type Codelabs from "../../core"; export default class LoroDataProviderImplementation implements TreeDataProvider { - private doc: Loro; + private codelabs: Codelabs; private docTree: LoroTree; private treeChangeListeners: ((changedItemIds: TreeItemIndex[]) => void)[] = []; - constructor(doc: Loro, docTree: LoroTree) { - this.doc = doc; - this.docTree = docTree; + constructor(codelabs: Codelabs) { + this.codelabs = codelabs; + this.docTree = codelabs.docTree; this.docTree.subscribe(({ events, origin }) => { if (origin !== "fileTree") { @@ -99,7 +99,7 @@ export default class LoroDataProviderImplementation this.docTree .getNodeByID(item.index as `${number}@${number}`) .data.set("name", name); - this.doc.commit("fileTree"); + this.codelabs.doc.commit("fileTree"); } public async insertItem( @@ -114,7 +114,11 @@ export default class LoroDataProviderImplementation item.set("name", newItem.data); item.set("isFolder", newItem.isFolder); - this.doc.commit("fileTree"); + this.codelabs.doc.commit("fileTree"); + + for (const listener of this.treeChangeListeners) { + listener([parentItemId]); + } return item.id; } diff --git a/apps/web/src/components/FileTree/index.tsx b/apps/web/src/components/FileTree/index.tsx index 87fbfc0..f6370cf 100644 --- a/apps/web/src/components/FileTree/index.tsx +++ b/apps/web/src/components/FileTree/index.tsx @@ -1,4 +1,8 @@ import * as ContextMenu from "@radix-ui/react-context-menu"; +import { + getMaterialFileIcon, + getMaterialFolderIcon, +} from "file-extension-icon-js"; import { useState } from "react"; import { Tree, @@ -50,12 +54,12 @@ const FileTree: React.FC<{ handler={newFileDialog} onFileCreate={handleNewFileDialog} /> +

Files

item.data.split("/").pop()!} canDragAndDrop={true} renderItem={(item) => ( @@ -76,7 +80,22 @@ const FileTree: React.FC<{ } }} > - {item.item.isFolder ? <>{item.arrow}📁 : "📄"} + {item.item.isFolder ? ( + <> + {item.arrow} + {String(item.title)} + + ) : ( + {String(item.title)} + )} {item.title} diff --git a/apps/web/src/components/FileTree/newFileDialog.tsx b/apps/web/src/components/FileTree/newFileDialog.tsx index b6c499b..37e377d 100644 --- a/apps/web/src/components/FileTree/newFileDialog.tsx +++ b/apps/web/src/components/FileTree/newFileDialog.tsx @@ -70,13 +70,6 @@ const NewFileDialog: React.FC<{
- +
diff --git a/apps/web/src/components/Modal.tsx b/apps/web/src/components/Modal.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts new file mode 100644 index 0000000..07d5b4d --- /dev/null +++ b/apps/web/src/core/index.ts @@ -0,0 +1,139 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import { Loro, type LoroTree } from "loro-crdt"; +import { EventEmitter } from "tseep"; +import client from "../utils/httpClient"; + +type Events = { + newFile: (args: { + path: string; + id: string; + type: "file" | "folder"; + parent: { id: `${number}@${number}`; path: string }; + content?: string; + }) => void; + deleteFile: (args: { + path: string; + id: `${number}@${number}`; + type: "file" | "folder"; + parent: { id: `${number}@${number}`; path: string }; + }) => void; + renameFile: (args: { + path: string; + id: `${number}@${number}`; + parent: { id: `${number}@${number}`; path: string }; + newName: string; + }) => void; + editFile: (args: { + path: string; + id: `${number}@${number}`; + parent: { id: `${number}@${number}`; path: string }; + content: string; + }) => void; + iframeUrl: (url: string | null) => void; + terminalElement: (element: HTMLDivElement | null) => void; +}; + +export default class Codelabs { + doc: Loro; + docTree: LoroTree; + terminal: Terminal; + terminalAddon: FitAddon; + terminalElement: HTMLDivElement | null; + private emmiter = new EventEmitter(); + iframeUrl: string | null; + pathsMap = new Map(); + + constructor(id: string, userId: string, initialData: Uint8Array) { + this.doc = new Loro(); + this.doc.setRecordTimestamp(true); + this.docTree = this.doc.getTree("fileTree"); + this.terminalAddon = new FitAddon(); + this.terminal = new Terminal({ + convertEol: true, + }); + this.terminalElement = null; + this.iframeUrl = null; + + this.setupLoro(id, userId, initialData); + this.setupTerminal(); + } + + on(event: T, listener: Events[T]) { + this.emmiter.on(event, listener); + } + + off(event: T, listener: Events[T]) { + this.emmiter.off(event, listener); + } + + once(event: T, listener: Events[T]) { + this.emmiter.once(event, listener); + } + + private setupLoro(id: string, userId: string, initialData: Uint8Array) { + const workspace = client.api.workspaces({ id })({ userId }).subscribe(); + workspace.ws.binaryType = "arraybuffer"; + this.doc.import(initialData); + + let lastVersion = this.doc.version(); + + this.doc.subscribe(({ by, events }) => { + if (by === "local") { + workspace.ws.send(this.doc.exportFrom(lastVersion)); + lastVersion = this.doc.version(); + } + + for (const { diff: event } of events) { + if (event.type === "tree") { + for (const diff of event.diff) { + switch (diff.action) { + case "create": + this.pathsMap.set(this.getFullPath(diff.target), diff.target); + break; + case "delete": + this.pathsMap.delete(this.getFullPath(diff.target)); + break; + } + } + } + } + }); + } + + private setupTerminal() { + this.terminal.loadAddon(this.terminalAddon); + } + + setTerminalElement(element: HTMLDivElement | null) { + this.terminalElement = element; + + if (element) { + this.terminal.open(element); + this.terminalAddon.fit(); + } + } + + setIframeUrl(url: string | null) { + this.iframeUrl = url; + + this.emmiter.emit("iframeUrl", url); + } + + getFullPath(nodeId: `${number}@${number}`) { + const node = this.docTree.getNodeByID(nodeId); + const path = [node.data.get("name")]; + let parent = node.parent(); + + while (parent && parent.data.get("name") !== "/") { + path.unshift(parent.data.get("name")); + parent = parent.parent(); + } + + return path.join("/"); + } + + registerPlugin(plugin: (codelabs: Codelabs) => void) { + plugin(this); + } +} diff --git a/apps/web/src/core/languages/nodejs.ts b/apps/web/src/core/languages/nodejs.ts new file mode 100644 index 0000000..c307e60 --- /dev/null +++ b/apps/web/src/core/languages/nodejs.ts @@ -0,0 +1,258 @@ +import { type FileSystemTree, WebContainer } from "@webcontainer/api"; +import { LoroText, type LoroTreeNode } from "loro-crdt"; +import type Codelabs from ".."; + +type InputObject = { + [key: string]: { + fractional_index: string; + id: string; + parent: string | null; + meta: { + isFolder: boolean; + name: string; + content?: string; + }; + index: number; + }; +}; + +function convertToDesiredStructure(input: InputObject): FileSystemTree { + const output: FileSystemTree = {}; + + function buildStructure(currentId: string, parentNode: FileSystemTree) { + const currentItem = input[currentId]; + + if (currentItem.meta.isFolder) { + if (currentItem.meta.name !== "/") { + parentNode[currentItem.meta.name] = { directory: {} }; + // @ts-ignore + // biome-ignore lint/style/noParameterAssign: Necessary for recursion + parentNode = parentNode[currentItem.meta.name].directory; + } + for (const key in input) { + if (input[key].parent === currentId) { + buildStructure(input[key].id, parentNode); + } + } + } else { + parentNode[currentItem.meta.name] = { + file: { + contents: currentItem.meta.content || "", + }, + }; + } + } + + for (const key in input) { + if (input[key].parent === null) { + buildStructure(input[key].id, output); + } + } + + return output; +} + +export default function nodejs(codelabs: Codelabs) { + const docTree = codelabs.doc.getTree("fileTree"); + const rootId = docTree.roots()[0].id; + + WebContainer.boot({ workdirName: "codelabs" }).then( + async (webcontainerInstance) => { + webcontainerInstance.on("server-ready", (_port, url) => + codelabs.setIframeUrl(url), + ); + + const watch = await webcontainerInstance.spawn("npx", [ + "-y", + "chokidar-cli", + ".", + "-i", + '"(**/(node_modules|.git|_tmp_)**)"', + ]); + + let watching = false; + + watch.output.pipeTo( + new WritableStream({ + async write(data) { + try { + if (watching) { + const [type, path] = data.trim().split(":"); + const pathParts = path.split("/"); + const fileName = pathParts.pop(); + + function findOrCreateDir( + dirParent: LoroTreeNode>, + pathParts: string[], + ) { + const currentDir = pathParts.shift(); + if (!currentDir) { + return dirParent; + } + const existingDir = dirParent + .children() + ?.find( + (node) => + node.data.get("name") === currentDir && + Boolean(node.data.get("isFolder")), + ); + if (existingDir) { + return findOrCreateDir(existingDir, pathParts); + } + const newDir = docTree.createNode(dirParent.id); + newDir.data.set("name", currentDir); + newDir.data.set("isFolder", true); + return findOrCreateDir(newDir, pathParts); + } + + const parentDir = findOrCreateDir( + docTree.getNodeByID(rootId), + pathParts, + ); + + let item: LoroTreeNode> | undefined; + + switch (type) { + case "change": + item = parentDir + .children() + ?.find((node) => node.data.get("name") === fileName); + + if (item) { + item.data + .getOrCreateContainer("content", new LoroText()) + .insert( + 0, + // @ts-ignore + await webcontainerInstance.fs.readFile(path, "utf-8"), + ); + } + break; + case "add": + if (!codelabs.pathsMap.has(path)) { + item = docTree.createNode(parentDir.id); + item.data.set("name", fileName); + item.data.set("isFolder", false); + item.data + .getOrCreateContainer("content", new LoroText()) + .insert( + 0, + // @ts-ignore + await webcontainerInstance.fs.readFile(path, "utf-8"), + ); + } + break; + case "unlink": + if (codelabs.pathsMap.has(path)) { + docTree.delete( + codelabs.pathsMap.get(path) as `${number}@${number}`, + ); + } + break; + case "addDir": + if (!codelabs.pathsMap.has(path)) { + item = docTree.createNode(parentDir.id); + item.data.set("name", fileName); + item.data.set("isFolder", true); + } + break; + case "unlinkDir": + if (codelabs.pathsMap.has(path)) { + docTree.delete( + codelabs.pathsMap.get(path) as `${number}@${number}`, + ); + } + break; + } + codelabs.doc.commit("runtime"); + } else if (data.includes('Watching "."')) { + watching = true; + console.log("watching started"); + } + } catch (e) { + console.error(e); + } + }, + }), + ); + + docTree.subscribe(async ({ events, origin }) => { + if (origin !== "runtime") { + for (const { diff: event, path } of events) { + if (event.type === "tree") { + for (const diff of event.diff) { + const node = docTree.getNodeByID(diff.target); + const data = node.data; + const isFolder = Boolean(data.get("isFolder")); + + const fullPath = codelabs.getFullPath(diff.target); + + switch (diff.action) { + case "create": + if (isFolder) { + await webcontainerInstance.fs.mkdir(fullPath, { + recursive: true, + }); + } else { + await webcontainerInstance.fs.writeFile( + fullPath, + String(data.get("content")), + ); + } + break; + } + } + } else if (event.type === "text") { + console.log("text event", path); + const target = path[path.length - 2] as `${number}@${number}`; + await webcontainerInstance.fs.writeFile( + codelabs.getFullPath(target), + String(docTree.getNodeByID(target).data.get("content")), + ); + } + } + } + }); + + const fileTree: InputObject = {}; + + for (const fileTreeItem of docTree.toJSON()) { + fileTree[fileTreeItem.id] = fileTreeItem; + } + + const files = convertToDesiredStructure(fileTree); + + await webcontainerInstance.mount(files); + + const shellProcess = await webcontainerInstance.spawn("jsh", { + terminal: { + cols: codelabs.terminal.cols, + rows: codelabs.terminal.rows, + }, + }); + + shellProcess.output.pipeTo( + new WritableStream({ + write(data) { + codelabs.terminal.write(data); + }, + }), + ); + + const input = shellProcess.input.getWriter(); + codelabs.terminal.onData((data) => { + input.write(data); + }); + + codelabs.terminalElement?.addEventListener("resize", () => { + codelabs.terminalAddon.fit(); + shellProcess.resize({ + cols: codelabs.terminal.cols, + rows: codelabs.terminal.rows, + }); + }); + + codelabs.terminalAddon.fit(); + }, + ); +} diff --git a/apps/web/src/pages/workspace/[id].tsx b/apps/web/src/pages/workspace/[id].tsx index 32d90d6..1b481cc 100644 --- a/apps/web/src/pages/workspace/[id].tsx +++ b/apps/web/src/pages/workspace/[id].tsx @@ -1,8 +1,4 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import { type FileSystemTree, WebContainer } from "@webcontainer/api"; -import { FitAddon } from "@xterm/addon-fit"; -import { Terminal } from "@xterm/xterm"; -import { Loro, LoroText, type LoroTreeNode } from "loro-crdt"; import { useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import Editor from "../../components/Editor"; @@ -15,56 +11,8 @@ import useStore, { type User } from "../../utils/store"; import "@xterm/xterm/css/xterm.css"; import { useParams } from "wouter"; import FileTree from "../../components/FileTree"; - -type InputObject = { - [key: string]: { - fractional_index: string; - id: string; - parent: string | null; - meta: { - isFolder: boolean; - name: string; - content?: string; - }; - index: number; - }; -}; - -function convertToDesiredStructure(input: InputObject): FileSystemTree { - const output: FileSystemTree = {}; - - function buildStructure(currentId: string, parentNode: FileSystemTree) { - const currentItem = input[currentId]; - - if (currentItem.meta.isFolder) { - if (currentItem.meta.name !== "/") { - parentNode[currentItem.meta.name] = { directory: {} }; - // @ts-ignore - // biome-ignore lint/style/noParameterAssign: Necessary for recursion - parentNode = parentNode[currentItem.meta.name].directory; - } - for (const key in input) { - if (input[key].parent === currentId) { - buildStructure(input[key].id, parentNode); - } - } - } else { - parentNode[currentItem.meta.name] = { - file: { - contents: currentItem.meta.content || "", - }, - }; - } - } - - for (const key in input) { - if (input[key].parent === null) { - buildStructure(input[key].id, output); - } - } - - return output; -} +import Codelabs from "../../core"; +import nodejs from "../../core/languages/nodejs"; const WorkspacePage: React.FC = () => { const params = useParams(); @@ -82,285 +30,49 @@ const WorkspacePage: React.FC = () => { const [tabs, setTabs] = useState([]); const [activeTab, setActiveTab] = useState(null); - const { treeProvider, rootId, docTree, doc, fileTree } = useMemo(() => { - const workspace = client.api - .workspaces({ id })({ userId: treeId }) - .subscribe(); - workspace.ws.binaryType = "arraybuffer"; - - const doc = new Loro(); - doc.import(data.data as Uint8Array); + const { treeProvider, rootId, docTree, codelabs } = useMemo(() => { + const codelabs = new Codelabs(id, treeId, data.data as Uint8Array); - let lastVersion = doc.version(); - - doc.subscribe(({ by }) => { - if (by === "local") { - workspace.ws.send(doc.exportFrom(lastVersion)); - lastVersion = doc.version(); - } - }); - - const docTree = doc.getTree("fileTree"); + const docTree = codelabs.doc.getTree("fileTree"); const rootId = docTree.roots()[0].id; - const treeProvider = new LoroDataProviderImplementation(doc, docTree); - - workspace.ws.onmessage = (ev) => { - doc.import(new Uint8Array(ev.data)); - }; - - const presence = doc.getMap("presence"); + const treeProvider = new LoroDataProviderImplementation(codelabs); - const fileTree: InputObject = {}; + const presence = codelabs.doc.getMap("presence"); - for (const fileTreeItem of docTree.toJSON()) { - fileTree[fileTreeItem.id] = fileTreeItem; - } - - return { treeProvider, rootId, docTree, doc, presence, fileTree }; + return { treeProvider, rootId, docTree, codelabs, presence }; }, [data, id, treeId]); const [iframeSrc, setIframeSrc] = useState(null); useEffect(() => { - const fitAddon = new FitAddon(); - - const terminal = new Terminal({ - convertEol: true, + codelabs.on("iframeUrl", (iframeUrl) => { + setIframeSrc(iframeUrl); }); - terminal.loadAddon(fitAddon); - // biome-ignore lint/style/noNonNullAssertion: No need to check for null here - terminal.open(terminalRef.current!); - - WebContainer.boot({ workdirName: "codelabs" }).then( - async (webcontainerInstance) => { - webcontainerInstance.on("server-ready", (_port, url) => - setIframeSrc(url), - ); - - const watch = await webcontainerInstance.spawn("npx", [ - "-y", - "chokidar-cli", - ".", - "-i", - '"(**/(node_modules|.git|_tmp_)**)"', - ]); - - let watching = false; - watch.output.pipeTo( - new WritableStream({ - async write(data) { - try { - if (watching) { - const [type, path] = data.trim().split(":"); - const pathParts = path.split("/"); - const fileName = pathParts.pop(); - - function findOrCreateDir( - dirParent: LoroTreeNode>, - pathParts: string[], - ) { - const currentDir = pathParts.shift(); - if (!currentDir) { - return dirParent; - } - const existingDir = dirParent - .children() - ?.find( - (node) => - node.data.get("name") === currentDir && - Boolean(node.data.get("isFolder")), - ); - if (existingDir) { - return findOrCreateDir(existingDir, pathParts); - } - const newDir = docTree.createNode(dirParent.id); - newDir.data.set("name", currentDir); - newDir.data.set("isFolder", true); - return findOrCreateDir(newDir, pathParts); - } - - const parentDir = findOrCreateDir( - docTree.getNodeByID(rootId), - pathParts, - ); - - let item: LoroTreeNode> | undefined; - - switch (type) { - case "change": - item = parentDir - .children() - ?.find((node) => node.data.get("name") === fileName); - - if (item) { - item.data - .getOrCreateContainer("content", new LoroText()) - .insert( - 0, - // @ts-ignore - await webcontainerInstance.fs.readFile( - path, - "utf-8", - ), - ); - } - break; - case "add": - item = docTree.createNode(parentDir.id); - item.data.set("name", fileName); - item.data.set("isFolder", false); - item.data.setContainer("content", new LoroText()).insert( - 0, - // @ts-ignore - await webcontainerInstance.fs.readFile(path, "utf-8"), - ); - break; - case "unlink": - item = parentDir - .children() - ?.find((node) => node.data.get("name") === fileName); - - if (item) { - docTree.delete(item.id); - } - break; - case "addDir": - item = docTree.createNode(parentDir.id); - item.data.set("name", fileName); - item.data.set("isFolder", true); - break; - case "unlinkDir": - item = parentDir - .children() - ?.find((node) => node.data.get("name") === fileName); - - if (item) { - docTree.delete(item.id); - } - break; - } - } else if (data.includes('Watching "."')) { - watching = true; - console.log("watching started"); - } - - doc.commit("runtime"); - } catch (e) { - console.error(e); - } - }, - }), - ); - - function getFullPath(id: `${number}@${number}`): string { - const node = docTree.getNodeByID(id); - const name = String(node.data.get("name")); - const parent = node.parent()?.id; - if (!parent || docTree.getNodeByID(parent).data.get("name") === "/") { - return name; - } - return `${getFullPath(parent)}/${name}`; - } - - docTree.subscribe(async ({ events, origin }) => { - if (origin !== "runtime") { - for (const { diff: event, path } of events) { - if (event.type === "tree") { - for (const diff of event.diff) { - const node = docTree.getNodeByID(diff.target); - const data = node.data; - const isFolder = Boolean(data.get("isFolder")); - - const fullPath = getFullPath(diff.target); - - switch (diff.action) { - case "create": - if (isFolder) { - await webcontainerInstance.fs.mkdir(fullPath, { - recursive: true, - }); - } else { - await webcontainerInstance.fs.writeFile( - fullPath, - String(data.get("content")), - ); - } - break; - } - } - } else if (event.type === "text") { - const target = path[path.length - 2] as `${number}@${number}`; - await webcontainerInstance.fs.writeFile( - getFullPath(target), - String(docTree.getNodeByID(target).data.get("content")), - ); - } - } - } - }); - - const files = convertToDesiredStructure(fileTree); - - await webcontainerInstance.mount(files); - - const shellProcess = await webcontainerInstance.spawn("jsh", { - terminal: { - cols: terminal.cols, - rows: terminal.rows, - }, - }); - - shellProcess.output.pipeTo( - new WritableStream({ - write(data) { - terminal.write(data); - }, - }), - ); - - const input = shellProcess.input.getWriter(); - terminal.onData((data) => { - input.write(data); - }); - - terminalRef.current?.addEventListener("resize", () => { - fitAddon.fit(); - shellProcess.resize({ - cols: terminal.cols, - rows: terminal.rows, - }); - }); - - fitAddon.fit(); - }, - ); - - return () => { - terminal.dispose(); - }; + codelabs.registerPlugin(nodejs); }, []); - const { extensions, initialValue } = useMemo(() => { + const extensions = useMemo(() => { if (activeTab) { if (!tabs.includes(activeTab as string)) { setTabs((tabs) => [...tabs, activeTab as string]); } - const docText = docTree - .getNodeByID(activeTab as `${number}@${number}`) - .data.getOrCreateContainer("content", new LoroText()); - - return { - extensions: [codemirrorCollab(doc, activeTab, user.id)], - initialValue: docText.toString(), - }; + return [codemirrorCollab(codelabs, activeTab, user.id)]; } - return { extensions: [], initialValue: "" }; + return []; }, [activeTab]); + useEffect(() => { + if (terminalRef.current) { + codelabs.setTerminalElement(terminalRef.current); + } else { + codelabs.setTerminalElement(null); + } + }, [terminalRef]); + return (
OI
@@ -406,7 +118,7 @@ const WorkspacePage: React.FC = () => { ))} - + ) : (
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 109d097..981c862 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ Pages(), wasm(), topLevelAwait(), - VitePWA({ registerType: "autoUpdate" }), + // VitePWA({ registerType: "autoUpdate" }), compression(), analyzer(), ], diff --git a/biome.json b/biome.json index aed4398..d40a9b7 100644 --- a/biome.json +++ b/biome.json @@ -9,6 +9,9 @@ "recommended": true, "correctness": { "useExhaustiveDependencies": "off" + }, + "style": { + "noNonNullAssertion": "off" } } }, diff --git a/bun.lockb b/bun.lockb index f360f29..27e0f59 100755 Binary files a/bun.lockb and b/bun.lockb differ