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:
Stijnus
2025-05-08 00:07:32 +02:00
committed by GitHub
parent 5c9d413344
commit 9a5076d8c6
13 changed files with 1802 additions and 57 deletions

View File

@@ -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);
}