Fix file locking to be scoped by chat ID

This commit is contained in:
Stijnus 2025-05-02 17:37:17 +02:00
parent 4a3009837a
commit f447946037
4 changed files with 208 additions and 38 deletions

View File

@ -21,7 +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 { isFileLocked, getCurrentChatId } from '~/utils/fileLocks';
import { BinaryContent } from './BinaryContent';
import { getTheme, reconfigureTheme } from './cm-theme';
import { indentKeyBinding } from './indent';
@ -287,7 +287,8 @@ export const CodeMirrorEditor = memo(
);
// Check if the file is locked and update the editor state accordingly
const { locked } = isFileLocked(doc.filePath);
const currentChatId = getCurrentChatId();
const { locked } = isFileLocked(doc.filePath, currentChatId);
if (locked) {
view.dispatch({
@ -436,7 +437,8 @@ function setEditorDocument(
}
// Check if the file is locked
const { locked } = isFileLocked(doc.filePath);
const currentChatId = getCurrentChatId();
const { locked } = isFileLocked(doc.filePath, currentChatId);
// Set editable state based on both the editable prop and the file's lock state
view.dispatch({
@ -503,7 +505,8 @@ function getReadOnlyTooltip(state: EditorState) {
// If we have a current document, check if it's locked
if (currentDoc?.filePath) {
const { locked, lockMode } = isFileLocked(currentDoc.filePath);
const currentChatId = getCurrentChatId();
const { locked, lockMode } = isFileLocked(currentDoc.filePath, currentChatId);
if (locked) {
tooltipMessage =

View File

@ -8,6 +8,7 @@ export const LOCKED_FILES_KEY = 'bolt.lockedFiles';
export type LockMode = 'full' | 'scoped';
export interface LockedFile {
chatId: string; // Chat ID to scope locks to a specific project
path: string;
lockMode: LockMode;
}
@ -47,15 +48,18 @@ export function getLockedFiles(): LockedFile[] {
/**
* Add a file to the locked files list
* @param chatId The chat ID to scope the lock to
* @param filePath The path of the file to lock
* @param lockMode The type of lock to apply
*/
export function addLockedFile(filePath: string, lockMode: LockMode): void {
export function addLockedFile(chatId: string, filePath: string, lockMode: LockMode): void {
const lockedFiles = getLockedFiles();
// Remove any existing entry for this file
const filteredFiles = lockedFiles.filter((file) => file.path !== filePath);
// Remove any existing entry for this file in this chat
const filteredFiles = lockedFiles.filter((file) => !(file.chatId === chatId && file.path === filePath));
// Add the new entry
filteredFiles.push({ path: filePath, lockMode });
filteredFiles.push({ chatId, path: filePath, lockMode });
// Save the updated list
saveLockedFiles(filteredFiles);
@ -63,12 +67,14 @@ export function addLockedFile(filePath: string, lockMode: LockMode): void {
/**
* Remove a file from the locked files list
* @param chatId The chat ID the lock belongs to
* @param filePath The path of the file to unlock
*/
export function removeLockedFile(filePath: string): void {
export function removeLockedFile(chatId: string, filePath: string): void {
const lockedFiles = getLockedFiles();
// Filter out the file to remove
const filteredFiles = lockedFiles.filter((file) => file.path !== filePath);
// Filter out the file to remove for this specific chat
const filteredFiles = lockedFiles.filter((file) => !(file.chatId === chatId && file.path === filePath));
// Save the updated list
saveLockedFiles(filteredFiles);
@ -76,10 +82,13 @@ export function removeLockedFile(filePath: string): void {
/**
* Check if a file is locked
* @param chatId The chat ID to check locks for
* @param filePath The path of the file to check
* @returns Object with locked status and lock mode
*/
export function isFileLocked(filePath: string): { locked: boolean; lockMode?: LockMode } {
export function isFileLocked(chatId: string, filePath: string): { locked: boolean; lockMode?: LockMode } {
const lockedFiles = getLockedFiles();
const lockedFile = lockedFiles.find((file) => file.path === filePath);
const lockedFile = lockedFiles.find((file) => file.chatId === chatId && file.path === filePath);
if (lockedFile) {
return { locked: true, lockMode: lockedFile.lockMode };
@ -87,3 +96,51 @@ export function isFileLocked(filePath: string): { locked: boolean; lockMode?: Lo
return { locked: false };
}
/**
* Get all locked files for a specific chat
* @param chatId The chat ID to get locks for
* @returns Array of locked files for the specified chat
*/
export function getLockedFilesForChat(chatId: string): LockedFile[] {
const lockedFiles = getLockedFiles();
return lockedFiles.filter((file) => file.chatId === chatId);
}
/**
* Migrate legacy locks (without chatId) to the new format
* @param currentChatId The current chat ID to assign to legacy locks
*/
export function migrateLegacyLocks(currentChatId: string): void {
try {
if (typeof localStorage !== 'undefined') {
const lockedFilesJson = localStorage.getItem(LOCKED_FILES_KEY);
if (lockedFilesJson) {
const lockedFiles = JSON.parse(lockedFilesJson);
if (Array.isArray(lockedFiles)) {
let hasLegacyLocks = false;
// Check if any locks are in the old format (missing chatId)
const updatedLocks = lockedFiles.map((file) => {
if (!file.chatId) {
hasLegacyLocks = true;
return { ...file, chatId: currentChatId };
}
return file;
});
// Only save if we found and updated legacy locks
if (hasLegacyLocks) {
saveLockedFiles(updatedLocks);
logger.info(`Migrated ${updatedLocks.length} legacy locks to chat ID: ${currentChatId}`);
}
}
}
}
} catch (error) {
logger.error('Failed to migrate legacy locks', error);
}
}

View File

@ -8,7 +8,14 @@ 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';
import {
addLockedFile,
removeLockedFile,
getLockedFilesForChat,
migrateLegacyLocks,
type LockMode,
} from '~/lib/persistence/lockedFiles';
import { getCurrentChatId } from '~/utils/fileLocks';
const logger = createScopedLogger('FilesStore');
@ -89,22 +96,47 @@ export class FilesStore {
import.meta.hot.data.deletedPaths = this.#deletedPaths;
}
// Listen for URL changes to detect chat ID changes
if (typeof window !== 'undefined') {
let lastChatId = getCurrentChatId();
// Use MutationObserver to detect URL changes (for SPA navigation)
const observer = new MutationObserver(() => {
const currentChatId = getCurrentChatId();
if (currentChatId !== lastChatId) {
logger.info(`Chat ID changed from ${lastChatId} to ${currentChatId}, reloading locks`);
lastChatId = currentChatId;
this.#loadLockedFiles(currentChatId);
}
});
observer.observe(document, { subtree: true, childList: true });
}
this.#init();
}
/**
* Load locked files from localStorage and update the file objects
* @param chatId Optional chat ID to load locks for (defaults to current chat)
*/
#loadLockedFiles() {
#loadLockedFiles(chatId?: string) {
try {
const lockedFiles = getLockedFiles();
const currentChatId = chatId || getCurrentChatId();
// Migrate any legacy locks to the current chat
migrateLegacyLocks(currentChatId);
// Get locks for the current chat
const lockedFiles = getLockedFilesForChat(currentChatId);
if (lockedFiles.length === 0) {
logger.info('No locked files found in localStorage');
logger.info(`No locked files found for chat ID: ${currentChatId}`);
return;
}
logger.info(`Found ${lockedFiles.length} locked files in localStorage`);
logger.info(`Found ${lockedFiles.length} locked files for chat ID: ${currentChatId}`);
const currentFiles = this.files.get();
const updates: FileMap = {};
@ -141,9 +173,14 @@ export class FilesStore {
/**
* Lock a file
* @param filePath Path to the file to lock
* @param lockMode Type of lock to apply
* @param chatId Optional chat ID (defaults to current chat)
* @returns True if the file was successfully locked
*/
lockFile(filePath: string, lockMode: LockMode) {
lockFile(filePath: string, lockMode: LockMode, chatId?: string) {
const file = this.getFile(filePath);
const currentChatId = chatId || getCurrentChatId();
if (!file) {
logger.error(`Cannot lock non-existent file: ${filePath}`);
@ -157,19 +194,23 @@ export class FilesStore {
lockMode,
});
// Persist to localStorage
addLockedFile(filePath, lockMode);
// Persist to localStorage with chat ID
addLockedFile(currentChatId, filePath, lockMode);
logger.info(`File locked: ${filePath} (mode: ${lockMode})`);
logger.info(`File locked: ${filePath} (mode: ${lockMode}) for chat: ${currentChatId}`);
return true;
}
/**
* Unlock a file
* @param filePath Path to the file to unlock
* @param chatId Optional chat ID (defaults to current chat)
* @returns True if the file was successfully unlocked
*/
unlockFile(filePath: string) {
unlockFile(filePath: string, chatId?: string) {
const file = this.getFile(filePath);
const currentChatId = chatId || getCurrentChatId();
if (!file) {
logger.error(`Cannot unlock non-existent file: ${filePath}`);
@ -183,28 +224,55 @@ export class FilesStore {
lockMode: null,
});
// Remove from localStorage
removeLockedFile(filePath);
// Remove from localStorage with chat ID
removeLockedFile(currentChatId, filePath);
logger.info(`File unlocked: ${filePath}`);
logger.info(`File unlocked: ${filePath} for chat: ${currentChatId}`);
return true;
}
/**
* Check if a file is locked
* @param filePath Path to the file to check
* @param chatId Optional chat ID (defaults to current chat)
* @returns Object with locked status and lock mode
*/
isFileLocked(filePath: string): { locked: boolean; lockMode?: LockMode } {
isFileLocked(filePath: string, chatId?: string): { locked: boolean; lockMode?: LockMode } {
const file = this.getFile(filePath);
const currentChatId = chatId || getCurrentChatId();
if (!file) {
return { locked: false };
}
return {
locked: !!file.locked,
lockMode: file.lockMode as LockMode | undefined,
};
// First check the in-memory state
if (file.locked) {
return {
locked: true,
lockMode: file.lockMode as LockMode | undefined,
};
}
/*
* Then check localStorage for this specific chat
* This ensures we catch any locks that might have been set in other tabs
*/
const lockedFiles = getLockedFilesForChat(currentChatId);
const lockedFile = lockedFiles.find((file) => file.path === filePath);
if (lockedFile) {
// Update the in-memory state to match localStorage
this.files.setKey(filePath, {
...file,
locked: true,
lockMode: lockedFile.lockMode,
});
return { locked: true, lockMode: lockedFile.lockMode };
}
return { locked: false };
}
getFile(filePath: string) {
@ -304,21 +372,28 @@ export class FilesStore {
bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
);
// Load locked files immediately
this.#loadLockedFiles();
// Get the current chat ID
const currentChatId = getCurrentChatId();
// Migrate any legacy locks to the current chat
migrateLegacyLocks(currentChatId);
// Load locked files immediately for the current chat
this.#loadLockedFiles(currentChatId);
/*
* 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');
this.#loadLockedFiles(currentChatId);
logger.info(`Reapplied locked files for chat ID: ${currentChatId}`);
}, 2000);
// Set up a periodic check to ensure locks remain applied
setInterval(() => {
this.#loadLockedFiles();
const latestChatId = getCurrentChatId();
this.#loadLockedFiles(latestChatId);
}, 10000);
}

View File

@ -3,19 +3,54 @@ import { createScopedLogger } from './logger';
const logger = createScopedLogger('FileLocks');
/**
* Get the current chat ID from the URL
* @returns The current chat ID or a default value if not found
*/
export function getCurrentChatId(): string {
try {
if (typeof window !== 'undefined') {
// Extract chat ID from URL (format: /chat/123)
const match = window.location.pathname.match(/\/chat\/([^/]+)/);
if (match && match[1]) {
return match[1];
}
}
// Return a default chat ID if none is found
return 'default';
} catch (error) {
logger.error('Failed to get current chat ID', error);
return 'default';
}
}
/**
* Check if a file is locked directly from localStorage
* This avoids circular dependencies between components and stores
* @param filePath The path of the file to check
* @param chatId Optional chat ID (will be extracted from URL if not provided)
*/
export function isFileLocked(filePath: string): { locked: boolean; lockMode?: LockMode } {
export function isFileLocked(filePath: string, chatId?: string): { locked: boolean; lockMode?: LockMode } {
try {
const currentChatId = chatId || getCurrentChatId();
const lockedFiles = getLockedFiles();
const lockedFile = lockedFiles.find((file) => file.path === filePath);
// First check for a chat-specific lock
const lockedFile = lockedFiles.find((file) => file.chatId === currentChatId && file.path === filePath);
if (lockedFile) {
return { locked: true, lockMode: lockedFile.lockMode };
}
// For backward compatibility, also check for legacy locks without chatId
const legacyLock = lockedFiles.find((file) => !file.chatId && file.path === filePath);
if (legacyLock) {
return { locked: true, lockMode: legacyLock.lockMode };
}
return { locked: false };
} catch (error) {
logger.error('Failed to check if file is locked', error);