Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const buildOptions = {
legalComments: "none",
bundle: true,
target: "es2022",
entryPoints: ["src/main.ts", "src/preview.ts", "src/compilation-worker.ts"],
entryPoints: ["src/main.tsx", "src/preview.ts", "src/compilation-worker.ts"],
entryNames: "[name]",
loader: {
".json": "json",
Expand Down
7 changes: 0 additions & 7 deletions src/main.ts

This file was deleted.

30 changes: 30 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { PlaygroundApp } from "./playground-app";
import { Editor } from "./components/editor";
import "./helpers/reset.css";
import "./main.css";

const root = createRoot(document.getElementById("root")!);

const app = new PlaygroundApp(() => {
const project = app.projects.getActiveProject();
root.render(
<StrictMode>
<Editor
openFiles={project?.openFiles ?? []}
selectedFileIdx={project?.selectedFileIdx ?? -1}
fileTreeItems={project?.fileTreeItems ?? []}
savedProjectNames={app.savedProjectNames}
onTabClick={app.selectOpenedFile}
onTabClose={app.closeOpenFile}
onOpenLocal={app.openLocalProject}
onFileTreeItemClick={app.onFileTreeItemClick}
onOpenSaved={app.openSavedProject}
onClearSaved={app.removeSavedProject}
/>
</StrictMode>,
);
});

await app.loadSavedProjects();
276 changes: 76 additions & 200 deletions src/playground-app.tsx
Original file line number Diff line number Diff line change
@@ -1,199 +1,98 @@
import { extname } from "@file-services/path";
import { del, get, update } from "idb-keyval";
import React from "react";
import { createRoot, type Root } from "react-dom/client";
import { type Compilation, type LibraryVersions } from "./compilation-worker";
import { Editor } from "./components/editor";
import type { IndentedList } from "./components/indented-list";
import {
compilationBundleName,
compilationWorkerName,
defaultLibVersions,
ignoredFileTreeDirectories,
openProjectsIDBKey,
} from "./constants";
import { generateIndentedFsItems } from "./fs/async-fs-operations";
import { openProjectsIDBKey } from "./constants";
import { createBrowserFileSystem, type BrowserFileSystem } from "./fs/browser-file-system";
import { imageMimeTypes } from "./helpers/dom";
import { clamp, collectIntoArray, ignoreRejections } from "./helpers/javascript";
import type { Preview } from "./preview";
import { createRPCIframe, type RPCIframe } from "./rpc/rpc-iframe";
import { createRPCWorker, type RPCWorker } from "./rpc/rpc-worker";
import { getCoduxConfig } from "./helpers/codux";
import { ignoreRejections } from "./helpers/javascript";
import { ProjectsManager } from "./projects-manager";

export class PlaygroundApp {
private fs: BrowserFileSystem | undefined;

private appRoot: Root | undefined;
private openDirectories = new Set<string>();
projects = new ProjectsManager();
savedProjectNames?: readonly string[] | undefined;
private savedDirectoryHandles?: Record<string, FileSystemDirectoryHandle> | undefined;
private compilation?: RPCWorker<Compilation> | undefined;
private previews = new Map<string, RPCIframe<Preview>>();

// passed to UI
private openFiles: readonly Editor.OpenFile[] = [];
private selectedFileIdx = -1;
private fileTreeItems: readonly IndentedList.Item[] | undefined;
private savedProjectNames?: readonly string[] | undefined;

public showUI(container: HTMLElement) {
this.appRoot = createRoot(container);
this.renderApp();
}

public async loadSavedProjects() {
const savedDirectoryHandles = await get<Record<string, FileSystemDirectoryHandle>>(openProjectsIDBKey);
this.savedDirectoryHandles = savedDirectoryHandles;
this.savedProjectNames = savedDirectoryHandles && Object.keys(savedDirectoryHandles);
}

private renderApp() {
this.appRoot?.render(
<React.StrictMode>
<Editor
openFiles={this.openFiles}
selectedFileIdx={this.selectedFileIdx}
fileTreeItems={this.fileTreeItems}
savedProjectNames={this.savedProjectNames}
onTabClick={this.onTabClick}
onOpenLocal={this.onOpenLocal}
onFileTreeItemClick={this.onFileTreeItemClick}
onOpenSaved={this.onOpenSaved}
onClearSaved={this.onClearSaved}
onTabClose={this.onTabClose}
onPreviewLoad={this.registerPreview}
onPreviewClose={this.closePreview}
/>
</React.StrictMode>,
);
}

private registerPreview = async (filePath: string, iframe: HTMLIFrameElement) => {
const previewRPC = createRPCIframe<Preview>(iframe);
const existingRPC = this.previews.get(filePath);
if (existingRPC) {
existingRPC.close();
}
this.previews.set(filePath, previewRPC);

const coduxCodux = await getCoduxConfig(this.fs!, "/");
const boardGlobalSetupPath = coduxCodux?.boardGlobalSetup
? await this.compilation?.api.resolveSpecifier(this.fs!.root, "/", coduxCodux.boardGlobalSetup)
: undefined;

const entryModules = boardGlobalSetupPath ? [boardGlobalSetupPath, filePath] : filePath;
const moduleGraph = await this.compilation?.api.calculateModuleGraph(this.fs!.root, entryModules);
if (moduleGraph && this.previews.get(filePath) === previewRPC) {
await previewRPC.api.evaluateAndRender(
moduleGraph,
filePath,
boardGlobalSetupPath ? boardGlobalSetupPath : undefined,
);
}
};

private closePreview = (filePath: string) => {
const previewRPC = this.previews.get(filePath);
if (previewRPC) {
previewRPC.close();
this.previews.delete(filePath);
constructor(private changeSignal: () => void) {}
onFileTreeItemClick = async (filePath: string, itemType: string, altKey: boolean) => {
const project = this.projects.getActiveProject();
if (!project) {
return;
}
};

private onFileTreeItemClick = async (itemId: string, itemType: string, altKey: boolean) => {
const { id, fs, openDirectories, openFiles } = project;
if (itemType === "directory") {
if (this.openDirectories.has(itemId)) {
this.openDirectories.delete(itemId);
if (openDirectories.has(filePath)) {
openDirectories.delete(filePath);
} else {
this.openDirectories.add(itemId);
openDirectories.add(filePath);
}
await this.calculateFileTreeItems();
await this.projects.updateFileTreeItems(id);
} else if (itemType === "file") {
const itemIdx = this.openFiles.findIndex(({ filePath }) => itemId === filePath);
const itemIdx = openFiles.findIndex(({ filePath: match }) => filePath === match);
if (itemIdx !== -1) {
this.selectedFileIdx = itemIdx;
} else if (this.fs && (await this.fs.fileExists(itemId))) {
const fileExtension = extname(itemId);
const imageMimeType = imageMimeTypes.get(fileExtension);
const openFile: Editor.OpenFile = imageMimeType
? {
type: "image-viewer",
filePath: itemId,
imageUrl: URL.createObjectURL(new Blob([await this.fs.readFile(itemId)], { type: imageMimeType })),
}
: altKey
? { type: "preview", filePath: itemId }
: {
type: "code-editor",
filePath: itemId,
fileContents: await this.fs.readTextFile(itemId),
};
this.openFiles = [...this.openFiles, openFile];
this.selectedFileIdx = this.openFiles.length - 1;
this.projects.updateSelectedFileIdx(id, itemIdx);
} else if (await fs.fileExists(filePath)) {
const openFile = await this.getOpenFileType(filePath, fs, altKey);
this.projects.openFile(id, openFile);
}
}
this.renderApp();
this.changeSignal?.();
};

private onTabClick = (tabId: string) => {
this.selectedFileIdx = this.openFiles.findIndex(({ filePath }) => tabId === filePath);
this.renderApp();
};

private closeFile = (openFile: Editor.OpenFile) => {
if (openFile.type === "image-viewer") {
URL.revokeObjectURL(openFile.imageUrl);
selectOpenedFile = (filePath: string) => {
const project = this.projects.getActiveProject();
if (!project) {
return;
}
project.selectedFileIdx = project.openFiles.findIndex(({ filePath: match }) => match === filePath);
this.changeSignal?.();
};

private onTabClose = (tabId: string) => {
const { openFiles } = this;
const targetFileIdx = openFiles.findIndex(({ filePath }) => tabId === filePath);
if (targetFileIdx === -1) {
closeOpenFile = (tabId: string) => {
const project = this.projects.getActiveProject();
if (!project) {
return;
}
this.closeFile(openFiles[targetFileIdx]!);
this.openFiles = [...openFiles.slice(0, targetFileIdx), ...openFiles.slice(targetFileIdx + 1)];
if (targetFileIdx === this.selectedFileIdx) {
this.selectedFileIdx = this.openFiles.length ? clamp(targetFileIdx, 0, this.openFiles.length - 1) : -1;
} else if (this.selectedFileIdx > targetFileIdx) {
this.selectedFileIdx--;
const file = this.projects.closeFile(project.id, tabId);
if (file) {
this.closeFileCleanup(file);
}
this.renderApp();
this.changeSignal?.();
};

private async setRootDirectoryHandle(directoryHandle: FileSystemDirectoryHandle) {
for (const openFile of this.openFiles) {
this.closeFile(openFile);
}
this.openFiles = [];
this.openDirectories.clear();
this.fs = createBrowserFileSystem(directoryHandle);
await this.calculateFileTreeItems();
this.renderApp();
await this.initializeProject(this.fs);
}

private onOpenLocal = async () => {
openLocalProject = async () => {
const directoryHandle = await ignoreRejections(window.showDirectoryPicker());
if (directoryHandle) {
await this.saveProject(directoryHandle);
await this.loadSavedProjects();
await this.setRootDirectoryHandle(directoryHandle);
await this.projects.openProject(directoryHandle.name, createBrowserFileSystem(directoryHandle));
this.changeSignal?.();
}
};

private onOpenSaved = async (projectName: string) => {
openSavedProject = async (projectName: string) => {
const savedDirectoryHandle = this.savedDirectoryHandles?.[projectName];
if (!savedDirectoryHandle) {
return;
}
if (!(await this.handleHasPermission(savedDirectoryHandle))) {
return;
}
await this.setRootDirectoryHandle(savedDirectoryHandle);
await this.projects.openProject(savedDirectoryHandle.name, createBrowserFileSystem(savedDirectoryHandle));
this.changeSignal?.();
};

removeSavedProject = async () => {
await del(openProjectsIDBKey);
await this.loadSavedProjects();
this.changeSignal?.();
};

async loadSavedProjects() {
const savedDirectoryHandles = await get<Record<string, FileSystemDirectoryHandle>>(openProjectsIDBKey);
this.savedDirectoryHandles = savedDirectoryHandles;
this.savedProjectNames = savedDirectoryHandles && Object.keys(savedDirectoryHandles);
this.changeSignal?.();
}

private async saveProject(directoryHandle: FileSystemDirectoryHandle) {
await update<Record<string, FileSystemDirectoryHandle>>(openProjectsIDBKey, (savedProjects = {}) => {
savedProjects[directoryHandle.name] = directoryHandle;
Expand All @@ -208,53 +107,30 @@ export class PlaygroundApp {
return (await fileSystemHandle.requestPermission()) === "granted";
}

private onClearSaved = async () => {
await del(openProjectsIDBKey);
await this.loadSavedProjects();
this.renderApp();
};

private async calculateFileTreeItems() {
this.fileTreeItems = this.fs
? await collectIntoArray(
generateIndentedFsItems(await this.fs.openDirectory("/"), this.openDirectories, ignoredFileTreeDirectories),
)
: undefined;
}

private async initializeProject(fs: BrowserFileSystem) {
this.compilation?.close();
const compilationWorkerURL = new URL(compilationBundleName, import.meta.url);
const compilationWorker = createRPCWorker<Compilation>(compilationWorkerURL, {
name: compilationWorkerName,
type: "module",
});
this.compilation = compilationWorker;

const rootPackageLockPath = "/package-lock.json";
const libVersions = (await fs.fileExists(rootPackageLockPath))
? this.getLibVersions((await fs.readJSONFile(rootPackageLockPath)) as PackageLock)
: defaultLibVersions;
await compilationWorker.api.initialize(libVersions);
// const contents = await compilationWorker.api.compile("/a.ts", `export const a = 123;\nthrow new Error('wow');`);
// console.log(contents);
// (0, eval)(`(function (exports){${contents}\n})({})`);
// TODO: move to preview management
private async getOpenFileType(itemId: string, fs: BrowserFileSystem, showAsPreview: boolean) {
const fileExtension = extname(itemId);
const imageMimeType = imageMimeTypes.get(fileExtension);
const openFile: Editor.OpenFile = imageMimeType
? {
type: "image-viewer",
filePath: itemId,
imageUrl: URL.createObjectURL(new Blob([await fs.readFile(itemId)], { type: imageMimeType })),
}
: showAsPreview
? { type: "preview", filePath: itemId }
: {
type: "code-editor",
filePath: itemId,
fileContents: await fs.readTextFile(itemId),
};

return openFile;
}

private getLibVersions({ packages = {} }: PackageLock): LibraryVersions {
const typescriptVersion = packages[packagePath("typescript")]?.version ?? defaultLibVersions.typescript;
const sassVersion = packages[packagePath("sass")]?.version ?? defaultLibVersions.sass;
const immutableVersion =
packages[packagePath(`sass/${packagePath("immutable")}`)]?.version ??
packages[packagePath("immutable")]?.version ??
defaultLibVersions.immutable;

return { typescript: typescriptVersion, sass: sassVersion, immutable: immutableVersion };
private closeFileCleanup(openFile: Editor.OpenFile) {
if (openFile.type === "image-viewer") {
URL.revokeObjectURL(openFile.imageUrl);
}
}
}

interface PackageLock {
packages?: Record<string, { version?: string }>;
}

const packagePath = <T extends string>(packageName: T) => `node_modules/${packageName}` as const;
Loading