mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-25 17:56:12 +00:00
feat: add file targeting feature
This commit is contained in:
parent
6721e94359
commit
a01970fe1a
@ -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>
|
||||
|
98
app/lib/persistence/targetedFiles.ts
Normal file
98
app/lib/persistence/targetedFiles.ts
Normal 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();
|
||||
}
|
@ -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);
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
47
app/utils/targetFiles.ts
Normal 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';
|
Loading…
Reference in New Issue
Block a user