bolt.diy/app/lib/persistence/lockedFiles.ts
Stijnus 9a5076d8c6
Some checks failed
Docker Publish / docker-build-publish (push) Has been cancelled
Update Stable Branch / prepare-release (push) Has been cancelled
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>
2025-05-08 00:07:32 +02:00

512 lines
15 KiB
TypeScript

import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('LockedFiles');
// Key for storing locked files in localStorage
export const LOCKED_FILES_KEY = 'bolt.lockedFiles';
export interface LockedItem {
chatId: string; // Chat ID to scope locks to a specific project
path: string;
isFolder: boolean; // Indicates if this is a folder lock
}
// In-memory cache for locked items to reduce localStorage reads
let lockedItemsCache: LockedItem[] | null = null;
// Map for faster lookups by chatId and path
const lockedItemsMap = new Map<string, Map<string, LockedItem>>();
// Debounce timer for localStorage writes
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const SAVE_DEBOUNCE_MS = 300;
/**
* Get a chat-specific map from the lookup maps
*/
function getChatMap(chatId: string, createIfMissing = false): Map<string, LockedItem> | undefined {
if (createIfMissing && !lockedItemsMap.has(chatId)) {
lockedItemsMap.set(chatId, new Map());
}
return lockedItemsMap.get(chatId);
}
/**
* Initialize the in-memory cache and lookup maps
*/
function initializeCache(): LockedItem[] {
if (lockedItemsCache !== null) {
return lockedItemsCache;
}
try {
if (typeof localStorage !== 'undefined') {
const lockedItemsJson = localStorage.getItem(LOCKED_FILES_KEY);
if (lockedItemsJson) {
const items = JSON.parse(lockedItemsJson);
// Handle legacy format (without isFolder property)
const normalizedItems = items.map((item: any) => ({
...item,
isFolder: item.isFolder !== undefined ? item.isFolder : false,
}));
// Update the cache
lockedItemsCache = normalizedItems;
// Build the lookup maps
rebuildLookupMaps(normalizedItems);
return normalizedItems;
}
}
// Initialize with empty array if no data in localStorage
lockedItemsCache = [];
return [];
} catch (error) {
logger.error('Failed to initialize locked items cache', error);
lockedItemsCache = [];
return [];
}
}
/**
* Rebuild the lookup maps from the items array
*/
function rebuildLookupMaps(items: LockedItem[]): void {
// Clear existing maps
lockedItemsMap.clear();
// Build new maps
for (const item of items) {
if (!lockedItemsMap.has(item.chatId)) {
lockedItemsMap.set(item.chatId, new Map());
}
const chatMap = lockedItemsMap.get(item.chatId)!;
chatMap.set(item.path, item);
}
}
/**
* Save locked items to localStorage with debouncing
*/
export function saveLockedItems(items: LockedItem[]): void {
// Update the in-memory cache immediately
lockedItemsCache = [...items];
// Rebuild the lookup maps
rebuildLookupMaps(items);
// Debounce the localStorage write
if (saveDebounceTimer) {
clearTimeout(saveDebounceTimer);
}
saveDebounceTimer = setTimeout(() => {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(LOCKED_FILES_KEY, JSON.stringify(items));
logger.info(`Saved ${items.length} locked items to localStorage`);
}
} catch (error) {
logger.error('Failed to save locked items to localStorage', error);
}
}, SAVE_DEBOUNCE_MS);
}
/**
* Get locked items from cache or localStorage
*/
export function getLockedItems(): LockedItem[] {
// Use cache if available
if (lockedItemsCache !== null) {
return lockedItemsCache;
}
// Initialize cache if not yet done
return initializeCache();
}
/**
* Add a file or folder to the locked items list
* @param chatId The chat ID to scope the lock to
* @param path The path of the file or folder to lock
* @param isFolder Whether this is a folder lock
*/
export function addLockedItem(chatId: string, path: string, isFolder: boolean = false): void {
// Ensure cache is initialized
const lockedItems = getLockedItems();
// Create the new item
const newItem = { chatId, path, isFolder };
// Update the in-memory map directly for faster access
const chatMap = getChatMap(chatId, true)!;
chatMap.set(path, newItem);
// Remove any existing entry for this path in this chat and add the new one
const filteredItems = lockedItems.filter((item) => !(item.chatId === chatId && item.path === path));
filteredItems.push(newItem);
// Save the updated list (this will update the cache and maps)
saveLockedItems(filteredItems);
logger.info(`Added locked ${isFolder ? 'folder' : 'file'}: ${path} for chat: ${chatId}`);
}
/**
* Add a file to the locked items list (for backward compatibility)
*/
export function addLockedFile(chatId: string, filePath: string): void {
addLockedItem(chatId, filePath);
}
/**
* Add a folder to the locked items list
*/
export function addLockedFolder(chatId: string, folderPath: string): void {
addLockedItem(chatId, folderPath);
}
/**
* Remove an item from the locked items list
* @param chatId The chat ID the lock belongs to
* @param path The path of the item to unlock
*/
export function removeLockedItem(chatId: string, path: string): void {
// Ensure cache is initialized
const lockedItems = getLockedItems();
// Update the in-memory map directly for faster access
const chatMap = getChatMap(chatId);
if (chatMap) {
chatMap.delete(path);
}
// Filter out the item to remove for this specific chat
const filteredItems = lockedItems.filter((item) => !(item.chatId === chatId && item.path === path));
// Save the updated list (this will update the cache and maps)
saveLockedItems(filteredItems);
logger.info(`Removed lock for: ${path} in chat: ${chatId}`);
}
/**
* Remove a file from the locked items list (for backward compatibility)
*/
export function removeLockedFile(chatId: string, filePath: string): void {
removeLockedItem(chatId, filePath);
}
/**
* Remove a folder from the locked items list
*/
export function removeLockedFolder(chatId: string, folderPath: string): void {
removeLockedItem(chatId, folderPath);
}
/**
* Check if a path is directly locked (not considering parent folders)
* @param chatId The chat ID to check locks for
* @param path The path to check
* @returns Object with locked status, lock mode, and whether it's a folder lock
*/
export function isPathDirectlyLocked(chatId: string, path: string): { locked: boolean; isFolder?: boolean } {
// Ensure cache is initialized
getLockedItems();
// Check the in-memory map for faster lookup
const chatMap = getChatMap(chatId);
if (chatMap) {
const lockedItem = chatMap.get(path);
if (lockedItem) {
return { locked: true, isFolder: lockedItem.isFolder };
}
}
return { locked: false };
}
/**
* Check if a file is locked, either directly or by a parent folder
* @param chatId The chat ID to check locks for
* @param filePath The path of the file to check
* @returns Object with locked status, lock mode, and the path that caused the lock
*/
export function isFileLocked(chatId: string, filePath: string): { locked: boolean; lockedBy?: string } {
// Ensure cache is initialized
getLockedItems();
// Check the in-memory map for direct file lock
const chatMap = getChatMap(chatId);
if (chatMap) {
// First check if the file itself is locked
const directLock = chatMap.get(filePath);
if (directLock && !directLock.isFolder) {
return { locked: true, lockedBy: filePath };
}
}
// Then check if any parent folder is locked
return checkParentFolderLocks(chatId, filePath);
}
/**
* Check if a folder is locked
* @param chatId The chat ID to check locks for
* @param folderPath The path of the folder to check
* @returns Object with locked status and lock mode
*/
export function isFolderLocked(chatId: string, folderPath: string): { locked: boolean; lockedBy?: string } {
// Ensure cache is initialized
getLockedItems();
// Check the in-memory map for direct folder lock
const chatMap = getChatMap(chatId);
if (chatMap) {
// First check if the folder itself is locked
const directLock = chatMap.get(folderPath);
if (directLock && directLock.isFolder) {
return { locked: true, lockedBy: folderPath };
}
}
// Then check if any parent folder is locked
return checkParentFolderLocks(chatId, folderPath);
}
/**
* Helper function to check if any parent folder of a path is locked
* @param chatId The chat ID to check locks for
* @param path The path to check
* @returns Object with locked status, lock mode, and the folder that caused the lock
*/
function checkParentFolderLocks(chatId: string, path: string): { locked: boolean; lockedBy?: string } {
const chatMap = getChatMap(chatId);
if (!chatMap) {
return { locked: false };
}
// Check each parent folder
const pathParts = path.split('/');
let currentPath = '';
for (let i = 0; i < pathParts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${pathParts[i]}` : pathParts[i];
const folderLock = chatMap.get(currentPath);
if (folderLock && folderLock.isFolder) {
return { locked: true, lockedBy: currentPath };
}
}
return { locked: false };
}
/**
* Get all locked items for a specific chat
* @param chatId The chat ID to get locks for
* @returns Array of locked items for the specified chat
*/
export function getLockedItemsForChat(chatId: string): LockedItem[] {
// Ensure cache is initialized
const allItems = getLockedItems();
// Use the chat map if available for faster filtering
const chatMap = getChatMap(chatId);
if (chatMap) {
// Convert the map values to an array
return Array.from(chatMap.values());
}
// Fallback to filtering the full list
return allItems.filter((item) => item.chatId === chatId);
}
/**
* Get all locked files for a specific chat (for backward compatibility)
*/
export function getLockedFilesForChat(chatId: string): LockedItem[] {
// Get all items for this chat
const chatItems = getLockedItemsForChat(chatId);
// Filter to only include files
return chatItems.filter((item) => !item.isFolder);
}
/**
* Get all locked folders for a specific chat
*/
export function getLockedFoldersForChat(chatId: string): LockedItem[] {
// Get all items for this chat
const chatItems = getLockedItemsForChat(chatId);
// Filter to only include folders
return chatItems.filter((item) => item.isFolder);
}
/**
* Check if a path is within a locked folder
* @param chatId The chat ID to check locks for
* @param path The path to check
* @returns Object with locked status, lock mode, and the folder that caused the lock
*/
export function isPathInLockedFolder(chatId: string, path: string): { locked: boolean; lockedBy?: string } {
// This is already optimized by using checkParentFolderLocks
return checkParentFolderLocks(chatId, path);
}
/**
* Migrate legacy locks (without chatId or isFolder) to the new format
* @param currentChatId The current chat ID to assign to legacy locks
*/
export function migrateLegacyLocks(currentChatId: string): void {
try {
// Force a fresh read from localStorage
clearCache();
// Get the items directly from localStorage
if (typeof localStorage !== 'undefined') {
const lockedItemsJson = localStorage.getItem(LOCKED_FILES_KEY);
if (lockedItemsJson) {
const lockedItems = JSON.parse(lockedItemsJson);
if (Array.isArray(lockedItems)) {
let hasLegacyItems = false;
// Check if any locks are in the old format (missing chatId or isFolder)
const updatedItems = lockedItems.map((item) => {
const needsUpdate = !item.chatId || item.isFolder === undefined;
if (needsUpdate) {
hasLegacyItems = true;
return {
...item,
chatId: item.chatId || currentChatId,
isFolder: item.isFolder !== undefined ? item.isFolder : false,
};
}
return item;
});
// Only save if we found and updated legacy items
if (hasLegacyItems) {
saveLockedItems(updatedItems);
logger.info(`Migrated ${updatedItems.length} legacy locks to chat ID: ${currentChatId}`);
}
}
}
}
} catch (error) {
logger.error('Failed to migrate legacy locks', error);
}
}
/**
* Clear the in-memory cache and force a reload from localStorage on next access
* This is useful when you suspect the cache might be out of sync with localStorage
* (e.g., after another tab has modified the locks)
*/
export function clearCache(): void {
lockedItemsCache = null;
lockedItemsMap.clear();
logger.info('Cleared locked items cache');
}
/**
* Batch operation to lock multiple items at once
* @param chatId The chat ID to scope the locks to
* @param items Array of items to lock with their paths, modes, and folder flags
*/
export function batchLockItems(chatId: string, items: Array<{ path: string; isFolder: boolean }>): void {
if (items.length === 0) {
return;
}
// Ensure cache is initialized
const lockedItems = getLockedItems();
// Create a set of paths to lock for faster lookups
const pathsToLock = new Set(items.map((item) => item.path));
// Filter out existing items for these paths
const filteredItems = lockedItems.filter((item) => !(item.chatId === chatId && pathsToLock.has(item.path)));
// Add all the new items
const newItems = items.map((item) => ({
chatId,
path: item.path,
isFolder: item.isFolder,
}));
// Combine and save
const updatedItems = [...filteredItems, ...newItems];
saveLockedItems(updatedItems);
logger.info(`Batch locked ${items.length} items for chat: ${chatId}`);
}
/**
* Batch operation to unlock multiple items at once
* @param chatId The chat ID the locks belong to
* @param paths Array of paths to unlock
*/
export function batchUnlockItems(chatId: string, paths: string[]): void {
if (paths.length === 0) {
return;
}
// Ensure cache is initialized
const lockedItems = getLockedItems();
// Create a set of paths to unlock for faster lookups
const pathsToUnlock = new Set(paths);
// Update the in-memory maps
const chatMap = getChatMap(chatId);
if (chatMap) {
paths.forEach((path) => chatMap.delete(path));
}
// Filter out the items to remove
const filteredItems = lockedItems.filter((item) => !(item.chatId === chatId && pathsToUnlock.has(item.path)));
// Save the updated list
saveLockedItems(filteredItems);
logger.info(`Batch unlocked ${paths.length} items for chat: ${chatId}`);
}
/**
* Add event listener for storage events to sync cache across tabs
* This ensures that if locks are modified in another tab, the changes are reflected here
*/
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if (event.key === LOCKED_FILES_KEY) {
logger.info('Detected localStorage change for locked items, refreshing cache');
clearCache();
}
});
}