mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-25 09:47:37 +00:00
* Add persistent file locking feature with enhanced UI * Fix file locking to be scoped by chat ID * Add folder locking functionality * Update CHANGES.md to include folder locking functionality * Add early detection of locked files/folders in user prompts * Improve locked files detection with smarter pattern matching and prevent AI from attempting to modify locked files * Add detection for unlocked files to allow AI to continue with modifications in the same chat session * Implement dialog-based Lock Manager with improved styling for dark/light modes * Add remaining files for file locking implementation * refactor(lock-manager): simplify lock management UI and remove scoped lock options Consolidate lock management UI by removing scoped lock options and integrating LockManager directly into the EditorPanel. Simplify the lock management interface by removing the dialog and replacing it with a tab-based view. This improves maintainability and user experience by reducing complexity and streamlining the lock management process. Change Lock & Unlock action to use toast instead of alert. Remove LockManagerDialog as it is now tab based. * Optimize file locking mechanism for better performance - Add in-memory caching to reduce localStorage reads - Implement debounced localStorage writes - Use Map data structures for faster lookups - Add batch operations for locking/unlocking multiple items - Reduce polling frequency and add event-based updates - Add performance monitoring and cross-tab synchronization * refactor(file-locking): simplify file locking mechanism and remove scoped locks This commit removes the scoped locking feature and simplifies the file locking mechanism. The `LockMode` type and related logic have been removed, and all locks are now treated as full locks. The `isLocked` property has been standardized across the codebase, replacing the previous `locked` and `lockMode` properties. Additionally, the `useLockedFilesChecker` hook and `LockAlert` component have been removed as they are no longer needed with the simplified locking system. This gives the LLM a clear understanding of locked files and strict instructions not to make any changes to these files * refactor: remove debug console.log statements --------- Co-authored-by: KevIsDev <zennerd404@gmail.com>
983 lines
28 KiB
TypeScript
983 lines
28 KiB
TypeScript
import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';
|
|
import { getEncoding } from 'istextorbinary';
|
|
import { map, type MapStore } from 'nanostores';
|
|
import { Buffer } from 'node:buffer';
|
|
import { path } from '~/utils/path';
|
|
import { bufferWatchEvents } from '~/utils/buffer';
|
|
import { WORK_DIR } from '~/utils/constants';
|
|
import { computeFileModifications } from '~/utils/diff';
|
|
import { createScopedLogger } from '~/utils/logger';
|
|
import { unreachable } from '~/utils/unreachable';
|
|
import {
|
|
addLockedFile,
|
|
removeLockedFile,
|
|
addLockedFolder,
|
|
removeLockedFolder,
|
|
getLockedItemsForChat,
|
|
getLockedFilesForChat,
|
|
getLockedFoldersForChat,
|
|
isPathInLockedFolder,
|
|
migrateLegacyLocks,
|
|
clearCache,
|
|
} from '~/lib/persistence/lockedFiles';
|
|
import { getCurrentChatId } from '~/utils/fileLocks';
|
|
|
|
const logger = createScopedLogger('FilesStore');
|
|
|
|
const utf8TextDecoder = new TextDecoder('utf8', { fatal: true });
|
|
|
|
export interface File {
|
|
type: 'file';
|
|
content: string;
|
|
isBinary: boolean;
|
|
isLocked?: boolean;
|
|
lockedByFolder?: string; // Path of the folder that locked this file
|
|
}
|
|
|
|
export interface Folder {
|
|
type: 'folder';
|
|
isLocked?: boolean;
|
|
lockedByFolder?: string; // Path of the folder that locked this folder (for nested folders)
|
|
}
|
|
|
|
type Dirent = File | Folder;
|
|
|
|
export type FileMap = Record<string, Dirent | undefined>;
|
|
|
|
export class FilesStore {
|
|
#webcontainer: Promise<WebContainer>;
|
|
|
|
/**
|
|
* Tracks the number of files without folders.
|
|
*/
|
|
#size = 0;
|
|
|
|
/**
|
|
* @note Keeps track all modified files with their original content since the last user message.
|
|
* Needs to be reset when the user sends another message and all changes have to be submitted
|
|
* for the model to be aware of the changes.
|
|
*/
|
|
#modifiedFiles: Map<string, string> = import.meta.hot?.data.modifiedFiles ?? new Map();
|
|
|
|
/**
|
|
* Keeps track of deleted files and folders to prevent them from reappearing on reload
|
|
*/
|
|
#deletedPaths: Set<string> = import.meta.hot?.data.deletedPaths ?? new Set();
|
|
|
|
/**
|
|
* Map of files that matches the state of WebContainer.
|
|
*/
|
|
files: MapStore<FileMap> = import.meta.hot?.data.files ?? map({});
|
|
|
|
get filesCount() {
|
|
return this.#size;
|
|
}
|
|
|
|
constructor(webcontainerPromise: Promise<WebContainer>) {
|
|
this.#webcontainer = webcontainerPromise;
|
|
|
|
// Load deleted paths from localStorage if available
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
const deletedPathsJson = localStorage.getItem('bolt-deleted-paths');
|
|
|
|
if (deletedPathsJson) {
|
|
const deletedPaths = JSON.parse(deletedPathsJson);
|
|
|
|
if (Array.isArray(deletedPaths)) {
|
|
deletedPaths.forEach((path) => this.#deletedPaths.add(path));
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to load deleted paths from localStorage', error);
|
|
}
|
|
|
|
// Load locked files from localStorage
|
|
this.#loadLockedFiles();
|
|
|
|
if (import.meta.hot) {
|
|
// Persist our state across hot reloads
|
|
import.meta.hot.data.files = this.files;
|
|
import.meta.hot.data.modifiedFiles = this.#modifiedFiles;
|
|
import.meta.hot.data.deletedPaths = this.#deletedPaths;
|
|
}
|
|
|
|
// Listen for URL changes to detect chat ID changes
|
|
if (typeof window !== 'undefined') {
|
|
let lastChatId = getCurrentChatId();
|
|
|
|
// Use MutationObserver to detect URL changes (for SPA navigation)
|
|
const observer = new MutationObserver(() => {
|
|
const currentChatId = getCurrentChatId();
|
|
|
|
if (currentChatId !== lastChatId) {
|
|
logger.info(`Chat ID changed from ${lastChatId} to ${currentChatId}, reloading locks`);
|
|
lastChatId = currentChatId;
|
|
this.#loadLockedFiles(currentChatId);
|
|
}
|
|
});
|
|
|
|
observer.observe(document, { subtree: true, childList: true });
|
|
}
|
|
|
|
this.#init();
|
|
}
|
|
|
|
/**
|
|
* Load locked files and folders from localStorage and update the file objects
|
|
* @param chatId Optional chat ID to load locks for (defaults to current chat)
|
|
*/
|
|
#loadLockedFiles(chatId?: string) {
|
|
try {
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
const startTime = performance.now();
|
|
|
|
// Migrate any legacy locks to the current chat
|
|
migrateLegacyLocks(currentChatId);
|
|
|
|
// Get all locked items for this chat (uses optimized cache)
|
|
const lockedItems = getLockedItemsForChat(currentChatId);
|
|
|
|
// Split into files and folders
|
|
const lockedFiles = lockedItems.filter((item) => !item.isFolder);
|
|
const lockedFolders = lockedItems.filter((item) => item.isFolder);
|
|
|
|
if (lockedItems.length === 0) {
|
|
logger.info(`No locked items found for chat ID: ${currentChatId}`);
|
|
return;
|
|
}
|
|
|
|
logger.info(
|
|
`Found ${lockedFiles.length} locked files and ${lockedFolders.length} locked folders for chat ID: ${currentChatId}`,
|
|
);
|
|
|
|
const currentFiles = this.files.get();
|
|
const updates: FileMap = {};
|
|
|
|
// Process file locks
|
|
for (const lockedFile of lockedFiles) {
|
|
const file = currentFiles[lockedFile.path];
|
|
|
|
if (file?.type === 'file') {
|
|
updates[lockedFile.path] = {
|
|
...file,
|
|
isLocked: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Process folder locks
|
|
for (const lockedFolder of lockedFolders) {
|
|
const folder = currentFiles[lockedFolder.path];
|
|
|
|
if (folder?.type === 'folder') {
|
|
updates[lockedFolder.path] = {
|
|
...folder,
|
|
isLocked: true,
|
|
};
|
|
|
|
// Also mark all files within the folder as locked
|
|
this.#applyLockToFolderContents(currentFiles, updates, lockedFolder.path);
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
this.files.set({ ...currentFiles, ...updates });
|
|
}
|
|
|
|
const endTime = performance.now();
|
|
logger.info(`Loaded locked items in ${Math.round(endTime - startTime)}ms`);
|
|
} catch (error) {
|
|
logger.error('Failed to load locked files from localStorage', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply a lock to all files within a folder
|
|
* @param currentFiles Current file map
|
|
* @param updates Updates to apply
|
|
* @param folderPath Path of the folder to lock
|
|
*/
|
|
#applyLockToFolderContents(currentFiles: FileMap, updates: FileMap, folderPath: string) {
|
|
const folderPrefix = folderPath.endsWith('/') ? folderPath : `${folderPath}/`;
|
|
|
|
// Find all files that are within this folder
|
|
Object.entries(currentFiles).forEach(([path, file]) => {
|
|
if (path.startsWith(folderPrefix) && file) {
|
|
if (file.type === 'file') {
|
|
updates[path] = {
|
|
...file,
|
|
isLocked: true,
|
|
|
|
// Add a property to indicate this is locked by a parent folder
|
|
lockedByFolder: folderPath,
|
|
};
|
|
} else if (file.type === 'folder') {
|
|
updates[path] = {
|
|
...file,
|
|
isLocked: true,
|
|
|
|
// Add a property to indicate this is locked by a parent folder
|
|
lockedByFolder: folderPath,
|
|
};
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Lock a file
|
|
* @param filePath Path to the file to lock
|
|
* @param chatId Optional chat ID (defaults to current chat)
|
|
* @returns True if the file was successfully locked
|
|
*/
|
|
lockFile(filePath: string, chatId?: string) {
|
|
const file = this.getFile(filePath);
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
|
|
if (!file) {
|
|
logger.error(`Cannot lock non-existent file: ${filePath}`);
|
|
return false;
|
|
}
|
|
|
|
// Update the file in the store
|
|
this.files.setKey(filePath, {
|
|
...file,
|
|
isLocked: true,
|
|
});
|
|
|
|
// Persist to localStorage with chat ID
|
|
addLockedFile(currentChatId, filePath);
|
|
|
|
logger.info(`File locked: ${filePath} for chat: ${currentChatId}`);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Lock a folder and all its contents
|
|
* @param folderPath Path to the folder to lock
|
|
* @param chatId Optional chat ID (defaults to current chat)
|
|
* @returns True if the folder was successfully locked
|
|
*/
|
|
lockFolder(folderPath: string, chatId?: string) {
|
|
const folder = this.getFileOrFolder(folderPath);
|
|
const currentFiles = this.files.get();
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
|
|
if (!folder || folder.type !== 'folder') {
|
|
logger.error(`Cannot lock non-existent folder: ${folderPath}`);
|
|
return false;
|
|
}
|
|
|
|
const updates: FileMap = {};
|
|
|
|
// Update the folder in the store
|
|
updates[folderPath] = {
|
|
type: folder.type,
|
|
isLocked: true,
|
|
};
|
|
|
|
// Apply lock to all files within the folder
|
|
this.#applyLockToFolderContents(currentFiles, updates, folderPath);
|
|
|
|
// Update the store with all changes
|
|
this.files.set({ ...currentFiles, ...updates });
|
|
|
|
// Persist to localStorage with chat ID
|
|
addLockedFolder(currentChatId, folderPath);
|
|
|
|
logger.info(`Folder locked: ${folderPath} for chat: ${currentChatId}`);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Unlock a file
|
|
* @param filePath Path to the file to unlock
|
|
* @param chatId Optional chat ID (defaults to current chat)
|
|
* @returns True if the file was successfully unlocked
|
|
*/
|
|
unlockFile(filePath: string, chatId?: string) {
|
|
const file = this.getFile(filePath);
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
|
|
if (!file) {
|
|
logger.error(`Cannot unlock non-existent file: ${filePath}`);
|
|
return false;
|
|
}
|
|
|
|
// Update the file in the store
|
|
this.files.setKey(filePath, {
|
|
...file,
|
|
isLocked: false,
|
|
lockedByFolder: undefined, // Clear the parent folder lock reference if it exists
|
|
});
|
|
|
|
// Remove from localStorage with chat ID
|
|
removeLockedFile(currentChatId, filePath);
|
|
|
|
logger.info(`File unlocked: ${filePath} for chat: ${currentChatId}`);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Unlock a folder and all its contents
|
|
* @param folderPath Path to the folder to unlock
|
|
* @param chatId Optional chat ID (defaults to current chat)
|
|
* @returns True if the folder was successfully unlocked
|
|
*/
|
|
unlockFolder(folderPath: string, chatId?: string) {
|
|
const folder = this.getFileOrFolder(folderPath);
|
|
const currentFiles = this.files.get();
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
|
|
if (!folder || folder.type !== 'folder') {
|
|
logger.error(`Cannot unlock non-existent folder: ${folderPath}`);
|
|
return false;
|
|
}
|
|
|
|
const updates: FileMap = {};
|
|
|
|
// Update the folder in the store
|
|
updates[folderPath] = {
|
|
type: folder.type,
|
|
isLocked: false,
|
|
};
|
|
|
|
// Find all files that are within this folder and unlock them
|
|
const folderPrefix = folderPath.endsWith('/') ? folderPath : `${folderPath}/`;
|
|
|
|
Object.entries(currentFiles).forEach(([path, file]) => {
|
|
if (path.startsWith(folderPrefix) && file) {
|
|
if (file.type === 'file' && file.lockedByFolder === folderPath) {
|
|
updates[path] = {
|
|
...file,
|
|
isLocked: false,
|
|
lockedByFolder: undefined,
|
|
};
|
|
} else if (file.type === 'folder' && file.lockedByFolder === folderPath) {
|
|
updates[path] = {
|
|
type: file.type,
|
|
isLocked: false,
|
|
lockedByFolder: undefined,
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
// Update the store with all changes
|
|
this.files.set({ ...currentFiles, ...updates });
|
|
|
|
// Remove from localStorage with chat ID
|
|
removeLockedFolder(currentChatId, folderPath);
|
|
|
|
logger.info(`Folder unlocked: ${folderPath} for chat: ${currentChatId}`);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if a file is locked
|
|
* @param filePath Path to the file to check
|
|
* @param chatId Optional chat ID (defaults to current chat)
|
|
* @returns Object with locked status, lock mode, and what caused the lock
|
|
*/
|
|
isFileLocked(filePath: string, chatId?: string): { locked: boolean; lockedBy?: string } {
|
|
const file = this.getFile(filePath);
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
|
|
if (!file) {
|
|
return { locked: false };
|
|
}
|
|
|
|
// First check the in-memory state
|
|
if (file.isLocked) {
|
|
// If the file is locked by a folder, include that information
|
|
if (file.lockedByFolder) {
|
|
return {
|
|
locked: true,
|
|
lockedBy: file.lockedByFolder as string,
|
|
};
|
|
}
|
|
|
|
return {
|
|
locked: true,
|
|
lockedBy: filePath,
|
|
};
|
|
}
|
|
|
|
// Then check localStorage for direct file locks
|
|
const lockedFiles = getLockedFilesForChat(currentChatId);
|
|
const lockedFile = lockedFiles.find((item) => item.path === filePath);
|
|
|
|
if (lockedFile) {
|
|
// Update the in-memory state to match localStorage
|
|
this.files.setKey(filePath, {
|
|
...file,
|
|
isLocked: true,
|
|
});
|
|
|
|
return { locked: true, lockedBy: filePath };
|
|
}
|
|
|
|
// Finally, check if the file is in a locked folder
|
|
const folderLockResult = this.isFileInLockedFolder(filePath, currentChatId);
|
|
|
|
if (folderLockResult.locked) {
|
|
// Update the in-memory state to reflect the folder lock
|
|
this.files.setKey(filePath, {
|
|
...file,
|
|
isLocked: true,
|
|
lockedByFolder: folderLockResult.lockedBy,
|
|
});
|
|
|
|
return folderLockResult;
|
|
}
|
|
|
|
return { locked: false };
|
|
}
|
|
|
|
/**
|
|
* Check if a file is within a locked folder
|
|
* @param filePath Path to the file to check
|
|
* @param chatId Optional chat ID (defaults to current chat)
|
|
* @returns Object with locked status, lock mode, and the folder that caused the lock
|
|
*/
|
|
isFileInLockedFolder(filePath: string, chatId?: string): { locked: boolean; lockedBy?: string } {
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
|
|
// Use the optimized function from lockedFiles.ts
|
|
return isPathInLockedFolder(currentChatId, filePath);
|
|
}
|
|
|
|
/**
|
|
* Check if a folder is locked
|
|
* @param folderPath Path to the folder to check
|
|
* @param chatId Optional chat ID (defaults to current chat)
|
|
* @returns Object with locked status and lock mode
|
|
*/
|
|
isFolderLocked(folderPath: string, chatId?: string): { isLocked: boolean; lockedBy?: string } {
|
|
const folder = this.getFileOrFolder(folderPath);
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
|
|
if (!folder || folder.type !== 'folder') {
|
|
return { isLocked: false };
|
|
}
|
|
|
|
// First check the in-memory state
|
|
if (folder.isLocked) {
|
|
return {
|
|
isLocked: true,
|
|
lockedBy: folderPath,
|
|
};
|
|
}
|
|
|
|
// Then check localStorage for this specific chat
|
|
const lockedFolders = getLockedFoldersForChat(currentChatId);
|
|
const lockedFolder = lockedFolders.find((item) => item.path === folderPath);
|
|
|
|
if (lockedFolder) {
|
|
// Update the in-memory state to match localStorage
|
|
this.files.setKey(folderPath, {
|
|
type: folder.type,
|
|
isLocked: true,
|
|
});
|
|
|
|
return { isLocked: true, lockedBy: folderPath };
|
|
}
|
|
|
|
return { isLocked: false };
|
|
}
|
|
|
|
getFile(filePath: string) {
|
|
const dirent = this.files.get()[filePath];
|
|
|
|
if (!dirent) {
|
|
return undefined;
|
|
}
|
|
|
|
// For backward compatibility, only return file type dirents
|
|
if (dirent.type !== 'file') {
|
|
return undefined;
|
|
}
|
|
|
|
return dirent;
|
|
}
|
|
|
|
/**
|
|
* Get any file or folder from the file system
|
|
* @param path Path to the file or folder
|
|
* @returns The file or folder, or undefined if it doesn't exist
|
|
*/
|
|
getFileOrFolder(path: string) {
|
|
return this.files.get()[path];
|
|
}
|
|
|
|
getFileModifications() {
|
|
return computeFileModifications(this.files.get(), this.#modifiedFiles);
|
|
}
|
|
getModifiedFiles() {
|
|
let modifiedFiles: { [path: string]: File } | undefined = undefined;
|
|
|
|
for (const [filePath, originalContent] of this.#modifiedFiles) {
|
|
const file = this.files.get()[filePath];
|
|
|
|
if (file?.type !== 'file') {
|
|
continue;
|
|
}
|
|
|
|
if (file.content === originalContent) {
|
|
continue;
|
|
}
|
|
|
|
if (!modifiedFiles) {
|
|
modifiedFiles = {};
|
|
}
|
|
|
|
modifiedFiles[filePath] = file;
|
|
}
|
|
|
|
return modifiedFiles;
|
|
}
|
|
|
|
resetFileModifications() {
|
|
this.#modifiedFiles.clear();
|
|
}
|
|
|
|
async saveFile(filePath: string, content: string) {
|
|
const webcontainer = await this.#webcontainer;
|
|
|
|
try {
|
|
const relativePath = path.relative(webcontainer.workdir, filePath);
|
|
|
|
if (!relativePath) {
|
|
throw new Error(`EINVAL: invalid file path, write '${relativePath}'`);
|
|
}
|
|
|
|
const oldContent = this.getFile(filePath)?.content;
|
|
|
|
if (!oldContent && oldContent !== '') {
|
|
unreachable('Expected content to be defined');
|
|
}
|
|
|
|
await webcontainer.fs.writeFile(relativePath, content);
|
|
|
|
if (!this.#modifiedFiles.has(filePath)) {
|
|
this.#modifiedFiles.set(filePath, oldContent);
|
|
}
|
|
|
|
// Get the current lock state before updating
|
|
const currentFile = this.files.get()[filePath];
|
|
const isLocked = currentFile?.type === 'file' ? currentFile.isLocked : false;
|
|
|
|
// we immediately update the file and don't rely on the `change` event coming from the watcher
|
|
this.files.setKey(filePath, {
|
|
type: 'file',
|
|
content,
|
|
isBinary: false,
|
|
isLocked,
|
|
});
|
|
|
|
logger.info('File updated');
|
|
} catch (error) {
|
|
logger.error('Failed to update file content\n\n', error);
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async #init() {
|
|
const webcontainer = await this.#webcontainer;
|
|
|
|
// Clean up any files that were previously deleted
|
|
this.#cleanupDeletedFiles();
|
|
|
|
// Set up file watcher
|
|
webcontainer.internal.watchPaths(
|
|
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
|
|
bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
|
|
);
|
|
|
|
// Get the current chat ID
|
|
const currentChatId = getCurrentChatId();
|
|
|
|
// Migrate any legacy locks to the current chat
|
|
migrateLegacyLocks(currentChatId);
|
|
|
|
// Load locked files immediately for the current chat
|
|
this.#loadLockedFiles(currentChatId);
|
|
|
|
/**
|
|
* Also set up a timer to load locked files again after a delay.
|
|
* This ensures that locks are applied even if files are loaded asynchronously.
|
|
*/
|
|
setTimeout(() => {
|
|
this.#loadLockedFiles(currentChatId);
|
|
}, 2000);
|
|
|
|
/**
|
|
* Set up a less frequent periodic check to ensure locks remain applied.
|
|
* This is now less critical since we have the storage event listener.
|
|
*/
|
|
setInterval(() => {
|
|
// Clear the cache to force a fresh read from localStorage
|
|
clearCache();
|
|
|
|
const latestChatId = getCurrentChatId();
|
|
this.#loadLockedFiles(latestChatId);
|
|
}, 30000); // Reduced from 10s to 30s
|
|
}
|
|
|
|
/**
|
|
* Removes any deleted files/folders from the store
|
|
*/
|
|
#cleanupDeletedFiles() {
|
|
if (this.#deletedPaths.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const currentFiles = this.files.get();
|
|
const pathsToDelete = new Set<string>();
|
|
|
|
// Precompute prefixes for efficient checking
|
|
const deletedPrefixes = [...this.#deletedPaths].map((p) => p + '/');
|
|
|
|
// Iterate through all current files/folders once
|
|
for (const [path, dirent] of Object.entries(currentFiles)) {
|
|
// Skip if dirent is already undefined (shouldn't happen often but good practice)
|
|
if (!dirent) {
|
|
continue;
|
|
}
|
|
|
|
// Check for exact match in deleted paths
|
|
if (this.#deletedPaths.has(path)) {
|
|
pathsToDelete.add(path);
|
|
continue; // No need to check prefixes if it's an exact match
|
|
}
|
|
|
|
// Check if the path starts with any of the deleted folder prefixes
|
|
for (const prefix of deletedPrefixes) {
|
|
if (path.startsWith(prefix)) {
|
|
pathsToDelete.add(path);
|
|
break; // Found a match, no need to check other prefixes for this path
|
|
}
|
|
}
|
|
}
|
|
|
|
// Perform the deletions and updates based on the collected paths
|
|
if (pathsToDelete.size > 0) {
|
|
const updates: FileMap = {};
|
|
|
|
for (const pathToDelete of pathsToDelete) {
|
|
const dirent = currentFiles[pathToDelete];
|
|
updates[pathToDelete] = undefined; // Mark for deletion in the map update
|
|
|
|
if (dirent?.type === 'file') {
|
|
this.#size--;
|
|
|
|
if (this.#modifiedFiles.has(pathToDelete)) {
|
|
this.#modifiedFiles.delete(pathToDelete);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply all deletions to the store at once for potential efficiency
|
|
this.files.set({ ...currentFiles, ...updates });
|
|
}
|
|
}
|
|
|
|
#processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) {
|
|
const watchEvents = events.flat(2);
|
|
|
|
for (const { type, path: eventPath, buffer } of watchEvents) {
|
|
// remove any trailing slashes
|
|
const sanitizedPath = eventPath.replace(/\/+$/g, '');
|
|
|
|
// Skip processing if this file/folder was explicitly deleted
|
|
if (this.#deletedPaths.has(sanitizedPath)) {
|
|
continue;
|
|
}
|
|
|
|
let isInDeletedFolder = false;
|
|
|
|
for (const deletedPath of this.#deletedPaths) {
|
|
if (sanitizedPath.startsWith(deletedPath + '/')) {
|
|
isInDeletedFolder = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (isInDeletedFolder) {
|
|
continue;
|
|
}
|
|
|
|
switch (type) {
|
|
case 'add_dir': {
|
|
// we intentionally add a trailing slash so we can distinguish files from folders in the file tree
|
|
this.files.setKey(sanitizedPath, { type: 'folder' });
|
|
break;
|
|
}
|
|
case 'remove_dir': {
|
|
this.files.setKey(sanitizedPath, undefined);
|
|
|
|
for (const [direntPath] of Object.entries(this.files)) {
|
|
if (direntPath.startsWith(sanitizedPath)) {
|
|
this.files.setKey(direntPath, undefined);
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
case 'add_file':
|
|
case 'change': {
|
|
if (type === 'add_file') {
|
|
this.#size++;
|
|
}
|
|
|
|
let content = '';
|
|
const isBinary = isBinaryFile(buffer);
|
|
|
|
if (isBinary && buffer) {
|
|
// For binary files, we need to preserve the content as base64
|
|
content = Buffer.from(buffer).toString('base64');
|
|
} else if (!isBinary) {
|
|
content = this.#decodeFileContent(buffer);
|
|
|
|
/*
|
|
* If the content is a single space and this is from our empty file workaround,
|
|
* convert it back to an actual empty string
|
|
*/
|
|
if (content === ' ' && type === 'add_file') {
|
|
content = '';
|
|
}
|
|
}
|
|
|
|
const existingFile = this.files.get()[sanitizedPath];
|
|
|
|
if (existingFile?.type === 'file' && existingFile.isBinary && existingFile.content && !content) {
|
|
content = existingFile.content;
|
|
}
|
|
|
|
// Preserve lock state if the file already exists
|
|
const isLocked = existingFile?.type === 'file' ? existingFile.isLocked : false;
|
|
|
|
this.files.setKey(sanitizedPath, {
|
|
type: 'file',
|
|
content,
|
|
isBinary,
|
|
isLocked,
|
|
});
|
|
break;
|
|
}
|
|
case 'remove_file': {
|
|
this.#size--;
|
|
this.files.setKey(sanitizedPath, undefined);
|
|
break;
|
|
}
|
|
case 'update_directory': {
|
|
// we don't care about these events
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#decodeFileContent(buffer?: Uint8Array) {
|
|
if (!buffer || buffer.byteLength === 0) {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
return utf8TextDecoder.decode(buffer);
|
|
} catch (error) {
|
|
console.log(error);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
async createFile(filePath: string, content: string | Uint8Array = '') {
|
|
const webcontainer = await this.#webcontainer;
|
|
|
|
try {
|
|
const relativePath = path.relative(webcontainer.workdir, filePath);
|
|
|
|
if (!relativePath) {
|
|
throw new Error(`EINVAL: invalid file path, create '${relativePath}'`);
|
|
}
|
|
|
|
const dirPath = path.dirname(relativePath);
|
|
|
|
if (dirPath !== '.') {
|
|
await webcontainer.fs.mkdir(dirPath, { recursive: true });
|
|
}
|
|
|
|
const isBinary = content instanceof Uint8Array;
|
|
|
|
if (isBinary) {
|
|
await webcontainer.fs.writeFile(relativePath, Buffer.from(content));
|
|
|
|
const base64Content = Buffer.from(content).toString('base64');
|
|
this.files.setKey(filePath, {
|
|
type: 'file',
|
|
content: base64Content,
|
|
isBinary: true,
|
|
isLocked: false,
|
|
});
|
|
|
|
this.#modifiedFiles.set(filePath, base64Content);
|
|
} else {
|
|
const contentToWrite = (content as string).length === 0 ? ' ' : content;
|
|
await webcontainer.fs.writeFile(relativePath, contentToWrite);
|
|
|
|
this.files.setKey(filePath, {
|
|
type: 'file',
|
|
content: content as string,
|
|
isBinary: false,
|
|
isLocked: false,
|
|
});
|
|
|
|
this.#modifiedFiles.set(filePath, content as string);
|
|
}
|
|
|
|
logger.info(`File created: ${filePath}`);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('Failed to create file\n\n', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async createFolder(folderPath: string) {
|
|
const webcontainer = await this.#webcontainer;
|
|
|
|
try {
|
|
const relativePath = path.relative(webcontainer.workdir, folderPath);
|
|
|
|
if (!relativePath) {
|
|
throw new Error(`EINVAL: invalid folder path, create '${relativePath}'`);
|
|
}
|
|
|
|
await webcontainer.fs.mkdir(relativePath, { recursive: true });
|
|
|
|
this.files.setKey(folderPath, { type: 'folder' });
|
|
|
|
logger.info(`Folder created: ${folderPath}`);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('Failed to create folder\n\n', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async deleteFile(filePath: string) {
|
|
const webcontainer = await this.#webcontainer;
|
|
|
|
try {
|
|
const relativePath = path.relative(webcontainer.workdir, filePath);
|
|
|
|
if (!relativePath) {
|
|
throw new Error(`EINVAL: invalid file path, delete '${relativePath}'`);
|
|
}
|
|
|
|
await webcontainer.fs.rm(relativePath);
|
|
|
|
this.#deletedPaths.add(filePath);
|
|
|
|
this.files.setKey(filePath, undefined);
|
|
this.#size--;
|
|
|
|
if (this.#modifiedFiles.has(filePath)) {
|
|
this.#modifiedFiles.delete(filePath);
|
|
}
|
|
|
|
this.#persistDeletedPaths();
|
|
|
|
logger.info(`File deleted: ${filePath}`);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('Failed to delete file\n\n', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async deleteFolder(folderPath: string) {
|
|
const webcontainer = await this.#webcontainer;
|
|
|
|
try {
|
|
const relativePath = path.relative(webcontainer.workdir, folderPath);
|
|
|
|
if (!relativePath) {
|
|
throw new Error(`EINVAL: invalid folder path, delete '${relativePath}'`);
|
|
}
|
|
|
|
await webcontainer.fs.rm(relativePath, { recursive: true });
|
|
|
|
this.#deletedPaths.add(folderPath);
|
|
|
|
this.files.setKey(folderPath, undefined);
|
|
|
|
const allFiles = this.files.get();
|
|
|
|
for (const [path, dirent] of Object.entries(allFiles)) {
|
|
if (path.startsWith(folderPath + '/')) {
|
|
this.files.setKey(path, undefined);
|
|
|
|
this.#deletedPaths.add(path);
|
|
|
|
if (dirent?.type === 'file') {
|
|
this.#size--;
|
|
}
|
|
|
|
if (dirent?.type === 'file' && this.#modifiedFiles.has(path)) {
|
|
this.#modifiedFiles.delete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.#persistDeletedPaths();
|
|
|
|
logger.info(`Folder deleted: ${folderPath}`);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('Failed to delete folder\n\n', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// method to persist deleted paths to localStorage
|
|
#persistDeletedPaths() {
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
localStorage.setItem('bolt-deleted-paths', JSON.stringify([...this.#deletedPaths]));
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to persist deleted paths to localStorage', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isBinaryFile(buffer: Uint8Array | undefined) {
|
|
if (buffer === undefined) {
|
|
return false;
|
|
}
|
|
|
|
return getEncoding(convertToBuffer(buffer), { chunkLength: 100 }) === 'binary';
|
|
}
|
|
|
|
/**
|
|
* Converts a `Uint8Array` into a Node.js `Buffer` by copying the prototype.
|
|
* The goal is to avoid expensive copies. It does create a new typed array
|
|
* but that's generally cheap as long as it uses the same underlying
|
|
* array buffer.
|
|
*/
|
|
function convertToBuffer(view: Uint8Array): Buffer {
|
|
return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
|
|
}
|