mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
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:
parent
5c9d413344
commit
9a5076d8c6
92
CHANGES.md
Normal file
92
CHANGES.md
Normal file
@ -0,0 +1,92 @@
|
||||
# File and Folder Locking Feature Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation adds persistent file and folder locking functionality to the BoltDIY project. When a file or folder is locked, it cannot be modified by either the user or the AI until it is unlocked. All locks are scoped to the current chat/project to prevent locks from one project affecting files with matching names in other projects.
|
||||
|
||||
## New Files
|
||||
|
||||
### 1. `app/components/chat/LockAlert.tsx`
|
||||
|
||||
- A dedicated alert component for displaying lock-related error messages
|
||||
- Features a distinctive amber/yellow color scheme and lock icon
|
||||
- Provides clear instructions to the user about locked files
|
||||
|
||||
### 2. `app/lib/persistence/lockedFiles.ts`
|
||||
|
||||
- Core functionality for persisting file and folder locks in localStorage
|
||||
- Provides functions for adding, removing, and retrieving locked files and folders
|
||||
- Defines the lock modes: "full" (no modifications) and "scoped" (only additions allowed)
|
||||
- Implements chat ID scoping to isolate locks to specific projects
|
||||
|
||||
### 3. `app/utils/fileLocks.ts`
|
||||
|
||||
- Utility functions for checking if a file or folder is locked
|
||||
- Helps avoid circular dependencies between components and stores
|
||||
- Provides a consistent interface for lock checking across the application
|
||||
- Extracts chat ID from URL for project-specific lock scoping
|
||||
|
||||
## Modified Files
|
||||
|
||||
### 1. `app/components/chat/ChatAlert.tsx`
|
||||
|
||||
- Updated to use the new LockAlert component for locked file errors
|
||||
- Maintains backward compatibility with other error types
|
||||
|
||||
### 2. `app/components/editor/codemirror/CodeMirrorEditor.tsx`
|
||||
|
||||
- Added checks to prevent editing of locked files
|
||||
- Updated to use the new fileLocks utility
|
||||
- Displays appropriate tooltips when a user attempts to edit a locked file
|
||||
|
||||
### 3. `app/components/workbench/EditorPanel.tsx`
|
||||
|
||||
- Added safety checks for unsavedFiles to prevent errors
|
||||
- Improved handling of locked files in the editor panel
|
||||
|
||||
### 4. `app/components/workbench/FileTree.tsx`
|
||||
|
||||
- Added visual indicators for locked files and folders in the file tree
|
||||
- Improved handling of locked files and folders in the file tree
|
||||
- Added context menu options for locking and unlocking folders
|
||||
|
||||
### 5. `app/lib/stores/editor.ts`
|
||||
|
||||
- Added checks to prevent updating locked files
|
||||
- Improved error handling for locked files
|
||||
|
||||
### 6. `app/lib/stores/files.ts`
|
||||
|
||||
- Added core functionality for locking and unlocking files and folders
|
||||
- Implemented persistence of locked files and folders across page refreshes
|
||||
- Added methods for checking if a file or folder is locked
|
||||
- Added chat ID scoping to prevent locks from affecting other projects
|
||||
|
||||
### 7. `app/lib/stores/workbench.ts`
|
||||
|
||||
- Added methods for locking and unlocking files and folders
|
||||
- Improved error handling for locked files and folders
|
||||
- Fixed issues with alert initialization
|
||||
- Added support for chat ID scoping of locks
|
||||
|
||||
### 8. `app/types/actions.ts`
|
||||
|
||||
- Added `isLockedFile` property to the ActionAlert interface
|
||||
- Improved type definitions for locked file alerts
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **Persistent File and Folder Locking**: Locks are stored in localStorage and persist across page refreshes
|
||||
2. **Visual Indicators**: Locked files and folders are clearly marked in the UI with lock icons
|
||||
3. **Improved Error Messages**: Clear, visually distinct error messages when attempting to modify locked items
|
||||
4. **Lock Modes**: Support for both full locks (no modifications) and scoped locks (only additions allowed)
|
||||
5. **Prevention of AI Modifications**: The AI is prevented from modifying locked files and folders
|
||||
6. **Project-Specific Locks**: Locks are scoped to the current chat/project to prevent conflicts
|
||||
7. **Recursive Folder Locking**: Locking a folder automatically locks all files and subfolders within it
|
||||
|
||||
## UI Improvements
|
||||
|
||||
1. **Enhanced Alert Design**: Modern, visually appealing alert design with better spacing and typography
|
||||
2. **Contextual Icons**: Different icons and colors for different types of alerts
|
||||
3. **Improved Error Details**: Better formatting of error details with monospace font and left border
|
||||
4. **Responsive Buttons**: Better positioned and styled buttons with appropriate hover effects
|
@ -310,6 +310,9 @@ export const ChatImpl = memo(
|
||||
return;
|
||||
}
|
||||
|
||||
// If no locked items, proceed normally with the original message
|
||||
const finalMessageContent = messageContent;
|
||||
|
||||
runAnimation();
|
||||
|
||||
if (!chatStarted) {
|
||||
@ -317,7 +320,7 @@ export const ChatImpl = memo(
|
||||
|
||||
if (autoSelectTemplate) {
|
||||
const { template, title } = await selectStarterTemplate({
|
||||
message: messageContent,
|
||||
message: finalMessageContent,
|
||||
model,
|
||||
provider,
|
||||
});
|
||||
@ -342,7 +345,7 @@ export const ChatImpl = memo(
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
@ -387,7 +390,7 @@ export const ChatImpl = memo(
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
@ -426,7 +429,7 @@ export const ChatImpl = memo(
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${messageContent}`,
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${finalMessageContent}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
@ -442,7 +445,7 @@ export const ChatImpl = memo(
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
|
@ -21,6 +21,7 @@ import type { Theme } from '~/types/theme';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
import { isFileLocked, getCurrentChatId } from '~/utils/fileLocks';
|
||||
import { BinaryContent } from './BinaryContent';
|
||||
import { getTheme, reconfigureTheme } from './cm-theme';
|
||||
import { indentKeyBinding } from './indent';
|
||||
@ -29,6 +30,9 @@ import { createEnvMaskingExtension } from './EnvMasking';
|
||||
|
||||
const logger = createScopedLogger('CodeMirrorEditor');
|
||||
|
||||
// Create a module-level reference to the current document for use in tooltip functions
|
||||
let currentDocRef: EditorDocument | undefined;
|
||||
|
||||
export interface EditorDocument {
|
||||
value: string;
|
||||
isBinary: boolean;
|
||||
@ -158,6 +162,9 @@ export const CodeMirrorEditor = memo(
|
||||
onChangeRef.current = onChange;
|
||||
onSaveRef.current = onSave;
|
||||
docRef.current = doc;
|
||||
|
||||
// Update the module-level reference for use in tooltip functions
|
||||
currentDocRef = doc;
|
||||
themeRef.current = theme;
|
||||
});
|
||||
|
||||
@ -291,6 +298,16 @@ export const CodeMirrorEditor = memo(
|
||||
autoFocusOnDocumentChange,
|
||||
doc as TextEditorDocument,
|
||||
);
|
||||
|
||||
// Check if the file is locked and update the editor state accordingly
|
||||
const currentChatId = getCurrentChatId();
|
||||
const { locked } = isFileLocked(doc.filePath, currentChatId);
|
||||
|
||||
if (locked) {
|
||||
view.dispatch({
|
||||
effects: [editableStateEffect.of(false)],
|
||||
});
|
||||
}
|
||||
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
|
||||
|
||||
return (
|
||||
@ -432,8 +449,13 @@ function setEditorDocument(
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the file is locked
|
||||
const currentChatId = getCurrentChatId();
|
||||
const { locked } = isFileLocked(doc.filePath, currentChatId);
|
||||
|
||||
// Set editable state based on both the editable prop and the file's lock state
|
||||
view.dispatch({
|
||||
effects: [editableStateEffect.of(editable && !doc.isBinary)],
|
||||
effects: [editableStateEffect.of(editable && !doc.isBinary && !locked)],
|
||||
});
|
||||
|
||||
getLanguage(doc.filePath).then((languageSupport) => {
|
||||
@ -503,6 +525,20 @@ function getReadOnlyTooltip(state: EditorState) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get the current document from the module-level reference
|
||||
const currentDoc = currentDocRef;
|
||||
let tooltipMessage = 'Cannot edit file while AI response is being generated';
|
||||
|
||||
// If we have a current document, check if it's locked
|
||||
if (currentDoc?.filePath) {
|
||||
const currentChatId = getCurrentChatId();
|
||||
const { locked } = isFileLocked(currentDoc.filePath, currentChatId);
|
||||
|
||||
if (locked) {
|
||||
tooltipMessage = 'This file is locked and cannot be edited';
|
||||
}
|
||||
}
|
||||
|
||||
return state.selection.ranges
|
||||
.filter((range) => {
|
||||
return range.empty;
|
||||
@ -516,7 +552,7 @@ function getReadOnlyTooltip(state: EditorState) {
|
||||
create: () => {
|
||||
const divElement = document.createElement('div');
|
||||
divElement.className = 'cm-readonly-tooltip';
|
||||
divElement.textContent = 'Cannot edit file while AI response is being generated';
|
||||
divElement.textContent = tooltipMessage;
|
||||
|
||||
return { dom: divElement };
|
||||
},
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
|
262
app/components/workbench/LockManager.tsx
Normal file
262
app/components/workbench/LockManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
|
@ -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);
|
||||
|
511
app/lib/persistence/lockedFiles.ts
Normal file
511
app/lib/persistence/lockedFiles.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
96
app/utils/fileLocks.ts
Normal file
96
app/utils/fileLocks.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import {
|
||||
getLockedItems,
|
||||
isFileLocked as isFileLockedInternal,
|
||||
isFolderLocked as isFolderLockedInternal,
|
||||
isPathInLockedFolder,
|
||||
} from '~/lib/persistence/lockedFiles';
|
||||
import { createScopedLogger } from './logger';
|
||||
|
||||
const logger = createScopedLogger('FileLocks');
|
||||
|
||||
/**
|
||||
* Get the current chat ID from the URL
|
||||
* @returns The current chat ID or a default value if not found
|
||||
*/
|
||||
export function getCurrentChatId(): string {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Extract chat ID from URL (format: /chat/123)
|
||||
const match = window.location.pathname.match(/\/chat\/([^/]+)/);
|
||||
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Return a default chat ID if none is found
|
||||
return 'default';
|
||||
} catch (error) {
|
||||
logger.error('Failed to get current chat ID', error);
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is locked directly from localStorage
|
||||
* This avoids circular dependencies between components and stores
|
||||
* @param filePath The path of the file to check
|
||||
* @param chatId Optional chat ID (will be extracted from URL if not provided)
|
||||
*/
|
||||
export function isFileLocked(filePath: string, chatId?: string): { locked: boolean; lockedBy?: string } {
|
||||
try {
|
||||
const currentChatId = chatId || getCurrentChatId();
|
||||
|
||||
// Use the internal function from lockedFiles.ts
|
||||
const result = isFileLockedInternal(currentChatId, filePath);
|
||||
|
||||
// If the file itself is not locked, check if it's in a locked folder
|
||||
if (!result.locked) {
|
||||
const folderLockResult = isPathInLockedFolder(currentChatId, filePath);
|
||||
|
||||
if (folderLockResult.locked) {
|
||||
return folderLockResult;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Failed to check if file is locked', error);
|
||||
return { locked: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a folder is locked directly from localStorage
|
||||
* This avoids circular dependencies between components and stores
|
||||
* @param folderPath The path of the folder to check
|
||||
* @param chatId Optional chat ID (will be extracted from URL if not provided)
|
||||
*/
|
||||
export function isFolderLocked(folderPath: string, chatId?: string): { locked: boolean; lockedBy?: string } {
|
||||
try {
|
||||
const currentChatId = chatId || getCurrentChatId();
|
||||
|
||||
// Use the internal function from lockedFiles.ts
|
||||
return isFolderLockedInternal(currentChatId, folderPath);
|
||||
} catch (error) {
|
||||
logger.error('Failed to check if folder is locked', error);
|
||||
return { locked: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any files are locked in the current chat
|
||||
* @param chatId Optional chat ID (will be extracted from URL if not provided)
|
||||
* @returns True if any files or folders are locked
|
||||
*/
|
||||
export function hasLockedItems(chatId?: string): boolean {
|
||||
try {
|
||||
const currentChatId = chatId || getCurrentChatId();
|
||||
const lockedItems = getLockedItems();
|
||||
|
||||
return lockedItems.some((item) => item.chatId === currentChatId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to check for locked items', error);
|
||||
return false;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user