diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx index f762ab7d..bcf8415f 100644 --- a/app/components/workbench/FileTree.tsx +++ b/app/components/workbench/FileTree.tsx @@ -442,6 +442,38 @@ function FileContextMenu({ } }; + const handleTargetFile = () => { + try { + if (isFolder) { + return; + } + + const success = workbenchStore.targetFile(fullPath); + if (success) { + toast.success(`File targeted`); + } + } catch (error) { + toast.error('Error targeting file'); + logger.error(error); + } + }; + + const handleUnTargetFile = () => { + try { + if (isFolder) { + return; + } + + const success = workbenchStore.unTargetFile(fullPath); + if (success) { + toast.success(`File un-targeted`); + } + } catch (error) { + toast.error('Error removing target'); + logger.error(error); + } + }; + // Handler for locking a folder with full lock const handleLockFolder = () => { try { @@ -537,6 +569,18 @@ function FileContextMenu({ Unlock File + +
+
+ Target File +
+ + +
+
+ Un-target File +
+ ) : ( <> @@ -643,6 +687,7 @@ function File({ // Check if the file is locked const { locked } = workbenchStore.isFileLocked(fullPath); + const isTargeted = workbenchStore.isFileTargeted(fullPath); const fileModifications = fileHistory[fullPath]; @@ -716,6 +761,9 @@ function File({ title={'File is locked'} /> )} + {isTargeted && ( + + )} {unsavedChanges && }
diff --git a/app/lib/persistence/targetedFiles.ts b/app/lib/persistence/targetedFiles.ts new file mode 100644 index 00000000..a0602351 --- /dev/null +++ b/app/lib/persistence/targetedFiles.ts @@ -0,0 +1,98 @@ +export const TARGETED_FILES_KEY = 'bolt.targetedFiles'; + +export interface TargetedFile { + chatId: string; + path: string; +} + +let targetedFilesCache: TargetedFile[] | null = null; +const targetedFilesMap = new Map>(); + +function getChatSet(chatId: string, create = false): Set | undefined { + if (create && !targetedFilesMap.has(chatId)) { + targetedFilesMap.set(chatId, new Set()); + } + return targetedFilesMap.get(chatId); +} + +function initializeCache(): TargetedFile[] { + if (targetedFilesCache !== null) { + return targetedFilesCache; + } + try { + if (typeof localStorage !== 'undefined') { + const json = localStorage.getItem(TARGETED_FILES_KEY); + if (json) { + const items = JSON.parse(json) as TargetedFile[]; + targetedFilesCache = items; + rebuildLookup(items); + return items; + } + } + targetedFilesCache = []; + return []; + } catch { + targetedFilesCache = []; + return []; + } +} + +function rebuildLookup(items: TargetedFile[]): void { + targetedFilesMap.clear(); + for (const item of items) { + let set = targetedFilesMap.get(item.chatId); + if (!set) { + set = new Set(); + targetedFilesMap.set(item.chatId, set); + } + set.add(item.path); + } +} + +export function saveTargetedFiles(items: TargetedFile[]): void { + targetedFilesCache = [...items]; + rebuildLookup(items); + try { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(TARGETED_FILES_KEY, JSON.stringify(items)); + } + } catch {} +} + +export function getTargetedFiles(): TargetedFile[] { + return initializeCache(); +} + +export function addTargetedFile(chatId: string, path: string): void { + const files = getTargetedFiles(); + const set = getChatSet(chatId, true)!; + set.add(path); + const filtered = files.filter((f) => !(f.chatId === chatId && f.path === path)); + filtered.push({ chatId, path }); + saveTargetedFiles(filtered); +} + +export function removeTargetedFile(chatId: string, path: string): void { + const files = getTargetedFiles(); + const set = getChatSet(chatId); + if (set) set.delete(path); + const filtered = files.filter((f) => !(f.chatId === chatId && f.path === path)); + saveTargetedFiles(filtered); +} + +export function isFileTargeted(chatId: string, path: string): boolean { + initializeCache(); + const set = getChatSet(chatId); + return set ? set.has(path) : false; +} + +export function getTargetedFilesForChat(chatId: string): string[] { + initializeCache(); + const set = getChatSet(chatId); + return set ? Array.from(set) : []; +} + +export function clearCache(): void { + targetedFilesCache = null; + targetedFilesMap.clear(); +} diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index a7234c49..8e49da82 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -6,6 +6,8 @@ import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; import type { ActionCallbackData } from './message-parser'; import type { BoltShell } from '~/utils/shell'; +import { isFileLocked } from '~/utils/fileLocks'; +import { isFileTargeted, hasTargetedFiles } from '~/utils/targetFiles'; const logger = createScopedLogger('ActionRunner'); @@ -304,6 +306,16 @@ export class ActionRunner { unreachable('Expected file action'); } + if (isFileLocked(action.filePath).locked) { + logger.warn(`Skipping locked file ${action.filePath}`); + return; + } + + if (hasTargetedFiles() && !isFileTargeted(action.filePath)) { + logger.info(`Skipping non-targeted file ${action.filePath}`); + return; + } + const webcontainer = await this.#webcontainer; const relativePath = nodePath.relative(webcontainer.workdir, action.filePath); diff --git a/app/lib/stores/files.ts b/app/lib/stores/files.ts index d4b87198..8097df4b 100644 --- a/app/lib/stores/files.ts +++ b/app/lib/stores/files.ts @@ -20,6 +20,11 @@ import { migrateLegacyLocks, clearCache, } from '~/lib/persistence/lockedFiles'; +import { + getTargetedFilesForChat, + addTargetedFile, + removeTargetedFile, +} from '~/lib/persistence/targetedFiles'; import { getCurrentChatId } from '~/utils/fileLocks'; const logger = createScopedLogger('FilesStore'); @@ -31,6 +36,7 @@ export interface File { content: string; isBinary: boolean; isLocked?: boolean; + isTargeted?: boolean; lockedByFolder?: string; // Path of the folder that locked this file } @@ -95,6 +101,8 @@ export class FilesStore { // Load locked files from localStorage this.#loadLockedFiles(); + // Load targeted files from localStorage + this.#loadTargetedFiles(); if (import.meta.hot) { // Persist our state across hot reloads @@ -193,6 +201,38 @@ export class FilesStore { } } + /** + * Load targeted files from localStorage and mark them in the store + * @param chatId Optional chat ID (defaults to current chat) + */ + #loadTargetedFiles(chatId?: string) { + try { + const currentChatId = chatId || getCurrentChatId(); + const targeted = getTargetedFilesForChat(currentChatId); + + if (targeted.length === 0) { + return; + } + + const currentFiles = this.files.get(); + const updates: FileMap = {}; + + for (const path of targeted) { + const file = currentFiles[path]; + + if (file?.type === 'file') { + updates[path] = { ...file, isTargeted: true }; + } + } + + if (Object.keys(updates).length > 0) { + this.files.set({ ...currentFiles, ...updates }); + } + } catch (error) { + logger.error('Failed to load targeted files from localStorage', error); + } + } + /** * Apply a lock to all files within a folder * @param currentFiles Current file map @@ -323,6 +363,40 @@ export class FilesStore { return true; } + /** + * Mark a file as targeted for AI edits + */ + targetFile(filePath: string, chatId?: string) { + const file = this.getFile(filePath); + const currentChatId = chatId || getCurrentChatId(); + + if (!file) { + logger.error(`Cannot target non-existent file: ${filePath}`); + return false; + } + + this.files.setKey(filePath, { ...file, isTargeted: true }); + addTargetedFile(currentChatId, filePath); + return true; + } + + /** + * Remove a file from targeted list + */ + unTargetFile(filePath: string, chatId?: string) { + const file = this.getFile(filePath); + const currentChatId = chatId || getCurrentChatId(); + + if (!file) { + logger.error(`Cannot untarget non-existent file: ${filePath}`); + return false; + } + + this.files.setKey(filePath, { ...file, isTargeted: false }); + removeTargetedFile(currentChatId, filePath); + return true; + } + /** * Unlock a folder and all its contents * @param folderPath Path to the folder to unlock @@ -440,6 +514,20 @@ export class FilesStore { return { locked: false }; } + /** + * Check if a file is targeted for AI edits + */ + isFileTargeted(filePath: string, chatId?: string): boolean { + const currentChatId = chatId || getCurrentChatId(); + const file = this.getFile(filePath); + + if (file?.isTargeted) { + return true; + } + + return getTargetedFilesForChat(currentChatId).includes(filePath); + } + /** * Check if a file is within a locked folder * @param filePath Path to the file to check diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index e90114cd..a2d209d3 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -312,6 +312,14 @@ export class WorkbenchStore { return this.#filesStore.unlockFile(filePath); } + targetFile(filePath: string) { + return this.#filesStore.targetFile(filePath); + } + + unTargetFile(filePath: string) { + return this.#filesStore.unTargetFile(filePath); + } + /** * Unlock a folder and all its contents to allow edits * @param folderPath Path to the folder to unlock @@ -330,6 +338,10 @@ export class WorkbenchStore { return this.#filesStore.isFileLocked(filePath); } + isFileTargeted(filePath: string) { + return this.#filesStore.isFileTargeted(filePath); + } + /** * Check if a folder is locked * @param folderPath Path to the folder to check diff --git a/app/utils/targetFiles.ts b/app/utils/targetFiles.ts new file mode 100644 index 00000000..e26c2457 --- /dev/null +++ b/app/utils/targetFiles.ts @@ -0,0 +1,47 @@ +import { + addTargetedFile, + removeTargetedFile, + isFileTargeted as isFileTargetedInternal, + getTargetedFiles, +} from '~/lib/persistence/targetedFiles'; +import { createScopedLogger } from './logger'; + +const logger = createScopedLogger('TargetFiles'); + +export function getCurrentChatId(): string { + try { + if (typeof window !== 'undefined') { + const match = window.location.pathname.match(/\/chat\/([^/]+)/); + if (match && match[1]) { + return match[1]; + } + } + return 'default'; + } catch (error) { + logger.error('Failed to get current chat ID', error); + return 'default'; + } +} + +export function isFileTargeted(filePath: string, chatId?: string): boolean { + try { + const currentChatId = chatId || getCurrentChatId(); + return isFileTargetedInternal(currentChatId, filePath); + } catch (error) { + logger.error('Failed to check if file is targeted', error); + return false; + } +} + +export function hasTargetedFiles(chatId?: string): boolean { + try { + const currentChatId = chatId || getCurrentChatId(); + const files = getTargetedFiles(); + return files.some((f) => f.chatId === currentChatId); + } catch (error) { + logger.error('Failed to check for targeted files', error); + return false; + } +} + +export { addTargetedFile, removeTargetedFile } from '~/lib/persistence/targetedFiles';