import { v4 as uuidv4 } from 'uuid'; import JSZip from 'jszip'; import { logger } from '@/lib/utils'; import { Project, VirtualFile, FileTreeNode, getFileTypeFromPath, getSpecificMimeType, FILE_SIZE_LIMITS, } from './types'; import { saveManager } from './save-manager'; import { skillsService } from './skills'; import { StorageAdapter } from './adapters/types'; import { createClientAdapter } from './adapters/factory'; import { IndexedDBAdapter } from './adapters/indexeddb-adapter'; import { getRuntimeConfig } from '@/lib/runtimes/registry'; import { stripPreviewScripts } from '@/lib/preview/strip-preview-scripts'; import { replaceBlobUrlsWithPaths } from '@/lib/publishing/rewrite-asset-urls'; import { arrayBufferToBase64, base64ToArrayBuffer } from './binary-encoding'; /** * A VirtualFile prepared for JSON export. Binary content is base64-encoded and * flagged with `encoding: 'base64'`; text files keep their string content and * omit the flag. */ export interface ExportedFile extends Omit { content: string | ArrayBuffer; encoding?: 'base64'; } export class VirtualFileSystem { private adapter: StorageAdapter; private initialized = false; private initPromise: Promise | null = null; private transientFiles: Map = new Map(); private generatedFiles: Map = new Map(); private syncTimeouts: Map = new Map(); // Debounce sync calls constructor(adapter?: StorageAdapter) { this.adapter = adapter ?? createClientAdapter(); } async init(): Promise { if (!this.initialized) { if (!this.initPromise) { this.initPromise = (async () => { await this.adapter.init(); await this.mountTransientSkills(); this.initialized = true; })(); } await this.initPromise; } else { // Already initialized — re-check adapter connection (handles HMR / connection loss) await this.adapter.init(); } } /** * Get the underlying storage adapter for direct template/skill operations */ getStorageAdapter(): StorageAdapter { if (!this.initialized) { throw new Error('VirtualFileSystem not initialized. Call init() first.'); } return this.adapter; } /** * Get direct database access for checkpoint and conversation managers * Only works with IndexedDBAdapter */ getDatabase(): IDBDatabase { if (!(this.adapter instanceof IndexedDBAdapter)) { throw new Error('Direct database access only available with IndexedDBAdapter'); } return this.adapter.getDatabase(); } /** * Mount skills as transient files at /.skills/ */ private async mountTransientSkills(): Promise { try { const skills = await skillsService.getEnabledSkills(); for (const skill of skills) { const path = `/.skills/${skill.id}.md`; const transientFile: VirtualFile = { id: `transient-skill-${skill.id}`, projectId: 'transient', // Not associated with any project path, name: `${skill.id}.md`, type: 'text', content: skill.content, mimeType: 'text/markdown', size: new Blob([skill.content]).size, createdAt: skill.createdAt, updatedAt: skill.updatedAt, metadata: { isTransient: true, isBuiltIn: skill.isBuiltIn } }; this.transientFiles.set(path, transientFile); } logger.info(`[VFS] Mounted ${this.transientFiles.size} transient skill files`); } catch (error) { logger.error('[VFS] Failed to mount transient skills', error); } } /** * Check if a path is transient (managed in-memory, not persisted to adapter). * Only known transient namespaces: /.server/ (backend context) and /.skills/ (skills) */ private isTransientPath(path: string): boolean { return path.startsWith('/.server/') || path.startsWith('/.skills/'); } // --- Generated files (bundle.js, bundle.css) --- setGeneratedFile(path: string, content: string, mimeType: string): void { const now = new Date(); this.generatedFiles.set(path, { id: `generated-${path}`, projectId: 'generated', path, name: path.split('/').pop() || path, type: path.endsWith('.css') ? 'css' : 'js', content, mimeType, size: content.length, createdAt: now, updatedAt: now, metadata: { isGenerated: true }, }); } clearGeneratedFiles(): void { this.generatedFiles.clear(); } getGeneratedFiles(): VirtualFile[] { return Array.from(this.generatedFiles.values()); } isGeneratedPath(path: string): boolean { return this.generatedFiles.has(path); } /** * Reload transient skills (call after adding/updating/deleting skills) */ async reloadTransientSkills(): Promise { // Preserve server context files when reloading skills const serverContextFiles = new Map(); for (const [path, file] of this.transientFiles) { if (path.startsWith('/.server/')) { serverContextFiles.set(path, file); } } this.transientFiles.clear(); await this.mountTransientSkills(); // Restore server context files for (const [path, file] of serverContextFiles) { this.transientFiles.set(path, file); } if (typeof window !== 'undefined') { window.dispatchEvent(new Event('filesChanged')); } } // Track current server context (project-scoped features + optional deployment runtime) private serverContextProjectId: string | null = null; private runtimeDeploymentId: string | null = null; private serverContextMetadata: { projectId: string; runtimeDeploymentId?: string; hasDatabase: boolean; edgeFunctionCount: number; serverFunctionCount: number; secretCount: number; scheduledFunctionCount: number; } | null = null; /** * Get current server context project ID */ getServerContextProjectId(): string | null { return this.serverContextProjectId; } /** * Get runtime deployment ID (for sqlite3, logs) */ getRuntimeDeploymentId(): string | null { return this.runtimeDeploymentId; } /** * Check if server context is mounted */ hasServerContext(): boolean { return this.serverContextProjectId !== null; } /** * Get server context metadata */ getServerContextMetadata(): { projectId: string; runtimeDeploymentId?: string; hasDatabase: boolean; edgeFunctionCount: number; serverFunctionCount: number; secretCount: number; scheduledFunctionCount: number; } | null { return this.serverContextMetadata; } /** * Mount project backend context * Reads backend features from the project's IndexedDB/SQLite stores and creates transient files. * This is auto-mounted when a project opens in the workspace. */ async mountProjectBackendContext(projectId: string): Promise { // Only works in Server Mode — Backend features exist in IndexedDB in both modes, // but the /.server/ context is only mounted in Server Mode where deployments can use them if (process.env.NEXT_PUBLIC_SERVER_MODE !== 'true') { return; } try { // Clear existing server context this.unmountBackendContext(); const { generateEdgeFunctionFile, generateServerFunctionFile, generateSecretFile, generateScheduledFunctionFile, } = await import('./server-context'); // Read backend features from the adapter (IndexedDB on client, SQLite on server) const adapter = this.adapter; const edgeFunctions = adapter.listEdgeFunctions ? await adapter.listEdgeFunctions(projectId) : []; const serverFunctions = adapter.listServerFunctions ? await adapter.listServerFunctions(projectId) : []; const secrets = adapter.listSecrets ? await adapter.listSecrets(projectId) : []; const scheduledFunctions = adapter.listScheduledFunctions ? await adapter.listScheduledFunctions(projectId) : []; // Mount edge functions for (const fn of edgeFunctions) { this.mountTransientFile(`/.server/edge-functions/${fn.name}.json`, generateEdgeFunctionFile(fn), false); } // Mount server functions for (const fn of serverFunctions) { this.mountTransientFile(`/.server/server-functions/${fn.name}.json`, generateServerFunctionFile(fn), false); } // Mount secrets for (const secret of secrets) { this.mountTransientFile(`/.server/secrets/${secret.name}.json`, generateSecretFile(secret), false); } // Mount scheduled functions for (const fn of scheduledFunctions) { const edgeFn = edgeFunctions.find(ef => ef.id === fn.functionId); this.mountTransientFile( `/.server/scheduled-functions/${fn.name}.json`, generateScheduledFunctionFile(fn, edgeFn?.name ?? 'unknown'), false ); } // Mount database schema if present in localStorage if (typeof window !== 'undefined') { const dbSchema = localStorage.getItem(`osw-db-schema-${projectId}`); if (dbSchema) { this.mountTransientFile('/.server/db/schema.sql', dbSchema, true); } } // Ensure all known server context directories are visible (even if empty) const knownDirs = ['edge-functions', 'server-functions', 'secrets', 'scheduled-functions', 'db']; for (const dir of knownDirs) { const dirPrefix = `/.server/${dir}/`; const hasFiles = Array.from(this.transientFiles.keys()).some(p => p.startsWith(dirPrefix)); if (!hasFiles) { this.mountTransientFile(`/.server/${dir}/.gitkeep`, '', true); } } // Store metadata this.serverContextProjectId = projectId; this.serverContextMetadata = { projectId, runtimeDeploymentId: this.runtimeDeploymentId || undefined, hasDatabase: !!this.runtimeDeploymentId, edgeFunctionCount: edgeFunctions.filter(f => f.enabled).length, serverFunctionCount: serverFunctions.filter(f => f.enabled).length, secretCount: secrets.length, scheduledFunctionCount: scheduledFunctions.filter(f => f.enabled).length, }; // Persist projectId to sessionStorage for HMR resilience if (typeof sessionStorage !== 'undefined') { sessionStorage.setItem('vfs_serverContextProjectId', projectId); } logger.info(`[VFS] Mounted project server context for project ${projectId}`); if (typeof window !== 'undefined') { window.dispatchEvent(new Event('filesChanged')); } } catch (error) { logger.error('[VFS] Failed to mount project server context', error); } } /** * Mount deployment runtime context (optional, for sqlite3 and logs) * Only available when a deployment exists AND is published. */ async mountDeploymentRuntimeContext(deploymentId: string): Promise { if (process.env.NEXT_PUBLIC_SERVER_MODE !== 'true') { return; } try { // On server side, get database schema if (typeof window === 'undefined') { const { getSQLiteAdapter } = await import(/* webpackIgnore: true */ './adapters/server'); const sqliteAdapter = getSQLiteAdapter(); await sqliteAdapter.init(); const deploymentDb = sqliteAdapter.getDeploymentDatabaseForAnalytics(deploymentId); if (deploymentDb) { const schema = deploymentDb.getSchemaForExport(); this.mountTransientFile('/.server/db/schema.sql', schema, true); } } else { // Client side: fetch schema from API try { // Use workspace-scoped URL if in server mode with a workspace cookie const isServerMode = process.env.NEXT_PUBLIC_SERVER_MODE === 'true'; const wsCookie = isServerMode && typeof document !== 'undefined' && document.cookie.match(/osw_workspace=([^;]+)/); const ctxUrl = wsCookie ? `/api/w/${wsCookie[1]}/admin/deployments/${deploymentId}/server-context` : `/api/admin/deployments/${deploymentId}/server-context`; const response = await fetch(ctxUrl); if (response.ok) { const data = await response.json(); const schemaFile = data.files?.find((f: { path: string }) => f.path === '/.server/db/schema.sql'); if (schemaFile) { this.mountTransientFile('/.server/db/schema.sql', schemaFile.content, true); } } } catch { logger.warn('[VFS] Could not fetch deployment schema'); } } this.runtimeDeploymentId = deploymentId; // Update metadata to include runtime info if (this.serverContextMetadata) { this.serverContextMetadata.runtimeDeploymentId = deploymentId; this.serverContextMetadata.hasDatabase = true; } // Persist to sessionStorage for HMR resilience if (typeof sessionStorage !== 'undefined') { sessionStorage.setItem('vfs_runtimeDeploymentId', deploymentId); } logger.info(`[VFS] Connected deployment runtime: ${deploymentId}`); if (typeof window !== 'undefined') { window.dispatchEvent(new Event('filesChanged')); } } catch (error) { logger.error('[VFS] Failed to mount deployment runtime context', error); } } /** * Disconnect deployment runtime context */ unmountDeploymentRuntimeContext(): void { if (this.runtimeDeploymentId) { // Remove schema file this.transientFiles.delete('/.server/db/schema.sql'); this.runtimeDeploymentId = null; if (this.serverContextMetadata) { this.serverContextMetadata.runtimeDeploymentId = undefined; this.serverContextMetadata.hasDatabase = false; } if (typeof sessionStorage !== 'undefined') { sessionStorage.removeItem('vfs_runtimeDeploymentId'); } logger.info('[VFS] Disconnected deployment runtime'); if (typeof window !== 'undefined') { window.dispatchEvent(new Event('filesChanged')); } } } /** * Refresh server context (re-reads from adapter stores) * Call after orchestrator completes to ensure file explorer is up to date */ async refreshServerContext(): Promise { if (!this.serverContextProjectId) return; await this.mountProjectBackendContext(this.serverContextProjectId); // Re-mount runtime if connected if (this.runtimeDeploymentId) { await this.mountDeploymentRuntimeContext(this.runtimeDeploymentId); } } /** * Unmount all backend context (project features + runtime) */ unmountBackendContext(): void { const removed: string[] = []; for (const path of this.transientFiles.keys()) { if (path.startsWith('/.server/')) { this.transientFiles.delete(path); removed.push(path); } } if (this.serverContextProjectId || this.runtimeDeploymentId) { logger.info(`[VFS] Unmounted server context (${removed.length} files)`); this.serverContextProjectId = null; this.runtimeDeploymentId = null; this.serverContextMetadata = null; // Clear sessionStorage when unmounting if (typeof sessionStorage !== 'undefined') { sessionStorage.removeItem('vfs_serverContextProjectId'); sessionStorage.removeItem('vfs_runtimeDeploymentId'); } if (typeof window !== 'undefined') { window.dispatchEvent(new Event('filesChanged')); } } } /** * Get all transient files in a directory */ getTransientFilesInDirectory(dirPath: string): VirtualFile[] { const normalizedPath = dirPath.endsWith('/') ? dirPath : dirPath + '/'; const files: VirtualFile[] = []; for (const [path, file] of this.transientFiles) { if (path.startsWith(normalizedPath)) { files.push(file); } } return files; } /** * Mount a single transient file * @param isReadOnly - If false, the file can be edited via bash commands (edge functions, server functions) */ private mountTransientFile(path: string, content: string, isReadOnly = true): void { const file: VirtualFile = { id: `transient-server-${path.replace(/[^a-z0-9]/gi, '-')}`, projectId: 'transient', path, name: path.split('/').pop() || '', type: 'text', content, mimeType: path.endsWith('.sql') ? 'text/sql' : path.endsWith('.json') ? 'application/json' : path.endsWith('.js') ? 'application/javascript' : 'text/markdown', size: new Blob([content]).size, createdAt: new Date(), updatedAt: new Date(), metadata: { isTransient: true, isServerContext: true, isReadOnly, } }; this.transientFiles.set(path, file); } /** * Update a server context file (/.server/) * Validates content and syncs to project IndexedDB/SQLite stores */ private async updateServerContextFile(path: string, content: string): Promise { // Recover projectId from sessionStorage if needed (HMR resilience) if (!this.serverContextProjectId && typeof sessionStorage !== 'undefined') { const storedProjectId = sessionStorage.getItem('vfs_serverContextProjectId'); if (storedProjectId) { logger.info(`[VFS] Recovered serverContextProjectId from sessionStorage: ${storedProjectId}`); this.serverContextProjectId = storedProjectId; } } if (!this.serverContextProjectId) { throw new Error('No project server context mounted.'); } // Check for read-only files if (path === '/.server/db/schema.sql') { throw new Error(`Cannot modify ${path} - read-only file`); } // Route to appropriate handler if (path.startsWith('/.server/edge-functions/') && path.endsWith('.json')) { return await this.upsertEdgeFunctionFromFile(path, content); } if (path.startsWith('/.server/server-functions/') && path.endsWith('.json')) { return await this.upsertServerFunctionFromFile(path, content); } if (path.startsWith('/.server/secrets/') && path.endsWith('.json')) { return await this.upsertSecretFromFile(path, content); } if (path.startsWith('/.server/scheduled-functions/') && path.endsWith('.json')) { return await this.upsertScheduledFunctionFromFile(path, content); } throw new Error(`Cannot modify ${path} - unrecognized server context path`); } /** * Upsert edge function from file content into project stores */ private async upsertEdgeFunctionFromFile(path: string, content: string): Promise { const { validateEdgeFunctionData, generateEdgeFunctionFile } = await import('./server-context'); const { v4: uuidv4Gen } = await import('uuid'); let data: unknown; try { data = JSON.parse(content); } catch (e: unknown) { throw new Error(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`); } const validation = validateEdgeFunctionData(data); if (!validation.valid) throw new Error(`Validation failed: ${validation.errors.join('; ')}`); const fnData = data as { name: string; method: string; code: string; description?: string; enabled?: boolean; timeoutMs?: number }; const projectId = this.serverContextProjectId!; const adapter = this.adapter; // Find existing by filename const filename = path.split('/').pop()!.replace('.json', ''); const existing = adapter.listEdgeFunctions ? (await adapter.listEdgeFunctions(projectId)).find(f => f.name === filename) : undefined; const now = new Date(); if (existing && adapter.updateEdgeFunction) { const updated = { ...existing, name: fnData.name, code: fnData.code, method: fnData.method as any, description: fnData.description, enabled: fnData.enabled ?? true, timeoutMs: fnData.timeoutMs ?? 5000, updatedAt: now }; await adapter.updateEdgeFunction(updated); if (fnData.name !== filename) { this.transientFiles.delete(path); const newPath = `/.server/edge-functions/${fnData.name}.json`; this.mountTransientFile(newPath, generateEdgeFunctionFile(updated), false); this.removeGitkeep('/.server/edge-functions/'); this.notifyFilesChanged(); return this.transientFiles.get(newPath)!; } this.mountTransientFile(path, generateEdgeFunctionFile(updated), false); } else if (adapter.createEdgeFunction) { const newFn = { id: uuidv4Gen(), projectId, name: fnData.name, code: fnData.code, method: fnData.method as any, description: fnData.description, enabled: fnData.enabled ?? true, timeoutMs: fnData.timeoutMs ?? 5000, createdAt: now, updatedAt: now }; await adapter.createEdgeFunction(newFn); this.mountTransientFile(path, generateEdgeFunctionFile(newFn), false); } this.removeGitkeep('/.server/edge-functions/'); this.notifyFilesChanged(); this.triggerServerFeatureSync(projectId); const result = this.transientFiles.get(path); if (!result) throw new Error(`Failed to create server context file at ${path}`); return result; } /** * Upsert server function from file content into project stores */ private async upsertServerFunctionFromFile(path: string, content: string): Promise { const { validateServerFunctionData, generateServerFunctionFile } = await import('./server-context'); const { v4: uuidv4Gen } = await import('uuid'); let data: unknown; try { data = JSON.parse(content); } catch (e: unknown) { throw new Error(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`); } const validation = validateServerFunctionData(data); if (!validation.valid) throw new Error(`Validation failed: ${validation.errors.join('; ')}`); const fnData = data as { name: string; code: string; description?: string; enabled?: boolean }; const projectId = this.serverContextProjectId!; const adapter = this.adapter; const filename = path.split('/').pop()!.replace('.json', ''); const existing = adapter.listServerFunctions ? (await adapter.listServerFunctions(projectId)).find(f => f.name === filename) : undefined; const now = new Date(); if (existing && adapter.updateServerFunction) { const updated = { ...existing, name: fnData.name, code: fnData.code, description: fnData.description, enabled: fnData.enabled ?? true, updatedAt: now }; await adapter.updateServerFunction(updated); if (fnData.name !== filename) { this.transientFiles.delete(path); const newPath = `/.server/server-functions/${fnData.name}.json`; this.mountTransientFile(newPath, generateServerFunctionFile(updated), false); this.removeGitkeep('/.server/server-functions/'); this.notifyFilesChanged(); return this.transientFiles.get(newPath)!; } this.mountTransientFile(path, generateServerFunctionFile(updated), false); } else if (adapter.createServerFunction) { const newFn = { id: uuidv4Gen(), projectId, name: fnData.name, code: fnData.code, description: fnData.description, enabled: fnData.enabled ?? true, createdAt: now, updatedAt: now }; await adapter.createServerFunction(newFn); this.mountTransientFile(path, generateServerFunctionFile(newFn), false); } this.removeGitkeep('/.server/server-functions/'); this.notifyFilesChanged(); this.triggerServerFeatureSync(projectId); const result = this.transientFiles.get(path); if (!result) throw new Error(`Failed to create server context file at ${path}`); return result; } /** * Upsert secret from file content into project stores */ private async upsertSecretFromFile(path: string, content: string): Promise { const { validateSecretData, generateSecretFile } = await import('./server-context'); const { v4: uuidv4Gen } = await import('uuid'); let data: unknown; try { data = JSON.parse(content); } catch (e: unknown) { throw new Error(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`); } const validation = validateSecretData(data); if (!validation.valid) throw new Error(`Validation failed: ${validation.errors.join('; ')}`); const secretData = data as { name: string; description?: string }; const projectId = this.serverContextProjectId!; const adapter = this.adapter; const filename = path.split('/').pop()!.replace('.json', ''); const existing = adapter.listSecrets ? (await adapter.listSecrets(projectId)).find(s => s.name === filename) : undefined; const now = new Date(); if (existing && adapter.updateSecret) { const updated = { ...existing, name: secretData.name, description: secretData.description, updatedAt: now }; await adapter.updateSecret(updated); if (secretData.name !== filename) { this.transientFiles.delete(path); const newPath = `/.server/secrets/${secretData.name}.json`; this.mountTransientFile(newPath, generateSecretFile(updated), false); this.removeGitkeep('/.server/secrets/'); this.notifyFilesChanged(); return this.transientFiles.get(newPath)!; } this.mountTransientFile(path, generateSecretFile(updated), false); } else if (adapter.createSecret) { const newSecret = { id: uuidv4Gen(), projectId, name: secretData.name, description: secretData.description, hasValue: false, createdAt: now, updatedAt: now }; await adapter.createSecret(newSecret); this.mountTransientFile(path, generateSecretFile(newSecret), false); } this.removeGitkeep('/.server/secrets/'); this.notifyFilesChanged(); this.triggerServerFeatureSync(projectId); const result = this.transientFiles.get(path); if (!result) throw new Error(`Failed to create server context file at ${path}`); return result; } /** * Upsert scheduled function from file content into project stores */ private async upsertScheduledFunctionFromFile(path: string, content: string): Promise { const { validateScheduledFunctionData, generateScheduledFunctionFile } = await import('./server-context'); const { v4: uuidv4Gen } = await import('uuid'); let data: unknown; try { data = JSON.parse(content); } catch (e: unknown) { throw new Error(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`); } const validation = validateScheduledFunctionData(data); if (!validation.valid) throw new Error(`Validation failed: ${validation.errors.join('; ')}`); const fnData = data as { name: string; functionName: string; cronExpression: string; timezone?: string; enabled?: boolean; description?: string; config?: Record }; const projectId = this.serverContextProjectId!; const adapter = this.adapter; // Resolve functionName to functionId const edgeFunctions = adapter.listEdgeFunctions ? await adapter.listEdgeFunctions(projectId) : []; const edgeFn = edgeFunctions.find(f => f.name === fnData.functionName); if (!edgeFn) { throw new Error(`Edge function "${fnData.functionName}" not found. Create it first.`); } const filename = path.split('/').pop()!.replace('.json', ''); const existing = adapter.listScheduledFunctions ? (await adapter.listScheduledFunctions(projectId)).find(f => f.name === filename) : undefined; const now = new Date(); if (existing && adapter.updateScheduledFunction) { const updated = { ...existing, name: fnData.name, functionId: edgeFn.id, cronExpression: fnData.cronExpression, timezone: fnData.timezone || 'UTC', description: fnData.description, enabled: fnData.enabled ?? true, config: fnData.config || {}, updatedAt: now }; await adapter.updateScheduledFunction(updated); if (fnData.name !== filename) { this.transientFiles.delete(path); const newPath = `/.server/scheduled-functions/${fnData.name}.json`; this.mountTransientFile(newPath, generateScheduledFunctionFile(updated, fnData.functionName), false); this.removeGitkeep('/.server/scheduled-functions/'); this.notifyFilesChanged(); return this.transientFiles.get(newPath)!; } this.mountTransientFile(path, generateScheduledFunctionFile(updated, fnData.functionName), false); } else if (adapter.createScheduledFunction) { const newFn = { id: uuidv4Gen(), projectId, name: fnData.name, functionId: edgeFn.id, cronExpression: fnData.cronExpression, timezone: fnData.timezone || 'UTC', description: fnData.description, enabled: fnData.enabled ?? true, config: fnData.config || {}, createdAt: now, updatedAt: now }; await adapter.createScheduledFunction(newFn); this.mountTransientFile(path, generateScheduledFunctionFile(newFn, fnData.functionName), false); } this.removeGitkeep('/.server/scheduled-functions/'); this.notifyFilesChanged(); this.triggerServerFeatureSync(projectId); const result = this.transientFiles.get(path); if (!result) throw new Error(`Failed to create server context file at ${path}`); return result; } /** * Remove .gitkeep stub from a directory if real files exist */ private removeGitkeep(dirPath: string): void { const gitkeepPath = dirPath + '.gitkeep'; if (this.transientFiles.has(gitkeepPath)) { this.transientFiles.delete(gitkeepPath); } } /** * Notify file explorer of changes */ private notifyFilesChanged(): void { if (typeof window !== 'undefined') { window.dispatchEvent(new Event('filesChanged')); } } /** * Trigger debounced backend feature sync (for server mode) * After writing to IndexedDB, schedule a sync to the server * * WARNING: This is currently a no-op placeholder called from 5 production paths * (edge functions, server functions, secrets, scheduled functions, and delete). * In Server Mode, users must manually sync (push) backend features before publishing. * The publish pipeline reads from core SQLite, not IndexedDB. */ private triggerServerFeatureSync(_projectId: string): void { // No-op — automatic sync not yet implemented. // Users must manually push backend features via the sync panel before publishing. } /** * Create a new server context file (/.server/) * Validates content and creates in project stores */ private async createServerContextFile(path: string, content: string): Promise { if (!this.serverContextProjectId && typeof sessionStorage !== 'undefined') { const storedProjectId = sessionStorage.getItem('vfs_serverContextProjectId'); if (storedProjectId) { this.serverContextProjectId = storedProjectId; } } if (!this.serverContextProjectId) { throw new Error('No project server context mounted.'); } if (this.transientFiles.has(path)) { throw new Error(`File already exists: ${path}`); } // Route to appropriate handler (upsert handles create too) return await this.updateServerContextFile(path, content); } /** * Delete a server context file (/.server/) * Deletes from project stores */ async deleteServerContextFile(path: string): Promise { if (!this.serverContextProjectId && typeof sessionStorage !== 'undefined') { const storedProjectId = sessionStorage.getItem('vfs_serverContextProjectId'); if (storedProjectId) { this.serverContextProjectId = storedProjectId; } } if (!this.serverContextProjectId) { throw new Error('No project server context mounted.'); } if (path === '/.server/db/schema.sql') { throw new Error(`Cannot delete ${path} - read-only file`); } const projectId = this.serverContextProjectId; const adapter = this.adapter; const filename = path.split('/').pop()!.replace('.json', ''); if (path.startsWith('/.server/edge-functions/') && path.endsWith('.json')) { const items = adapter.listEdgeFunctions ? await adapter.listEdgeFunctions(projectId) : []; const item = items.find(f => f.name === filename); if (item && adapter.deleteEdgeFunction) { await adapter.deleteEdgeFunction(item.id); } } else if (path.startsWith('/.server/server-functions/') && path.endsWith('.json')) { const items = adapter.listServerFunctions ? await adapter.listServerFunctions(projectId) : []; const item = items.find(f => f.name === filename); if (item && adapter.deleteServerFunction) { await adapter.deleteServerFunction(item.id); } } else if (path.startsWith('/.server/secrets/') && path.endsWith('.json')) { const items = adapter.listSecrets ? await adapter.listSecrets(projectId) : []; const item = items.find(s => s.name === filename); if (item && adapter.deleteSecret) { await adapter.deleteSecret(item.id); } } else if (path.startsWith('/.server/scheduled-functions/') && path.endsWith('.json')) { const items = adapter.listScheduledFunctions ? await adapter.listScheduledFunctions(projectId) : []; const item = items.find(f => f.name === filename); if (item && adapter.deleteScheduledFunction) { await adapter.deleteScheduledFunction(item.id); } } else { throw new Error(`Cannot delete ${path} - read-only file`); } this.transientFiles.delete(path); this.notifyFilesChanged(); this.triggerServerFeatureSync(projectId); } private ensureInitialized() { if (!this.initialized) { throw new Error('VirtualFileSystem not initialized. Call init() first.'); } } /** * Trigger auto-sync in background (debounced) * Only runs in Server Mode and in browser environment */ private triggerAutoSync(projectId: string) { // Only sync in browser and Server Mode if (typeof window === 'undefined' || process.env.NEXT_PUBLIC_SERVER_MODE !== 'true') { return; } // Clear existing timeout for this project (debounce) const existingTimeout = this.syncTimeouts.get(projectId); if (existingTimeout) { clearTimeout(existingTimeout); } // Schedule sync after 2 seconds of inactivity const timeout = setTimeout(async () => { try { const { autoSyncProject } = await import('./auto-sync'); await autoSyncProject(projectId); } catch (error) { logger.error(`[VFS] Auto-sync failed for project ${projectId}:`, error); } finally { this.syncTimeouts.delete(projectId); } }, 2000); this.syncTimeouts.set(projectId, timeout); } /** * Flush any pending sync for a project immediately (don't wait for debounce). * Called when leaving a project to ensure sync completes. */ async flushSyncTimeout(projectId: string): Promise { const timeout = this.syncTimeouts.get(projectId); if (timeout) { clearTimeout(timeout); this.syncTimeouts.delete(projectId); try { const { autoSyncProject } = await import('./auto-sync'); await autoSyncProject(projectId); } catch (error) { logger.error(`[VFS] Flush sync failed for project ${projectId}:`, error); } } } /** * Flush all pending sync timeouts immediately. * Called on beforeunload — best-effort only, as the browser may kill async * operations before they complete. Primary sync guarantee is flushSyncTimeout() * on workspace exit, which runs before navigation. */ flushAllSyncTimeouts(): void { if (this.syncTimeouts.size === 0) return; for (const [projectId, timeout] of this.syncTimeouts) { clearTimeout(timeout); import('./auto-sync').then(({ autoSyncProject }) => { autoSyncProject(projectId); }).catch(() => {}); } this.syncTimeouts.clear(); } async createFile(projectId: string, path: string, content: string | ArrayBuffer, options?: { silent?: boolean }): Promise { this.ensureInitialized(); try { // Clean path of any trailing newlines or escape sequences const cleanPath = path.replace(/\\n$|\\r$|\n$|\r$/, '').trim(); path = cleanPath; // Handle server context file creation (/.server/) if (path.startsWith('/.server/')) { return await this.createServerContextFile(path, content as string); } const existing = await this.adapter.getFile(projectId, path); if (existing) { logger.error('VFS: File already exists', { projectId, path }); throw new Error(`File already exists: ${path}`); } const type = getFileTypeFromPath(path); const size = content instanceof ArrayBuffer ? content.byteLength : new Blob([content]).size; const sizeLimit = FILE_SIZE_LIMITS[type]; if (size > sizeLimit) { throw new Error(`File too large. Maximum size for ${type} files is ${Math.round(sizeLimit / 1024 / 1024)}MB`); } const file: VirtualFile = { id: uuidv4(), projectId, path, name: path.split('/').pop() || '', type, content, mimeType: getSpecificMimeType(path), size, createdAt: new Date(), updatedAt: new Date(), metadata: { isEntry: path === '/index.html' } }; await this.adapter.createFile(file); await this.updateFileTree(projectId, path, 'create', options?.silent === true); saveManager.markDirty(projectId); return file; } catch (error) { throw error; } } async readFile(projectId: string, path: string): Promise { this.ensureInitialized(); // Validate inputs if (!projectId || typeof projectId !== 'string') { logger.error('VFS: Invalid projectId for readFile', { projectId, path }); throw new Error('Invalid projectId provided'); } if (!path || typeof path !== 'string') { logger.error('VFS: Invalid path for readFile', { projectId, path }); throw new Error('Invalid file path provided'); } // Clean path of any trailing newlines or escape sequences const cleanPath = path.replace(/\\n$|\\r$|\n$|\r$/, '').trim(); if (!cleanPath) { logger.error('VFS: Empty path after cleaning for readFile', { projectId, originalPath: path, cleanPath }); throw new Error('Empty file path after cleaning'); } // Check transient files first (for /.skills/, /.agents/, etc.) if (this.isTransientPath(cleanPath)) { const transientFile = this.transientFiles.get(cleanPath); if (transientFile) { return transientFile; } throw new Error(`Transient file not found: ${cleanPath}`); } // Check generated files (bundle.js, bundle.css) const generatedFile = this.generatedFiles.get(cleanPath); if (generatedFile) { return generatedFile; } const file = await this.adapter.getFile(projectId, cleanPath); if (!file) { logger.debug('VFS: File not found for read', { projectId, path: cleanPath, originalPath: path }); throw new Error(`File not found: ${cleanPath}`); } return file; } async fileExists(projectId: string, path: string): Promise { this.ensureInitialized(); try { // Check transient files first if (this.isTransientPath(path)) { return this.transientFiles.has(path); } // Check generated files if (this.generatedFiles.has(path)) { return true; } const file = await this.adapter.getFile(projectId, path); return !!file; } catch { return false; } } async updateFile(projectId: string, path: string, content: string | ArrayBuffer, options?: { silent?: boolean }): Promise { this.ensureInitialized(); try { // Clean and validate path before database operation const cleanPath = path.replace(/\\n$|\\r$|\n$|\r$/, '').trim(); if (cleanPath.includes('\n') || cleanPath.includes('@@') || cleanPath.includes('\\n') || cleanPath.length > 200) { logger.error('VFS: Invalid path detected', { projectId, path: path.slice(0, 100) + '...' }); throw new Error(`Invalid file path: ${path.slice(0, 50)}...`); } // Use cleaned path for lookup path = cleanPath; // Handle server context file writes (/.server/) if (path.startsWith('/.server/')) { return await this.updateServerContextFile(path, content as string); } const file = await this.adapter.getFile(projectId, path); if (!file) { logger.error('VFS: File not found for update', { projectId, path }); throw new Error(`File not found: ${path}`); } file.content = content; file.size = content instanceof ArrayBuffer ? content.byteLength : new Blob([content]).size; file.updatedAt = new Date(); await this.adapter.updateFile(file); saveManager.markDirty(projectId); if (!options?.silent && typeof window !== 'undefined') { const detail = { projectId, path }; window.dispatchEvent(new CustomEvent('fileContentChanged', { detail })); window.dispatchEvent(new Event('filesChanged')); } return file; } catch (error) { throw error; } } async deleteFile(projectId: string, path: string, options?: { silent?: boolean }): Promise { this.ensureInitialized(); try { await this.adapter.deleteFile(projectId, path); await this.updateFileTree(projectId, path, 'delete', options?.silent === true); saveManager.markDirty(projectId); } catch (error) { throw error; } } async renameFile(projectId: string, oldPath: string, newPath: string): Promise { this.ensureInitialized(); const file = await this.readFile(projectId, oldPath); await this.deleteFile(projectId, oldPath); return await this.createFile(projectId, newPath, file.content as string); } async createDirectory(projectId: string, path: string): Promise { this.ensureInitialized(); const existing = await this.adapter.getTreeNode(projectId, path); if (existing) { return; } const name = path.split('/').pop() || path; const node: FileTreeNode = { id: uuidv4(), projectId, path, name, type: 'directory', parentPath: this.getParentPath(path), children: [] }; await this.adapter.createTreeNode(node); saveManager.markDirty(projectId); if (typeof window !== 'undefined') { window.dispatchEvent(new Event('filesChanged')); } } async listDirectory(projectId: string, path: string, options?: { includeTransient?: boolean }): Promise { this.ensureInitialized(); const allFiles = await this.adapter.listFiles(projectId); let files: VirtualFile[]; if (path === '/') { files = allFiles; } else { files = allFiles.filter(file => { const filePath = file.path; const dirPath = path.endsWith('/') ? path : path + '/'; return filePath.startsWith(dirPath) && filePath.slice(dirPath.length).indexOf('/') === -1; }); } // Include generated files (bundle.js, bundle.css) — always visible at root if (path === '/' && this.generatedFiles.size > 0) { const existingPaths = new Set(files.map(f => f.path)); for (const gf of this.generatedFiles.values()) { if (!existingPaths.has(gf.path)) { files.push(gf); } } } // Include transient files if requested if (options?.includeTransient) { const transientFilesArray = Array.from(this.transientFiles.values()); if (path === '/') { // Root: include all transient files files = [...files, ...transientFilesArray]; } else { // Subdirectory: filter transient files by path const dirPath = path.endsWith('/') ? path : path + '/'; // Direct children (files at this exact level) const matchingTransient = transientFilesArray.filter(file => { return file.path.startsWith(dirPath) && file.path.slice(dirPath.length).indexOf('/') === -1; }); // Synthesize directory entries from deeper transient files const synthDirs = new Set(); for (const file of transientFilesArray) { if (file.path.startsWith(dirPath)) { const rest = file.path.slice(dirPath.length); const slashIdx = rest.indexOf('/'); if (slashIdx !== -1) { synthDirs.add(rest.slice(0, slashIdx)); } } } const now = new Date(); const synthEntries: VirtualFile[] = Array.from(synthDirs).map(dirName => ({ id: `transient-dir-${dirPath}${dirName}`, projectId: 'transient', path: dirPath + dirName, name: dirName, type: 'text' as const, content: '', mimeType: '', size: 0, createdAt: now, updatedAt: now, metadata: { isTransient: true, isServerContext: true, isReadOnly: true }, })); files = [...files, ...matchingTransient, ...synthEntries]; } } return files; } async getAllFilesAndDirectories(projectId: string, options?: { includeTransient?: boolean }): Promise> { this.ensureInitialized(); const allFiles = await this.adapter.listFiles(projectId); const treeNodes = await this.adapter.getAllTreeNodes(projectId); const directories = treeNodes .filter(node => node.type === 'directory') .map(node => ({ path: node.path, name: node.path.split('/').filter(Boolean).pop() || node.path, type: 'directory' as const })); let result: Array = [...allFiles, ...directories]; // Include generated files (bundle.js, bundle.css) — always visible if (this.generatedFiles.size > 0) { const existingPaths = new Set(result.map(item => item.path)); for (const gf of this.generatedFiles.values()) { if (!existingPaths.has(gf.path)) { result.push(gf); } } } // Include transient files if requested if (options?.includeTransient) { const transientFilesArray = Array.from(this.transientFiles.values()); result = [...result, ...transientFilesArray]; } return result; } async deleteDirectory(projectId: string, path: string): Promise { this.ensureInitialized(); const allFiles = await this.adapter.listFiles(projectId); const dirPath = path.endsWith('/') ? path : path + '/'; // Delete each contained file silently so listeners (preview compile, // file-tree reload) don't fire N times — one event is dispatched at the // end of this method instead. for (const file of allFiles) { if (file.path.startsWith(dirPath)) { await this.deleteFile(projectId, file.path, { silent: true }); } } await this.adapter.deleteTreeNode(projectId, path); saveManager.markDirty(projectId); if (typeof window !== 'undefined') { window.dispatchEvent(new Event('filesChanged')); } } async renameDirectory(projectId: string, oldPath: string, newPath: string): Promise { this.ensureInitialized(); const oldNode = await this.adapter.getTreeNode(projectId, oldPath); if (oldNode) { await this.adapter.deleteTreeNode(projectId, oldPath); const newNode: FileTreeNode = { id: uuidv4(), projectId, path: newPath, name: newPath.split('/').pop() || newPath, type: 'directory', parentPath: this.getParentPath(newPath), children: oldNode.children }; await this.adapter.createTreeNode(newNode); saveManager.markDirty(projectId); } const oldDirPath = oldPath.endsWith('/') ? oldPath : oldPath + '/'; const newDirPath = newPath.endsWith('/') ? newPath : newPath + '/'; const allFiles = await this.adapter.listFiles(projectId); const filesToMove = allFiles.filter(file => file.path.startsWith(oldDirPath)); for (const file of filesToMove) { const relativePath = file.path.substring(oldDirPath.length); const newFilePath = newDirPath + relativePath; await this.renameFile(projectId, file.path, newFilePath); } const allTreeNodes = await this.adapter.getAllTreeNodes(projectId); const subdirNodes = allTreeNodes.filter(node => node.type === 'directory' && node.path.startsWith(oldDirPath) && node.path !== oldPath ); for (const node of subdirNodes) { const relativePath = node.path.substring(oldDirPath.length); const newSubdirPath = newDirPath + relativePath; await this.adapter.deleteTreeNode(projectId, node.path); const newNode: FileTreeNode = { id: uuidv4(), projectId, path: newSubdirPath, name: newSubdirPath.split('/').pop() || newSubdirPath, type: 'directory', parentPath: this.getParentPath(newSubdirPath), children: node.children }; await this.adapter.createTreeNode(newNode); } if (typeof window !== 'undefined') { window.dispatchEvent(new Event('filesChanged')); } } async moveFile(projectId: string, oldPath: string, newPath: string): Promise { this.ensureInitialized(); const existing = await this.adapter.getFile(projectId, newPath); if (existing) { throw new Error(`File already exists at destination: ${newPath}`); } const file = await this.readFile(projectId, oldPath); const movedFile = await this.createFile(projectId, newPath, file.content); await this.deleteFile(projectId, oldPath); return movedFile; } async moveDirectory(projectId: string, oldPath: string, newPath: string): Promise { this.ensureInitialized(); const normalizedNew = newPath.endsWith('/') ? newPath : newPath + '/'; const normalizedOld = oldPath.endsWith('/') ? oldPath : oldPath + '/'; if (normalizedNew.startsWith(normalizedOld)) { throw new Error('Cannot move a directory into itself'); } await this.renameDirectory(projectId, oldPath, newPath); } async createProject(name: string, description?: string, id?: string): Promise { this.ensureInitialized(); try { const project: Project = { id: id || uuidv4(), name, description, createdAt: new Date(), updatedAt: new Date(), settings: {}, lastSavedCheckpointId: null, lastSavedAt: null, costTracking: { totalCost: 0, providerBreakdown: {}, sessionHistory: [] } }; await this.adapter.createProject(project); const rootNode: FileTreeNode = { id: uuidv4(), projectId: project.id, path: '/', name: '/', type: 'directory', parentPath: null, children: [] }; await this.adapter.createTreeNode(rootNode); return project; } catch (error) { throw error; } } async getProject(id: string): Promise { this.ensureInitialized(); const project = await this.adapter.getProject(id); if (!project) { throw new Error(`Project not found: ${id}`); } return project; } async updateProject(project: Project, options?: { preserveUpdatedAt?: boolean }): Promise { this.ensureInitialized(); // Only update timestamp if not preserving (for sync metadata updates) if (!options?.preserveUpdatedAt) { project.updatedAt = new Date(); } await this.adapter.updateProject(project); } async updateProjectCost( projectId: string, usage: { cost: number; provider: string; tokenUsage?: { input: number; output: number }; sessionId?: string; mode?: 'absolute' | 'delta'; } ): Promise { this.ensureInitialized(); const project = await this.getProject(projectId); if (!project) { throw new Error(`Project not found: ${projectId}`); } if (!project.costTracking || !project.costTracking.providerBreakdown) { project.costTracking = { totalCost: project.costTracking?.totalCost ?? 0, providerBreakdown: project.costTracking?.providerBreakdown ?? {}, sessionHistory: project.costTracking?.sessionHistory ?? [] }; } project.costTracking.totalCost += usage.cost; if (!project.costTracking.providerBreakdown[usage.provider]) { project.costTracking.providerBreakdown[usage.provider] = { totalCost: 0, tokenUsage: { input: 0, output: 0 }, requestCount: 0, lastUpdated: new Date() }; } const providerStats = project.costTracking.providerBreakdown[usage.provider]; providerStats.totalCost += usage.cost; if (usage.mode !== 'delta') { providerStats.requestCount += 1; } providerStats.lastUpdated = new Date(); if (usage.tokenUsage) { providerStats.tokenUsage.input += usage.tokenUsage.input; providerStats.tokenUsage.output += usage.tokenUsage.output; } if (usage.sessionId && usage.mode !== 'delta') { if (!project.costTracking.sessionHistory) { project.costTracking.sessionHistory = []; } project.costTracking.sessionHistory.push({ sessionId: usage.sessionId, cost: usage.cost, provider: usage.provider, timestamp: new Date(), tokenUsage: usage.tokenUsage }); if (project.costTracking.sessionHistory.length > 100) { project.costTracking.sessionHistory = project.costTracking.sessionHistory.slice(-100); } } await this.updateProject(project); } async applyProjectCostDelta( projectId: string, usage: { costDelta: number; provider: string; tokenUsageDelta?: { input: number; output: number }; sessionId?: string; } ): Promise { this.ensureInitialized(); const project = await this.getProject(projectId); if (!project) { throw new Error(`Project not found: ${projectId}`); } if (!project.costTracking) { project.costTracking = { totalCost: 0, providerBreakdown: {}, sessionHistory: [] }; } project.costTracking.totalCost += usage.costDelta; if (!project.costTracking.providerBreakdown[usage.provider]) { project.costTracking.providerBreakdown[usage.provider] = { totalCost: 0, tokenUsage: { input: 0, output: 0 }, requestCount: 0, lastUpdated: new Date() }; } const providerStats = project.costTracking.providerBreakdown[usage.provider]; providerStats.totalCost += usage.costDelta; providerStats.lastUpdated = new Date(); if (usage.tokenUsageDelta) { providerStats.tokenUsage.input += usage.tokenUsageDelta.input; providerStats.tokenUsage.output += usage.tokenUsageDelta.output; } if (usage.sessionId) { if (!project.costTracking.sessionHistory) { project.costTracking.sessionHistory = []; } project.costTracking.sessionHistory.push({ sessionId: usage.sessionId, cost: usage.costDelta, provider: usage.provider, timestamp: new Date(), tokenUsage: usage.tokenUsageDelta, correction: true }); if (project.costTracking.sessionHistory.length > 100) { project.costTracking.sessionHistory = project.costTracking.sessionHistory.slice(-100); } } await this.updateProject(project); } async deleteProject(id: string): Promise { this.ensureInitialized(); await this.adapter.deleteProject(id); } async listProjects(): Promise { this.ensureInitialized(); return await this.adapter.listProjects(); } async listFiles(projectId: string): Promise { this.ensureInitialized(); return await this.adapter.listFiles(projectId); } async getFileTree(projectId: string): Promise { this.ensureInitialized(); return await this.adapter.getTreeNode(projectId, '/'); } async searchFiles( projectId: string, query: string, options?: { regex?: boolean; fileType?: string; limit?: number; searchIn?: 'content' | 'filename' | 'both'; } ): Promise { this.ensureInitialized(); const allFiles = await this.adapter.listFiles(projectId); const { regex = false, fileType, limit = 20, searchIn = 'both' } = options || {}; let filesToSearch = allFiles; if (fileType) { const extension = fileType.startsWith('.') ? fileType : `.${fileType}`; filesToSearch = allFiles.filter(file => file.path.endsWith(extension)); } const searchFunction = regex ? (text: string) => { try { const pattern = new RegExp(query, 'i'); return pattern.test(text); } catch { return text.toLowerCase().includes(query.toLowerCase()); } } : (text: string) => text.toLowerCase().includes(query.toLowerCase()); const results = filesToSearch.filter(file => { if (searchIn === 'filename') { return searchFunction(file.name) || searchFunction(file.path); } else if (searchIn === 'content') { return typeof file.content === 'string' && searchFunction(file.content); } else { return searchFunction(file.name) || searchFunction(file.path) || (typeof file.content === 'string' && searchFunction(file.content)); } }); return results.slice(0, limit); } async findReferences( projectId: string, identifier: string, type: 'class' | 'id' | 'function' | 'variable' | 'any' = 'any' ): Promise }>> { this.ensureInitialized(); const allFiles = await this.adapter.listFiles(projectId); const results: Array<{ file: VirtualFile; matches: Array<{ line: number; text: string }> }> = []; const patterns: RegExp[] = []; switch (type) { case 'class': patterns.push(new RegExp(`class=["'][^"']*\\b${identifier}\\b[^"']*["']`, 'gi')); patterns.push(new RegExp(`\\.${identifier}\\b`, 'g')); patterns.push(new RegExp(`classList\\.(add|remove|toggle|contains)\\(['"\`]${identifier}['"\`]`, 'g')); break; case 'id': patterns.push(new RegExp(`id=["']${identifier}["']`, 'gi')); patterns.push(new RegExp(`#${identifier}\\b`, 'g')); patterns.push(new RegExp(`getElementById\\(['"\`]${identifier}['"\`]`, 'g')); patterns.push(new RegExp(`querySelector\\(['"\`]#${identifier}['"\`]`, 'g')); break; case 'function': patterns.push(new RegExp(`function\\s+${identifier}\\s*\\(`, 'g')); patterns.push(new RegExp(`(?:const|let|var)\\s+${identifier}\\s*=\\s*(?:\\([^)]*\\)|[^=])\\s*=>`, 'g')); patterns.push(new RegExp(`${identifier}\\s*\\(`, 'g')); break; case 'variable': patterns.push(new RegExp(`(?:const|let|var)\\s+${identifier}\\b`, 'g')); patterns.push(new RegExp(`\\b${identifier}\\b`, 'g')); break; case 'any': default: patterns.push(new RegExp(`\\b${identifier}\\b`, 'gi')); break; } for (const file of allFiles) { if (typeof file.content !== 'string') continue; const matches: Array<{ line: number; text: string }> = []; const lines = file.content.split('\n'); lines.forEach((line, index) => { for (const pattern of patterns) { if (pattern.test(line)) { matches.push({ line: index + 1, text: line.trim() }); break; } } }); if (matches.length > 0) { results.push({ file, matches }); } } return results; } async getFileStats(projectId: string, path: string): Promise<{ path: string; size: number; lines: number; type: string; preview: string[]; lastModified: Date; }> { this.ensureInitialized(); const file = await this.adapter.getFile(projectId, path); if (!file) { throw new Error(`File not found: ${path}`); } const content = typeof file.content === 'string' ? file.content : ''; const lines = content.split('\n'); return { path: file.path, size: file.size, lines: lines.length, type: file.type, preview: lines.slice(0, 10), lastModified: file.updatedAt }; } async getProjectSize(projectId: string): Promise { this.ensureInitialized(); const files = await this.adapter.listFiles(projectId); return files.reduce((total, file) => total + file.size, 0); } async getProjectStats(projectId: string): Promise<{ fileCount: number; totalSize: number; fileTypes: Record; formattedSize: string; }> { this.ensureInitialized(); const files = await this.adapter.listFiles(projectId); let totalSize = 0; const fileTypes: Record = {}; for (const file of files) { totalSize += file.size; const ext = file.path.split('.').pop()?.toUpperCase() || 'OTHER'; fileTypes[ext] = (fileTypes[ext] || 0) + 1; } let formattedSize: string; if (totalSize < 1024) { formattedSize = `${totalSize} B`; } else if (totalSize < 1024 * 1024) { formattedSize = `${(totalSize / 1024).toFixed(1)} KB`; } else { formattedSize = `${(totalSize / (1024 * 1024)).toFixed(2)} MB`; } return { fileCount: files.length, totalSize, fileTypes, formattedSize }; } async exportProject(projectId: string): Promise<{ project: Project; files: ExportedFile[] }> { this.ensureInitialized(); const project = await this.getProject(projectId); const rawFiles = await this.adapter.listFiles(projectId); // Encode binary content (ArrayBuffer) as base64 so it survives JSON.stringify, // which would otherwise turn an ArrayBuffer into `{}` and drop the file. const files: ExportedFile[] = rawFiles.map((file) => { if (file.content instanceof ArrayBuffer) { return { ...file, content: arrayBufferToBase64(file.content), encoding: 'base64' }; } return { ...file, content: file.content }; }); return { project, files }; } async exportProjectAsZip(projectId: string): Promise { this.ensureInitialized(); const zip = new JSZip(); try { const project = await this.getProject(projectId); const runtime = project?.settings?.runtime || 'handlebars'; const runtimeConfig = getRuntimeConfig(runtime); // For terminal-mode runtimes (Python, Lua), package raw source files if (runtimeConfig.previewMode === 'terminal') { const files = await this.adapter.listFiles(projectId); for (const file of files) { if (this.shouldExcludeFromExport(file.path)) continue; const zipPath = file.path.startsWith('/') ? file.path.slice(1) : file.path; if (typeof file.content === 'string') { zip.file(zipPath, file.content); } else { zip.file(zipPath, file.content); } } // Add a README with run instructions const readmeLines = [ `# ${project?.name || 'Project'}`, '', project?.description || '', '', '## How to Run', '', ]; if (runtime === 'python') { readmeLines.push('```bash', 'python main.py', '```'); } else if (runtime === 'lua') { readmeLines.push('```bash', 'lua main.lua', '```'); } zip.file('README.md', readmeLines.join('\n')); return zip.generateAsync({ type: 'blob' }); } // Create VirtualServer instance and compile the project const { VirtualServer } = await import('@/lib/preview/virtual-server'); const server = new VirtualServer(this, projectId, { runtime: project?.settings?.runtime }); const compiledProject = await server.compileProject(); // compileProject() rewrites internal asset references into instance-local // blob: URLs for the live preview. Build a reverse map so the export can // restore the real root-relative paths — otherwise the exported HTML/CSS // points at blob URLs from the machine that ran the export and fails to // load anywhere else. const blobUrlToPath = new Map(); for (const [filePath, blobUrl] of compiledProject.blobUrls) { blobUrlToPath.set(blobUrl, filePath); } // Add compiled files to ZIP, filtering out template-related files for (const file of compiledProject.files) { const zipPath = file.path.startsWith('/') ? file.path.slice(1) : file.path; // Skip template files, data files, and template directories if (this.shouldExcludeFromExport(file.path)) { continue; } let content = file.content; if (typeof content === 'string') { content = replaceBlobUrlsWithPaths(content, blobUrlToPath); // Strip the preview-only instrumentation (VFS interceptor, console capture) if (file.path.endsWith('.html')) { content = stripPreviewScripts(content); } } zip.file(zipPath, content); } // For bundled projects: also include raw source files (.tsx/.ts/.jsx) // and inject package.json + vite.config.ts for local development const allFiles = await this.adapter.listFiles(projectId); const hasBundleableFiles = allFiles.some(f => f.path.endsWith('.tsx') || f.path.endsWith('.ts') || f.path.endsWith('.jsx') ); if (hasBundleableFiles) { // Add source files that were compiled into the bundle for (const file of allFiles) { if (file.path.endsWith('.tsx') || file.path.endsWith('.ts') || file.path.endsWith('.jsx')) { const zipPath = file.path.startsWith('/') ? file.path.slice(1) : file.path; if (typeof file.content === 'string') { zip.file(zipPath, file.content); } } } // Inject package.json for local dev with Vite const projectSlug = (project?.name || 'my-app').toLowerCase().replace(/[^a-z0-9]+/g, '-'); zip.file('package.json', JSON.stringify({ name: projectSlug, private: true, type: 'module', scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview' }, dependencies: { react: '^19.0.0', 'react-dom': '^19.0.0' }, devDependencies: { '@types/react': '^19.0.0', '@types/react-dom': '^19.0.0', typescript: '^5.6.0', vite: '^6.0.0', '@vitejs/plugin-react': '^4.0.0' } }, null, 2)); zip.file('vite.config.ts', `import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\nexport default defineConfig({ plugins: [react()] });\n` ); } // Clean up VirtualServer resources server.cleanupBlobUrls(); } catch (error) { logger.warn('Failed to compile Handlebars templates during export, falling back to raw files:', error); // Fallback to original behavior if Handlebars compilation fails const files = await this.adapter.listFiles(projectId); for (const file of files) { const zipPath = file.path.startsWith('/') ? file.path.slice(1) : file.path; // Skip template files even in fallback mode if (this.shouldExcludeFromExport(file.path)) { continue; } zip.file(zipPath, file.content); } } const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } }); return blob; } private shouldExcludeFromExport(filePath: string): boolean { // Exclude template files and related development files if (filePath.endsWith('.hbs') || filePath.endsWith('.handlebars')) { return true; } // Exclude templates directory if (filePath.startsWith('/templates/')) { return true; } // Exclude data.json file (since it's compiled into HTML) if (filePath === '/data.json') { return true; } // Exclude dot-prefixed files and directories (e.g. .PROMPT.md, .DESIGN.md, .skills/) const firstSegment = filePath.split('/').filter(Boolean)[0]; if (firstSegment && firstSegment.startsWith('.')) { return true; } return false; } async duplicateProject(projectId: string): Promise { this.ensureInitialized(); const originalProject = await this.getProject(projectId); const files = await this.adapter.listFiles(projectId); const newName = `${originalProject.name} (Copy)`.slice(0, 50); const newProject = await this.createProject( newName, originalProject.description ); // Carry over project settings (runtime, default template, etc.) so the copy // isn't silently reset to the legacy 'handlebars' default. if (originalProject.settings && Object.keys(originalProject.settings).length > 0) { newProject.settings = { ...originalProject.settings }; await this.updateProject(newProject); } await saveManager.runWithSuppressedDirty(newProject.id, async () => { for (const file of files) { await this.createFile(newProject.id, file.path, file.content); } }); return newProject; } async importProject(data: { project: Project; files: ExportedFile[] }): Promise { this.ensureInitialized(); const newProject = await this.createProject(data.project.name, data.project.description); // Preserve project settings (runtime, default template, etc.) from the export // so the imported project isn't reset to the legacy 'handlebars' default. if (data.project.settings && Object.keys(data.project.settings).length > 0) { newProject.settings = { ...data.project.settings }; await this.updateProject(newProject); } await saveManager.runWithSuppressedDirty(newProject.id, async () => { for (const file of data.files) { // Restore base64-encoded binary content back to an ArrayBuffer; text // files (and older exports without the flag) pass through as strings. const content = file.encoding === 'base64' && typeof file.content === 'string' ? base64ToArrayBuffer(file.content) : (file.content as string); await this.createFile(newProject.id, file.path, content); } }); return newProject; } private getParentPath(path: string): string | null { if (path === '/') return null; const parts = path.split('/').filter(Boolean); if (parts.length === 1) return '/'; parts.pop(); return '/' + parts.join('/'); } private async updateFileTree(projectId: string, path: string, operation: 'create' | 'delete', silent = false): Promise { const parentPath = this.getParentPath(path); if (parentPath === null) return; let parentNode = await this.adapter.getTreeNode(projectId, parentPath); if (!parentNode && operation === 'create') { await this.createDirectory(projectId, parentPath); parentNode = await this.adapter.getTreeNode(projectId, parentPath); } if (parentNode) { const children = parentNode.children || []; if (operation === 'create' && !children.includes(path)) { children.push(path); } else if (operation === 'delete') { const index = children.indexOf(path); if (index > -1) { children.splice(index, 1); } } parentNode.children = children; await this.adapter.updateTreeNode(parentNode); if (!silent && typeof window !== 'undefined') { window.dispatchEvent(new Event('filesChanged')); } } } } export const vfs = new VirtualFileSystem(); let _contextVFSProvider: (() => VirtualFileSystem | undefined) | null = null; export function registerContextVFSProvider(provider: () => VirtualFileSystem | undefined): void { _contextVFSProvider = provider; } export function getActiveVFS(): VirtualFileSystem { return _contextVFSProvider?.() ?? vfs; } export * from './types';