mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-23 02:16:08 +00:00
1045 lines
30 KiB
TypeScript
1045 lines
30 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 { getTargetedFilesForChat, addTargetedFile, removeTargetedFile } from '~/lib/persistence/targetedFiles';
|
|
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;
|
|
isTargeted?: 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();
|
|
|
|
// Load targeted files from localStorage
|
|
this.#loadTargetedFiles();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load targeted files from localStorage and mark them in the store
|
|
* @param chatId Optional chat ID (defaults to current chat)
|
|
*/
|
|
#loadTargetedFiles(chatId?: string) {
|
|
try {
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
const targeted = getTargetedFilesForChat(currentChatId);
|
|
|
|
if (targeted.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const currentFiles = this.files.get();
|
|
const updates: FileMap = {};
|
|
|
|
for (const path of targeted) {
|
|
const file = currentFiles[path];
|
|
|
|
if (file?.type === 'file') {
|
|
updates[path] = { ...file, isTargeted: true };
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
this.files.set({ ...currentFiles, ...updates });
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to load targeted 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;
|
|
}
|
|
|
|
/**
|
|
* Mark a file as targeted for AI edits
|
|
*/
|
|
targetFile(filePath: string, chatId?: string) {
|
|
const file = this.getFile(filePath);
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
|
|
if (!file) {
|
|
logger.error(`Cannot target non-existent file: ${filePath}`);
|
|
return false;
|
|
}
|
|
|
|
this.files.setKey(filePath, { ...file, isTargeted: true });
|
|
addTargetedFile(currentChatId, filePath);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Remove a file from targeted list
|
|
*/
|
|
unTargetFile(filePath: string, chatId?: string) {
|
|
const file = this.getFile(filePath);
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
|
|
if (!file) {
|
|
logger.error(`Cannot untarget non-existent file: ${filePath}`);
|
|
return false;
|
|
}
|
|
|
|
this.files.setKey(filePath, { ...file, isTargeted: false });
|
|
removeTargetedFile(currentChatId, filePath);
|
|
|
|
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 targeted for AI edits
|
|
*/
|
|
isFileTargeted(filePath: string, chatId?: string): boolean {
|
|
const currentChatId = chatId || getCurrentChatId();
|
|
const file = this.getFile(filePath);
|
|
|
|
if (file?.isTargeted) {
|
|
return true;
|
|
}
|
|
|
|
return getTargetedFilesForChat(currentChatId).includes(filePath);
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
|
|
try {
|
|
const existing = await webcontainer.fs.readFile(relativePath, 'utf-8');
|
|
|
|
if (existing === content) {
|
|
logger.debug(`Skipped writing ${relativePath} (no changes)`);
|
|
} else {
|
|
await webcontainer.fs.writeFile(relativePath, content);
|
|
}
|
|
} catch {
|
|
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, buffer } of watchEvents) {
|
|
// remove any trailing slashes
|
|
const sanitizedPath = path.replace(/\/+$/g, '');
|
|
|
|
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 = '';
|
|
|
|
/**
|
|
* @note This check is purely for the editor. The way we detect this is not
|
|
* bullet-proof and it's a best guess so there might be false-positives.
|
|
* The reason we do this is because we don't want to display binary files
|
|
* in the editor nor allow to edit them.
|
|
*/
|
|
const isBinary = isBinaryFile(buffer);
|
|
|
|
if (!isBinary) {
|
|
content = this.#decodeFileContent(buffer);
|
|
}
|
|
|
|
this.files.setKey(sanitizedPath, { type: 'file', content, isBinary });
|
|
|
|
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);
|
|
}
|