From f78ee2abe969673a5e270b5e18362282b87f79cf Mon Sep 17 00:00:00 2001 From: Aaqid Masoodi Date: Tue, 16 Dec 2025 21:43:47 +0000 Subject: [PATCH 1/5] feat: implement hybrid UUID migration and unique terminal paths --- .../dashboard/CreateScapeDialog.tsx | 34 ++++++++++++------- src/components/editor/TerminalPane.tsx | 20 ++++++++++- src/hooks/useFileSystem.ts | 5 +-- src/lib/db.ts | 14 ++++++-- src/pages/ScapeEditor.tsx | 19 ++++++++--- 5 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/components/dashboard/CreateScapeDialog.tsx b/src/components/dashboard/CreateScapeDialog.tsx index c1718cea..3dd49b33 100644 --- a/src/components/dashboard/CreateScapeDialog.tsx +++ b/src/components/dashboard/CreateScapeDialog.tsx @@ -54,29 +54,37 @@ export function CreateScapeDialog() { if (!templateConfig) throw new Error("Template not found") - // 1. Create Scape - const scapeId = await db.scapes.add({ + // Create Scape (Hybrid: New ones use UUIDs) + const newId = crypto.randomUUID() + + await db.scapes.add({ + id: newId, name: name.trim(), environment: selectedEnv, - template: selectedTemplate, + template: templateConfig.id, source: source, + syncStatus: "offline", createdAt: new Date(), updatedAt: new Date(), + thumbnail: undefined, + dependencies: [], }) - // 2. Create Files from Template - await db.files.bulkAdd( - templateConfig.files.map((file) => ({ - scapeId: scapeId as number, - name: file.name, - content: file.content, - language: file.language, + // Add default files from template + if (templateConfig.files) { + const filesToAdd = templateConfig.files.map((f) => ({ + scapeId: newId, + name: f.name, + content: f.content, + language: f.language || "plaintext", })) - ) + await db.files.bulkAdd(filesToAdd) + } - // 3. Navigate setOpen(false) - navigate("/scape/" + scapeId) + setName("") + // Redirect using the new ID (string) + navigate(`/scape/${newId}`) // Reset form setName("") diff --git a/src/components/editor/TerminalPane.tsx b/src/components/editor/TerminalPane.tsx index fb3bc463..654e07ce 100644 --- a/src/components/editor/TerminalPane.tsx +++ b/src/components/editor/TerminalPane.tsx @@ -15,6 +15,7 @@ interface TerminalPaneProps { onClose?: () => void isCollapsed?: boolean files?: ScapeFile[] + scapeId?: string | number scapeName?: string onDeleteFile?: (path: string) => void outputLogs?: LogEntry[] @@ -38,6 +39,7 @@ export function TerminalPane({ isCollapsed = false, files = [], scapeName = "project", + scapeId, onDeleteFile, outputLogs = [], onExecCommand, @@ -365,7 +367,23 @@ export function TerminalPane({ <> - ~/{scapeName?.replace(/\s+/g, "-").toLowerCase() || "project"} + {(() => { + const nameSlug = (scapeName || "project") + .trim() + .replace(/\s+/g, "-") + .toLowerCase() + let idSuffix = "" + if (scapeId) { + const idStr = String(scapeId) + // if it looks like a UUID (long string), shorten it + if (idStr.length > 10) { + idSuffix = `-${idStr.slice(0, 8)}` + } else { + idSuffix = `-${idStr}` + } + } + return `~/${nameSlug}${idSuffix}` + })()} )} diff --git a/src/hooks/useFileSystem.ts b/src/hooks/useFileSystem.ts index 3bf2ecd9..fed765d5 100644 --- a/src/hooks/useFileSystem.ts +++ b/src/hooks/useFileSystem.ts @@ -3,7 +3,7 @@ import { useLiveQuery } from "dexie-react-hooks" import { db } from "@/lib/db" import type { ScapeFile, FileType } from "@/types/file" -export function useFileSystem(scapeId: number) { +export function useFileSystem(scapeId: string | number) { const [files, setFiles] = useState([]) const [isInitialized, setIsInitialized] = useState(false) @@ -79,7 +79,8 @@ export function useFileSystem(scapeId: number) { saveTimeoutRef.current[fileId] = setTimeout(async () => { await db.files.update(fileId, { content }) - await db.scapes.update(scapeId, { updatedAt: new Date() }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await db.scapes.update(scapeId as any, { updatedAt: new Date() }) // Cast scapeId for update delete saveTimeoutRef.current[fileId] }, 500) // 500ms Debounce for DB writes }, diff --git a/src/lib/db.ts b/src/lib/db.ts index 1b92d322..ac0bcd32 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -2,7 +2,7 @@ import Dexie, { type EntityTable } from "dexie" import type { EnvironmentId } from "@/types/environment" export interface Scape { - id: number + id: string | number name: string environment: EnvironmentId template: string // The ID of the template used (e.g. 'blank', 'threejs') @@ -18,7 +18,7 @@ export interface Scape { export interface File { id: number - scapeId: number + scapeId: string | number name: string content: string language: string @@ -94,8 +94,16 @@ db.version(5) }) }) +// Version 6: Hybrid UUID Migration +// We KEEP '++' to avoid "UpgradeError: Not yet support for changing primary key". +// IndexedDB allows string keys in auto-increment tables if provided manually. +db.version(6).stores({ + scapes: "++id, name, environment, source, createdAt, updatedAt, *dependencies", + files: "++id, scapeId, name, [scapeId+name]", +}) + // Helper to delete a scape and its files transactionally -export async function deleteScape(id: number) { +export async function deleteScape(id: string | number) { await db.transaction("rw", db.scapes, db.files, async () => { await db.files.where("scapeId").equals(id).delete() await db.scapes.delete(id) diff --git a/src/pages/ScapeEditor.tsx b/src/pages/ScapeEditor.tsx index fa1ac682..c2b0f639 100644 --- a/src/pages/ScapeEditor.tsx +++ b/src/pages/ScapeEditor.tsx @@ -96,12 +96,21 @@ const getLayout = (key: string, defaults: number[]) => { export default function ScapeEditor() { const { scapeId } = useParams() - // Force HMR update - const id = parseInt(scapeId || "0") + // POLYMORPHIC ID LOGIC: + // If the URL ID is numeric (e.g. "123"), treat it as a number (legacy). + // If `scapeId` is undefined/UUID, keep as string. + const rawId = scapeId || "" + const isNumericId = !isNaN(Number(rawId)) && rawId.trim() !== "" + const id = isNumericId ? Number(rawId) : rawId // Load Scape and Files - const scape = useLiveQuery(() => db.scapes.get(id), [id]) - const { files, isInitialized, createFile, updateFile, deleteFile, bulkRename } = useFileSystem(id) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const scape = useLiveQuery(() => db.scapes.get(id as any), [id]) // Cast as any because Dexie get usually expects specific type, but Key is valid + + const { files, isInitialized, createFile, updateFile, deleteFile, bulkRename } = useFileSystem( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + id as any + ) // Local State // Preview Collapsed State (Lifted to top for safety) @@ -853,6 +862,7 @@ export default function ScapeEditor() { isCollapsed={false} files={files} scapeName={scape?.name} + scapeId={id} onDeleteFile={deleteFileDirectly} outputLogs={outputLogs} onExecCommand={handleExecCommand} @@ -872,6 +882,7 @@ export default function ScapeEditor() { isCollapsed={true} files={files} scapeName={scape?.name} + scapeId={id} onDeleteFile={deleteFileDirectly} outputLogs={outputLogs} onExecCommand={handleExecCommand} From cbf68d01529ff4961af4f5e2fb916c0802f716f1 Mon Sep 17 00:00:00 2001 From: Aaqid Masoodi Date: Tue, 16 Dec 2025 22:11:07 +0000 Subject: [PATCH 2/5] feat: Implement Real File System with Binary Support (Drag & Drop, Runtime Integration, Hybrid IDs) --- src/components/editor/FileExplorer.tsx | 84 ++++++++++++++++++++--- src/hooks/useFileSystem.ts | 10 ++- src/lib/db.ts | 2 +- src/pages/ScapeEditor.tsx | 69 +++++++++++++------ src/runners/python/worker.ts | 7 +- src/runners/web/WebRunner.tsx | 93 +++++++++++++++++++------- src/types/file.ts | 13 +++- 7 files changed, 217 insertions(+), 61 deletions(-) diff --git a/src/components/editor/FileExplorer.tsx b/src/components/editor/FileExplorer.tsx index cf96a960..9dffd16a 100644 --- a/src/components/editor/FileExplorer.tsx +++ b/src/components/editor/FileExplorer.tsx @@ -26,7 +26,11 @@ interface FileExplorerProps { activeFileId?: string onFileSelect: (file: ScapeFile) => void onToggleFolder: (path: string) => void - onCreateFile: (name: string) => void + onCreateFile: ( + name: string, + type?: string, + content?: string | Blob | ArrayBuffer | Uint8Array + ) => void onCreateFolder: (name: string) => void onDelete: (path: string) => void onMove: (oldPath: string, newPath: string) => void @@ -98,12 +102,17 @@ export function FileExplorer({ return } - // Only allow dropping into folders + // If over a folder, target that folder if (node.type === "folder") { setDragOverNodeId(node.path) e.dataTransfer.dropEffect = "move" } else { - setDragOverNodeId(null) + // If over a file, target its parent folder + const parentPath = node.path.includes("/") + ? node.path.substring(0, node.path.lastIndexOf("/")) + : "root" + setDragOverNodeId(parentPath) + e.dataTransfer.dropEffect = "move" } } @@ -112,18 +121,75 @@ export function FileExplorer({ setDragOverNodeId(null) } - const handleDrop = (e: React.DragEvent, targetNode: FileNode | null) => { + const handleDrop = async (e: React.DragEvent, targetNode: FileNode | null) => { e.preventDefault() e.stopPropagation() setDragOverNodeId(null) - const sourcePath = e.dataTransfer.getData("text/plain") + // Determine Target Path + let targetPath = "" + if (targetNode) { + if (targetNode.type === "folder") { + targetPath = targetNode.path + } else { + // Dropped on a file -> Goes to same folder + targetPath = targetNode.path.includes("/") + ? targetNode.path.substring(0, targetNode.path.lastIndexOf("/")) + : "" + } + } - // Strict Check: Can only drop into Folders or Root - if (targetNode && targetNode.type !== "folder") return + // Check for Native Files first + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const filesList = Array.from(e.dataTransfer.files) + + for (const file of filesList) { + // 1. Size Check (10MB) + if (file.size > 10 * 1024 * 1024) { + alert(`File ${file.name} is too large (Max 10MB).`) + continue + } + + // 2. Determine Type & Read Strategy + const isText = + file.type.startsWith("text/") || + file.name.endsWith(".json") || + file.name.endsWith(".js") || + file.name.endsWith(".ts") || + file.name.endsWith(".py") || + file.name.endsWith(".md") + + let content: string | ArrayBuffer + let fileType = "binary" + + if (isText) { + content = await file.text() + // Infer simpler type for code editor support + if (file.name.endsWith(".html")) fileType = "html" + else if (file.name.endsWith(".css")) fileType = "css" + else if (file.name.endsWith(".json")) fileType = "json" + else if (file.name.endsWith(".md")) fileType = "markdown" + else if (file.name.endsWith(".py")) fileType = "python" + else if (file.name.endsWith(".js") || file.name.endsWith(".ts")) fileType = "javascript" + else fileType = "binary" // Fallback if unsure, but we read as text + } else { + // Binary (Images, WASM, etc.) + content = await file.arrayBuffer() + if (file.type.startsWith("image/")) fileType = "image" + else fileType = "binary" + } + + // 3. Create File + const newPath = targetPath ? `${targetPath}/${file.name}` : file.name + onCreateFile(newPath, fileType, content) + } + return + } + + // --- Internal App DnD (Moving Files) --- + const sourcePath = e.dataTransfer.getData("text/plain") - // Target path is empty string for root, or node.path - const targetPath = targetNode ? targetNode.path : "" + // Strict Check removed: Can drop on files (to resolve parent) if (!sourcePath) return diff --git a/src/hooks/useFileSystem.ts b/src/hooks/useFileSystem.ts index fed765d5..b74fa1b5 100644 --- a/src/hooks/useFileSystem.ts +++ b/src/hooks/useFileSystem.ts @@ -72,7 +72,7 @@ export function useFileSystem(scapeId: string | number) { const saveTimeoutRef = useRef>({}) const saveContentToDb = useCallback( - (fileId: number, content: string) => { + (fileId: number, content: string | Blob | ArrayBuffer | Uint8Array) => { if (saveTimeoutRef.current[fileId]) { clearTimeout(saveTimeoutRef.current[fileId]) } @@ -90,7 +90,11 @@ export function useFileSystem(scapeId: string | number) { // --- Actions --- const createFile = useCallback( - async (name: string, language: FileType, content: string = "") => { + async ( + name: string, + language: FileType, + content: string | Blob | ArrayBuffer | Uint8Array = "" + ) => { // Optimistic: We can't really do optimistic create easily because we need the ID from DB. // So we await DB. The `useEffect` above will handle adding it to state when it detects structure change. await db.files.add({ @@ -104,7 +108,7 @@ export function useFileSystem(scapeId: string | number) { ) const updateFile = useCallback( - (name: string, content: string) => { + (name: string, content: string | Blob | ArrayBuffer | Uint8Array) => { setFiles((prev) => prev.map((f) => { if (f.name === name) { diff --git a/src/lib/db.ts b/src/lib/db.ts index ac0bcd32..32b7cc94 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -20,7 +20,7 @@ export interface File { id: number scapeId: string | number name: string - content: string + content: string | Blob | ArrayBuffer | Uint8Array language: string } diff --git a/src/pages/ScapeEditor.tsx b/src/pages/ScapeEditor.tsx index c2b0f639..0f758e8a 100644 --- a/src/pages/ScapeEditor.tsx +++ b/src/pages/ScapeEditor.tsx @@ -464,19 +464,30 @@ export default function ScapeEditor() { setRuntimeProblems([]) } - const handleCreateFile = async (fileName: string) => { + const handleCreateFile = async ( + fileName: string, + type?: string, + content?: string | Blob | ArrayBuffer | Uint8Array + ) => { if (!fileName) return if (files.some((f) => f.name === fileName)) { alert("File already exists") return } - const language = fileName.endsWith(".css") - ? "css" - : fileName.endsWith(".html") - ? "html" - : "javascript" - await createFile(fileName, language) + let language: ScapeFile["language"] = "javascript" + if (type) { + language = type as ScapeFile["language"] + } else { + language = fileName.endsWith(".css") + ? "css" + : fileName.endsWith(".html") + ? "html" + : "javascript" + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await createFile(fileName, language as any, content as any) // Auto-select new file setActiveFilePath(fileName) } @@ -510,13 +521,13 @@ export default function ScapeEditor() { const updates: { id: number; name: string }[] = [] - // Rename folder itself if it exists as a node - const folderEntry = files.find((f) => f.name === oldPath && f.language === "folder") - if (folderEntry && folderEntry.id) { - updates.push({ id: folderEntry.id, name: newPath }) + // Rename the node itself (file or folder) + const exactNode = files.find((f) => f.name === oldPath) + if (exactNode && exactNode.id) { + updates.push({ id: exactNode.id, name: newPath }) } - // Rename children + // Rename children (if it was a folder) const filesToMove = files.filter((f) => f.name.startsWith(oldPath + "/")) filesToMove.forEach((f) => { if (f.id) { @@ -832,16 +843,30 @@ export default function ScapeEditor() { } > {activeFile ? ( - + typeof activeFile.content === "string" ? ( + + ) : ( +
+

Binary File

+

+ {activeFile.name} ( + {activeFile.content instanceof Blob + ? `${activeFile.content.size} bytes` + : "ArrayBuffer"} + ) +

+

Cannot edit binary files.

+
+ ) ) : (

Select a file to start editing

diff --git a/src/runners/python/worker.ts b/src/runners/python/worker.ts index ed1ed2fd..ebfde5e7 100644 --- a/src/runners/python/worker.ts +++ b/src/runners/python/worker.ts @@ -217,7 +217,12 @@ self.onmessage = async (e: MessageEvent) => { } } } - py.FS.writeFile(file.name, file.content) + let contentToWrite = file.content + if (contentToWrite instanceof ArrayBuffer) { + contentToWrite = new Uint8Array(contentToWrite) + } + + py.FS.writeFile(file.name, contentToWrite) } // 2. Run Entry Point diff --git a/src/runners/web/WebRunner.tsx b/src/runners/web/WebRunner.tsx index a7fccddf..39a958d0 100644 --- a/src/runners/web/WebRunner.tsx +++ b/src/runners/web/WebRunner.tsx @@ -97,18 +97,35 @@ export const WebRunner = memo( files.forEach((file) => { let content = file.content - let type = "text/javascript" - - if (file.name.endsWith(".js")) { - // Rewrite relative imports to bare imports - content = content.replace(/from\s+['"]\.\/([^'"]+)['"]/g, "from '$1'") - content = content.replace(/import\s+['"]\.\/([^'"]+)['"]/g, "import '$1'") + let type = "application/octet-stream" // Default binary + + if (typeof content === "string") { + // Text Files + if (file.name.endsWith(".js")) { + type = "text/javascript" + // Rewrite relative imports to bare imports + content = content.replace(/from\s+['"]\.\/([^'"]+)['"]/g, "from '$1'") + content = content.replace(/import\s+['"]\.\/([^'"]+)['"]/g, "import '$1'") + } else if (file.name.endsWith(".css")) { + type = "text/css" + } else if (file.name.endsWith(".html")) { + type = "text/html" + } else if (file.name.endsWith(".json")) { + type = "application/json" + } + } else { + // Binary Files - Infer type from extension if possible + if (file.name.endsWith(".png")) type = "image/png" + else if (file.name.endsWith(".jpg") || file.name.endsWith(".jpeg")) type = "image/jpeg" + else if (file.name.endsWith(".svg")) type = "image/svg+xml" + else if (file.name.endsWith(".gif")) type = "image/gif" + else if (file.name.endsWith(".webp")) type = "image/webp" + else if (file.name.endsWith(".wasm")) type = "application/wasm" } - if (file.name.endsWith(".css")) type = "text/css" - if (file.name.endsWith(".html")) type = "text/html" - - const blob = new Blob([content], { type }) + // Uint8Array/ArrayBuffer is valid for Blob but TS definitions can be strict + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const blob = new Blob([content as any], { type }) newUrls[file.name] = URL.createObjectURL(blob) }) @@ -131,6 +148,10 @@ export const WebRunner = memo( return `
File '${activePreviewPath}' not found
` } + if (typeof htmlFile.content !== "string") { + return `
Cannot render binary file '${activePreviewPath}' as HTML
` + } + // Build Import Map const imports: Record = { three: "https://esm.sh/three@0.160.0", @@ -146,7 +167,7 @@ export const WebRunner = memo( const importMap = { imports } - let processedHtml = htmlFile.content + let processedHtml = htmlFile.content as string // Inject Import Map, WebGL Shim, and Navigation Interceptor const importMapScript = ` @@ -199,30 +220,56 @@ export const WebRunner = memo( processedHtml = importMapScript + processedHtml } - // Inject CSS Link Replacements + // Generic Replacer for src, href, and url() + const replaceUrl = (name: string) => { + const cleanName = name.replace(/^\.\//, "") + if (blobUrls[cleanName]) return blobUrls[cleanName] + return name // No match, return original + } + + // Inject CSS Link Replacements (href) processedHtml = processedHtml.replace( /]*href=["']([^"']+)["'][^>]*>/gi, (match, href) => { - const cleanName = href.replace(/^\.\//, "") - if (blobUrls[cleanName] && cleanName.endsWith(".css")) { - return `` - } - return match + const newUrl = replaceUrl(href) + return match.replace(href, newUrl) } ) - // Inject JS Script Replacements + // Inject JS Script Replacements (src) processedHtml = processedHtml.replace( /]*src=["']([^"']+)["'][^>]*><\/script>/gi, (match, src) => { - const cleanName = src.replace(/^\.\//, "") - if (blobUrls[cleanName] && cleanName.endsWith(".js")) { - return match.replace(src, blobUrls[cleanName]) - } - return match + const newUrl = replaceUrl(src) + return match.replace(src, newUrl) + } + ) + + // Inject Image Replacements (src) + processedHtml = processedHtml.replace( + /]*src=["']([^"']+)["'][^>]*>/gi, + (match, src) => { + const newUrl = replaceUrl(src) + return match.replace(src, newUrl) + } + ) + + // Catch-all for other src elements (audio, video, source, iframe) + processedHtml = processedHtml.replace( + /<(audio|video|source|iframe|embed|object)[^>]*src=["']([^"']+)["'][^>]*>/gi, + (match, _tag, src) => { + const newUrl = replaceUrl(src) + return match.replace(src, newUrl) } ) + // CSS url() replacements (e.g. background-image) - Simple regex, might need more robustness + // Looks for url('...') or url("...") or url(...) + processedHtml = processedHtml.replace(/url\(\s*['"]?([^'")]+)['"]?\s*\)/gi, (_match, url) => { + const newUrl = replaceUrl(url) + return `url('${newUrl}')` + }) + return processedHtml }, [files, blobUrls, activePreviewPath]) diff --git a/src/types/file.ts b/src/types/file.ts index ddad2c76..120ce5d8 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -1,8 +1,17 @@ -export type FileType = "html" | "css" | "javascript" | "json" | "markdown" | "folder" | "python" +export type FileType = + | "html" + | "css" + | "javascript" + | "json" + | "markdown" + | "folder" + | "python" + | "image" + | "binary" export interface ScapeFile { id?: number name: string - content: string + content: string | Blob | ArrayBuffer | Uint8Array language: FileType } From e4b8103a5c27307d260877896d1db4fefe2edd8c Mon Sep 17 00:00:00 2001 From: Aaqid Masoodi Date: Tue, 16 Dec 2025 22:20:28 +0000 Subject: [PATCH 3/5] fix: Python Runner FS Cleanup & Directory Handling (resolves stale files and FS errors) --- src/runners/python/PythonRunner.tsx | 1 + src/runners/python/worker.ts | 47 +++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/runners/python/PythonRunner.tsx b/src/runners/python/PythonRunner.tsx index c642cd18..c0638703 100644 --- a/src/runners/python/PythonRunner.tsx +++ b/src/runners/python/PythonRunner.tsx @@ -234,6 +234,7 @@ export const PythonRunner = memo( files: currentFiles.map((f) => ({ name: f.name, content: f.content, + language: f.language, })), entryPoint, }, diff --git a/src/runners/python/worker.ts b/src/runners/python/worker.ts index ebfde5e7..7a2506cf 100644 --- a/src/runners/python/worker.ts +++ b/src/runners/python/worker.ts @@ -202,9 +202,42 @@ self.onmessage = async (e: MessageEvent) => { builtins.input = _input `) - // 1. Write files to Virtual FS + // 1. Clean up Virtual FS (Remove old user files) + // Safer and Easier: Use Python to clean up the current directory + // This handles recursion and permissions cleanly and avoids missing definitions in PyodideInterface + await py.runPythonAsync(` + import os + import shutil + + # Keep internal pyodide stuff if any, generally safe to wipe . + for item in os.listdir('.'): + if item in ['.', '..']: continue + try: + if os.path.isfile(item) or os.path.islink(item): + os.unlink(item) + elif os.path.isdir(item): + shutil.rmtree(item) + except Exception as e: + pass + `) + + // 2. Write files to Virtual FS postMessage({ type: "STATUS", payload: "Writing files..." }) + + // Sort files so folders/parents come first if possible, though mkdir -p handles it. + // Actually, standard naive approach works if we differentiate folders. + for (const file of files) { + if (file.language === "folder") { + try { + py.FS.mkdir(file.name) + } catch { + // Ignore if exists + } + continue + } + + // It's a file const parts = file.name.split("/") if (parts.length > 1) { let currentPath = "" @@ -217,12 +250,22 @@ self.onmessage = async (e: MessageEvent) => { } } } + let contentToWrite = file.content if (contentToWrite instanceof ArrayBuffer) { contentToWrite = new Uint8Array(contentToWrite) } - py.FS.writeFile(file.name, contentToWrite) + // Ensure we don't overwrite a directory with a file of same name (shouldn't happen with valid tree) + try { + py.FS.writeFile(file.name, contentToWrite) + } catch (err: any) { + // If 'FS error', it might be because a directory exists at this path? + // Or parent missing? + // We already tried to create parents. + console.warn(`Failed to write ${file.name}: ${err.message}`) + // Don't throw, let other files try to write + } } // 2. Run Entry Point From 6bc5d245ecf4865a0f995730c419de21708de5dc Mon Sep 17 00:00:00 2001 From: Aaqid Masoodi Date: Tue, 16 Dec 2025 23:19:22 +0000 Subject: [PATCH 4/5] fix(web-runner): switch to Data URIs to support credentialless iframe & CSS paths --- src/runners/web/WebRunner.tsx | 150 ++++++++++++++++++++++++---------- 1 file changed, 106 insertions(+), 44 deletions(-) diff --git a/src/runners/web/WebRunner.tsx b/src/runners/web/WebRunner.tsx index 39a958d0..aa42a5c2 100644 --- a/src/runners/web/WebRunner.tsx +++ b/src/runners/web/WebRunner.tsx @@ -89,55 +89,98 @@ export const WebRunner = memo( } }, [files, activePreviewPath]) - // 1. Create Blob URLs for all files - const [blobUrls, setBlobUrls] = useState>({}) + // 1. Create Data URLs for all files (Support Credentialless Iframe) + const [assetUrls, setAssetUrls] = useState>({}) useEffect(() => { - const newUrls: Record = {} - - files.forEach((file) => { - let content = file.content - let type = "application/octet-stream" // Default binary - - if (typeof content === "string") { - // Text Files - if (file.name.endsWith(".js")) { - type = "text/javascript" - // Rewrite relative imports to bare imports - content = content.replace(/from\s+['"]\.\/([^'"]+)['"]/g, "from '$1'") - content = content.replace(/import\s+['"]\.\/([^'"]+)['"]/g, "import '$1'") - } else if (file.name.endsWith(".css")) { - type = "text/css" - } else if (file.name.endsWith(".html")) { - type = "text/html" - } else if (file.name.endsWith(".json")) { - type = "application/json" - } - } else { - // Binary Files - Infer type from extension if possible + let isMounted = true + + const generateUrls = async () => { + const rawAssets: Record = {} + const processedAssets: Record = {} + + // 1. Process Leaf Assets (Images, Fonts, WASM, JSON, etc.) + // These have no dependencies. + const leafFiles = files.filter( + (f) => !f.name.endsWith(".css") && !f.name.endsWith(".js") && !f.name.endsWith(".html") + ) + const leafPromises = leafFiles.map(async (file) => { + let type = "application/octet-stream" if (file.name.endsWith(".png")) type = "image/png" else if (file.name.endsWith(".jpg") || file.name.endsWith(".jpeg")) type = "image/jpeg" else if (file.name.endsWith(".svg")) type = "image/svg+xml" else if (file.name.endsWith(".gif")) type = "image/gif" else if (file.name.endsWith(".webp")) type = "image/webp" else if (file.name.endsWith(".wasm")) type = "application/wasm" - } + else if (file.name.endsWith(".json")) type = "application/json" - // Uint8Array/ArrayBuffer is valid for Blob but TS definitions can be strict - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const blob = new Blob([content as any], { type }) - newUrls[file.name] = URL.createObjectURL(blob) - }) + let blob: Blob + if (typeof file.content === "string") { + blob = new Blob([file.content], { type }) + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + blob = new Blob([file.content as any], { type }) + } - // eslint-disable-next-line react-hooks/set-state-in-effect - setBlobUrls(newUrls) + return new Promise((resolve) => { + const reader = new FileReader() + reader.onloadend = () => { + if (reader.result && typeof reader.result === "string") { + rawAssets[file.name] = reader.result + } + resolve() + } + reader.readAsDataURL(blob) + }) + }) + await Promise.all(leafPromises) + + // 2. Process CSS (Inject Leaf Assets) + const cssFiles = files.filter((f) => f.name.endsWith(".css")) + cssFiles.forEach((file) => { + let content = file.content as string + // Replace url('./asset.png') or url("asset.png") with Data URI + content = content.replace( + /url\(\s*['"]?(\.\/)?([^'")]+)['"]?\s*\)/gi, + (match, _dotSlash, name) => { + const cleanName = name // The regex group 2 captures the name + if (rawAssets[cleanName]) return `url('${rawAssets[cleanName]}')` + return match + } + ) + // Convert to Base64 + const encoded = btoa(unescape(encodeURIComponent(content))) + processedAssets[file.name] = `data:text/css;base64,${encoded}` + }) + + // 3. Process JS (Imports) + const jsFiles = files.filter((f) => f.name.endsWith(".js")) + jsFiles.forEach((file) => { + let content = file.content as string + // Try to handle imports? + // Ideally we replace imports with Data URIs too, but circular deps are hard. + // For now, simpler: just bare imports or blob imports if we had them. + // Since we use importmap, we don't need to replace imports in content generally, + // EXCEPT relative paths if we want them to work without importmap magic. + // But map handles "file.js". + // We do need to handle other assets in JS? No, usually handled by runtime fetch, which we can't easily patch inside strict syntax. + + // Just rewrite relative imports for Module system + content = content.replace(/from\s+['"]\.\/([^'"]+)['"]/g, "from '$1'") + content = content.replace(/import\s+['"]\.\/([^'"]+)['"]/g, "import '$1'") + + const encoded = btoa(unescape(encodeURIComponent(content))) + processedAssets[file.name] = `data:text/javascript;base64,${encoded}` + }) + + if (isMounted) { + setAssetUrls({ ...rawAssets, ...processedAssets }) + } + } - // Cleanup with delay to prevent 404s on rapid reloads + generateUrls() return () => { - const urlsToRevoke = Object.values(newUrls) - setTimeout(() => { - urlsToRevoke.forEach((url) => URL.revokeObjectURL(url)) - }, 5000) + isMounted = false } }, [files]) @@ -158,8 +201,8 @@ export const WebRunner = memo( "three/": "https://esm.sh/three@0.160.0/", } - // Map bare filenames to Blob URLs - Object.entries(blobUrls).forEach(([name, url]) => { + // Map bare filenames to Data URLs + Object.entries(assetUrls).forEach(([name, url]) => { if (name.endsWith(".js")) { imports[name] = url } @@ -222,8 +265,19 @@ export const WebRunner = memo( // Generic Replacer for src, href, and url() const replaceUrl = (name: string) => { + // Strictly ignore external URLs and data URIs + if ( + name.startsWith("http://") || + name.startsWith("https://") || + name.startsWith("//") || + name.startsWith("data:") || + name.startsWith("blob:") + ) { + return name + } + const cleanName = name.replace(/^\.\//, "") - if (blobUrls[cleanName]) return blobUrls[cleanName] + if (assetUrls[cleanName]) return assetUrls[cleanName] return name // No match, return original } @@ -249,6 +303,13 @@ export const WebRunner = memo( processedHtml = processedHtml.replace( /]*src=["']([^"']+)["'][^>]*>/gi, (match, src) => { + // If external, add crossorigin="anonymous" + if (src.startsWith("http") || src.startsWith("//")) { + if (!match.includes("crossorigin")) { + return match.replace(" { const newUrl = replaceUrl(url) return `url('${newUrl}')` }) return processedHtml - }, [files, blobUrls, activePreviewPath]) + }, [files, assetUrls, activePreviewPath]) return (
@@ -301,7 +361,9 @@ export const WebRunner = memo( title="preview" srcDoc={srcDoc} className="h-full w-full border-0" - sandbox="allow-scripts allow-same-origin allow-modals allow-popups" + sandbox="allow-scripts allow-forms allow-popups allow-modals allow-downloads" + // @ts-expect-error - credentialless is a new feature for COEP isolation + credentialless="true" />
From 33dcff4d07eca94d120c26319207a2290b5fda89 Mon Sep 17 00:00:00 2001 From: Aaqid Masoodi Date: Tue, 16 Dec 2025 23:51:03 +0000 Subject: [PATCH 5/5] feat(python-runner): enable requests support via pyodide-http --- src/runners/python/worker.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/runners/python/worker.ts b/src/runners/python/worker.ts index 7a2506cf..9e2e0c88 100644 --- a/src/runners/python/worker.ts +++ b/src/runners/python/worker.ts @@ -51,10 +51,19 @@ const loadPyodide = async (): Promise => { try { await pyodide.loadPackage(["micropip"]) } catch { + // Fallback or retry await pyodide.loadPackage(["micropip"]) } - // Verify micropip loading + // Install and patch pyodide-http to enable 'requests' + await pyodide.runPythonAsync(` + import micropip + await micropip.install('pyodide-http') + import pyodide_http + pyodide_http.patch_all() + `) + + // Verify imports (optional but good for stability) await pyodide.runPythonAsync(` import sys import importlib