Skip to content

Commit 35cbae1

Browse files
committed
feat: Enhanced file locking and diff improvements for conflict prevention
This commit implements comprehensive enhancements to the file locking system and diff generation to provide better conflict prevention and tracking. ## Key Improvements ### 1. File Version Tracking - Added version number, lastModified timestamp, and modifiedBy fields to File interface - Automatically increment version on every file modification - Track modification source (user, AI, or external) - Foundation for future optimistic locking and conflict detection ### 2. Lock Timeout & Expiration - Added timestamp tracking for when locks are acquired (lockedAt) - Implemented configurable lock timeout (default: 30 minutes) - Automatic cleanup of expired locks every 5 minutes - Prevents stale locks from persisting indefinitely - Expired locks are automatically removed when checked ### 3. Enhanced Diff Metadata - Diffs now include timestamp, modification source, and version information - Better context for conflict resolution and change tracking - Helps AI understand when and how files were modified ### 4. Automatic Lock Acquisition - Files are automatically locked when user starts editing - 2-second debounce prevents locking files being briefly viewed - Auto-locks are clearly marked for distinction from manual locks - Reduces manual lock management burden ### 5. Lock Validation in ActionRunner - ActionRunner now validates locks before writing files - AI cannot modify locked files - Clear error messages indicate which file/folder caused the lock - User alerts notify of lock conflicts ### 6. Improved Lock Checking - Lock expiration checked on every lock status query - Expired locks automatically removed inline - Parent folder lock checking includes expiration validation ## Files Modified - app/lib/stores/files.ts: Added version tracking to File interface and file operations - app/lib/persistence/lockedFiles.ts: Implemented lock timeouts and expiration - app/lib/stores/editor.ts: Added automatic lock acquisition on edit - app/lib/runtime/action-runner.ts: Added lock validation before file writes - app/utils/diff.ts: Enhanced diff metadata with timestamps and version info ## Future Enhancements - Version-based conflict detection in ActionRunner (requires FilesStore access) - Conflict resolution UI for handling version mismatches - Three-way merge capabilities for conflicting changes - Lock renewal for long-running edit sessions ## Benefits - ✅ Prevents AI from overwriting user changes (via lock validation) - ✅ Prevents stale locks from blocking work indefinitely (via expiration) - ✅ Reduces manual lock management (via auto-locking) - ✅ Better change tracking and context (via version + metadata) - ✅ Foundation for optimistic locking (via version numbers)
1 parent 3f6050b commit 35cbae1

File tree

5 files changed

+207
-10
lines changed

5 files changed

+207
-10
lines changed

app/lib/persistence/lockedFiles.ts

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@ const logger = createScopedLogger('LockedFiles');
55
// Key for storing locked files in localStorage
66
export const LOCKED_FILES_KEY = 'bolt.lockedFiles';
77

8+
// Default lock timeout: 30 minutes
9+
export const DEFAULT_LOCK_TIMEOUT_MS = 30 * 60 * 1000;
10+
11+
// Cleanup interval for expired locks: 5 minutes
12+
const LOCK_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
13+
814
export interface LockedItem {
915
chatId: string; // Chat ID to scope locks to a specific project
1016
path: string;
1117
isFolder: boolean; // Indicates if this is a folder lock
18+
lockedAt?: number; // Timestamp when the lock was acquired
19+
lockTimeout?: number; // Lock timeout duration in milliseconds (default: 30 minutes)
20+
autoLock?: boolean; // Whether this lock was automatically acquired
1221
}
1322

1423
// In-memory cache for locked items to reduce localStorage reads
@@ -133,18 +142,66 @@ export function getLockedItems(): LockedItem[] {
133142
return initializeCache();
134143
}
135144

145+
/**
146+
* Check if a lock has expired based on its timestamp and timeout
147+
* @param item The locked item to check
148+
* @returns true if the lock has expired, false otherwise
149+
*/
150+
export function isLockExpired(item: LockedItem): boolean {
151+
// If no timestamp, treat as non-expiring (legacy lock)
152+
if (!item.lockedAt) {
153+
return false;
154+
}
155+
156+
const timeout = item.lockTimeout || DEFAULT_LOCK_TIMEOUT_MS;
157+
const now = Date.now();
158+
const expiresAt = item.lockedAt + timeout;
159+
160+
return now > expiresAt;
161+
}
162+
163+
/**
164+
* Remove expired locks from the locked items list
165+
* @returns Number of locks removed
166+
*/
167+
export function cleanupExpiredLocks(): number {
168+
const lockedItems = getLockedItems();
169+
const validItems = lockedItems.filter((item) => !isLockExpired(item));
170+
const removedCount = lockedItems.length - validItems.length;
171+
172+
if (removedCount > 0) {
173+
saveLockedItems(validItems);
174+
logger.info(`Cleaned up ${removedCount} expired locks`);
175+
}
176+
177+
return removedCount;
178+
}
179+
136180
/**
137181
* Add a file or folder to the locked items list
138182
* @param chatId The chat ID to scope the lock to
139183
* @param path The path of the file or folder to lock
140184
* @param isFolder Whether this is a folder lock
185+
* @param options Optional lock options (timeout, autoLock)
141186
*/
142-
export function addLockedItem(chatId: string, path: string, isFolder: boolean = false): void {
187+
export function addLockedItem(
188+
chatId: string,
189+
path: string,
190+
isFolder: boolean = false,
191+
options?: { timeout?: number; autoLock?: boolean },
192+
): void {
143193
// Ensure cache is initialized
144194
const lockedItems = getLockedItems();
145195

146-
// Create the new item
147-
const newItem = { chatId, path, isFolder };
196+
// Create the new item with timestamp
197+
const newItem: LockedItem = {
198+
chatId,
199+
path,
200+
isFolder,
201+
lockedAt: Date.now(),
202+
lockTimeout: options?.timeout || DEFAULT_LOCK_TIMEOUT_MS,
203+
autoLock: options?.autoLock || false,
204+
};
148205

149206
// Update the in-memory map directly for faster access
150207
const chatMap = getChatMap(chatId, true)!;
@@ -157,7 +214,9 @@ export function addLockedItem(chatId: string, path: string, isFolder: boolean =
157214
// Save the updated list (this will update the cache and maps)
158215
saveLockedItems(filteredItems);
159216

160-
logger.info(`Added locked ${isFolder ? 'folder' : 'file'}: ${path} for chat: ${chatId}`);
217+
logger.info(
218+
`Added ${options?.autoLock ? 'auto-' : ''}locked ${isFolder ? 'folder' : 'file'}: ${path} for chat: ${chatId}`,
219+
);
161220
}
162221

163222
/**
@@ -255,7 +314,13 @@ export function isFileLocked(chatId: string, filePath: string): { locked: boolea
255314
const directLock = chatMap.get(filePath);
256315

257316
if (directLock && !directLock.isFolder) {
258-
return { locked: true, lockedBy: filePath };
317+
// Check if lock has expired
318+
if (isLockExpired(directLock)) {
319+
// Remove expired lock
320+
removeLockedItem(chatId, filePath);
321+
} else {
322+
return { locked: true, lockedBy: filePath };
323+
}
259324
}
260325
}
261326

@@ -281,7 +346,13 @@ export function isFolderLocked(chatId: string, folderPath: string): { locked: bo
281346
const directLock = chatMap.get(folderPath);
282347

283348
if (directLock && directLock.isFolder) {
284-
return { locked: true, lockedBy: folderPath };
349+
// Check if lock has expired
350+
if (isLockExpired(directLock)) {
351+
// Remove expired lock
352+
removeLockedItem(chatId, folderPath);
353+
} else {
354+
return { locked: true, lockedBy: folderPath };
355+
}
285356
}
286357
}
287358

@@ -312,6 +383,13 @@ function checkParentFolderLocks(chatId: string, path: string): { locked: boolean
312383
const folderLock = chatMap.get(currentPath);
313384

314385
if (folderLock && folderLock.isFolder) {
386+
// Check if lock has expired
387+
if (isLockExpired(folderLock)) {
388+
// Remove expired lock
389+
removeLockedItem(chatId, currentPath);
390+
continue;
391+
}
392+
315393
return { locked: true, lockedBy: currentPath };
316394
}
317395
}
@@ -451,11 +529,15 @@ export function batchLockItems(chatId: string, items: Array<{ path: string; isFo
451529
// Filter out existing items for these paths
452530
const filteredItems = lockedItems.filter((item) => !(item.chatId === chatId && pathsToLock.has(item.path)));
453531

454-
// Add all the new items
532+
// Add all the new items with timestamps
533+
const now = Date.now();
455534
const newItems = items.map((item) => ({
456535
chatId,
457536
path: item.path,
458537
isFolder: item.isFolder,
538+
lockedAt: now,
539+
lockTimeout: DEFAULT_LOCK_TIMEOUT_MS,
540+
autoLock: false,
459541
}));
460542

461543
// Combine and save
@@ -497,6 +579,22 @@ export function batchUnlockItems(chatId: string, paths: string[]): void {
497579
logger.info(`Batch unlocked ${paths.length} items for chat: ${chatId}`);
498580
}
499581

582+
/**
583+
* Set up periodic cleanup of expired locks
584+
* Runs every 5 minutes to remove stale locks
585+
*/
586+
if (typeof window !== 'undefined') {
587+
// Run cleanup on initial load
588+
setTimeout(() => {
589+
cleanupExpiredLocks();
590+
}, 5000); // Wait 5 seconds after page load
591+
592+
// Set up periodic cleanup
593+
setInterval(() => {
594+
cleanupExpiredLocks();
595+
}, LOCK_CLEANUP_INTERVAL_MS);
596+
}
597+
500598
/**
501599
* Add event listener for storage events to sync cache across tabs
502600
* This ensures that if locks are modified in another tab, the changes are reflected here

app/lib/runtime/action-runner.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createScopedLogger } from '~/utils/logger';
66
import { unreachable } from '~/utils/unreachable';
77
import type { ActionCallbackData } from './message-parser';
88
import type { BoltShell } from '~/utils/shell';
9+
import { isFileLocked, getCurrentChatId } from '~/utils/fileLocks';
910

1011
const logger = createScopedLogger('ActionRunner');
1112

@@ -316,6 +317,34 @@ export class ActionRunner {
316317
const webcontainer = await this.#webcontainer;
317318
const relativePath = nodePath.relative(webcontainer.workdir, action.filePath);
318319

320+
// CONFLICT PREVENTION: Check if the file is locked before attempting to write
321+
const chatId = getCurrentChatId();
322+
const lockStatus = isFileLocked(chatId, action.filePath);
323+
324+
if (lockStatus.locked) {
325+
const errorMessage = `Cannot modify locked file: ${action.filePath}${lockStatus.lockedBy ? ` (locked by: ${lockStatus.lockedBy})` : ''}`;
326+
logger.warn(errorMessage);
327+
328+
// Trigger an alert to notify the user
329+
this.onAlert?.({
330+
type: 'error',
331+
title: 'File Locked',
332+
description: errorMessage,
333+
});
334+
335+
throw new Error(errorMessage);
336+
}
337+
338+
/*
339+
* FUTURE ENHANCEMENT: Version-based conflict detection
340+
* TODO: Add version checking to detect if file was modified externally
341+
* This would require:
342+
* 1. Passing FilesStore to ActionRunner
343+
* 2. Checking file.version before writing
344+
* 3. Comparing with expected version from when action was created
345+
* 4. Providing conflict resolution UI if mismatch detected
346+
*/
347+
319348
let folder = nodePath.dirname(relativePath);
320349

321350
// remove trailing slashes
@@ -335,6 +364,7 @@ export class ActionRunner {
335364
logger.debug(`File written ${relativePath}`);
336365
} catch (error) {
337366
logger.error('Failed to write file\n\n', error);
367+
throw error;
338368
}
339369
}
340370

app/lib/stores/editor.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostore
22
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
33
import type { FileMap, FilesStore } from './files';
44
import { createScopedLogger } from '~/utils/logger';
5+
import { getCurrentChatId } from '~/utils/fileLocks';
6+
import { addLockedItem } from '~/lib/persistence/lockedFiles';
57

68
export type EditorDocuments = Record<string, EditorDocument>;
79

@@ -11,6 +13,8 @@ const logger = createScopedLogger('EditorStore');
1113

1214
export class EditorStore {
1315
#filesStore: FilesStore;
16+
#autoLockEnabled = true; // Enable automatic lock acquisition
17+
#autoLockTimers: Map<string, ReturnType<typeof setTimeout>> = new Map(); // Track debounce timers for auto-locking
1418

1519
selectedFile: SelectedFile = import.meta.hot?.data.selectedFile ?? atom<string | undefined>();
1620
documents: MapStore<EditorDocuments> = import.meta.hot?.data.documents ?? map({});
@@ -32,6 +36,43 @@ export class EditorStore {
3236
}
3337
}
3438

39+
/**
40+
* Automatically lock a file when user starts editing
41+
* Uses a short delay to avoid locking files that are just being viewed
42+
*/
43+
#autoLockFile(filePath: string) {
44+
if (!this.#autoLockEnabled) {
45+
return;
46+
}
47+
48+
// Clear any existing timer for this file
49+
const existingTimer = this.#autoLockTimers.get(filePath);
50+
51+
if (existingTimer) {
52+
clearTimeout(existingTimer);
53+
}
54+
55+
// Set a new timer to lock the file after a short delay (2 seconds of editing)
56+
const timer = setTimeout(() => {
57+
try {
58+
const chatId = getCurrentChatId();
59+
const file = this.#filesStore.getFile(filePath);
60+
61+
// Only auto-lock if not already locked
62+
if (file && !file.isLocked) {
63+
addLockedItem(chatId, filePath, false, { autoLock: true });
64+
logger.info(`Auto-locked file: ${filePath}`);
65+
}
66+
} catch (error) {
67+
logger.error(`Failed to auto-lock file: ${filePath}`, error);
68+
} finally {
69+
this.#autoLockTimers.delete(filePath);
70+
}
71+
}, 2000); // 2 second delay before auto-locking
72+
73+
this.#autoLockTimers.set(filePath, timer);
74+
}
75+
3576
setDocuments(files: FileMap) {
3677
const previousDocuments = this.documents.value;
3778

@@ -104,6 +145,9 @@ export class EditorStore {
104145
const contentChanged = currentContent !== newContent;
105146

106147
if (contentChanged) {
148+
// Trigger auto-lock on edit
149+
this.#autoLockFile(filePath);
150+
107151
this.documents.setKey(filePath, {
108152
...documentState,
109153
value: newContent,

app/lib/stores/files.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export interface File {
3232
isBinary: boolean;
3333
isLocked?: boolean;
3434
lockedByFolder?: string; // Path of the folder that locked this file
35+
version?: number; // Version number for conflict detection
36+
lastModified?: number; // Timestamp of last modification
37+
modifiedBy?: 'user' | 'ai' | 'external'; // Source of last modification
3538
}
3639

3740
export interface Folder {
@@ -569,16 +572,22 @@ export class FilesStore {
569572
this.#modifiedFiles.set(filePath, oldContent);
570573
}
571574

572-
// Get the current lock state before updating
575+
// Get the current file state before updating
573576
const currentFile = this.files.get()[filePath];
574577
const isLocked = currentFile?.type === 'file' ? currentFile.isLocked : false;
578+
const currentVersion = currentFile?.type === 'file' ? currentFile.version || 0 : 0;
579+
const lockedByFolder = currentFile?.type === 'file' ? currentFile.lockedByFolder : undefined;
575580

576581
// we immediately update the file and don't rely on the `change` event coming from the watcher
577582
this.files.setKey(filePath, {
578583
type: 'file',
579584
content,
580585
isBinary: false,
581586
isLocked,
587+
lockedByFolder,
588+
version: currentVersion + 1, // Increment version
589+
lastModified: Date.now(), // Update timestamp
590+
modifiedBy: 'user', // Mark as user modification
582591
});
583592

584593
logger.info('File updated');
@@ -794,6 +803,9 @@ export class FilesStore {
794803
content: base64Content,
795804
isBinary: true,
796805
isLocked: false,
806+
version: 1, // Initial version
807+
lastModified: Date.now(),
808+
modifiedBy: 'ai', // New files from AI
797809
});
798810

799811
this.#modifiedFiles.set(filePath, base64Content);
@@ -806,6 +818,9 @@ export class FilesStore {
806818
content: content as string,
807819
isBinary: false,
808820
isLocked: false,
821+
version: 1, // Initial version
822+
lastModified: Date.now(),
823+
modifiedBy: 'ai', // New files from AI
809824
});
810825

811826
this.#modifiedFiles.set(filePath, content as string);

app/utils/diff.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export const modificationsRegex = new RegExp(
1010
interface ModifiedFile {
1111
type: 'diff' | 'file';
1212
content: string;
13+
timestamp?: number; // When the modification was made
14+
modifiedBy?: 'user' | 'ai' | 'external'; // Source of the modification
15+
version?: number; // Version number of the file
1316
}
1417

1518
type FileModifications = Record<string, ModifiedFile>;
@@ -35,12 +38,19 @@ export function computeFileModifications(files: FileMap, modifiedFiles: Map<stri
3538

3639
hasModifiedFiles = true;
3740

41+
// Extract metadata from file
42+
const metadata = {
43+
timestamp: file.lastModified || Date.now(),
44+
modifiedBy: file.modifiedBy || ('external' as const),
45+
version: file.version,
46+
};
47+
3848
if (unifiedDiff.length > file.content.length) {
3949
// if there are lots of changes we simply grab the current file content since it's smaller than the diff
40-
modifications[filePath] = { type: 'file', content: file.content };
50+
modifications[filePath] = { type: 'file', content: file.content, ...metadata };
4151
} else {
4252
// otherwise we use the diff since it's smaller
43-
modifications[filePath] = { type: 'diff', content: unifiedDiff };
53+
modifications[filePath] = { type: 'diff', content: unifiedDiff, ...metadata };
4454
}
4555
}
4656

0 commit comments

Comments
 (0)