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}
+
+ >
+ ) : (
+
+ )}
{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