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

@@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react';
import { memo, useMemo } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import * as Tabs from '@radix-ui/react-tabs'; // <-- Import Radix UI Tabs
import * as Tabs from '@radix-ui/react-tabs';
import {
CodeMirrorEditor,
type EditorDocument,
@@ -24,6 +24,7 @@ import { DEFAULT_TERMINAL_SIZE, TerminalTabs } from './terminal/TerminalTabs';
import { workbenchStore } from '~/lib/stores/workbench';
import { Search } from './Search'; // <-- Ensure Search is imported
import { classNames } from '~/utils/classNames'; // <-- Import classNames if not already present
import { LockManager } from './LockManager'; // <-- Import LockManager
interface EditorPanelProps {
files?: FileMap;
@@ -71,7 +72,12 @@ export const EditorPanel = memo(
}, [editorDocument]);
const activeFileUnsaved = useMemo(() => {
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
if (!editorDocument || !unsavedFiles) {
return false;
}
// Make sure unsavedFiles is a Set before calling has()
return unsavedFiles instanceof Set && unsavedFiles.has(editorDocument.filePath);
}, [editorDocument, unsavedFiles]);
return (
@@ -79,45 +85,61 @@ export const EditorPanel = memo(
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
<PanelGroup direction="horizontal">
<Panel defaultSize={20} minSize={15} collapsible className="border-r border-bolt-elements-borderColor">
<Tabs.Root defaultValue="files" className="flex flex-col h-full">
<PanelHeader className="w-full text-sm font-medium text-bolt-elements-textSecondary px-1">
<Tabs.List className="h-full flex-shrink-0 flex items-center">
<Tabs.Trigger
value="files"
className={classNames(
'h-full bg-transparent hover:bg-bolt-elements-background-depth-3 py-0.5 px-2 rounded-lg text-sm font-medium text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary data-[state=active]:text-bolt-elements-textPrimary',
)}
>
Files
</Tabs.Trigger>
<Tabs.Trigger
value="search"
className={classNames(
'h-full bg-transparent hover:bg-bolt-elements-background-depth-3 py-0.5 px-2 rounded-lg text-sm font-medium text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary data-[state=active]:text-bolt-elements-textPrimary',
)}
>
Search
</Tabs.Trigger>
</Tabs.List>
</PanelHeader>
<div className="h-full">
<Tabs.Root defaultValue="files" className="flex flex-col h-full">
<PanelHeader className="w-full text-sm font-medium text-bolt-elements-textSecondary px-1">
<div className="h-full flex-shrink-0 flex items-center justify-between w-full">
<Tabs.List className="h-full flex-shrink-0 flex items-center">
<Tabs.Trigger
value="files"
className={classNames(
'h-full bg-transparent hover:bg-bolt-elements-background-depth-3 py-0.5 px-2 rounded-lg text-sm font-medium text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary data-[state=active]:text-bolt-elements-textPrimary',
)}
>
Files
</Tabs.Trigger>
<Tabs.Trigger
value="search"
className={classNames(
'h-full bg-transparent hover:bg-bolt-elements-background-depth-3 py-0.5 px-2 rounded-lg text-sm font-medium text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary data-[state=active]:text-bolt-elements-textPrimary',
)}
>
Search
</Tabs.Trigger>
<Tabs.Trigger
value="locks"
className={classNames(
'h-full bg-transparent hover:bg-bolt-elements-background-depth-3 py-0.5 px-2 rounded-lg text-sm font-medium text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary data-[state=active]:text-bolt-elements-textPrimary',
)}
>
Locks
</Tabs.Trigger>
</Tabs.List>
</div>
</PanelHeader>
<Tabs.Content value="files" className="flex-grow overflow-auto focus-visible:outline-none">
<FileTree
className="h-full"
files={files}
hideRoot
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
rootFolder={WORK_DIR}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
/>
</Tabs.Content>
<Tabs.Content value="files" className="flex-grow overflow-auto focus-visible:outline-none">
<FileTree
className="h-full"
files={files}
hideRoot
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
rootFolder={WORK_DIR}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
/>
</Tabs.Content>
<Tabs.Content value="search" className="flex-grow overflow-auto focus-visible:outline-none">
<Search />
</Tabs.Content>
</Tabs.Root>
<Tabs.Content value="search" className="flex-grow overflow-auto focus-visible:outline-none">
<Search />
</Tabs.Content>
<Tabs.Content value="locks" className="flex-grow overflow-auto focus-visible:outline-none">
<LockManager />
</Tabs.Content>
</Tabs.Root>
</div>
</Panel>
<PanelResizeHandle />

View File

@@ -152,7 +152,7 @@ export const FileTree = memo(
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
unsavedChanges={unsavedFiles instanceof Set && unsavedFiles.has(fileOrFolder.fullPath)}
fileHistory={fileHistory}
onCopyPath={() => {
onCopyPath(fileOrFolder);
@@ -402,6 +402,86 @@ function FileContextMenu({
}
};
// Handler for locking a file with full lock
const handleLockFile = () => {
try {
if (isFolder) {
return;
}
const success = workbenchStore.lockFile(fullPath);
if (success) {
toast.success(`File locked successfully`);
} else {
toast.error(`Failed to lock file`);
}
} catch (error) {
toast.error(`Error locking file`);
logger.error(error);
}
};
// Handler for unlocking a file
const handleUnlockFile = () => {
try {
if (isFolder) {
return;
}
const success = workbenchStore.unlockFile(fullPath);
if (success) {
toast.success(`File unlocked successfully`);
} else {
toast.error(`Failed to unlock file`);
}
} catch (error) {
toast.error(`Error unlocking file`);
logger.error(error);
}
};
// Handler for locking a folder with full lock
const handleLockFolder = () => {
try {
if (!isFolder) {
return;
}
const success = workbenchStore.lockFolder(fullPath);
if (success) {
toast.success(`Folder locked successfully`);
} else {
toast.error(`Failed to lock folder`);
}
} catch (error) {
toast.error(`Error locking folder`);
logger.error(error);
}
};
// Handler for unlocking a folder
const handleUnlockFolder = () => {
try {
if (!isFolder) {
return;
}
const success = workbenchStore.unlockFolder(fullPath);
if (success) {
toast.success(`Folder unlocked successfully`);
} else {
toast.error(`Failed to unlock folder`);
}
} catch (error) {
toast.error(`Error unlocking folder`);
logger.error(error);
}
};
return (
<>
<ContextMenu.Root>
@@ -441,6 +521,40 @@ function FileContextMenu({
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
</ContextMenu.Group>
{/* Add lock/unlock options for files and folders */}
<ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor">
{!isFolder ? (
<>
<ContextMenuItem onSelect={handleLockFile}>
<div className="flex items-center gap-2">
<div className="i-ph:lock-simple" />
Lock File
</div>
</ContextMenuItem>
<ContextMenuItem onSelect={handleUnlockFile}>
<div className="flex items-center gap-2">
<div className="i-ph:lock-key-open" />
Unlock File
</div>
</ContextMenuItem>
</>
) : (
<>
<ContextMenuItem onSelect={handleLockFolder}>
<div className="flex items-center gap-2">
<div className="i-ph:lock-simple" />
Lock Folder
</div>
</ContextMenuItem>
<ContextMenuItem onSelect={handleUnlockFolder}>
<div className="flex items-center gap-2">
<div className="i-ph:lock-key-open" />
Unlock Folder
</div>
</ContextMenuItem>
</>
)}
</ContextMenu.Group>
{/* Add delete option in a new group */}
<ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor">
<ContextMenuItem onSelect={handleDelete}>
@@ -474,6 +588,9 @@ function FileContextMenu({
}
function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
// Check if the folder is locked
const { isLocked } = workbenchStore.isFolderLocked(folder.fullPath);
return (
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} fullPath={folder.fullPath}>
<NodeButton
@@ -489,7 +606,15 @@ function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativ
})}
onClick={onClick}
>
{folder.name}
<div className="flex items-center w-full">
<div className="flex-1 truncate pr-2">{folder.name}</div>
{isLocked && (
<span
className={classNames('shrink-0', 'i-ph:lock-simple scale-80 text-red-500')}
title={'Folder is locked'}
/>
)}
</div>
</NodeButton>
</FileContextMenu>
);
@@ -516,6 +641,9 @@ function File({
}: FileProps) {
const { depth, name, fullPath } = file;
// Check if the file is locked
const { locked } = workbenchStore.isFileLocked(fullPath);
const fileModifications = fileHistory[fullPath];
const { additions, deletions } = useMemo(() => {
@@ -582,6 +710,12 @@ function File({
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
</div>
)}
{locked && (
<span
className={classNames('shrink-0', 'i-ph:lock-simple scale-80 text-red-500')}
title={'File is locked'}
/>
)}
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
</div>
</div>

View File

@@ -0,0 +1,262 @@
import { useState, useEffect } from 'react';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { Checkbox } from '~/components/ui/Checkbox';
import { toast } from '~/components/ui/use-toast';
interface LockedItem {
path: string;
type: 'file' | 'folder';
}
export function LockManager() {
const [lockedItems, setLockedItems] = useState<LockedItem[]>([]);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState<'all' | 'files' | 'folders'>('all');
const [searchTerm, setSearchTerm] = useState('');
// Load locked items
useEffect(() => {
const loadLockedItems = () => {
// We don't need to filter by chat ID here as we want to show all locked files
const items: LockedItem[] = [];
// Get all files and folders from the workbench store
const allFiles = workbenchStore.files.get();
// Check each file/folder for locks
Object.entries(allFiles).forEach(([path, item]) => {
if (!item) {
return;
}
if (item.type === 'file' && item.isLocked) {
items.push({
path,
type: 'file',
});
} else if (item.type === 'folder' && item.isLocked) {
items.push({
path,
type: 'folder',
});
}
});
setLockedItems(items);
};
loadLockedItems();
// Set up an interval to refresh the list periodically
const intervalId = setInterval(loadLockedItems, 5000);
return () => clearInterval(intervalId);
}, []);
// Filter and sort the locked items
const filteredAndSortedItems = lockedItems
.filter((item) => {
// Apply type filter
if (filter === 'files' && item.type !== 'file') {
return false;
}
if (filter === 'folders' && item.type !== 'folder') {
return false;
}
// Apply search filter
if (searchTerm && !item.path.toLowerCase().includes(searchTerm.toLowerCase())) {
return false;
}
return true;
})
.sort((a, b) => {
return a.path.localeCompare(b.path);
});
// Handle selecting/deselecting a single item
const handleSelectItem = (path: string) => {
const newSelectedItems = new Set(selectedItems);
if (newSelectedItems.has(path)) {
newSelectedItems.delete(path);
} else {
newSelectedItems.add(path);
}
setSelectedItems(newSelectedItems);
};
// Handle selecting/deselecting all visible items
const handleSelectAll = (checked: boolean | 'indeterminate') => {
if (checked === true) {
// Select all filtered items
const allVisiblePaths = new Set(filteredAndSortedItems.map((item) => item.path));
setSelectedItems(allVisiblePaths);
} else {
// Deselect all (clear selection)
setSelectedItems(new Set());
}
};
// Handle unlocking selected items
const handleUnlockSelected = () => {
if (selectedItems.size === 0) {
toast.error('No items selected to unlock.');
return;
}
let unlockedCount = 0;
selectedItems.forEach((path) => {
const item = lockedItems.find((i) => i.path === path);
if (item) {
if (item.type === 'file') {
workbenchStore.unlockFile(path);
} else {
workbenchStore.unlockFolder(path);
}
unlockedCount++;
}
});
if (unlockedCount > 0) {
toast.success(`Unlocked ${unlockedCount} selected item(s).`);
setSelectedItems(new Set()); // Clear selection after unlocking
}
};
// Determine the state of the "Select All" checkbox
const isAllSelected = filteredAndSortedItems.length > 0 && selectedItems.size === filteredAndSortedItems.length;
const isSomeSelected = selectedItems.size > 0 && selectedItems.size < filteredAndSortedItems.length;
const selectAllCheckedState: boolean | 'indeterminate' = isAllSelected
? true
: isSomeSelected
? 'indeterminate'
: false;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Controls */}
<div className="flex items-center gap-1 px-2 py-1 border-b border-bolt-elements-borderColor">
{/* Search Input */}
<div className="relative flex-1">
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary i-ph:magnifying-glass text-xs pointer-events-none" />
<input
type="text"
placeholder="Search..."
className="w-full text-xs pl-6 pr-2 py-0.5 h-6 bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary rounded border border-bolt-elements-borderColor focus:outline-none"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ minWidth: 0 }}
/>
</div>
{/* Filter Select */}
<select
className="text-xs px-1 py-0.5 h-6 bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary rounded border border-bolt-elements-borderColor focus:outline-none"
value={filter}
onChange={(e) => setFilter(e.target.value as any)}
>
<option value="all">All</option>
<option value="files">Files</option>
<option value="folders">Folders</option>
</select>
</div>
{/* Header Row with Select All */}
<div className="flex items-center justify-between px-2 py-1 text-xs text-bolt-elements-textSecondary">
<div>
<Checkbox
checked={selectAllCheckedState}
onCheckedChange={handleSelectAll}
className="w-3 h-3 rounded border-bolt-elements-borderColor mr-2"
aria-label="Select all items"
disabled={filteredAndSortedItems.length === 0} // Disable if no items to select
/>
<span>All</span>
</div>
{selectedItems.size > 0 && (
<button
className="ml-auto px-2 py-0.5 rounded bg-bolt-elements-button-secondary-background hover:bg-bolt-elements-button-secondary-backgroundHover text-bolt-elements-button-secondary-text text-xs flex items-center gap-1"
onClick={handleUnlockSelected}
title="Unlock all selected items"
>
Unlock all
</button>
)}
<div></div>
</div>
{/* List of locked items */}
<div className="flex-1 overflow-auto modern-scrollbar px-1 py-1">
{filteredAndSortedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-bolt-elements-textTertiary text-xs gap-2">
<span className="i-ph:lock-open-duotone text-lg opacity-50" />
<span>No locked items found</span>
</div>
) : (
<ul className="space-y-1">
{filteredAndSortedItems.map((item) => (
<li
key={item.path}
className={classNames(
'text-bolt-elements-textTertiary flex items-center gap-2 px-2 py-1 rounded hover:bg-bolt-elements-background-depth-2 transition-colors group',
selectedItems.has(item.path) ? 'bg-bolt-elements-background-depth-2' : '',
)}
>
<Checkbox
checked={selectedItems.has(item.path)}
onCheckedChange={() => handleSelectItem(item.path)}
className="w-3 h-3 rounded border-bolt-elements-borderColor"
aria-labelledby={`item-label-${item.path}`} // For accessibility
/>
<span
className={classNames(
'shrink-0 text-bolt-elements-textTertiary text-xs',
item.type === 'file' ? 'i-ph:file-text-duotone' : 'i-ph:folder-duotone',
)}
/>
<span id={`item-label-${item.path}`} className="truncate flex-1 text-xs" title={item.path}>
{item.path.replace('/home/project/', '')}
</span>
{/* ... rest of the item details and buttons ... */}
<span
className={classNames(
'inline-flex items-center px-1 rounded-sm text-xs',
'bg-red-500/10 text-red-500',
)}
></span>
<button
className="flex items-center px-1 py-0.5 text-xs rounded bg-transparent hover:bg-bolt-elements-background-depth-3"
onClick={() => {
if (item.type === 'file') {
workbenchStore.unlockFile(item.path);
} else {
workbenchStore.unlockFolder(item.path);
}
toast.success(`${item.path.replace('/home/project/', '')} unlocked`);
}}
title="Unlock"
>
<span className="i-ph:lock-open text-xs" />
</button>
</li>
))}
</ul>
)}
</div>
{/* Footer */}
<div className="px-2 py-1 border-t border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 text-xs text-bolt-elements-textTertiary flex justify-between items-center">
<div>
{filteredAndSortedItems.length} item(s) {selectedItems.size} selected
</div>
</div>
</div>
);
}