feat: lock files (#1681)
* 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>
This commit is contained in:
@@ -8,6 +8,19 @@ 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');
|
||||
|
||||
@@ -17,10 +30,14 @@ 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;
|
||||
@@ -76,6 +93,9 @@ export class FilesStore {
|
||||
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;
|
||||
@@ -83,19 +103,419 @@ export class FilesStore {
|
||||
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?.type !== 'file') {
|
||||
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);
|
||||
}
|
||||
@@ -149,8 +569,17 @@ export class FilesStore {
|
||||
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 });
|
||||
this.files.setKey(filePath, {
|
||||
type: 'file',
|
||||
content,
|
||||
isBinary: false,
|
||||
isLocked,
|
||||
});
|
||||
|
||||
logger.info('File updated');
|
||||
} catch (error) {
|
||||
@@ -166,10 +595,40 @@ export class FilesStore {
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,7 +761,15 @@ export class FilesStore {
|
||||
content = existingFile.content;
|
||||
}
|
||||
|
||||
this.files.setKey(sanitizedPath, { type: 'file', content, isBinary });
|
||||
// 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': {
|
||||
@@ -353,14 +820,24 @@ export class FilesStore {
|
||||
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 });
|
||||
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 });
|
||||
this.files.setKey(filePath, {
|
||||
type: 'file',
|
||||
content: content as string,
|
||||
isBinary: false,
|
||||
isLocked: false,
|
||||
});
|
||||
|
||||
this.#modifiedFiles.set(filePath, content as string);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user