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,10 +8,14 @@ export interface File {
type: 'file';
content: string;
isBinary: boolean;
isLocked?: boolean;
lockedByFolder?: string;
}
export interface Folder {
type: 'folder';
isLocked?: boolean;
lockedByFolder?: string;
}
type Dirent = File | Folder;

View File

@@ -42,6 +42,7 @@ export async function streamText(props: {
env: serverEnv,
options,
apiKeys,
files,
providerSettings,
promptId,
contextOptimization,
@@ -153,6 +154,30 @@ ${props.summary}
}
}
const effectiveLockedFilePaths = new Set<string>();
if (files) {
for (const [filePath, fileDetails] of Object.entries(files)) {
if (fileDetails?.isLocked) {
effectiveLockedFilePaths.add(filePath);
}
}
}
if (effectiveLockedFilePaths.size > 0) {
const lockedFilesListString = Array.from(effectiveLockedFilePaths)
.map((filePath) => `- ${filePath}`)
.join('\n');
systemPrompt = `${systemPrompt}
IMPORTANT: The following files are locked and MUST NOT be modified in any way. Do not suggest or make any changes to these files. You can proceed with the request but DO NOT make any changes to these files specifically:
${lockedFilesListString}
---
`;
} else {
console.log('No locked files found from any source for prompt.');
}
logger.info(`Sending llm call to ${provider.name} with model ${modelDetails.name}`);
// console.log(systemPrompt, processedMessages);

View File

@@ -0,0 +1,511 @@
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();
}
});
}

View File

@@ -1,11 +1,14 @@
import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores';
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
import type { FileMap, FilesStore } from './files';
import { createScopedLogger } from '~/utils/logger';
export type EditorDocuments = Record<string, EditorDocument>;
type SelectedFile = WritableAtom<string | undefined>;
const logger = createScopedLogger('EditorStore');
export class EditorStore {
#filesStore: FilesStore;
@@ -36,7 +39,7 @@ export class EditorStore {
Object.fromEntries<EditorDocument>(
Object.entries(files)
.map(([filePath, dirent]) => {
if (dirent === undefined || dirent.type === 'folder') {
if (dirent === undefined || dirent.type !== 'file') {
return undefined;
}
@@ -82,6 +85,20 @@ export class EditorStore {
return;
}
// Check if the file is locked by getting the file from the filesStore
const file = this.#filesStore.getFile(filePath);
if (file?.isLocked) {
logger.warn(`Attempted to update locked file: ${filePath}`);
return;
}
/*
* For scoped locks, we would need to implement diff checking here
* to determine if the edit is modifying existing code or just adding new code
* This is a more complex feature that would be implemented in a future update
*/
const currentContent = documentState.value;
const contentChanged = currentContent !== newContent;

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

View File

@@ -49,11 +49,11 @@ export class WorkbenchStore {
currentView: WritableAtom<WorkbenchViewType> = import.meta.hot?.data.currentView ?? atom('code');
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
actionAlert: WritableAtom<ActionAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
import.meta.hot?.data.actionAlert ?? atom<ActionAlert | undefined>(undefined);
supabaseAlert: WritableAtom<SupabaseAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
import.meta.hot?.data.supabaseAlert ?? atom<SupabaseAlert | undefined>(undefined);
deployAlert: WritableAtom<DeployAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<DeployAlert | undefined>(undefined);
import.meta.hot?.data.deployAlert ?? atom<DeployAlert | undefined>(undefined);
modifiedFiles = new Set<string>();
artifactIdList: string[] = [];
#globalExecutionQueue = Promise.resolve();
@@ -226,6 +226,12 @@ export class WorkbenchStore {
return;
}
/*
* For scoped locks, we would need to implement diff checking here
* to determine if the user is modifying existing code or just adding new code
* This is a more complex feature that would be implemented in a future update
*/
await this.#filesStore.saveFile(filePath, document.value);
const newUnsavedFiles = new Set(this.unsavedFiles.get());
@@ -279,6 +285,60 @@ export class WorkbenchStore {
this.#filesStore.resetFileModifications();
}
/**
* Lock a file to prevent edits
* @param filePath Path to the file to lock
* @returns True if the file was successfully locked
*/
lockFile(filePath: string) {
return this.#filesStore.lockFile(filePath);
}
/**
* Lock a folder and all its contents to prevent edits
* @param folderPath Path to the folder to lock
* @returns True if the folder was successfully locked
*/
lockFolder(folderPath: string) {
return this.#filesStore.lockFolder(folderPath);
}
/**
* Unlock a file to allow edits
* @param filePath Path to the file to unlock
* @returns True if the file was successfully unlocked
*/
unlockFile(filePath: string) {
return this.#filesStore.unlockFile(filePath);
}
/**
* Unlock a folder and all its contents to allow edits
* @param folderPath Path to the folder to unlock
* @returns True if the folder was successfully unlocked
*/
unlockFolder(folderPath: string) {
return this.#filesStore.unlockFolder(folderPath);
}
/**
* Check if a file is locked
* @param filePath Path to the file to check
* @returns Object with locked status, lock mode, and what caused the lock
*/
isFileLocked(filePath: string) {
return this.#filesStore.isFileLocked(filePath);
}
/**
* Check if a folder is locked
* @param folderPath Path to the folder to check
* @returns Object with locked status, lock mode, and what caused the lock
*/
isFolderLocked(folderPath: string) {
return this.#filesStore.isFolderLocked(folderPath);
}
async createFile(filePath: string, content: string | Uint8Array = '') {
try {
const success = await this.#filesStore.createFile(filePath, content);
@@ -497,6 +557,12 @@ export class WorkbenchStore {
const wc = await webcontainer;
const fullPath = path.join(wc.workdir, data.action.filePath);
/*
* For scoped locks, we would need to implement diff checking here
* to determine if the AI is modifying existing code or just adding new code
* This is a more complex feature that would be implemented in a future update
*/
if (this.selectedFile.value !== fullPath) {
this.setSelectedFile(fullPath);
}