diff --git a/app/components/chat/ChatAlert.tsx b/app/components/chat/ChatAlert.tsx index 5aeb08c7..917d0e73 100644 --- a/app/components/chat/ChatAlert.tsx +++ b/app/components/chat/ChatAlert.tsx @@ -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 ; + } const isPreview = source === 'preview'; const title = isPreview ? 'Preview Error' : 'Terminal Error'; diff --git a/app/components/chat/LockAlert.tsx b/app/components/chat/LockAlert.tsx new file mode 100644 index 00000000..f4b5c347 --- /dev/null +++ b/app/components/chat/LockAlert.tsx @@ -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 ( + + + {/* Header */} +
+ +
+
+ + Terminal Error + +
+ + {/* Content */} +
+ +

+ The file is locked and cannot be modified. You need to unlock the file before making changes. +

+ + {description && ( +
+
+
+ Error Details +
+
{description}
+
+ )} +
+
+ + {/* Actions */} + +
+ +
+
+
+
+ ); +} diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index f2ab3e15..6533ba99 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -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 }; }, diff --git a/app/components/workbench/EditorPanel.tsx b/app/components/workbench/EditorPanel.tsx index 11d2630c..1f716474 100644 --- a/app/components/workbench/EditorPanel.tsx +++ b/app/components/workbench/EditorPanel.tsx @@ -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 ( diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx index 7ea96f84..e165d78d 100644 --- a/app/components/workbench/FileTree.tsx +++ b/app/components/workbench/FileTree.tsx @@ -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 ( <> @@ -441,6 +501,29 @@ function FileContextMenu({ Copy path Copy relative path + {/* Add lock/unlock options for files only */} + {!isFolder && ( + + +
+
+ Lock File (Full) +
+ + +
+
+ Lock File (Scoped) +
+ + +
+
+ Unlock File +
+ + + )} {/* Add delete option in a new group */} @@ -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 && -{deletions}}
)} + {locked && ( + + )} {unsavedChanges && }
diff --git a/app/lib/persistence/lockedFiles.ts b/app/lib/persistence/lockedFiles.ts new file mode 100644 index 00000000..da0410ee --- /dev/null +++ b/app/lib/persistence/lockedFiles.ts @@ -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 }; +} diff --git a/app/lib/stores/editor.ts b/app/lib/stores/editor.ts index ff3b3375..58b4e66b 100644 --- a/app/lib/stores/editor.ts +++ b/app/lib/stores/editor.ts @@ -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; type SelectedFile = WritableAtom; +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; diff --git a/app/lib/stores/files.ts b/app/lib/stores/files.ts index 8355bb9b..31a1cac8 100644 --- a/app/lib/stores/files.ts +++ b/app/lib/stores/files.ts @@ -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); } diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index 88f7dddb..efcf5993 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -49,11 +49,11 @@ export class WorkbenchStore { currentView: WritableAtom = import.meta.hot?.data.currentView ?? atom('code'); unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); actionAlert: WritableAtom = - import.meta.hot?.data.unsavedFiles ?? atom(undefined); + import.meta.hot?.data.actionAlert ?? atom(undefined); supabaseAlert: WritableAtom = - import.meta.hot?.data.unsavedFiles ?? atom(undefined); + import.meta.hot?.data.supabaseAlert ?? atom(undefined); deployAlert: WritableAtom = - import.meta.hot?.data.unsavedFiles ?? atom(undefined); + import.meta.hot?.data.deployAlert ?? atom(undefined); modifiedFiles = new Set(); 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); } diff --git a/app/types/actions.ts b/app/types/actions.ts index 0e1411d8..c29b0985 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -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 { diff --git a/app/utils/fileLocks.ts b/app/utils/fileLocks.ts new file mode 100644 index 00000000..587973d0 --- /dev/null +++ b/app/utils/fileLocks.ts @@ -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 }; + } +}