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>(); // Debounce timer for localStorage writes let saveDebounceTimer: ReturnType | null = null; const SAVE_DEBOUNCE_MS = 300; /** * Get a chat-specific map from the lookup maps */ function getChatMap(chatId: string, createIfMissing = false): Map | 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(); } }); }