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 { AnimatePresence, motion } from 'framer-motion';
|
||||||
import type { ActionAlert } from '~/types/actions';
|
import type { ActionAlert } from '~/types/actions';
|
||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import LockAlert from './LockAlert';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
alert: ActionAlert;
|
alert: ActionAlert;
|
||||||
@ -9,7 +10,12 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatAlert({ alert, clearAlert, postMessage }: 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 isPreview = source === 'preview';
|
||||||
const title = isPreview ? 'Preview Error' : 'Terminal Error';
|
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 { classNames } from '~/utils/classNames';
|
||||||
import { debounce } from '~/utils/debounce';
|
import { debounce } from '~/utils/debounce';
|
||||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||||
|
import { isFileLocked } from '~/utils/fileLocks';
|
||||||
import { BinaryContent } from './BinaryContent';
|
import { BinaryContent } from './BinaryContent';
|
||||||
import { getTheme, reconfigureTheme } from './cm-theme';
|
import { getTheme, reconfigureTheme } from './cm-theme';
|
||||||
import { indentKeyBinding } from './indent';
|
import { indentKeyBinding } from './indent';
|
||||||
@ -29,6 +30,9 @@ import { createEnvMaskingExtension } from './EnvMasking';
|
|||||||
|
|
||||||
const logger = createScopedLogger('CodeMirrorEditor');
|
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 {
|
export interface EditorDocument {
|
||||||
value: string;
|
value: string;
|
||||||
isBinary: boolean;
|
isBinary: boolean;
|
||||||
@ -158,6 +162,9 @@ export const CodeMirrorEditor = memo(
|
|||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
onSaveRef.current = onSave;
|
onSaveRef.current = onSave;
|
||||||
docRef.current = doc;
|
docRef.current = doc;
|
||||||
|
|
||||||
|
// Update the module-level reference for use in tooltip functions
|
||||||
|
currentDocRef = doc;
|
||||||
themeRef.current = theme;
|
themeRef.current = theme;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -278,6 +285,15 @@ export const CodeMirrorEditor = memo(
|
|||||||
autoFocusOnDocumentChange,
|
autoFocusOnDocumentChange,
|
||||||
doc as TextEditorDocument,
|
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]);
|
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
|
||||||
|
|
||||||
return (
|
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({
|
view.dispatch({
|
||||||
effects: [editableStateEffect.of(editable && !doc.isBinary)],
|
effects: [editableStateEffect.of(editable && !doc.isBinary && !locked)],
|
||||||
});
|
});
|
||||||
|
|
||||||
getLanguage(doc.filePath).then((languageSupport) => {
|
getLanguage(doc.filePath).then((languageSupport) => {
|
||||||
@ -477,6 +497,22 @@ function getReadOnlyTooltip(state: EditorState) {
|
|||||||
return [];
|
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
|
return state.selection.ranges
|
||||||
.filter((range) => {
|
.filter((range) => {
|
||||||
return range.empty;
|
return range.empty;
|
||||||
@ -490,7 +526,7 @@ function getReadOnlyTooltip(state: EditorState) {
|
|||||||
create: () => {
|
create: () => {
|
||||||
const divElement = document.createElement('div');
|
const divElement = document.createElement('div');
|
||||||
divElement.className = 'cm-readonly-tooltip';
|
divElement.className = 'cm-readonly-tooltip';
|
||||||
divElement.textContent = 'Cannot edit file while AI response is being generated';
|
divElement.textContent = tooltipMessage;
|
||||||
|
|
||||||
return { dom: divElement };
|
return { dom: divElement };
|
||||||
},
|
},
|
||||||
|
@ -71,7 +71,12 @@ export const EditorPanel = memo(
|
|||||||
}, [editorDocument]);
|
}, [editorDocument]);
|
||||||
|
|
||||||
const activeFileUnsaved = useMemo(() => {
|
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]);
|
}, [editorDocument, unsavedFiles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -152,7 +152,7 @@ export const FileTree = memo(
|
|||||||
key={fileOrFolder.id}
|
key={fileOrFolder.id}
|
||||||
selected={selectedFile === fileOrFolder.fullPath}
|
selected={selectedFile === fileOrFolder.fullPath}
|
||||||
file={fileOrFolder}
|
file={fileOrFolder}
|
||||||
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
unsavedChanges={unsavedFiles instanceof Set && unsavedFiles.has(fileOrFolder.fullPath)}
|
||||||
fileHistory={fileHistory}
|
fileHistory={fileHistory}
|
||||||
onCopyPath={() => {
|
onCopyPath={() => {
|
||||||
onCopyPath(fileOrFolder);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
@ -441,6 +501,29 @@ function FileContextMenu({
|
|||||||
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
|
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
|
||||||
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
|
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
|
||||||
</ContextMenu.Group>
|
</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 */}
|
{/* Add delete option in a new group */}
|
||||||
<ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor">
|
<ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor">
|
||||||
<ContextMenuItem onSelect={handleDelete}>
|
<ContextMenuItem onSelect={handleDelete}>
|
||||||
@ -516,6 +599,9 @@ function File({
|
|||||||
}: FileProps) {
|
}: FileProps) {
|
||||||
const { depth, name, fullPath } = file;
|
const { depth, name, fullPath } = file;
|
||||||
|
|
||||||
|
// Check if the file is locked
|
||||||
|
const { locked, lockMode } = workbenchStore.isFileLocked(fullPath);
|
||||||
|
|
||||||
const fileModifications = fileHistory[fullPath];
|
const fileModifications = fileHistory[fullPath];
|
||||||
|
|
||||||
const { additions, deletions } = useMemo(() => {
|
const { additions, deletions } = useMemo(() => {
|
||||||
@ -582,6 +668,17 @@ function File({
|
|||||||
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
|
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
|
||||||
</div>
|
</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" />}
|
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
||||||
</div>
|
</div>
|
||||||
</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 { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores';
|
||||||
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
|
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||||
import type { FileMap, FilesStore } from './files';
|
import type { FileMap, FilesStore } from './files';
|
||||||
|
import { createScopedLogger } from '~/utils/logger';
|
||||||
|
|
||||||
export type EditorDocuments = Record<string, EditorDocument>;
|
export type EditorDocuments = Record<string, EditorDocument>;
|
||||||
|
|
||||||
type SelectedFile = WritableAtom<string | undefined>;
|
type SelectedFile = WritableAtom<string | undefined>;
|
||||||
|
|
||||||
|
const logger = createScopedLogger('EditorStore');
|
||||||
|
|
||||||
export class EditorStore {
|
export class EditorStore {
|
||||||
#filesStore: FilesStore;
|
#filesStore: FilesStore;
|
||||||
|
|
||||||
@ -82,6 +85,20 @@ export class EditorStore {
|
|||||||
return;
|
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 currentContent = documentState.value;
|
||||||
const contentChanged = currentContent !== newContent;
|
const contentChanged = currentContent !== newContent;
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import { WORK_DIR } from '~/utils/constants';
|
|||||||
import { computeFileModifications } from '~/utils/diff';
|
import { computeFileModifications } from '~/utils/diff';
|
||||||
import { createScopedLogger } from '~/utils/logger';
|
import { createScopedLogger } from '~/utils/logger';
|
||||||
import { unreachable } from '~/utils/unreachable';
|
import { unreachable } from '~/utils/unreachable';
|
||||||
|
import { getLockedFiles, addLockedFile, removeLockedFile, type LockMode } from '~/lib/persistence/lockedFiles';
|
||||||
|
|
||||||
const logger = createScopedLogger('FilesStore');
|
const logger = createScopedLogger('FilesStore');
|
||||||
|
|
||||||
@ -17,6 +18,8 @@ export interface File {
|
|||||||
type: 'file';
|
type: 'file';
|
||||||
content: string;
|
content: string;
|
||||||
isBinary: boolean;
|
isBinary: boolean;
|
||||||
|
locked?: boolean;
|
||||||
|
lockMode?: 'full' | 'scoped' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
@ -76,6 +79,9 @@ export class FilesStore {
|
|||||||
logger.error('Failed to load deleted paths from localStorage', error);
|
logger.error('Failed to load deleted paths from localStorage', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load locked files from localStorage
|
||||||
|
this.#loadLockedFiles();
|
||||||
|
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
// Persist our state across hot reloads
|
// Persist our state across hot reloads
|
||||||
import.meta.hot.data.files = this.files;
|
import.meta.hot.data.files = this.files;
|
||||||
@ -86,6 +92,121 @@ export class FilesStore {
|
|||||||
this.#init();
|
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) {
|
getFile(filePath: string) {
|
||||||
const dirent = this.files.get()[filePath];
|
const dirent = this.files.get()[filePath];
|
||||||
|
|
||||||
@ -149,8 +270,19 @@ export class FilesStore {
|
|||||||
this.#modifiedFiles.set(filePath, oldContent);
|
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
|
// 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');
|
logger.info('File updated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -166,10 +298,28 @@ export class FilesStore {
|
|||||||
// Clean up any files that were previously deleted
|
// Clean up any files that were previously deleted
|
||||||
this.#cleanupDeletedFiles();
|
this.#cleanupDeletedFiles();
|
||||||
|
|
||||||
|
// Set up file watcher
|
||||||
webcontainer.internal.watchPaths(
|
webcontainer.internal.watchPaths(
|
||||||
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
|
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
|
||||||
bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
|
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;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'remove_file': {
|
case 'remove_file': {
|
||||||
@ -353,14 +513,26 @@ export class FilesStore {
|
|||||||
await webcontainer.fs.writeFile(relativePath, Buffer.from(content));
|
await webcontainer.fs.writeFile(relativePath, Buffer.from(content));
|
||||||
|
|
||||||
const base64Content = Buffer.from(content).toString('base64');
|
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);
|
this.#modifiedFiles.set(filePath, base64Content);
|
||||||
} else {
|
} else {
|
||||||
const contentToWrite = (content as string).length === 0 ? ' ' : content;
|
const contentToWrite = (content as string).length === 0 ? ' ' : content;
|
||||||
await webcontainer.fs.writeFile(relativePath, contentToWrite);
|
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);
|
this.#modifiedFiles.set(filePath, content as string);
|
||||||
}
|
}
|
||||||
|
@ -49,11 +49,11 @@ export class WorkbenchStore {
|
|||||||
currentView: WritableAtom<WorkbenchViewType> = import.meta.hot?.data.currentView ?? atom('code');
|
currentView: WritableAtom<WorkbenchViewType> = import.meta.hot?.data.currentView ?? atom('code');
|
||||||
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
||||||
actionAlert: WritableAtom<ActionAlert | undefined> =
|
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> =
|
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> =
|
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>();
|
modifiedFiles = new Set<string>();
|
||||||
artifactIdList: string[] = [];
|
artifactIdList: string[] = [];
|
||||||
#globalExecutionQueue = Promise.resolve();
|
#globalExecutionQueue = Promise.resolve();
|
||||||
@ -226,6 +226,30 @@ export class WorkbenchStore {
|
|||||||
return;
|
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);
|
await this.#filesStore.saveFile(filePath, document.value);
|
||||||
|
|
||||||
const newUnsavedFiles = new Set(this.unsavedFiles.get());
|
const newUnsavedFiles = new Set(this.unsavedFiles.get());
|
||||||
@ -279,6 +303,34 @@ export class WorkbenchStore {
|
|||||||
this.#filesStore.resetFileModifications();
|
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 = '') {
|
async createFile(filePath: string, content: string | Uint8Array = '') {
|
||||||
try {
|
try {
|
||||||
const success = await this.#filesStore.createFile(filePath, content);
|
const success = await this.#filesStore.createFile(filePath, content);
|
||||||
@ -497,6 +549,43 @@ export class WorkbenchStore {
|
|||||||
const wc = await webcontainer;
|
const wc = await webcontainer;
|
||||||
const fullPath = path.join(wc.workdir, data.action.filePath);
|
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) {
|
if (this.selectedFile.value !== fullPath) {
|
||||||
this.setSelectedFile(fullPath);
|
this.setSelectedFile(fullPath);
|
||||||
}
|
}
|
||||||
|
@ -40,6 +40,7 @@ export interface ActionAlert {
|
|||||||
description: string;
|
description: string;
|
||||||
content: string;
|
content: string;
|
||||||
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
|
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 {
|
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