mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Add persistent file locking feature with enhanced UI
This commit is contained in:
parent
844da4b1c2
commit
4a3009837a
@ -1,6 +1,7 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import type { ActionAlert } from '~/types/actions';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import LockAlert from './LockAlert';
|
||||
|
||||
interface Props {
|
||||
alert: ActionAlert;
|
||||
@ -9,7 +10,12 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
|
||||
const { description, content, source } = alert;
|
||||
const { description, content, source, isLockedFile } = alert;
|
||||
|
||||
/* If this is a locked file alert, use the dedicated LockAlert component */
|
||||
if (isLockedFile) {
|
||||
return <LockAlert alert={alert} clearAlert={clearAlert} />;
|
||||
}
|
||||
|
||||
const isPreview = source === 'preview';
|
||||
const title = isPreview ? 'Preview Error' : 'Terminal Error';
|
||||
|
92
app/components/chat/LockAlert.tsx
Normal file
92
app/components/chat/LockAlert.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import type { ActionAlert } from '~/types/actions';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface Props {
|
||||
alert: ActionAlert;
|
||||
clearAlert: () => void;
|
||||
}
|
||||
|
||||
export default function LockAlert({ alert, clearAlert }: Props) {
|
||||
const { description } = alert;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="rounded-lg border border-bolt-elements-borderColor border-l-2 border-l-amber-500 bg-bolt-elements-background-depth-2 mb-2 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 pb-2 flex items-center">
|
||||
<motion.div
|
||||
className="flex-shrink-0 mr-2"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="i-ph:lock-simple-duotone text-xl text-amber-500"></div>
|
||||
</motion.div>
|
||||
<motion.h3
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-sm font-medium text-amber-500"
|
||||
>
|
||||
Terminal Error
|
||||
</motion.h3>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-sm text-bolt-elements-textSecondary"
|
||||
>
|
||||
<p className="mb-3">
|
||||
The file is locked and cannot be modified. You need to unlock the file before making changes.
|
||||
</p>
|
||||
|
||||
{description && (
|
||||
<div className="text-xs p-3 bg-bolt-elements-background-depth-3 rounded-md mb-3 font-mono border border-bolt-elements-borderColor">
|
||||
<div className="flex items-center mb-1">
|
||||
<div className="i-ph:lock-simple-duotone text-sm text-amber-500 mr-1.5"></div>
|
||||
<span className="font-medium text-bolt-elements-textPrimary">Error Details</span>
|
||||
</div>
|
||||
<div className="pl-5 border-l border-bolt-elements-borderColor">{description}</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<motion.div
|
||||
className="p-3 bg-bolt-elements-background-depth-1 border-t border-bolt-elements-borderColor"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={clearAlert}
|
||||
className={classNames(
|
||||
`px-3 py-2 rounded-md text-sm font-medium transition-colors`,
|
||||
'bg-bolt-elements-button-secondary-background',
|
||||
'hover:bg-bolt-elements-button-secondary-backgroundHover',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background',
|
||||
'text-bolt-elements-button-secondary-text',
|
||||
'hover:bg-amber-50 dark:hover:bg-amber-950/20',
|
||||
)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
@ -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 } 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;
|
||||
});
|
||||
|
||||
@ -278,6 +285,15 @@ export const CodeMirrorEditor = memo(
|
||||
autoFocusOnDocumentChange,
|
||||
doc as TextEditorDocument,
|
||||
);
|
||||
|
||||
// Check if the file is locked and update the editor state accordingly
|
||||
const { locked } = isFileLocked(doc.filePath);
|
||||
|
||||
if (locked) {
|
||||
view.dispatch({
|
||||
effects: [editableStateEffect.of(false)],
|
||||
});
|
||||
}
|
||||
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
|
||||
|
||||
return (
|
||||
@ -419,8 +435,12 @@ function setEditorDocument(
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the file is locked
|
||||
const { locked } = isFileLocked(doc.filePath);
|
||||
|
||||
// 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) => {
|
||||
@ -477,6 +497,22 @@ 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 { locked, lockMode } = isFileLocked(currentDoc.filePath);
|
||||
|
||||
if (locked) {
|
||||
tooltipMessage =
|
||||
lockMode === 'full'
|
||||
? 'This file is locked and cannot be edited'
|
||||
: 'This file has scoped locking - only additions are allowed';
|
||||
}
|
||||
}
|
||||
|
||||
return state.selection.ranges
|
||||
.filter((range) => {
|
||||
return range.empty;
|
||||
@ -490,7 +526,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 };
|
||||
},
|
||||
|
@ -71,7 +71,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 (
|
||||
|
@ -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,66 @@ function FileContextMenu({
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for locking a file with full lock
|
||||
const handleLockFile = () => {
|
||||
try {
|
||||
if (isFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = workbenchStore.lockFile(fullPath, 'full');
|
||||
|
||||
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 locking a file with scoped lock
|
||||
const handleScopedLockFile = () => {
|
||||
try {
|
||||
if (isFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = workbenchStore.lockFile(fullPath, 'scoped');
|
||||
|
||||
if (success) {
|
||||
toast.success(`File locked (scoped) 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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
@ -441,6 +501,29 @@ function FileContextMenu({
|
||||
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
|
||||
</ContextMenu.Group>
|
||||
{/* Add lock/unlock options for files only */}
|
||||
{!isFolder && (
|
||||
<ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor">
|
||||
<ContextMenuItem onSelect={handleLockFile}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:lock-simple" />
|
||||
Lock File (Full)
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={handleScopedLockFile}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:lock-simple-open" />
|
||||
Lock File (Scoped)
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={handleUnlockFile}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:lock-key-open" />
|
||||
Unlock File
|
||||
</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}>
|
||||
@ -516,6 +599,9 @@ function File({
|
||||
}: FileProps) {
|
||||
const { depth, name, fullPath } = file;
|
||||
|
||||
// Check if the file is locked
|
||||
const { locked, lockMode } = workbenchStore.isFileLocked(fullPath);
|
||||
|
||||
const fileModifications = fileHistory[fullPath];
|
||||
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
@ -582,6 +668,17 @@ function File({
|
||||
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
|
||||
</div>
|
||||
)}
|
||||
{locked && (
|
||||
<span
|
||||
className={classNames(
|
||||
'shrink-0',
|
||||
lockMode === 'full'
|
||||
? 'i-ph:lock-simple scale-80 text-red-500'
|
||||
: 'i-ph:lock-simple-open scale-80 text-yellow-500',
|
||||
)}
|
||||
title={lockMode === 'full' ? 'File is fully locked' : 'File has scoped locking'}
|
||||
/>
|
||||
)}
|
||||
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
||||
</div>
|
||||
</div>
|
||||
|
89
app/lib/persistence/lockedFiles.ts
Normal file
89
app/lib/persistence/lockedFiles.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('LockedFiles');
|
||||
|
||||
// Key for storing locked files in localStorage
|
||||
export const LOCKED_FILES_KEY = 'bolt.lockedFiles';
|
||||
|
||||
export type LockMode = 'full' | 'scoped';
|
||||
|
||||
export interface LockedFile {
|
||||
path: string;
|
||||
lockMode: LockMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save locked files to localStorage
|
||||
*/
|
||||
export function saveLockedFiles(files: LockedFile[]): void {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(LOCKED_FILES_KEY, JSON.stringify(files));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to save locked files to localStorage', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locked files from localStorage
|
||||
*/
|
||||
export function getLockedFiles(): LockedFile[] {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const lockedFilesJson = localStorage.getItem(LOCKED_FILES_KEY);
|
||||
|
||||
if (lockedFilesJson) {
|
||||
return JSON.parse(lockedFilesJson);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error('Failed to get locked files from localStorage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a file to the locked files list
|
||||
*/
|
||||
export function addLockedFile(filePath: string, lockMode: LockMode): void {
|
||||
const lockedFiles = getLockedFiles();
|
||||
|
||||
// Remove any existing entry for this file
|
||||
const filteredFiles = lockedFiles.filter((file) => file.path !== filePath);
|
||||
|
||||
// Add the new entry
|
||||
filteredFiles.push({ path: filePath, lockMode });
|
||||
|
||||
// Save the updated list
|
||||
saveLockedFiles(filteredFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from the locked files list
|
||||
*/
|
||||
export function removeLockedFile(filePath: string): void {
|
||||
const lockedFiles = getLockedFiles();
|
||||
|
||||
// Filter out the file to remove
|
||||
const filteredFiles = lockedFiles.filter((file) => file.path !== filePath);
|
||||
|
||||
// Save the updated list
|
||||
saveLockedFiles(filteredFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is locked
|
||||
*/
|
||||
export function isFileLocked(filePath: string): { locked: boolean; lockMode?: LockMode } {
|
||||
const lockedFiles = getLockedFiles();
|
||||
const lockedFile = lockedFiles.find((file) => file.path === filePath);
|
||||
|
||||
if (lockedFile) {
|
||||
return { locked: true, lockMode: lockedFile.lockMode };
|
||||
}
|
||||
|
||||
return { locked: false };
|
||||
}
|
@ -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;
|
||||
|
||||
@ -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?.locked && file.lockMode === 'full') {
|
||||
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,7 @@ import { WORK_DIR } from '~/utils/constants';
|
||||
import { computeFileModifications } from '~/utils/diff';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { unreachable } from '~/utils/unreachable';
|
||||
import { getLockedFiles, addLockedFile, removeLockedFile, type LockMode } from '~/lib/persistence/lockedFiles';
|
||||
|
||||
const logger = createScopedLogger('FilesStore');
|
||||
|
||||
@ -17,6 +18,8 @@ export interface File {
|
||||
type: 'file';
|
||||
content: string;
|
||||
isBinary: boolean;
|
||||
locked?: boolean;
|
||||
lockMode?: 'full' | 'scoped' | null;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
@ -76,6 +79,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;
|
||||
@ -86,6 +92,121 @@ export class FilesStore {
|
||||
this.#init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load locked files from localStorage and update the file objects
|
||||
*/
|
||||
#loadLockedFiles() {
|
||||
try {
|
||||
const lockedFiles = getLockedFiles();
|
||||
|
||||
if (lockedFiles.length === 0) {
|
||||
logger.info('No locked files found in localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${lockedFiles.length} locked files in localStorage`);
|
||||
|
||||
const currentFiles = this.files.get();
|
||||
const updates: FileMap = {};
|
||||
|
||||
for (const lockedFile of lockedFiles) {
|
||||
logger.info(`Applying lock to file: ${lockedFile.path} (mode: ${lockedFile.lockMode})`);
|
||||
|
||||
const file = currentFiles[lockedFile.path];
|
||||
|
||||
if (file?.type === 'file') {
|
||||
updates[lockedFile.path] = {
|
||||
...file,
|
||||
locked: true,
|
||||
lockMode: lockedFile.lockMode,
|
||||
};
|
||||
} else {
|
||||
/*
|
||||
* The file exists in localStorage but not in the current files
|
||||
* This could happen if the file was deleted or renamed
|
||||
* We'll keep track of it anyway in case it gets created later
|
||||
*/
|
||||
logger.warn(`Locked file not found in current files: ${lockedFile.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
logger.info(`Updating ${Object.keys(updates).length} files with lock state`);
|
||||
this.files.set({ ...currentFiles, ...updates });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load locked files from localStorage', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock a file
|
||||
*/
|
||||
lockFile(filePath: string, lockMode: LockMode) {
|
||||
const file = this.getFile(filePath);
|
||||
|
||||
if (!file) {
|
||||
logger.error(`Cannot lock non-existent file: ${filePath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the file in the store
|
||||
this.files.setKey(filePath, {
|
||||
...file,
|
||||
locked: true,
|
||||
lockMode,
|
||||
});
|
||||
|
||||
// Persist to localStorage
|
||||
addLockedFile(filePath, lockMode);
|
||||
|
||||
logger.info(`File locked: ${filePath} (mode: ${lockMode})`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock a file
|
||||
*/
|
||||
unlockFile(filePath: string) {
|
||||
const file = this.getFile(filePath);
|
||||
|
||||
if (!file) {
|
||||
logger.error(`Cannot unlock non-existent file: ${filePath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the file in the store
|
||||
this.files.setKey(filePath, {
|
||||
...file,
|
||||
locked: false,
|
||||
lockMode: null,
|
||||
});
|
||||
|
||||
// Remove from localStorage
|
||||
removeLockedFile(filePath);
|
||||
|
||||
logger.info(`File unlocked: ${filePath}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is locked
|
||||
*/
|
||||
isFileLocked(filePath: string): { locked: boolean; lockMode?: LockMode } {
|
||||
const file = this.getFile(filePath);
|
||||
|
||||
if (!file) {
|
||||
return { locked: false };
|
||||
}
|
||||
|
||||
return {
|
||||
locked: !!file.locked,
|
||||
lockMode: file.lockMode as LockMode | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getFile(filePath: string) {
|
||||
const dirent = this.files.get()[filePath];
|
||||
|
||||
@ -149,8 +270,19 @@ export class FilesStore {
|
||||
this.#modifiedFiles.set(filePath, oldContent);
|
||||
}
|
||||
|
||||
// Get the current lock state before updating
|
||||
const currentFile = this.files.get()[filePath];
|
||||
const locked = currentFile?.type === 'file' ? currentFile.locked : false;
|
||||
const lockMode = currentFile?.type === 'file' ? currentFile.lockMode : null;
|
||||
|
||||
// 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,
|
||||
locked,
|
||||
lockMode,
|
||||
});
|
||||
|
||||
logger.info('File updated');
|
||||
} catch (error) {
|
||||
@ -166,10 +298,28 @@ 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)),
|
||||
);
|
||||
|
||||
// Load locked files immediately
|
||||
this.#loadLockedFiles();
|
||||
|
||||
/*
|
||||
* 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();
|
||||
logger.info('Reapplied locked files from localStorage');
|
||||
}, 2000);
|
||||
|
||||
// Set up a periodic check to ensure locks remain applied
|
||||
setInterval(() => {
|
||||
this.#loadLockedFiles();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -302,7 +452,17 @@ export class FilesStore {
|
||||
content = existingFile.content;
|
||||
}
|
||||
|
||||
this.files.setKey(sanitizedPath, { type: 'file', content, isBinary });
|
||||
// Preserve lock state if the file already exists
|
||||
const locked = existingFile?.type === 'file' ? existingFile.locked : false;
|
||||
const lockMode = existingFile?.type === 'file' ? existingFile.lockMode : null;
|
||||
|
||||
this.files.setKey(sanitizedPath, {
|
||||
type: 'file',
|
||||
content,
|
||||
isBinary,
|
||||
locked,
|
||||
lockMode,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'remove_file': {
|
||||
@ -353,14 +513,26 @@ 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,
|
||||
locked: false,
|
||||
lockMode: null,
|
||||
});
|
||||
|
||||
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,
|
||||
locked: false,
|
||||
lockMode: null,
|
||||
});
|
||||
|
||||
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,30 @@ export class WorkbenchStore {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the file is locked
|
||||
const { locked, lockMode } = this.isFileLocked(filePath);
|
||||
|
||||
if (locked && lockMode === 'full') {
|
||||
// File is fully locked, don't allow saving
|
||||
console.warn(`Attempted to save locked file: ${filePath}`);
|
||||
|
||||
this.actionAlert.set({
|
||||
type: 'error',
|
||||
title: 'File Save Blocked',
|
||||
description: `Cannot save locked file: ${filePath}`,
|
||||
content: 'This file is locked and cannot be modified.',
|
||||
isLockedFile: true,
|
||||
});
|
||||
|
||||
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 +303,34 @@ export class WorkbenchStore {
|
||||
this.#filesStore.resetFileModifications();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock a file to prevent edits
|
||||
* @param filePath Path to the file to lock
|
||||
* @param lockMode Type of lock to apply ("full" or "scoped")
|
||||
* @returns True if the file was successfully locked
|
||||
*/
|
||||
lockFile(filePath: string, lockMode: 'full' | 'scoped') {
|
||||
return this.#filesStore.lockFile(filePath, lockMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is locked
|
||||
* @param filePath Path to the file to check
|
||||
* @returns Object with locked status and lock mode
|
||||
*/
|
||||
isFileLocked(filePath: string) {
|
||||
return this.#filesStore.isFileLocked(filePath);
|
||||
}
|
||||
|
||||
async createFile(filePath: string, content: string | Uint8Array = '') {
|
||||
try {
|
||||
const success = await this.#filesStore.createFile(filePath, content);
|
||||
@ -497,6 +549,43 @@ export class WorkbenchStore {
|
||||
const wc = await webcontainer;
|
||||
const fullPath = path.join(wc.workdir, data.action.filePath);
|
||||
|
||||
// Check if the file is locked
|
||||
const { locked, lockMode } = this.isFileLocked(fullPath);
|
||||
|
||||
if (locked && lockMode === 'full') {
|
||||
// File is fully locked, don't allow any modifications
|
||||
console.warn(`AI attempted to modify locked file: ${fullPath}`);
|
||||
|
||||
/*
|
||||
* Instead of trying to update the action directly, we'll just add a notification
|
||||
* and prevent the action from being executed
|
||||
*/
|
||||
this.actionAlert.set({
|
||||
type: 'error',
|
||||
title: 'File Modification Blocked',
|
||||
description: `AI attempted to modify locked file: ${data.action.filePath}`,
|
||||
content: 'This file is locked and cannot be modified by the AI.',
|
||||
isLockedFile: true,
|
||||
});
|
||||
|
||||
// Still select the file to show the user what the AI tried to modify
|
||||
if (this.selectedFile.value !== fullPath) {
|
||||
this.setSelectedFile(fullPath);
|
||||
}
|
||||
|
||||
if (this.currentView.value !== 'code') {
|
||||
this.currentView.set('code');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ export interface ActionAlert {
|
||||
description: string;
|
||||
content: string;
|
||||
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
|
||||
isLockedFile?: boolean; // Indicates if the error is related to a locked file
|
||||
}
|
||||
|
||||
export interface SupabaseAlert {
|
||||
|
24
app/utils/fileLocks.ts
Normal file
24
app/utils/fileLocks.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { getLockedFiles, type LockMode } from '~/lib/persistence/lockedFiles';
|
||||
import { createScopedLogger } from './logger';
|
||||
|
||||
const logger = createScopedLogger('FileLocks');
|
||||
|
||||
/**
|
||||
* Check if a file is locked directly from localStorage
|
||||
* This avoids circular dependencies between components and stores
|
||||
*/
|
||||
export function isFileLocked(filePath: string): { locked: boolean; lockMode?: LockMode } {
|
||||
try {
|
||||
const lockedFiles = getLockedFiles();
|
||||
const lockedFile = lockedFiles.find((file) => file.path === filePath);
|
||||
|
||||
if (lockedFile) {
|
||||
return { locked: true, lockMode: lockedFile.lockMode };
|
||||
}
|
||||
|
||||
return { locked: false };
|
||||
} catch (error) {
|
||||
logger.error('Failed to check if file is locked', error);
|
||||
return { locked: false };
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user