bolt.diy/app/shared/lib/persistence/lockedFiles.ts
KevIsDev 4d3222ee96 refactor: reorganize project structure by moving files to a more dev friendly setup
- Move stores/utils/types to their relative directories (i.e chat stores in chat directory)
- Move utility files to shared/utils
- Move component files to shared/components
- Move type definitions to shared/types
- Move stores to shared/stores
- Update import paths across the project
2025-06-16 15:33:59 +01:00

512 lines
15 KiB
TypeScript

import { createScopedLogger } from '~/shared/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();
}
});
}