mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
* 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>
512 lines
15 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
}
|