feat: add file targeting feature

This commit is contained in:
vgcman16 2025-06-05 18:16:43 -05:00
parent 6721e94359
commit a01970fe1a
6 changed files with 305 additions and 0 deletions

View File

@ -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
</div>
</ContextMenuItem>
<ContextMenuItem onSelect={handleTargetFile}>
<div className="flex items-center gap-2">
<div className="i-ph:crosshair" />
Target File
</div>
</ContextMenuItem>
<ContextMenuItem onSelect={handleUnTargetFile}>
<div className="flex items-center gap-2">
<div className="i-ph:target" />
Un-target File
</div>
</ContextMenuItem>
</>
) : (
<>
@ -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 && (
<span className="shrink-0 i-ph:crosshair text-green-500 scale-80" title="Targeted file" />
)}
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
</div>
</div>

View File

@ -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<string, Set<string>>();
function getChatSet(chatId: string, create = false): Set<string> | 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();
}

View File

@ -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);

View File

@ -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

View File

@ -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

47
app/utils/targetFiles.ts Normal file
View File

@ -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';