diff --git a/apps/web/client/package.json b/apps/web/client/package.json
index 4f0dfaac37..5882792a8b 100644
--- a/apps/web/client/package.json
+++ b/apps/web/client/package.json
@@ -68,6 +68,7 @@
"freestyle-sandboxes": "^0.0.78",
"localforage": "^1.10.0",
"lucide-react": "^0.486.0",
+ "memfs": "^4.17.2",
"mobx-react-lite": "^4.1.0",
"motion": "^12.6.3",
"next": "^15.2.3",
diff --git a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/hooks/use-folder.ts b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/hooks/use-folder.ts
index e43865faa5..9ff218efd1 100644
--- a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/hooks/use-folder.ts
+++ b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/hooks/use-folder.ts
@@ -107,8 +107,6 @@ export const useFolder = () => {
await editorEngine.sandbox.rename(oldPath, newPath);
- editorEngine.image.scanImages();
-
setRenameState({
folderToRename: null,
newFolderName: '',
@@ -149,8 +147,6 @@ export const useFolder = () => {
await editorEngine.sandbox.delete(folderPath, true);
- editorEngine.image.scanImages();
-
setDeleteState({
folderToDelete: null,
isLoading: false,
@@ -209,9 +205,6 @@ export const useFolder = () => {
}
await editorEngine.sandbox.rename(oldPath, newPath);
- // Refresh images
- editorEngine.image.scanImages();
-
setMoveState({
folderToMove: null,
targetFolder: null,
diff --git a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/providers/images-provider.tsx b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/providers/images-provider.tsx
index df9fe7bb6e..cd793e3aaf 100644
--- a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/providers/images-provider.tsx
+++ b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/providers/images-provider.tsx
@@ -1,13 +1,15 @@
import { useEditorEngine } from '@/components/store/editor';
-import { createContext, useContext, useMemo, type ReactNode, useCallback, useState, useEffect } from 'react';
+import { useCleanupOnPageChange } from '@/hooks/use-subscription-cleanup';
+import { DefaultSettings } from '@onlook/constants';
+import { isImageFile } from '@onlook/utility';
import { observer } from 'mobx-react-lite';
-import { useImageUpload } from '../hooks/use-image-upload';
+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
+import { useFolder } from '../hooks/use-folder';
import { useImageDelete } from '../hooks/use-image-delete';
-import { useImageRename } from '../hooks/use-image-rename';
import { useImageMove } from '../hooks/use-image-move';
+import { useImageRename } from '../hooks/use-image-rename';
+import { useImageUpload } from '../hooks/use-image-upload';
import type { FolderNode } from './types';
-import { useFolder } from '../hooks/use-folder';
-import { DefaultSettings } from '@onlook/constants';
interface ImagesContextValue {
folderStructure: FolderNode;
@@ -30,7 +32,7 @@ interface ImagesProviderProps {
export const ImagesProvider = observer(({ children }: ImagesProviderProps) => {
const editorEngine = useEditorEngine();
-
+ const { addSubscription } = useCleanupOnPageChange();
const deleteOperations = useImageDelete();
const renameOperations = useImageRename();
@@ -91,11 +93,12 @@ export const ImagesProvider = observer(({ children }: ImagesProviderProps) => {
setFolderStructure(baseFolderStructure);
}, [baseFolderStructure]);
+
const triggerFolderStructureUpdate = useCallback(() => {
setFolderStructure(prev => ({ ...prev }));
}, []);
- const isOperating =
+ const isOperating =
deleteOperations.deleteState.isLoading ||
renameOperations.renameState.isLoading ||
uploadOperations.uploadState.isUploading ||
@@ -118,6 +121,15 @@ export const ImagesProvider = observer(({ children }: ImagesProviderProps) => {
},
};
+ useEffect(() => {
+ const unsubscribe = editorEngine.sandbox.fileEventBus.subscribe('*', (event) => {
+ if (event.paths && event.paths[0] && isImageFile(event.paths[0])) {
+ editorEngine.image.scanImages();
+ }
+ });
+ addSubscription('image-manager', unsubscribe);
+ }, [editorEngine.sandbox.fileEventBus]);
+
return {children};
});
diff --git a/apps/web/client/src/components/store/editor/font/index.ts b/apps/web/client/src/components/store/editor/font/index.ts
index 0e3ae846ee..f3d7c6248a 100644
--- a/apps/web/client/src/components/store/editor/font/index.ts
+++ b/apps/web/client/src/components/store/editor/font/index.ts
@@ -1,4 +1,4 @@
-"use client";
+'use client';
import type { ParseResult } from '@babel/parser';
import * as t from '@babel/types';
@@ -82,6 +82,7 @@ export class FontManager {
private _allFontFamilies: RawFont[] = FAMILIES as RawFont[];
private disposers: Array<() => void> = [];
private previousFonts: Font[] = [];
+ private fontConfigFileWatcher: (() => void) | null = null;
private _fontConfigPath: string | null = null;
tailwindConfigPath = normalizePath(DefaultSettings.TAILWIND_CONFIG);
@@ -95,9 +96,7 @@ export class FontManager {
return this._fontConfigPath;
}
- constructor(
- private editorEngine: EditorEngine,
- ) {
+ constructor(private editorEngine: EditorEngine) {
makeAutoObservable(this);
// Initialize FlexSearch index
@@ -131,21 +130,9 @@ export class FontManager {
if (session) {
await this.updateFontConfigPath();
this.loadInitialFonts();
- }
- },
- { fireImmediately: true },
- );
-
- const fontConfigDisposer = reaction(
- () =>
- this.editorEngine.state.brandTab === BrandTabValue.FONTS &&
- this.editorEngine.sandbox?.readFile(this.fontConfigPath),
- async (contentPromise) => {
- if (contentPromise) {
- const content = await contentPromise;
- if (content) {
- this.syncFontsWithConfigs();
- }
+ this.setupFontConfigFileWatcher();
+ } else {
+ this.cleanupFontConfigFileWatcher();
}
},
{ fireImmediately: true },
@@ -177,7 +164,7 @@ export class FontManager {
{ fireImmediately: true },
);
- this.disposers.push(sandboxDisposer, fontConfigDisposer, defaultFontDisposer);
+ this.disposers.push(sandboxDisposer, defaultFontDisposer);
}
private convertFont(font: RawFont): Font {
@@ -802,6 +789,9 @@ export class FontManager {
this._currentFontIndex = 0;
this._isFetching = false;
+ // Clean up file watcher
+ this.cleanupFontConfigFileWatcher();
+
// Clean up all reactions
this.disposers.forEach((disposer) => disposer());
this.disposers = [];
@@ -1532,4 +1522,36 @@ export class FontManager {
console.error(`Error traversing className in ${filePath}:`, error);
}
}
+
+ /**
+ * Sets up file watcher for the font config file
+ */
+ private setupFontConfigFileWatcher(): void {
+ this.cleanupFontConfigFileWatcher();
+
+ const sandbox = this.editorEngine.sandbox;
+ if (!sandbox || this.editorEngine.state.brandTab !== BrandTabValue.FONTS) {
+ return;
+ }
+
+ this.fontConfigFileWatcher = sandbox.fileEventBus.subscribe('*', async (event) => {
+ const normalizedFontConfigPath = normalizePath(this.fontConfigPath);
+ const affectsFont = event.paths.some(
+ (path) => normalizePath(path) === normalizedFontConfigPath,
+ );
+
+ if (!affectsFont) {
+ return;
+ }
+
+ await this.syncFontsWithConfigs();
+ });
+ }
+
+ private cleanupFontConfigFileWatcher(): void {
+ if (this.fontConfigFileWatcher) {
+ this.fontConfigFileWatcher();
+ this.fontConfigFileWatcher = null;
+ }
+ }
}
diff --git a/apps/web/client/src/components/store/editor/image/index.ts b/apps/web/client/src/components/store/editor/image/index.ts
index f8dfecb295..b48bbf40e8 100644
--- a/apps/web/client/src/components/store/editor/image/index.ts
+++ b/apps/web/client/src/components/store/editor/image/index.ts
@@ -23,16 +23,8 @@ export class ImageManager {
if (!isIndexingFiles) {
this.scanImages();
}
- }
- );
-
- reaction(
- () => this.editorEngine.sandbox.listBinaryFiles(DefaultSettings.IMAGE_FOLDER),
- () => {
- this.scanImages();
- }
+ },
);
-
}
async upload(file: File, destinationFolder: string): Promise {
@@ -50,7 +42,6 @@ export class ImageManager {
async delete(originPath: string): Promise {
try {
await this.editorEngine.sandbox.delete(originPath);
- this.scanImages();
} catch (error) {
console.error('Error deleting image:', error);
throw error;
@@ -62,7 +53,6 @@ export class ImageManager {
const basePath = getDirName(originPath);
const newPath = `${basePath}/${newName}`;
await this.editorEngine.sandbox.rename(originPath, newPath);
- this.scanImages();
} catch (error) {
console.error('Error renaming image:', error);
throw error;
@@ -150,9 +140,7 @@ export class ImageManager {
this._isScanning = true;
try {
- const files = this.editorEngine.sandbox.listBinaryFiles(
- DefaultSettings.IMAGE_FOLDER,
- );
+ const files = this.editorEngine.sandbox.listBinaryFiles(DefaultSettings.IMAGE_FOLDER);
if (files.length === 0) {
this.images = [];
@@ -161,13 +149,11 @@ export class ImageManager {
const imageFiles = files.filter((filePath: string) => isImageFile(filePath));
-
if (imageFiles.length === 0) {
return;
}
this.images = imageFiles;
-
} catch (error) {
console.error('Error scanning images:', error);
this.images = [];
@@ -227,11 +213,13 @@ export class ImageManager {
try {
// Process all images in parallel
- const imagePromises = imagePaths.map(path => this.readImageContent(path));
+ const imagePromises = imagePaths.map((path) => this.readImageContent(path));
const results = await Promise.all(imagePromises);
// Filter out null results
- const validImages = results.filter((result): result is ImageContentData => result !== null);
+ const validImages = results.filter(
+ (result): result is ImageContentData => result !== null,
+ );
return validImages;
} catch (error) {
diff --git a/apps/web/client/src/components/store/editor/sandbox/file-sync.ts b/apps/web/client/src/components/store/editor/sandbox/file-sync.ts
deleted file mode 100644
index 6c1d5c10b9..0000000000
--- a/apps/web/client/src/components/store/editor/sandbox/file-sync.ts
+++ /dev/null
@@ -1,373 +0,0 @@
-import { convertToBase64 } from '@onlook/utility';
-import localforage from 'localforage';
-import { makeAutoObservable } from 'mobx';
-import type { EditorEngine } from '../engine';
-
-export class FileSyncManager {
- private cache: Map;
- private binaryCache: Map;
- private storageKey
- private binaryStorageKey
-
- constructor(
- private readonly editorEngine: EditorEngine,
- ) {
- this.cache = new Map();
- this.binaryCache = new Map();
- this.storageKey = 'file-cache-' + this.editorEngine.projectId;
- this.binaryStorageKey = 'binary-file-cache-' + this.editorEngine.projectId;
-
- this.restoreFromLocalStorage();
- makeAutoObservable(this);
- }
-
- has(filePath: string) {
- return this.cache.has(filePath);
- }
-
- hasBinary(filePath: string) {
- return this.binaryCache.has(filePath);
- }
-
- // Track binary file path without reading content (using empty placeholder)
- async trackBinaryFile(filePath: string) {
- if (!this.hasBinary(filePath)) {
- this.binaryCache.set(filePath, new Uint8Array(0));
- await this.saveToLocalStorage();
- }
- }
-
- // Check if binary file has actual content loaded
- hasBinaryContent(filePath: string) {
- const content = this.binaryCache.get(filePath);
- return content && content.length > 0;
- }
-
- async readOrFetchBinaryFile(
- filePath: string,
- readFile: (path: string) => Promise,
- ): Promise {
- if (this.hasBinary(filePath)) {
- const cachedContent = this.binaryCache.get(filePath);
- // If content is empty (placeholder), fetch the actual content
- if (cachedContent && cachedContent.length === 0) {
- try {
- const content = await readFile(filePath);
- if (content === null) {
- throw new Error(`File content for ${filePath} not found`);
- }
- this.updateBinaryCache(filePath, content);
- return content;
- } catch (error) {
- console.error(`Error reading binary file ${filePath}:`, error);
- return null;
- }
- }
- return cachedContent ?? null;
- }
-
- try {
- const content = await readFile(filePath);
- if (content === null) {
- throw new Error(`File content for ${filePath} not found`);
- }
-
- this.updateBinaryCache(filePath, content);
- return content;
- } catch (error) {
- console.error(`Error reading binary file ${filePath}:`, error);
- return null;
- }
- }
-
- async readOrFetch(
- filePath: string,
- readFile: (path: string) => Promise,
- ): Promise {
- if (this.has(filePath)) {
- return this.cache.get(filePath) ?? null;
- }
-
- try {
- const content = await readFile(filePath);
- if (content === null) {
- throw new Error(`File content for ${filePath} not found`);
- }
- this.updateCache(filePath, content);
- return content;
- } catch (error) {
- console.error(`Error reading file ${filePath}:`, error);
- return null;
- }
- }
-
- async write(
- filePath: string,
- content: string,
- writeFile: (path: string, content: string) => Promise,
- ): Promise {
- try {
- // Write to cache first
- this.updateCache(filePath, content);
-
- // Then write to remote
- const success = await writeFile(filePath, content);
- if (!success) {
- throw new Error(`Failed to write file ${filePath}`);
- }
- return success;
- } catch (error) {
- // If any error occurs, remove from cache
- this.delete(filePath);
- console.error(`Error writing file ${filePath}:`, error);
- return false;
- }
- }
-
- async writeBinary(
- filePath: string,
- content: Uint8Array,
- writeFile: (path: string, content: Uint8Array) => Promise,
- ): Promise {
- try {
- // Write to cache first
-
- this.updateBinaryCache(filePath, content);
-
- // Then write to remote
- const success = await writeFile(filePath, content);
-
- if (!success) {
- throw new Error(`Failed to write file ${filePath}`);
- }
- return success;
- } catch (error) {
- // If any error occurs, remove from cache
- this.delete(filePath);
- console.error(`Error writing file ${filePath}:`, error);
- return false;
- }
- }
-
- async updateBinaryCache(filePath: string, content: Uint8Array): Promise {
- this.binaryCache.set(filePath, content);
- await this.saveToLocalStorage();
- }
-
- async updateCache(filePath: string, content: string): Promise {
- this.cache.set(filePath, content);
- await this.saveToLocalStorage();
- }
-
- async delete(filePath: string) {
- this.binaryCache.delete(filePath);
- this.cache.delete(filePath);
- await this.saveToLocalStorage();
- }
-
- async rename(oldPath: string, newPath: string) {
- let hasChanges = false;
-
- // Handle folder renaming - find all files that start with oldPath
- const normalizedOldPath = oldPath.endsWith('/') ? oldPath : oldPath + '/';
- const normalizedNewPath = newPath.endsWith('/') ? newPath : newPath + '/';
-
- // Update binary cache entries
- const binaryEntriesToUpdate: Array<{ oldKey: string; newKey: string; content: Uint8Array }> = [];
- for (const [filePath, content] of this.binaryCache.entries()) {
- if (filePath === oldPath) {
- binaryEntriesToUpdate.push({ oldKey: filePath, newKey: newPath, content });
- hasChanges = true;
- } else if (filePath.startsWith(normalizedOldPath)) {
- const relativePath = filePath.substring(normalizedOldPath.length);
- const newFilePath = normalizedNewPath + relativePath;
- binaryEntriesToUpdate.push({ oldKey: filePath, newKey: newFilePath, content });
- hasChanges = true;
- }
- }
-
- for (const { oldKey, newKey, content } of binaryEntriesToUpdate) {
- this.binaryCache.set(newKey, content);
- this.binaryCache.delete(oldKey);
- }
-
- // Update text cache entries
- const textEntriesToUpdate: Array<{ oldKey: string; newKey: string; content: string }> = [];
- for (const [filePath, content] of this.cache.entries()) {
- if (filePath === oldPath) {
- textEntriesToUpdate.push({ oldKey: filePath, newKey: newPath, content });
- hasChanges = true;
- } else if (filePath.startsWith(normalizedOldPath)) {
- const relativePath = filePath.substring(normalizedOldPath.length);
- const newFilePath = normalizedNewPath + relativePath;
- textEntriesToUpdate.push({ oldKey: filePath, newKey: newFilePath, content });
- hasChanges = true;
- }
- }
-
- // Apply text cache updates
- for (const { oldKey, newKey, content } of textEntriesToUpdate) {
- this.cache.set(newKey, content);
- this.cache.delete(oldKey);
- }
-
- if (hasChanges) {
- await this.saveToLocalStorage();
- }
- }
-
- listAllFiles() {
- return [
- ...Array.from(this.cache.keys()),
- ...Array.from(this.binaryCache.keys()),
- ];
- }
-
- listBinaryFiles(dir: string) {
- return Array.from(this.binaryCache.keys()).filter(filePath => filePath.startsWith(dir));
- }
-
- private async restoreFromLocalStorage() {
- try {
- // Restore text cache
- const storedCache = await localforage.getItem>(this.storageKey);
- if (storedCache) {
- Object.entries(storedCache).forEach(([key, value]) => {
- this.cache.set(key, value);
- });
- }
-
- // Restore binary cache
- const storedBinaryCache = await localforage.getItem>(this.binaryStorageKey);
- if (storedBinaryCache) {
- Object.entries(storedBinaryCache).forEach(([key, base64Value]) => {
- // Convert base64 back to Uint8Array
- const binaryString = atob(base64Value);
- const bytes = Uint8Array.from(binaryString, char => char.charCodeAt(0));
- this.binaryCache.set(key, bytes);
- });
- }
- } catch (error) {
- console.error('Error restoring from localForage:', error);
- }
- }
-
- private async saveToLocalStorage() {
- try {
- // Save text cache
- const cacheObject = Object.fromEntries(this.cache.entries());
- await localforage.setItem(this.storageKey, cacheObject);
-
- // Save binary cache (convert Uint8Array to base64)
- const binaryCacheObject: Record = {};
- this.binaryCache.forEach((value, key) => {
- binaryCacheObject[key] = convertToBase64(value);
- });
- await localforage.setItem(this.binaryStorageKey, binaryCacheObject);
- } catch (error) {
- console.error('Error saving to localForage:', error);
- }
- }
-
- private async clearLocalStorage() {
- try {
- await localforage.removeItem(this.storageKey);
- await localforage.removeItem(this.binaryStorageKey);
- } catch (error) {
- console.error('Error clearing localForage:', error);
- }
- }
-
- async syncFromRemote(
- filePath: string,
- remoteContent: string,
- ): Promise {
- const cachedContent = this.cache.get(filePath);
- const contentChanged = cachedContent !== remoteContent;
- if (contentChanged) {
- // Only update cache if content is different
- await this.updateCache(filePath, remoteContent);
- }
- return contentChanged;
- }
-
- async clear() {
- this.cache.clear();
- this.binaryCache.clear();
- this.cache = new Map();
- this.binaryCache = new Map();
- await this.clearLocalStorage();
- }
-
- /**
- * Batch read multiple files in parallel
- */
- async readOrFetchBatch(
- filePaths: string[],
- readFile: (path: string) => Promise,
- ): Promise> {
- const results: Record = {};
-
- const promises = filePaths.map(async (filePath) => {
- try {
- const content = await this.readOrFetch(filePath, readFile);
- if (content !== null) {
- return { path: filePath, content };
- }
- } catch (error) {
- console.warn(`Error reading file ${filePath}:`, error);
- }
- return null;
- });
-
- const batchResults = await Promise.all(promises);
-
- for (const result of batchResults) {
- if (result) {
- results[result.path] = result.content;
- }
- }
-
- return results;
- }
-
- /**
- * Batch update cache entries
- */
- async updateCacheBatch(entries: Array<{ path: string; content: string }>): Promise {
- for (const { path, content } of entries) {
- this.cache.set(path, content);
- }
-
- await this.saveToLocalStorage();
- }
-
- /**
- * Batch update binary cache entries
- */
- async updateBinaryCacheBatch(entries: Array<{ path: string; content: Uint8Array }>): Promise {
- for (const { path, content } of entries) {
- this.binaryCache.set(path, content);
- }
-
- await this.saveToLocalStorage();
- }
-
- /**
- * Track multiple binary files at once
- */
- async trackBinaryFilesBatch(filePaths: string[]): Promise {
- let hasChanges = false;
-
- for (const filePath of filePaths) {
- if (!this.hasBinary(filePath)) {
- this.binaryCache.set(filePath, new Uint8Array(0));
- hasChanges = true;
- }
- }
-
- if (hasChanges) {
- await this.saveToLocalStorage();
- }
- }
-}
\ No newline at end of file
diff --git a/apps/web/client/src/components/store/editor/sandbox/index.ts b/apps/web/client/src/components/store/editor/sandbox/index.ts
index 1140b18471..b3b75b8697 100644
--- a/apps/web/client/src/components/store/editor/sandbox/index.ts
+++ b/apps/web/client/src/components/store/editor/sandbox/index.ts
@@ -7,24 +7,23 @@ import { makeAutoObservable, reaction } from 'mobx';
import path from 'path';
import type { EditorEngine } from '../engine';
import { FileEventBus } from './file-event-bus';
-import { FileSyncManager } from './file-sync';
import { FileWatcher } from './file-watcher';
import { formatContent, normalizePath } from './helpers';
import { TemplateNodeMapper } from './mapping';
import { SessionManager } from './session';
+import { VFSSyncManager } from './vfs-sync-manager';
export class SandboxManager {
readonly session: SessionManager;
+ private templateNodeMap: TemplateNodeMapper;
private fileWatcher: FileWatcher | null = null;
- private fileSync: FileSyncManager
- private templateNodeMap: TemplateNodeMapper
+ private fileSync: VFSSyncManager = new VFSSyncManager();
readonly fileEventBus: FileEventBus = new FileEventBus();
private isIndexed = false;
private isIndexing = false;
constructor(private readonly editorEngine: EditorEngine) {
this.session = new SessionManager(this.editorEngine);
- this.fileSync = new FileSyncManager(this.editorEngine);
this.templateNodeMap = new TemplateNodeMapper(this.editorEngine);
makeAutoObservable(this);
@@ -441,6 +440,7 @@ export class SandboxManager {
}
const oldNormalizedPath = normalizePath(oldPath);
const newNormalizedPath = normalizePath(newPath);
+
await this.fileSync.rename(oldNormalizedPath, newNormalizedPath);
this.fileEventBus.publish({
@@ -607,16 +607,6 @@ export class SandboxManager {
// Delete the file using the filesystem API
await this.session.session.fs.remove(normalizedPath, recursive);
- // Clean up the file sync cache
- await this.fileSync.delete(normalizedPath);
-
- // Publish file deletion event
- this.fileEventBus.publish({
- type: 'remove',
- paths: [normalizedPath],
- timestamp: Date.now(),
- });
-
console.log(`Successfully deleted file: ${normalizedPath}`);
return true;
} catch (error) {
diff --git a/apps/web/client/src/components/store/editor/sandbox/vfs-sync-manager.ts b/apps/web/client/src/components/store/editor/sandbox/vfs-sync-manager.ts
new file mode 100644
index 0000000000..41d5d3fc79
--- /dev/null
+++ b/apps/web/client/src/components/store/editor/sandbox/vfs-sync-manager.ts
@@ -0,0 +1,246 @@
+import { makeAutoObservable } from 'mobx';
+import { VirtualFileSystem, type VirtualFileSystemInterface } from './virtual-fs';
+
+/**
+ * VFS-based sync manager that replaces the old FileSyncManager
+ * Provides the same interface but uses a proper virtual file system underneath
+ */
+export class VFSSyncManager {
+ private vfs: VirtualFileSystemInterface;
+
+ constructor() {
+ this.vfs = new VirtualFileSystem({
+ persistenceKey: 'vfs-file-sync-cache',
+ enablePersistence: true,
+ });
+ makeAutoObservable(this);
+ }
+
+ // Basic file operations
+ has(filePath: string): boolean {
+ return this.vfs.has(filePath);
+ }
+
+ hasBinary(filePath: string): boolean {
+ return this.vfs.hasBinary(filePath);
+ }
+
+ // Track binary file path without reading content (using empty placeholder)
+ async trackBinaryFile(filePath: string): Promise {
+ await this.vfs.trackBinaryFile(filePath);
+ }
+
+ // Check if binary file has actual content loaded
+ hasBinaryContent(filePath: string): boolean {
+ return this.vfs.hasBinaryContent(filePath);
+ }
+
+ async readOrFetchBinaryFile(
+ filePath: string,
+ readFile: (path: string) => Promise,
+ ): Promise {
+ // If we have the file and it has content, return it
+ if (this.hasBinary(filePath) && this.hasBinaryContent(filePath)) {
+ return await this.vfs.readBinaryFile(filePath);
+ }
+
+ // Otherwise, fetch from remote
+ try {
+ const content = await readFile(filePath);
+ if (content === null) {
+ throw new Error(`File content for ${filePath} not found`);
+ }
+
+ await this.vfs.writeBinaryFile(filePath, content);
+ return content;
+ } catch (error) {
+ console.error(`Error reading binary file ${filePath}:`, error);
+ return null;
+ }
+ }
+
+ async readOrFetch(
+ filePath: string,
+ readFile: (path: string) => Promise,
+ ): Promise {
+ // If we have the file, return it
+ if (this.has(filePath)) {
+ return await this.vfs.readFile(filePath);
+ }
+
+ // Otherwise, fetch from remote
+ try {
+ const content = await readFile(filePath);
+ if (content === null) {
+ throw new Error(`File content for ${filePath} not found`);
+ }
+
+ await this.vfs.writeFile(filePath, content);
+ return content;
+ } catch (error) {
+ console.error(`Error reading file ${filePath}:`, error);
+ return null;
+ }
+ }
+
+ async write(
+ filePath: string,
+ content: string,
+ writeFile: (path: string, content: string) => Promise,
+ ): Promise {
+ try {
+ // Write to VFS first
+ await this.vfs.writeFile(filePath, content);
+
+ // Then write to remote
+ const success = await writeFile(filePath, content);
+ if (!success) {
+ throw new Error(`Failed to write file ${filePath}`);
+ }
+ return success;
+ } catch (error) {
+ // If any error occurs, remove from VFS
+ await this.delete(filePath);
+ console.error(`Error writing file ${filePath}:`, error);
+ return false;
+ }
+ }
+
+ async writeBinary(
+ filePath: string,
+ content: Uint8Array,
+ writeFile: (path: string, content: Uint8Array) => Promise,
+ ): Promise {
+ try {
+ // Write to VFS first
+ await this.vfs.writeBinaryFile(filePath, content);
+
+ // Then write to remote
+ const success = await writeFile(filePath, content);
+
+ if (!success) {
+ throw new Error(`Failed to write file ${filePath}`);
+ }
+ return success;
+ } catch (error) {
+ // If any error occurs, remove from VFS
+ await this.delete(filePath);
+ console.error(`Error writing file ${filePath}:`, error);
+ return false;
+ }
+ }
+
+ async rename(oldPath: string, newPath: string): Promise {
+ return await this.vfs.rename(oldPath, newPath);
+ }
+
+ async updateBinaryCache(filePath: string, content: Uint8Array): Promise {
+ await this.vfs.writeBinaryFile(filePath, content);
+ }
+
+ async updateCache(filePath: string, content: string): Promise {
+ await this.vfs.writeFile(filePath, content);
+ }
+
+ async delete(filePath: string): Promise {
+ await this.vfs.delete(filePath, false);
+ }
+
+ listAllFiles(): string[] {
+ return this.vfs.listAllFiles();
+ }
+
+ listBinaryFiles(dir: string): string[] {
+ return this.vfs.listBinaryFiles(dir);
+ }
+
+ async clear(): Promise {
+ await this.vfs.clear();
+ }
+
+ async syncFromRemote(filePath: string, remoteContent: string): Promise {
+ return await this.vfs.syncFromRemote(filePath, remoteContent);
+ }
+
+ // Batch operations for performance
+ async updateCacheBatch(entries: Array<{ path: string; content: string }>): Promise {
+ await this.vfs.updateCacheBatch(entries);
+ }
+
+ async updateBinaryCacheBatch(
+ entries: Array<{ path: string; content: Uint8Array }>,
+ ): Promise {
+ await this.vfs.updateBinaryCacheBatch(entries);
+ }
+
+ // Additional VFS-specific methods
+ async mkdir(dirPath: string, recursive: boolean = true): Promise {
+ return await this.vfs.mkdir(dirPath, recursive);
+ }
+
+ async readdir(dirPath: string): Promise {
+ return await this.vfs.readdir(dirPath);
+ }
+
+ async stat(filePath: string) {
+ return await this.vfs.stat(filePath);
+ }
+
+ async fileExists(filePath: string): Promise {
+ return await this.vfs.fileExists(filePath);
+ }
+
+ async copy(
+ source: string,
+ destination: string,
+ recursive: boolean = false,
+ overwrite: boolean = false,
+ ): Promise {
+ return await this.vfs.copy(source, destination, recursive, overwrite);
+ }
+
+ // Path utilities
+ normalizePath(path: string): string {
+ return this.vfs.normalizePath(path);
+ }
+
+ dirname(path: string): string {
+ return this.vfs.dirname(path);
+ }
+
+ basename(path: string): string {
+ return this.vfs.basename(path);
+ }
+
+ join(...paths: string[]): string {
+ return this.vfs.join(...paths);
+ }
+
+ // Direct access to VFS for advanced operations
+ getVFS(): VirtualFileSystemInterface {
+ return this.vfs;
+ }
+
+ // Additional batch methods needed by SandboxManager
+ async trackBinaryFilesBatch(filePaths: string[]): Promise {
+ for (const filePath of filePaths) {
+ await this.trackBinaryFile(filePath);
+ }
+ }
+
+ async readOrFetchBatch(
+ filePaths: string[],
+ readFile: (path: string) => Promise,
+ ): Promise> {
+ const results: Record = {};
+
+ for (const filePath of filePaths) {
+ const content = await this.readOrFetch(filePath, readFile);
+ if (content !== null) {
+ results[filePath] = content;
+ }
+ }
+
+ return results;
+ }
+}
diff --git a/apps/web/client/src/components/store/editor/sandbox/virtual-fs.ts b/apps/web/client/src/components/store/editor/sandbox/virtual-fs.ts
new file mode 100644
index 0000000000..7ea5b55b43
--- /dev/null
+++ b/apps/web/client/src/components/store/editor/sandbox/virtual-fs.ts
@@ -0,0 +1,542 @@
+import { BINARY_EXTENSIONS } from '@onlook/constants';
+import { type FileOperations, getBaseName, getDirName } from '@onlook/utility';
+import localforage from 'localforage';
+import { Volume } from 'memfs';
+import { makeAutoObservable } from 'mobx';
+import { normalizePath as sandboxNormalizePath } from './helpers';
+
+export interface VirtualFileSystemOptions {
+ persistenceKey?: string;
+ enablePersistence?: boolean;
+}
+
+export interface FileStats {
+ isFile(): boolean;
+ isDirectory(): boolean;
+ size: number;
+ mtime: Date;
+ ctime: Date;
+}
+
+export interface VirtualFileSystemInterface extends FileOperations {
+ // Enhanced file operations
+ readBinaryFile(filePath: string): Promise;
+ writeBinaryFile(filePath: string, content: Uint8Array): Promise;
+
+ // Directory operations
+ mkdir(dirPath: string, recursive?: boolean): Promise;
+ rmdir(dirPath: string, recursive?: boolean): Promise;
+ readdir(dirPath: string): Promise;
+
+ // File metadata
+ stat(filePath: string): Promise;
+
+ // Utility operations
+ listAllFiles(): string[];
+ listBinaryFiles(dir?: string): string[];
+ clear(): Promise;
+
+ // Persistence
+ saveToStorage(): Promise;
+ loadFromStorage(): Promise;
+
+ // Path utilities (compatible with both helper.ts and utility package)
+ normalizePath(path: string): string;
+ dirname(path: string): string;
+ basename(path: string): string;
+ join(...paths: string[]): string;
+
+ // Sync operations for compatibility
+ has(filePath: string): boolean;
+ hasBinary(filePath: string): boolean;
+ hasBinaryContent(filePath: string): boolean;
+ syncFromRemote(filePath: string, remoteContent: string): Promise;
+ trackBinaryFile(filePath: string): Promise;
+ updateCacheBatch(entries: Array<{ path: string; content: string }>): Promise;
+ updateBinaryCacheBatch(entries: Array<{ path: string; content: Uint8Array }>): Promise;
+}
+
+/**
+ * Virtual File System implementation using memfs
+ * Provides a complete in-memory file system with persistence capabilities
+ */
+export class VirtualFileSystem implements VirtualFileSystemInterface {
+ private volume: Volume;
+ private options: VirtualFileSystemOptions;
+ private storageKey: string;
+
+ constructor(options: VirtualFileSystemOptions = {}) {
+ this.volume = new Volume();
+ this.options = {
+ persistenceKey: 'vfs-cache',
+ enablePersistence: true,
+ ...options,
+ };
+ this.storageKey = this.options.persistenceKey!;
+
+ makeAutoObservable(this);
+
+ // Initialize with root directory
+ this.volume.mkdirSync('/', { recursive: true });
+
+ // Load from storage if persistence is enabled
+ if (this.options.enablePersistence) {
+ this.loadFromStorage().catch(console.error);
+ }
+ }
+
+ // Internal VFS path utilities (always start with / for memfs)
+ private toVFSPath(path: string): string {
+ // Convert sandbox-relative path to VFS absolute path
+ if (!path.startsWith('/')) {
+ path = '/' + path;
+ }
+ // Normalize path separators and resolve . and ..
+ return path.replace(/\\/g, '/').replace(/\/+/g, '/');
+ }
+
+ // Public path utilities
+ normalizePath(path: string): string {
+ return sandboxNormalizePath(path);
+ }
+
+ dirname(path: string): string {
+ // Ensure path uses forward slashes and align exactly with files utility
+ const normalizedPath = path.replace(/\\/g, '/');
+ return getDirName(normalizedPath);
+ }
+
+ basename(path: string): string {
+ const normalizedPath = path.replace(/\\/g, '/');
+ return getBaseName(normalizedPath);
+ }
+
+ join(...paths: string[]): string {
+ const joined = paths.filter(Boolean).join('/').replace(/\/+/g, '/');
+ return sandboxNormalizePath(joined);
+ }
+
+ // Basic file operations (FileOperations interface)
+ async readFile(filePath: string): Promise {
+ try {
+ const vfsPath = this.toVFSPath(filePath);
+ const content = this.volume.readFileSync(vfsPath, {
+ encoding: 'utf8',
+ }) as string;
+ return content;
+ } catch (error) {
+ console.error(`Error reading file ${filePath}:`, error);
+ return null;
+ }
+ }
+
+ async writeFile(filePath: string, content: string): Promise {
+ try {
+ const vfsPath = this.toVFSPath(filePath);
+
+ // Ensure directory exists
+ const vfsDirPath = this.toVFSPath(this.dirname(filePath));
+ this.volume.mkdirSync(vfsDirPath, { recursive: true });
+
+ this.volume.writeFileSync(vfsPath, content, { encoding: 'utf8' });
+
+ if (this.options.enablePersistence) {
+ await this.saveToStorage();
+ }
+
+ return true;
+ } catch (error) {
+ console.error(`Error writing file ${filePath}:`, error);
+ return false;
+ }
+ }
+
+ async fileExists(filePath: string): Promise {
+ try {
+ const vfsPath = this.toVFSPath(filePath);
+ return this.volume.existsSync(vfsPath);
+ } catch (error) {
+ return false;
+ }
+ }
+
+ async delete(filePath: string, recursive: boolean = false): Promise {
+ try {
+ const vfsPath = this.toVFSPath(filePath);
+
+ if (!this.volume.existsSync(vfsPath)) {
+ return false;
+ }
+
+ const stats = this.volume.statSync(vfsPath);
+
+ if (stats.isDirectory()) {
+ if (recursive) {
+ this.volume.rmSync(vfsPath, { recursive: true, force: true });
+ } else {
+ this.volume.rmdirSync(vfsPath);
+ }
+ } else {
+ this.volume.unlinkSync(vfsPath);
+ }
+
+ if (this.options.enablePersistence) {
+ await this.saveToStorage();
+ }
+
+ return true;
+ } catch (error) {
+ console.error(`Error deleting ${filePath}:`, error);
+ return false;
+ }
+ }
+
+ async copy(
+ source: string,
+ destination: string,
+ recursive: boolean = false,
+ overwrite: boolean = false,
+ ): Promise {
+ try {
+ const vfsSource = this.toVFSPath(source);
+ const vfsDest = this.toVFSPath(destination);
+
+ if (!this.volume.existsSync(vfsSource)) {
+ return false;
+ }
+
+ if (this.volume.existsSync(vfsDest) && !overwrite) {
+ return false;
+ }
+
+ const stats = this.volume.statSync(vfsSource);
+
+ if (stats.isDirectory()) {
+ if (!recursive) {
+ return false;
+ }
+
+ // Create destination directory
+ this.volume.mkdirSync(vfsDest, { recursive: true });
+
+ // Copy all contents
+ const entries = this.volume.readdirSync(vfsSource) as string[];
+ for (const entry of entries) {
+ const srcPath = this.join(source, entry);
+ const destPath = this.join(destination, entry);
+ await this.copy(srcPath, destPath, recursive, overwrite);
+ }
+ } else {
+ // Ensure destination directory exists
+ const destDir = this.dirname(destination);
+ const vfsDestDir = this.toVFSPath(destDir);
+ this.volume.mkdirSync(vfsDestDir, { recursive: true });
+
+ // Copy file
+ const content = this.volume.readFileSync(vfsSource);
+ this.volume.writeFileSync(vfsDest, content);
+ }
+
+ if (this.options.enablePersistence) {
+ await this.saveToStorage();
+ }
+
+ return true;
+ } catch (error) {
+ console.error(`Error copying ${source} to ${destination}:`, error);
+ return false;
+ }
+ }
+
+ async rename(oldPath: string, newPath: string): Promise {
+ try {
+ const vfsOldPath = this.toVFSPath(oldPath);
+ const vfsNewPath = this.toVFSPath(newPath);
+
+ this.volume.renameSync(vfsOldPath, vfsNewPath);
+
+ if (this.options.enablePersistence) {
+ await this.saveToStorage();
+ }
+
+ return true;
+ } catch (error) {
+ console.error(`Error renaming ${oldPath} to ${newPath}:`, error);
+ return false;
+ }
+ }
+
+ // Enhanced file operations
+ async readBinaryFile(filePath: string): Promise {
+ try {
+ const vfsPath = this.toVFSPath(filePath);
+ const content = this.volume.readFileSync(vfsPath) as Buffer;
+ return new Uint8Array(content);
+ } catch (error) {
+ console.error(`Error reading binary file ${filePath}:`, error);
+ return null;
+ }
+ }
+
+ async writeBinaryFile(filePath: string, content: Uint8Array): Promise {
+ try {
+ const vfsPath = this.toVFSPath(filePath);
+
+ // Ensure directory exists
+ const vfsDirPath = this.toVFSPath(this.dirname(filePath));
+ this.volume.mkdirSync(vfsDirPath, { recursive: true });
+
+ this.volume.writeFileSync(vfsPath, Buffer.from(content));
+
+ if (this.options.enablePersistence) {
+ await this.saveToStorage();
+ }
+
+ return true;
+ } catch (error) {
+ console.error(`Error writing binary file ${filePath}:`, error);
+ return false;
+ }
+ }
+
+ // Directory operations
+ async mkdir(dirPath: string, recursive: boolean = false): Promise {
+ try {
+ const vfsPath = this.toVFSPath(dirPath);
+ this.volume.mkdirSync(vfsPath, { recursive });
+
+ if (this.options.enablePersistence) {
+ await this.saveToStorage();
+ }
+
+ return true;
+ } catch (error) {
+ console.error(`Error creating directory ${dirPath}:`, error);
+ return false;
+ }
+ }
+
+ async rmdir(dirPath: string, recursive: boolean = false): Promise {
+ try {
+ const vfsPath = this.toVFSPath(dirPath);
+
+ if (recursive) {
+ this.volume.rmSync(vfsPath, { recursive: true, force: true });
+ } else {
+ this.volume.rmdirSync(vfsPath);
+ }
+
+ if (this.options.enablePersistence) {
+ await this.saveToStorage();
+ }
+
+ return true;
+ } catch (error) {
+ console.error(`Error removing directory ${dirPath}:`, error);
+ return false;
+ }
+ }
+
+ async readdir(dirPath: string): Promise {
+ try {
+ const vfsPath = this.toVFSPath(dirPath);
+ const entries = this.volume.readdirSync(vfsPath) as string[];
+ return entries;
+ } catch (error) {
+ console.error(`Error reading directory ${dirPath}:`, error);
+ return [];
+ }
+ }
+
+ // File metadata
+ async stat(filePath: string): Promise {
+ try {
+ const vfsPath = this.toVFSPath(filePath);
+ const stats = this.volume.statSync(vfsPath);
+
+ return {
+ isFile: () => stats.isFile(),
+ isDirectory: () => stats.isDirectory(),
+ size: stats.size,
+ mtime: stats.mtime,
+ ctime: stats.ctime,
+ };
+ } catch (error) {
+ console.error(`Error getting stats for ${filePath}:`, error);
+ return null;
+ }
+ }
+
+ // Utility operations
+ listAllFiles(): string[] {
+ const files: string[] = [];
+
+ const walkDir = (vfsPath: string, sandboxPath: string = '') => {
+ try {
+ const entries = this.volume.readdirSync(vfsPath) as string[];
+
+ for (const entry of entries) {
+ const fullVfsPath = vfsPath === '/' ? `/${entry}` : `${vfsPath}/${entry}`;
+ const fullSandboxPath = sandboxPath ? `${sandboxPath}/${entry}` : entry;
+ const stats = this.volume.statSync(fullVfsPath);
+
+ if (stats.isFile()) {
+ files.push(fullSandboxPath);
+ } else if (stats.isDirectory()) {
+ walkDir(fullVfsPath, fullSandboxPath);
+ }
+ }
+ } catch (error) {
+ console.error(`Error walking directory ${vfsPath}:`, error);
+ }
+ };
+
+ walkDir('/');
+ return files;
+ }
+
+ listBinaryFiles(dir: string = ''): string[] {
+ const allFiles = this.listAllFiles();
+
+ return allFiles.filter((file) => {
+ if (dir && !file.startsWith(dir)) {
+ return false;
+ }
+
+ const ext = file.toLowerCase().substring(file.lastIndexOf('.'));
+ return BINARY_EXTENSIONS.includes(ext);
+ });
+ }
+
+ async clear(): Promise {
+ try {
+ // Remove all files and directories except root
+ const entries = this.volume.readdirSync('/') as string[];
+
+ for (const entry of entries) {
+ const fullPath = this.join('/', entry);
+ await this.delete(fullPath, true);
+ }
+
+ if (this.options.enablePersistence) {
+ await this.clearStorage();
+ }
+ } catch (error) {
+ console.error('Error clearing file system:', error);
+ }
+ }
+
+ // Persistence methods
+ async saveToStorage(): Promise {
+ if (!this.options.enablePersistence) {
+ return;
+ }
+
+ try {
+ const snapshot = this.volume.toJSON();
+ await localforage.setItem(this.storageKey, snapshot);
+ } catch (error) {
+ console.error('Error saving to storage:', error);
+ }
+ }
+
+ async loadFromStorage(): Promise {
+ if (!this.options.enablePersistence) {
+ return;
+ }
+
+ try {
+ const snapshot = await localforage.getItem(this.storageKey);
+ if (snapshot) {
+ this.volume.fromJSON(snapshot as any);
+ }
+ } catch (error) {
+ console.error('Error loading from storage:', error);
+ }
+ }
+
+ private async clearStorage(): Promise {
+ try {
+ await localforage.removeItem(this.storageKey);
+ } catch (error) {
+ console.error('Error clearing storage:', error);
+ }
+ }
+
+ // Additional utility methods for compatibility with existing code
+ async updateCache(filePath: string, content: string): Promise {
+ await this.writeFile(filePath, content);
+ }
+
+ async updateBinaryCache(filePath: string, content: Uint8Array): Promise {
+ await this.writeBinaryFile(filePath, content);
+ }
+
+ has(filePath: string): boolean {
+ return this.volume.existsSync(this.toVFSPath(filePath));
+ }
+
+ hasBinary(filePath: string): boolean {
+ const vfsPath = this.toVFSPath(filePath);
+ if (!this.volume.existsSync(vfsPath)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ isBinaryFile(filePath: string): boolean {
+ const ext = filePath.toLowerCase().substring(filePath.lastIndexOf('.'));
+ return BINARY_EXTENSIONS.includes(ext);
+ }
+
+ // Batch operations for performance
+ async updateCacheBatch(entries: Array<{ path: string; content: string }>): Promise {
+ for (const { path, content } of entries) {
+ await this.writeFile(path, content);
+ }
+ }
+
+ async updateBinaryCacheBatch(
+ entries: Array<{ path: string; content: Uint8Array }>,
+ ): Promise {
+ for (const { path, content } of entries) {
+ await this.writeBinaryFile(path, content);
+ }
+ }
+
+ // Sync operations for compatibility
+ async syncFromRemote(filePath: string, remoteContent: string): Promise {
+ const currentContent = await this.readFile(filePath);
+ const contentChanged = currentContent !== remoteContent;
+
+ if (contentChanged) {
+ await this.writeFile(filePath, remoteContent);
+ }
+
+ return contentChanged;
+ }
+
+ // Track binary file without loading content (for lazy loading)
+ async trackBinaryFile(filePath: string): Promise {
+ if (!this.has(filePath)) {
+ // Create empty placeholder file
+ await this.writeBinaryFile(filePath, new Uint8Array(0));
+ }
+ }
+
+ // Check if binary file has actual content loaded
+ hasBinaryContent(filePath: string): boolean {
+ const vfsPath = this.toVFSPath(filePath);
+ if (!this.volume.existsSync(vfsPath)) {
+ return false;
+ }
+
+ try {
+ const stats = this.volume.statSync(vfsPath);
+ return stats.size > 0;
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/apps/web/client/src/server/api/routers/publish/manager.ts b/apps/web/client/src/server/api/routers/publish/manager.ts
index ed4fcc1c26..56bb177caa 100644
--- a/apps/web/client/src/server/api/routers/publish/manager.ts
+++ b/apps/web/client/src/server/api/routers/publish/manager.ts
@@ -39,6 +39,10 @@ export class PublishManager {
await this.session.fs.remove(path, recursive);
return true;
},
+ rename: async (oldPath: string, newPath: string) => {
+ await this.session.fs.rename(oldPath, newPath);
+ return true;
+ },
};
}
diff --git a/apps/web/client/test/sandbox/file-sync.test.ts b/apps/web/client/test/sandbox/file-sync.test.ts
index 600a9defce..1755f5a44e 100644
--- a/apps/web/client/test/sandbox/file-sync.test.ts
+++ b/apps/web/client/test/sandbox/file-sync.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
-import { FileSyncManager } from '../../src/components/store/editor/sandbox/file-sync';
+import { VFSSyncManager } from '../../src/components/store/editor/sandbox/vfs-sync-manager';
mock.module('localforage', () => ({
getItem: mock(async () => null),
@@ -7,8 +7,8 @@ mock.module('localforage', () => ({
removeItem: mock(async () => undefined),
}));
-describe('FileSyncManager', async () => {
- let fileSyncManager: FileSyncManager;
+describe('VFSSyncManager', async () => {
+ let vfsSyncManager: VFSSyncManager;
let mockReadFile: any;
let mockWriteFile: any;
@@ -29,34 +29,34 @@ describe('FileSyncManager', async () => {
return true;
});
- // Create FileSyncManager instance
- fileSyncManager = new FileSyncManager();
+ // Create VFSSyncManager instance
+ vfsSyncManager = new VFSSyncManager();
// Wait for initialization to complete
await new Promise((resolve) => setTimeout(resolve, 10));
});
afterEach(async () => {
- await fileSyncManager.clear();
+ await vfsSyncManager.clear();
});
test('should check if file exists in cache', async () => {
// Initially cache is empty
- expect(fileSyncManager.has('file1.tsx')).toBe(false);
+ expect(vfsSyncManager.has('file1.tsx')).toBe(false);
// Add a file to cache
- await fileSyncManager.updateCache('file1.tsx', 'Test Component
');
+ await vfsSyncManager.updateCache('file1.tsx', 'Test Component
');
// Now it should exist
- expect(fileSyncManager.has('file1.tsx')).toBe(true);
+ expect(vfsSyncManager.has('file1.tsx')).toBe(true);
});
test('should read from cache if available', async () => {
// Seed the cache
- await fileSyncManager.updateCache('file1.tsx', 'Cached Content
');
+ await vfsSyncManager.updateCache('file1.tsx', 'Cached Content
');
// Read should return cached content without calling readFile
- const content = await fileSyncManager.readOrFetch('file1.tsx', mockReadFile);
+ const content = await vfsSyncManager.readOrFetch('file1.tsx', mockReadFile);
expect(content).toBe('Cached Content
');
expect(mockReadFile).not.toHaveBeenCalled();
@@ -64,7 +64,7 @@ describe('FileSyncManager', async () => {
test('should fetch from filesystem if not in cache', async () => {
// Read file that is not in cache
- const content = await fileSyncManager.readOrFetch('file1.tsx', mockReadFile);
+ const content = await vfsSyncManager.readOrFetch('file1.tsx', mockReadFile);
expect(content).toBe('Test Component
');
expect(mockReadFile).toHaveBeenCalledWith('file1.tsx');
@@ -73,24 +73,24 @@ describe('FileSyncManager', async () => {
test('should write file to filesystem and update cache', async () => {
const newContent = 'New Content
';
- await fileSyncManager.write('file1.tsx', newContent, mockWriteFile);
+ await vfsSyncManager.write('file1.tsx', newContent, mockWriteFile);
// Verify file was written to filesystem
expect(mockWriteFile).toHaveBeenCalledWith('file1.tsx', newContent);
// Verify cache was updated
- expect(fileSyncManager.has('file1.tsx')).toBe(true);
- expect(await fileSyncManager.readOrFetch('file1.tsx', mockReadFile)).toBe(newContent);
+ expect(vfsSyncManager.has('file1.tsx')).toBe(true);
+ expect(await vfsSyncManager.readOrFetch('file1.tsx', mockReadFile)).toBe(newContent);
});
test('should update cache without writing to filesystem', async () => {
const content = 'Updated Cache
';
- await fileSyncManager.updateCache('file1.tsx', content);
+ await vfsSyncManager.updateCache('file1.tsx', content);
// Verify cache was updated
- expect(fileSyncManager.has('file1.tsx')).toBe(true);
- expect(await fileSyncManager.readOrFetch('file1.tsx', mockReadFile)).toBe(content);
+ expect(vfsSyncManager.has('file1.tsx')).toBe(true);
+ expect(await vfsSyncManager.readOrFetch('file1.tsx', mockReadFile)).toBe(content);
// Verify filesystem was not written to
expect(mockWriteFile).not.toHaveBeenCalled();
@@ -98,27 +98,28 @@ describe('FileSyncManager', async () => {
test('should delete file from cache', async () => {
// Seed the cache
- await fileSyncManager.updateCache('file1.tsx', 'Test Content
');
+ await vfsSyncManager.updateCache('file1.tsx', 'Test Content
');
// Verify file is in cache
- expect(fileSyncManager.has('file1.tsx')).toBe(true);
+ expect(vfsSyncManager.has('file1.tsx')).toBe(true);
// Delete file from cache
- await fileSyncManager.delete('file1.tsx');
+ await vfsSyncManager.delete('file1.tsx');
// Verify file is no longer in cache
- expect(fileSyncManager.has('file1.tsx')).toBe(false);
+ expect(vfsSyncManager.has('file1.tsx')).toBe(false);
});
test('should list all files in cache', async () => {
// Seed the cache with multiple files
- await fileSyncManager.updateCache('file1.tsx', 'Content 1
');
- await fileSyncManager.updateCache('file2.tsx', 'Content 2
');
- await fileSyncManager.updateCache('file3.tsx', 'Content 3
');
+ await vfsSyncManager.updateCache('file1.tsx', 'Content 1
');
+ await vfsSyncManager.updateCache('file2.tsx', 'Content 2
');
+ await vfsSyncManager.updateCache('file3.tsx', 'Content 3
');
// Get list of files
- const files = fileSyncManager.listAllFiles();
+ const files = vfsSyncManager.listAllFiles();
+ console.log(files);
// Verify all files are listed
expect(files).toContain('file1.tsx');
expect(files).toContain('file2.tsx');
@@ -128,16 +129,16 @@ describe('FileSyncManager', async () => {
test('should clear all files from cache', async () => {
// Seed the cache with multiple files
- await fileSyncManager.updateCache('file1.tsx', 'Content 1
');
- await fileSyncManager.updateCache('file2.tsx', 'Content 2
');
+ await vfsSyncManager.updateCache('file1.tsx', 'Content 1
');
+ await vfsSyncManager.updateCache('file2.tsx', 'Content 2
');
// Verify files are in cache
- expect(fileSyncManager.listAllFiles().length).toBe(2);
+ expect(vfsSyncManager.listAllFiles().length).toBe(2);
// Clear cache
- await fileSyncManager.clear();
+ await vfsSyncManager.clear();
// Verify cache is empty
- expect(fileSyncManager.listAllFiles().length).toBe(0);
+ expect(vfsSyncManager.listAllFiles().length).toBe(0);
});
});
diff --git a/apps/web/client/test/sandbox/sandbox.test.ts b/apps/web/client/test/sandbox/sandbox.test.ts
index 5731f3b745..5d95dd9f2a 100644
--- a/apps/web/client/test/sandbox/sandbox.test.ts
+++ b/apps/web/client/test/sandbox/sandbox.test.ts
@@ -1,5 +1,6 @@
-import { IGNORED_DIRECTORIES, JSX_FILE_EXTENSIONS } from '@onlook/constants';
+import { EXCLUDED_SYNC_DIRECTORIES, JSX_FILE_EXTENSIONS } from '@onlook/constants';
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import { runInAction } from 'mobx';
// Setup mocks before imports
// Mock localforage before importing anything that uses it
@@ -7,13 +8,23 @@ const mockGetItem = mock<(key: string) => Promise>(async () => null);
const mockSetItem = mock<(key: string, value: any) => Promise>(async () => undefined);
const mockRemoveItem = mock<(key: string) => Promise>(async () => undefined);
-// Mock FileSyncManager before importing SandboxManager or any code that uses it
+// Mock VFSSyncManager before importing SandboxManager or any code that uses it
const mockReadOrFetch = mock(async (path: string) => 'Mocked Content
');
const mockWrite = mock(async (path: string, content: string) => true);
const mockClear = mock(async () => undefined);
+const mockHas = mock((path: string) => false);
+const mockListAllFiles = mock(() => []);
+const mockUpdateCache = mock(async (path: string, content: string) => undefined);
import { SandboxManager } from '../../src/components/store/editor/sandbox';
+// Mock EditorEngine
+const mockEditorEngine = {
+ error: {
+ addError: mock(() => { }),
+ },
+};
+
describe('SandboxManager', () => {
let sandboxManager: SandboxManager;
let mockSession: any;
@@ -31,11 +42,36 @@ describe('SandboxManager', () => {
removeItem: mockRemoveItem,
}));
- mock.module('../src/components/store/editor/sandbox/file-sync', () => ({
- FileSyncManager: class {
+ mock.module('../src/components/store/editor/sandbox/vfs-sync-manager', () => ({
+ VFSSyncManager: class {
readOrFetch = mockReadOrFetch;
write = mockWrite;
clear = mockClear;
+ has = mockHas;
+ listAllFiles = mockListAllFiles;
+ updateCache = mockUpdateCache;
+ writeBinary = mock(async () => true);
+ readOrFetchBinaryFile = mock(async () => new Uint8Array());
+ trackBinaryFile = mock(async () => undefined);
+ hasBinary = mock(() => false);
+ listBinaryFiles = mock(() => []);
+ updateBinaryCache = mock(async () => undefined);
+ delete = mock(async () => undefined);
+ syncFromRemote = mock(async () => false);
+ updateCacheBatch = mock(async () => undefined);
+ updateBinaryCacheBatch = mock(async () => undefined);
+ trackBinaryFilesBatch = mock(async () => undefined);
+ readOrFetchBatch = mock(async () => ({}));
+ mkdir = mock(async () => true);
+ readdir = mock(async () => []);
+ stat = mock(async () => null);
+ fileExists = mock(async () => false);
+ copy = mock(async () => true);
+ normalizePath = mock((path: string) => path);
+ dirname = mock((path: string) => '/');
+ basename = mock((path: string) => path);
+ join = mock((...paths: string[]) => paths.join('/'));
+ getVFS = mock(() => ({}));
},
}));
@@ -61,12 +97,36 @@ describe('SandboxManager', () => {
{ name: 'utils.ts', type: 'file' },
];
- // Create a sandbox with a mock FileSyncManager
+ // Create a sandbox with a mock VFSSyncManager
mockFileSync = {
readOrFetch: mock(async () => 'Mocked Content
'),
write: mock(async () => true),
clear: mock(async () => undefined),
updateCache: mock(async () => undefined),
+ has: mock(() => false),
+ listAllFiles: mock(() => []),
+ writeBinary: mock(async () => true),
+ readOrFetchBinaryFile: mock(async () => new Uint8Array()),
+ trackBinaryFile: mock(async () => undefined),
+ hasBinary: mock(() => false),
+ listBinaryFiles: mock(() => []),
+ updateBinaryCache: mock(async () => undefined),
+ delete: mock(async () => undefined),
+ syncFromRemote: mock(async () => false),
+ updateCacheBatch: mock(async () => undefined),
+ updateBinaryCacheBatch: mock(async () => undefined),
+ trackBinaryFilesBatch: mock(async () => undefined),
+ readOrFetchBatch: mock(async () => ({})),
+ mkdir: mock(async () => true),
+ readdir: mock(async () => []),
+ stat: mock(async () => null),
+ fileExists: mock(async () => false),
+ copy: mock(async () => true),
+ normalizePath: mock((path: string) => path),
+ dirname: mock((path: string) => '/'),
+ basename: mock((path: string) => path),
+ join: mock((...paths: string[]) => paths.join('/')),
+ getVFS: mock(() => ({})),
};
mockWatcher = {
@@ -98,9 +158,10 @@ describe('SandboxManager', () => {
}),
watch: mock(async () => mockWatcher),
},
+ disconnect: mock(async () => undefined),
};
- sandboxManager = new SandboxManager();
+ sandboxManager = new SandboxManager(mockEditorEngine as any);
});
afterEach(() => {
@@ -129,12 +190,15 @@ describe('SandboxManager', () => {
},
};
- const testManager = new SandboxManager();
- testManager.init(testMockSession);
+ const testManager = new SandboxManager(mockEditorEngine as any);
+ // Mock the session directly using runInAction to avoid MobX strict mode issues
+ runInAction(() => {
+ testManager.session.session = testMockSession;
+ });
const files = await testManager.listFilesRecursively(
'./',
- IGNORED_DIRECTORIES,
+ EXCLUDED_SYNC_DIRECTORIES,
JSX_FILE_EXTENSIONS,
);
@@ -167,24 +231,27 @@ describe('SandboxManager', () => {
expect(result).toBe(true);
expect(mockFileSync.write).toHaveBeenCalledWith(
'file1.tsx',
- 'Modified Component
',
+ 'Modified Component
;\n',
expect.any(Function),
);
});
- test('should read from localforage cache when reading files multiple times', async () => {
- // First read gets from filesystem and caches
- await sandboxManager.readFile('file1.tsx');
+ test('should read from VFS cache when reading files multiple times', async () => {
+ // Set up the session for the sandbox manager using runInAction
+ runInAction(() => {
+ sandboxManager.session.session = mockSession;
+ });
- // Clear the mock to reset call count
- mockSession.fs.readTextFile.mockClear();
+ // First read gets from filesystem and caches in VFS
+ const content1 = await sandboxManager.readFile('file1.tsx');
+ expect(content1).toBe('Test Component
');
- // Second read should use cache
+ // Second read should return the same content (VFS handles caching internally)
const content2 = await sandboxManager.readFile('file1.tsx');
expect(content2).toBe('Test Component
');
- // Filesystem should not be accessed for the second read
- expect(mockSession.fs.readTextFile).not.toHaveBeenCalled();
+ // Both reads should return the same content, demonstrating caching works
+ expect(content1).toBe(content2);
});
test('readRemoteFile and writeRemoteFile should handle session errors', async () => {
@@ -200,10 +267,14 @@ describe('SandboxManager', () => {
readdir: mock(async () => []),
watch: mock(async () => mockWatcher),
},
+ disconnect: mock(async () => undefined),
};
- const errorManager = new SandboxManager();
- errorManager.init(errorSession);
+ const errorManager = new SandboxManager(mockEditorEngine as any);
+ // Mock the session directly using runInAction
+ runInAction(() => {
+ errorManager.session.session = errorSession;
+ });
// We need to create a custom fileSync mock that returns null for this test
const errorFileSync = {
@@ -226,7 +297,7 @@ describe('SandboxManager', () => {
expect(writeResult).toBe(false);
expect(errorFileSync.write).toHaveBeenCalledWith(
'error.tsx',
- 'Content
',
+ 'Content
;\n',
expect.any(Function),
);
});
@@ -345,7 +416,7 @@ describe('SandboxManager', () => {
await sandboxManager.writeFile(variant, 'test');
expect(mockFileSync.write).toHaveBeenCalledWith(
normalizedPath,
- 'test',
+ 'test;\n',
expect.any(Function),
);
}
diff --git a/apps/web/client/test/sandbox/vfs-sync-manager.test.ts b/apps/web/client/test/sandbox/vfs-sync-manager.test.ts
new file mode 100644
index 0000000000..c2e4525b4e
--- /dev/null
+++ b/apps/web/client/test/sandbox/vfs-sync-manager.test.ts
@@ -0,0 +1,259 @@
+import { VFSSyncManager } from '../../src/components/store/editor/sandbox/vfs-sync-manager';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+
+mock.module('localforage', () => ({
+ getItem: mock(async () => null),
+ setItem: mock(async () => undefined),
+ removeItem: mock(async () => undefined),
+}));
+
+describe('VFSSyncManager', () => {
+ let syncManager: VFSSyncManager;
+ let mockReadFile: any;
+ let mockWriteFile: any;
+
+ beforeEach(() => {
+ // Create mock file operations
+ mockReadFile = mock(async (path: string) => {
+ if (path === '/file1.tsx') {
+ return 'Remote Content 1
';
+ } else if (path === '/file2.tsx') {
+ return 'Remote Content 2
';
+ }
+ return null;
+ });
+
+ mockWriteFile = mock(async (path: string, content: string) => {
+ return true;
+ });
+
+ syncManager = new VFSSyncManager();
+ });
+
+ afterEach(async () => {
+ await syncManager.clear();
+ });
+
+ describe('Basic Sync Operations', () => {
+ test('should read from VFS if file exists', async () => {
+ // Pre-populate VFS
+ await syncManager.updateCache('/file1.tsx', 'Cached Content
');
+
+ const content = await syncManager.readOrFetch('/file1.tsx', mockReadFile);
+
+ expect(content).toBe('Cached Content
');
+ expect(mockReadFile).not.toHaveBeenCalled();
+ });
+
+ test('should fetch from remote if file not in VFS', async () => {
+ const content = await syncManager.readOrFetch('/file1.tsx', mockReadFile);
+
+ expect(content).toBe('Remote Content 1
');
+ expect(mockReadFile).toHaveBeenCalledWith('/file1.tsx');
+
+ // Should now be cached
+ expect(syncManager.has('/file1.tsx')).toBe(true);
+ });
+
+ test('should write to VFS and remote', async () => {
+ const newContent = 'New Content
';
+
+ const success = await syncManager.write('/file1.tsx', newContent, mockWriteFile);
+
+ expect(success).toBe(true);
+ expect(mockWriteFile).toHaveBeenCalledWith('/file1.tsx', newContent);
+
+ // Should be cached in VFS
+ const cachedContent = await syncManager.readOrFetch('/file1.tsx', mockReadFile);
+ expect(cachedContent).toBe(newContent);
+ });
+
+ test('should handle write failures', async () => {
+ const failingWriteFile = mock(async () => false);
+
+ // Suppress console.error for this test since we expect an error
+ const originalConsoleError = console.error;
+ console.error = () => {};
+
+ const success = await syncManager.write('/file1.tsx', 'content', failingWriteFile);
+
+ // Restore console.error
+ console.error = originalConsoleError;
+
+ expect(success).toBe(false);
+ expect(syncManager.has('/file1.tsx')).toBe(false);
+ });
+ });
+
+ describe('Binary File Operations', () => {
+ test('should handle binary files', async () => {
+ const binaryData = new Uint8Array([1, 2, 3, 4, 5]);
+ const mockBinaryRead = mock(async () => binaryData);
+ const mockBinaryWrite = mock(async () => true);
+
+ // Test read or fetch
+ const result = await syncManager.readOrFetchBinaryFile('/image.png', mockBinaryRead);
+ expect(result).toEqual(binaryData);
+
+ // Test write
+ const success = await syncManager.writeBinary(
+ '/image2.png',
+ binaryData,
+ mockBinaryWrite,
+ );
+ expect(success).toBe(true);
+ expect(mockBinaryWrite).toHaveBeenCalledWith('/image2.png', binaryData);
+ });
+
+ test('should track binary files without content', async () => {
+ await syncManager.trackBinaryFile('/placeholder.png');
+
+ expect(syncManager.hasBinary('/placeholder.png')).toBe(true);
+ expect(syncManager.hasBinaryContent('/placeholder.png')).toBe(false);
+ });
+ });
+
+ describe('Batch Operations', () => {
+ test('should handle batch reads', async () => {
+ const filePaths = ['/file1.tsx', '/file2.tsx'];
+
+ const results = await syncManager.readOrFetchBatch(filePaths, mockReadFile);
+
+ expect(results['/file1.tsx']).toBe('Remote Content 1
');
+ expect(results['/file2.tsx']).toBe('Remote Content 2
');
+ expect(mockReadFile).toHaveBeenCalledTimes(2);
+ });
+
+ test('should handle batch binary tracking', async () => {
+ const filePaths = ['/image1.png', '/image2.jpg', '/image3.gif'];
+
+ await syncManager.trackBinaryFilesBatch(filePaths);
+
+ for (const path of filePaths) {
+ expect(syncManager.hasBinary(path)).toBe(true);
+ }
+ });
+
+ test('should handle batch cache updates', async () => {
+ const entries = [
+ { path: '/file1.tsx', content: 'Content 1' },
+ { path: '/file2.tsx', content: 'Content 2' },
+ ];
+
+ await syncManager.updateCacheBatch(entries);
+
+ for (const entry of entries) {
+ expect(await syncManager.readOrFetch(entry.path, mockReadFile)).toBe(entry.content);
+ }
+ });
+ });
+
+ describe('Directory Operations', () => {
+ test('should create directories', async () => {
+ const success = await syncManager.mkdir('/test-dir');
+ expect(success).toBe(true);
+
+ const stats = await syncManager.stat('/test-dir');
+ expect(stats?.isDirectory()).toBe(true);
+ });
+
+ test('should list directory contents', async () => {
+ await syncManager.updateCache('/dir/file1.txt', 'content1');
+ await syncManager.updateCache('/dir/file2.txt', 'content2');
+
+ const entries = await syncManager.readdir('/dir');
+ expect(entries).toContain('file1.txt');
+ expect(entries).toContain('file2.txt');
+ });
+ });
+
+ describe('File Listing', () => {
+ test('should list all files', async () => {
+ await syncManager.updateCache('file1.txt', 'content1');
+ await syncManager.updateCache('dir/file2.txt', 'content2');
+
+ const allFiles = syncManager.listAllFiles();
+ expect(allFiles).toContain('file1.txt');
+ expect(allFiles).toContain('dir/file2.txt');
+ });
+
+ test('should list binary files in directory', async () => {
+ await syncManager.trackBinaryFile('images/photo.jpg');
+ await syncManager.trackBinaryFile('images/icon.png');
+ await syncManager.updateCache('images/readme.txt', 'text file');
+
+ const binaryFiles = syncManager.listBinaryFiles('images');
+ expect(binaryFiles).toContain('images/photo.jpg');
+ expect(binaryFiles).toContain('images/icon.png');
+ expect(binaryFiles).not.toContain('images/readme.txt');
+ });
+ });
+
+ describe('Sync Operations', () => {
+ test('should sync from remote and detect changes', async () => {
+ await syncManager.updateCache('/file1.tsx', 'old content');
+
+ const changed = await syncManager.syncFromRemote('/file1.tsx', 'new content');
+ expect(changed).toBe(true);
+
+ const content = await syncManager.readOrFetch('/file1.tsx', mockReadFile);
+ expect(content).toBe('new content');
+ });
+
+ test('should detect no changes when content is same', async () => {
+ const content = 'same content';
+ await syncManager.updateCache('/file1.tsx', content);
+
+ const changed = await syncManager.syncFromRemote('/file1.tsx', content);
+ expect(changed).toBe(false);
+ });
+ });
+
+ describe('Path Utilities', () => {
+ test('should normalize paths', () => {
+ expect(syncManager.normalizePath('test.txt')).toBe('test.txt');
+ expect(syncManager.normalizePath('/project/sandbox/test.txt')).toBe('test.txt');
+ });
+
+ test('should provide path utilities', () => {
+ expect(syncManager.dirname('dir/file.txt')).toBe('dir');
+ expect(syncManager.basename('dir/file.txt')).toBe('file.txt');
+ expect(syncManager.join('dir', 'file.txt')).toBe('dir/file.txt');
+ });
+ });
+
+ describe('File Operations', () => {
+ test('should check file existence', async () => {
+ expect(await syncManager.fileExists('/nonexistent.txt')).toBe(false);
+
+ await syncManager.updateCache('/exists.txt', 'content');
+ expect(await syncManager.fileExists('/exists.txt')).toBe(true);
+ });
+
+ test('should copy files', async () => {
+ await syncManager.updateCache('/source.txt', 'original content');
+
+ const success = await syncManager.copy('/source.txt', '/destination.txt');
+ expect(success).toBe(true);
+
+ const content = await syncManager.readOrFetch('/destination.txt', mockReadFile);
+ expect(content).toBe('original content');
+ });
+
+ test('should delete files', async () => {
+ await syncManager.updateCache('/delete-me.txt', 'content');
+ expect(syncManager.has('/delete-me.txt')).toBe(true);
+
+ await syncManager.delete('/delete-me.txt');
+ expect(syncManager.has('/delete-me.txt')).toBe(false);
+ });
+ });
+
+ describe('VFS Access', () => {
+ test('should provide access to underlying VFS', () => {
+ const vfs = syncManager.getVFS();
+ expect(vfs).toBeDefined();
+ expect(typeof vfs.writeFile).toBe('function');
+ });
+ });
+});
diff --git a/apps/web/client/test/sandbox/virtual-fs.test.ts b/apps/web/client/test/sandbox/virtual-fs.test.ts
new file mode 100644
index 0000000000..db45045b7d
--- /dev/null
+++ b/apps/web/client/test/sandbox/virtual-fs.test.ts
@@ -0,0 +1,227 @@
+import { VirtualFileSystem } from '../../src/components/store/editor/sandbox/virtual-fs';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+
+mock.module('localforage', () => ({
+ getItem: mock(async () => null),
+ setItem: mock(async () => undefined),
+ removeItem: mock(async () => undefined),
+}));
+
+describe('VirtualFileSystem', () => {
+ let vfs: VirtualFileSystem;
+
+ beforeEach(() => {
+ vfs = new VirtualFileSystem({
+ persistenceKey: 'test-vfs',
+ enablePersistence: false, // Disable persistence for tests
+ });
+ });
+
+ afterEach(async () => {
+ await vfs.clear();
+ });
+
+ describe('Basic File Operations', () => {
+ test('should write and read text files', async () => {
+ const content = 'Hello World
';
+ const success = await vfs.writeFile('/test.tsx', content);
+
+ expect(success).toBe(true);
+
+ const readContent = await vfs.readFile('/test.tsx');
+ expect(readContent).toBe(content);
+ });
+
+ test('should check file existence', async () => {
+ expect(await vfs.fileExists('/nonexistent.tsx')).toBe(false);
+
+ await vfs.writeFile('/exists.tsx', 'content');
+ expect(await vfs.fileExists('/exists.tsx')).toBe(true);
+ });
+
+ test('should delete files', async () => {
+ await vfs.writeFile('/delete-me.tsx', 'content');
+ expect(await vfs.fileExists('/delete-me.tsx')).toBe(true);
+
+ const success = await vfs.delete('/delete-me.tsx');
+ expect(success).toBe(true);
+ expect(await vfs.fileExists('/delete-me.tsx')).toBe(false);
+ });
+
+ test('should handle binary files', async () => {
+ const binaryData = new Uint8Array([1, 2, 3, 4, 5]);
+ const success = await vfs.writeBinaryFile('/test.bin', binaryData);
+
+ expect(success).toBe(true);
+
+ const readData = await vfs.readBinaryFile('/test.bin');
+ expect(readData).toEqual(binaryData);
+ });
+ });
+
+ describe('Directory Operations', () => {
+ test('should create directories', async () => {
+ const success = await vfs.mkdir('/test-dir', true);
+ expect(success).toBe(true);
+
+ const stats = await vfs.stat('/test-dir');
+ expect(stats?.isDirectory()).toBe(true);
+ });
+
+ test('should create nested directories', async () => {
+ await vfs.writeFile('/deep/nested/file.txt', 'content');
+
+ expect(await vfs.fileExists('/deep/nested/file.txt')).toBe(true);
+
+ const stats = await vfs.stat('/deep');
+ expect(stats?.isDirectory()).toBe(true);
+ });
+
+ test('should list directory contents', async () => {
+ await vfs.writeFile('/dir/file1.txt', 'content1');
+ await vfs.writeFile('/dir/file2.txt', 'content2');
+ await vfs.mkdir('/dir/subdir');
+
+ const entries = await vfs.readdir('/dir');
+ expect(entries).toContain('file1.txt');
+ expect(entries).toContain('file2.txt');
+ expect(entries).toContain('subdir');
+ });
+
+ test('should remove directories', async () => {
+ await vfs.mkdir('/remove-me');
+ await vfs.writeFile('/remove-me/file.txt', 'content');
+
+ const success = await vfs.rmdir('/remove-me', true);
+ expect(success).toBe(true);
+ expect(await vfs.fileExists('/remove-me')).toBe(false);
+ });
+ });
+
+ describe('Path Utilities', () => {
+ test('should normalize paths', () => {
+ expect(vfs.normalizePath('test.txt')).toBe('test.txt');
+ expect(vfs.normalizePath('/project/sandbox/test.txt')).toBe('test.txt');
+ expect(vfs.normalizePath('dir\\file.txt')).toBe('dir/file.txt');
+ expect(vfs.normalizePath('/project/sandbox/dir//file.txt')).toBe('dir/file.txt');
+ });
+
+ test('should get directory name', () => {
+ expect(vfs.dirname('dir/file.txt')).toBe('dir');
+ expect(vfs.dirname('file.txt')).toBe('.'); // Returns '.' to match files utility behavior
+ expect(vfs.dirname('deep/nested/file.txt')).toBe('deep/nested');
+ });
+
+ test('should get base name', () => {
+ expect(vfs.basename('dir/file.txt')).toBe('file.txt');
+ expect(vfs.basename('file.txt')).toBe('file.txt');
+ expect(vfs.basename('deep/nested/file.txt')).toBe('file.txt');
+ });
+
+ test('should join paths', () => {
+ expect(vfs.join('dir', 'file.txt')).toBe('dir/file.txt');
+ expect(vfs.join('dir', 'subdir', 'file.txt')).toBe('dir/subdir/file.txt');
+ });
+ });
+
+ describe('File Listing', () => {
+ test('should list all files', async () => {
+ await vfs.writeFile('file1.txt', 'content1');
+ await vfs.writeFile('dir/file2.txt', 'content2');
+ await vfs.writeFile('dir/subdir/file3.txt', 'content3');
+
+ const allFiles = vfs.listAllFiles();
+ expect(allFiles).toContain('file1.txt');
+ expect(allFiles).toContain('dir/file2.txt');
+ expect(allFiles).toContain('dir/subdir/file3.txt');
+ expect(allFiles.length).toBe(3);
+ });
+
+ test('should identify binary files by extension', async () => {
+ await vfs.writeBinaryFile('image.png', new Uint8Array([1, 2, 3]));
+ await vfs.writeFile('text.txt', 'content');
+ await vfs.writeBinaryFile('archive.zip', new Uint8Array([4, 5, 6]));
+
+ const binaryFiles = vfs.listBinaryFiles();
+ expect(binaryFiles).toContain('image.png');
+ expect(binaryFiles).toContain('archive.zip');
+ expect(binaryFiles).not.toContain('text.txt');
+ });
+ });
+
+ describe('File Copy Operations', () => {
+ test('should copy files', async () => {
+ await vfs.writeFile('/source.txt', 'original content');
+
+ const success = await vfs.copy('/source.txt', '/destination.txt');
+ expect(success).toBe(true);
+
+ const content = await vfs.readFile('/destination.txt');
+ expect(content).toBe('original content');
+ });
+
+ test('should copy directories recursively', async () => {
+ await vfs.writeFile('/source-dir/file1.txt', 'content1');
+ await vfs.writeFile('/source-dir/subdir/file2.txt', 'content2');
+
+ const success = await vfs.copy('/source-dir', '/dest-dir', true);
+ expect(success).toBe(true);
+
+ expect(await vfs.readFile('/dest-dir/file1.txt')).toBe('content1');
+ expect(await vfs.readFile('/dest-dir/subdir/file2.txt')).toBe('content2');
+ });
+ });
+
+ describe('File Statistics', () => {
+ test('should provide file stats', async () => {
+ await vfs.writeFile('/test.txt', 'content');
+
+ const stats = await vfs.stat('/test.txt');
+ expect(stats).not.toBeNull();
+ expect(stats!.isFile()).toBe(true);
+ expect(stats!.isDirectory()).toBe(false);
+ expect(stats!.size).toBeGreaterThan(0);
+ });
+
+ test('should provide directory stats', async () => {
+ await vfs.mkdir('/test-dir');
+
+ const stats = await vfs.stat('/test-dir');
+ expect(stats).not.toBeNull();
+ expect(stats!.isFile()).toBe(false);
+ expect(stats!.isDirectory()).toBe(true);
+ });
+ });
+
+ describe('Compatibility Methods', () => {
+ test('should support has() method', () => {
+ expect(vfs.has('/nonexistent.txt')).toBe(false);
+ });
+
+ test('should support hasBinary() method', async () => {
+ await vfs.writeBinaryFile('/test.png', new Uint8Array([1, 2, 3]));
+ expect(vfs.hasBinary('/test.png')).toBe(true);
+ expect(vfs.hasBinary('/nonexistent.png')).toBe(false);
+ });
+
+ test('should support updateCache() method', async () => {
+ await vfs.updateCache('/test.txt', 'cached content');
+ const content = await vfs.readFile('/test.txt');
+ expect(content).toBe('cached content');
+ });
+
+ test('should support syncFromRemote() method', async () => {
+ await vfs.writeFile('/test.txt', 'old content');
+
+ const changed = await vfs.syncFromRemote('/test.txt', 'new content');
+ expect(changed).toBe(true);
+
+ const content = await vfs.readFile('/test.txt');
+ expect(content).toBe('new content');
+
+ // No change should return false
+ const unchanged = await vfs.syncFromRemote('/test.txt', 'new content');
+ expect(unchanged).toBe(false);
+ });
+ });
+});
diff --git a/bun.lock b/bun.lock
index c013e12207..3e4d099236 100644
--- a/bun.lock
+++ b/bun.lock
@@ -72,6 +72,7 @@
"freestyle-sandboxes": "^0.0.78",
"localforage": "^1.10.0",
"lucide-react": "^0.486.0",
+ "memfs": "^4.17.2",
"mobx-react-lite": "^4.1.0",
"motion": "^12.6.3",
"next": "^15.2.3",
@@ -218,7 +219,9 @@
"dependencies": {
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/anthropic": "^1.2.12",
+ "@ai-sdk/google": "^1.2.19",
"@ai-sdk/google-vertex": "2.2.24",
+ "@ai-sdk/openai": "^1.3.22",
"ai": "^4.3.10",
"fg": "^0.0.3",
"marked": "^15.0.7",
@@ -510,6 +513,8 @@
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@2.2.24", "", { "dependencies": { "@ai-sdk/anthropic": "1.2.12", "@ai-sdk/google": "1.2.19", "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "google-auth-library": "^9.15.0" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-zi1ZN6jQEBRke/WMbZv0YkeqQ3nOs8ihxjVh/8z1tUn+S1xgRaYXf4+r6+Izh2YqVHIMNwjhUYryQRBGq20cgQ=="],
+ "@ai-sdk/openai": ["@ai-sdk/openai@1.3.22", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw=="],
+
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
@@ -1102,6 +1107,12 @@
"@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
+ "@jsonjoy.com/base64": ["@jsonjoy.com/base64@1.1.2", "", { "peerDependencies": { "tslib": "2" } }, "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA=="],
+
+ "@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@1.2.0", "", { "dependencies": { "@jsonjoy.com/base64": "^1.1.1", "@jsonjoy.com/util": "^1.1.2", "hyperdyperid": "^1.2.0", "thingies": "^1.20.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA=="],
+
+ "@jsonjoy.com/util": ["@jsonjoy.com/util@1.6.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A=="],
+
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
"@lezer/common": ["@lezer/common@1.2.3", "", {}, "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA=="],
@@ -2882,6 +2893,8 @@
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
+ "hyperdyperid": ["hyperdyperid@1.2.0", "", {}, "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A=="],
+
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -3232,6 +3245,8 @@
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
+ "memfs": ["memfs@4.17.2", "", { "dependencies": { "@jsonjoy.com/json-pack": "^1.0.3", "@jsonjoy.com/util": "^1.3.0", "tree-dump": "^1.0.1", "tslib": "^2.0.0" } }, "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg=="],
+
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
@@ -4070,6 +4085,8 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
+ "thingies": ["thingies@1.21.0", "", { "peerDependencies": { "tslib": "^2" } }, "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g=="],
+
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
"throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="],
@@ -4096,6 +4113,8 @@
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
+ "tree-dump": ["tree-dump@1.0.3", "", { "peerDependencies": { "tslib": "2" } }, "sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg=="],
+
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
diff --git a/packages/utility/src/file.ts b/packages/utility/src/file.ts
index bd4912a8a5..9b64ba4376 100644
--- a/packages/utility/src/file.ts
+++ b/packages/utility/src/file.ts
@@ -25,6 +25,7 @@ export interface FileOperations {
recursive?: boolean,
overwrite?: boolean,
) => Promise;
+ rename: (oldPath: string, newPath: string) => Promise;
}
/**
@@ -110,3 +111,12 @@ export const convertToBase64 = (file: Uint8Array): string => {
.join(''),
);
};
+
+export const convertFromBase64 = (base64: string): Uint8Array => {
+ const binaryString = atob(base64);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ return bytes;
+};