mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
* Add persistent file locking feature with enhanced UI * Fix file locking to be scoped by chat ID * Add folder locking functionality * Update CHANGES.md to include folder locking functionality * Add early detection of locked files/folders in user prompts * Improve locked files detection with smarter pattern matching and prevent AI from attempting to modify locked files * Add detection for unlocked files to allow AI to continue with modifications in the same chat session * Implement dialog-based Lock Manager with improved styling for dark/light modes * Add remaining files for file locking implementation * refactor(lock-manager): simplify lock management UI and remove scoped lock options Consolidate lock management UI by removing scoped lock options and integrating LockManager directly into the EditorPanel. Simplify the lock management interface by removing the dialog and replacing it with a tab-based view. This improves maintainability and user experience by reducing complexity and streamlining the lock management process. Change Lock & Unlock action to use toast instead of alert. Remove LockManagerDialog as it is now tab based. * Optimize file locking mechanism for better performance - Add in-memory caching to reduce localStorage reads - Implement debounced localStorage writes - Use Map data structures for faster lookups - Add batch operations for locking/unlocking multiple items - Reduce polling frequency and add event-based updates - Add performance monitoring and cross-tab synchronization * refactor(file-locking): simplify file locking mechanism and remove scoped locks This commit removes the scoped locking feature and simplifies the file locking mechanism. The `LockMode` type and related logic have been removed, and all locks are now treated as full locks. The `isLocked` property has been standardized across the codebase, replacing the previous `locked` and `lockMode` properties. Additionally, the `useLockedFilesChecker` hook and `LockAlert` component have been removed as they are no longer needed with the simplified locking system. This gives the LLM a clear understanding of locked files and strict instructions not to make any changes to these files * refactor: remove debug console.log statements --------- Co-authored-by: KevIsDev <zennerd404@gmail.com>
562 lines
15 KiB
TypeScript
562 lines
15 KiB
TypeScript
import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete';
|
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
|
|
import { searchKeymap } from '@codemirror/search';
|
|
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
|
|
import {
|
|
drawSelection,
|
|
dropCursor,
|
|
EditorView,
|
|
highlightActiveLine,
|
|
highlightActiveLineGutter,
|
|
keymap,
|
|
lineNumbers,
|
|
scrollPastEnd,
|
|
showTooltip,
|
|
tooltips,
|
|
type Tooltip,
|
|
} from '@codemirror/view';
|
|
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
|
|
import type { Theme } from '~/types/theme';
|
|
import { classNames } from '~/utils/classNames';
|
|
import { debounce } from '~/utils/debounce';
|
|
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
|
import { isFileLocked, getCurrentChatId } from '~/utils/fileLocks';
|
|
import { BinaryContent } from './BinaryContent';
|
|
import { getTheme, reconfigureTheme } from './cm-theme';
|
|
import { indentKeyBinding } from './indent';
|
|
import { getLanguage } from './languages';
|
|
import { createEnvMaskingExtension } from './EnvMasking';
|
|
|
|
const logger = createScopedLogger('CodeMirrorEditor');
|
|
|
|
// Create a module-level reference to the current document for use in tooltip functions
|
|
let currentDocRef: EditorDocument | undefined;
|
|
|
|
export interface EditorDocument {
|
|
value: string;
|
|
isBinary: boolean;
|
|
filePath: string;
|
|
scroll?: ScrollPosition;
|
|
}
|
|
|
|
export interface EditorSettings {
|
|
fontSize?: string;
|
|
gutterFontSize?: string;
|
|
tabSize?: number;
|
|
}
|
|
|
|
type TextEditorDocument = EditorDocument & {
|
|
value: string;
|
|
};
|
|
|
|
export interface ScrollPosition {
|
|
top?: number;
|
|
left?: number;
|
|
line?: number;
|
|
column?: number;
|
|
}
|
|
|
|
export interface EditorUpdate {
|
|
selection: EditorSelection;
|
|
content: string;
|
|
}
|
|
|
|
export type OnChangeCallback = (update: EditorUpdate) => void;
|
|
export type OnScrollCallback = (position: ScrollPosition) => void;
|
|
export type OnSaveCallback = () => void;
|
|
|
|
interface Props {
|
|
theme: Theme;
|
|
id?: unknown;
|
|
doc?: EditorDocument;
|
|
editable?: boolean;
|
|
debounceChange?: number;
|
|
debounceScroll?: number;
|
|
autoFocusOnDocumentChange?: boolean;
|
|
onChange?: OnChangeCallback;
|
|
onScroll?: OnScrollCallback;
|
|
onSave?: OnSaveCallback;
|
|
className?: string;
|
|
settings?: EditorSettings;
|
|
}
|
|
|
|
type EditorStates = Map<string, EditorState>;
|
|
|
|
const readOnlyTooltipStateEffect = StateEffect.define<boolean>();
|
|
|
|
const editableTooltipField = StateField.define<readonly Tooltip[]>({
|
|
create: () => [],
|
|
update(_tooltips, transaction) {
|
|
if (!transaction.state.readOnly) {
|
|
return [];
|
|
}
|
|
|
|
for (const effect of transaction.effects) {
|
|
if (effect.is(readOnlyTooltipStateEffect) && effect.value) {
|
|
return getReadOnlyTooltip(transaction.state);
|
|
}
|
|
}
|
|
|
|
return [];
|
|
},
|
|
provide: (field) => {
|
|
return showTooltip.computeN([field], (state) => state.field(field));
|
|
},
|
|
});
|
|
|
|
const editableStateEffect = StateEffect.define<boolean>();
|
|
|
|
const editableStateField = StateField.define<boolean>({
|
|
create() {
|
|
return true;
|
|
},
|
|
update(value, transaction) {
|
|
for (const effect of transaction.effects) {
|
|
if (effect.is(editableStateEffect)) {
|
|
return effect.value;
|
|
}
|
|
}
|
|
|
|
return value;
|
|
},
|
|
});
|
|
|
|
export const CodeMirrorEditor = memo(
|
|
({
|
|
id,
|
|
doc,
|
|
debounceScroll = 100,
|
|
debounceChange = 150,
|
|
autoFocusOnDocumentChange = false,
|
|
editable = true,
|
|
onScroll,
|
|
onChange,
|
|
onSave,
|
|
theme,
|
|
settings,
|
|
className = '',
|
|
}: Props) => {
|
|
renderLogger.trace('CodeMirrorEditor');
|
|
|
|
const [languageCompartment] = useState(new Compartment());
|
|
|
|
// Add a compartment for the env masking extension
|
|
const [envMaskingCompartment] = useState(new Compartment());
|
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const viewRef = useRef<EditorView>();
|
|
const themeRef = useRef<Theme>();
|
|
const docRef = useRef<EditorDocument>();
|
|
const editorStatesRef = useRef<EditorStates>();
|
|
const onScrollRef = useRef(onScroll);
|
|
const onChangeRef = useRef(onChange);
|
|
const onSaveRef = useRef(onSave);
|
|
|
|
/**
|
|
* This effect is used to avoid side effects directly in the render function
|
|
* and instead the refs are updated after each render.
|
|
*/
|
|
useEffect(() => {
|
|
onScrollRef.current = onScroll;
|
|
onChangeRef.current = onChange;
|
|
onSaveRef.current = onSave;
|
|
docRef.current = doc;
|
|
|
|
// Update the module-level reference for use in tooltip functions
|
|
currentDocRef = doc;
|
|
themeRef.current = theme;
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!viewRef.current || !doc || doc.isBinary) {
|
|
return;
|
|
}
|
|
|
|
if (typeof doc.scroll?.line === 'number') {
|
|
const line = doc.scroll.line;
|
|
const column = doc.scroll.column ?? 0;
|
|
|
|
try {
|
|
// Check if the line number is valid for the current document
|
|
const totalLines = viewRef.current.state.doc.lines;
|
|
|
|
// Only proceed if the line number is within the document's range
|
|
if (line < totalLines) {
|
|
const linePos = viewRef.current.state.doc.line(line + 1).from + column;
|
|
viewRef.current.dispatch({
|
|
selection: { anchor: linePos },
|
|
scrollIntoView: true,
|
|
});
|
|
viewRef.current.focus();
|
|
} else {
|
|
logger.warn(`Invalid line number ${line + 1} in ${totalLines}-line document`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error scrolling to line:', error);
|
|
}
|
|
} else if (typeof doc.scroll?.top === 'number' || typeof doc.scroll?.left === 'number') {
|
|
viewRef.current.scrollDOM.scrollTo(doc.scroll.left ?? 0, doc.scroll.top ?? 0);
|
|
}
|
|
}, [doc?.scroll?.line, doc?.scroll?.column, doc?.scroll?.top, doc?.scroll?.left]);
|
|
|
|
useEffect(() => {
|
|
const onUpdate = debounce((update: EditorUpdate) => {
|
|
onChangeRef.current?.(update);
|
|
}, debounceChange);
|
|
|
|
const view = new EditorView({
|
|
parent: containerRef.current!,
|
|
dispatchTransactions(transactions) {
|
|
const previousSelection = view.state.selection;
|
|
|
|
view.update(transactions);
|
|
|
|
const newSelection = view.state.selection;
|
|
|
|
const selectionChanged =
|
|
newSelection !== previousSelection &&
|
|
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
|
|
|
|
if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) {
|
|
onUpdate({
|
|
selection: view.state.selection,
|
|
content: view.state.doc.toString(),
|
|
});
|
|
|
|
editorStatesRef.current!.set(docRef.current.filePath, view.state);
|
|
}
|
|
},
|
|
});
|
|
|
|
viewRef.current = view;
|
|
|
|
return () => {
|
|
viewRef.current?.destroy();
|
|
viewRef.current = undefined;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!viewRef.current) {
|
|
return;
|
|
}
|
|
|
|
viewRef.current.dispatch({
|
|
effects: [reconfigureTheme(theme)],
|
|
});
|
|
}, [theme]);
|
|
|
|
useEffect(() => {
|
|
editorStatesRef.current = new Map<string, EditorState>();
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
const editorStates = editorStatesRef.current!;
|
|
const view = viewRef.current!;
|
|
const theme = themeRef.current!;
|
|
|
|
if (!doc) {
|
|
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
|
languageCompartment.of([]),
|
|
envMaskingCompartment.of([]),
|
|
]);
|
|
|
|
view.setState(state);
|
|
|
|
setNoDocument(view);
|
|
|
|
return;
|
|
}
|
|
|
|
if (doc.isBinary) {
|
|
return;
|
|
}
|
|
|
|
if (doc.filePath === '') {
|
|
logger.warn('File path should not be empty');
|
|
}
|
|
|
|
let state = editorStates.get(doc.filePath);
|
|
|
|
if (!state) {
|
|
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
|
languageCompartment.of([]),
|
|
envMaskingCompartment.of([createEnvMaskingExtension(() => docRef.current?.filePath)]),
|
|
]);
|
|
|
|
editorStates.set(doc.filePath, state);
|
|
}
|
|
|
|
view.setState(state);
|
|
|
|
setEditorDocument(
|
|
view,
|
|
theme,
|
|
editable,
|
|
languageCompartment,
|
|
autoFocusOnDocumentChange,
|
|
doc as TextEditorDocument,
|
|
);
|
|
|
|
// Check if the file is locked and update the editor state accordingly
|
|
const currentChatId = getCurrentChatId();
|
|
const { locked } = isFileLocked(doc.filePath, currentChatId);
|
|
|
|
if (locked) {
|
|
view.dispatch({
|
|
effects: [editableStateEffect.of(false)],
|
|
});
|
|
}
|
|
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
|
|
|
|
return (
|
|
<div className={classNames('relative h-full', className)}>
|
|
{doc?.isBinary && <BinaryContent />}
|
|
<div className="h-full overflow-hidden" ref={containerRef} />
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
export default CodeMirrorEditor;
|
|
|
|
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
|
|
|
|
function newEditorState(
|
|
content: string,
|
|
theme: Theme,
|
|
settings: EditorSettings | undefined,
|
|
onScrollRef: MutableRefObject<OnScrollCallback | undefined>,
|
|
debounceScroll: number,
|
|
onFileSaveRef: MutableRefObject<OnSaveCallback | undefined>,
|
|
extensions: Extension[],
|
|
) {
|
|
return EditorState.create({
|
|
doc: content,
|
|
extensions: [
|
|
EditorView.domEventHandlers({
|
|
scroll: debounce((event, view) => {
|
|
if (event.target !== view.scrollDOM) {
|
|
return;
|
|
}
|
|
|
|
onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
|
|
}, debounceScroll),
|
|
keydown: (event, view) => {
|
|
if (view.state.readOnly) {
|
|
view.dispatch({
|
|
effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')],
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
}),
|
|
getTheme(theme, settings),
|
|
history(),
|
|
keymap.of([
|
|
...defaultKeymap,
|
|
...historyKeymap,
|
|
...searchKeymap,
|
|
{ key: 'Tab', run: acceptCompletion },
|
|
{
|
|
key: 'Mod-s',
|
|
preventDefault: true,
|
|
run: () => {
|
|
onFileSaveRef.current?.();
|
|
return true;
|
|
},
|
|
},
|
|
indentKeyBinding,
|
|
]),
|
|
indentUnit.of('\t'),
|
|
autocompletion({
|
|
closeOnBlur: false,
|
|
}),
|
|
tooltips({
|
|
position: 'absolute',
|
|
parent: document.body,
|
|
tooltipSpace: (view) => {
|
|
const rect = view.dom.getBoundingClientRect();
|
|
|
|
return {
|
|
top: rect.top - 50,
|
|
left: rect.left,
|
|
bottom: rect.bottom,
|
|
right: rect.right + 10,
|
|
};
|
|
},
|
|
}),
|
|
closeBrackets(),
|
|
lineNumbers(),
|
|
scrollPastEnd(),
|
|
dropCursor(),
|
|
drawSelection(),
|
|
bracketMatching(),
|
|
EditorState.tabSize.of(settings?.tabSize ?? 2),
|
|
indentOnInput(),
|
|
editableTooltipField,
|
|
editableStateField,
|
|
EditorState.readOnly.from(editableStateField, (editable) => !editable),
|
|
highlightActiveLineGutter(),
|
|
highlightActiveLine(),
|
|
foldGutter({
|
|
markerDOM: (open) => {
|
|
const icon = document.createElement('div');
|
|
|
|
icon.className = `fold-icon ${open ? 'i-ph-caret-down-bold' : 'i-ph-caret-right-bold'}`;
|
|
|
|
return icon;
|
|
},
|
|
}),
|
|
...extensions,
|
|
],
|
|
});
|
|
}
|
|
|
|
function setNoDocument(view: EditorView) {
|
|
view.dispatch({
|
|
selection: { anchor: 0 },
|
|
changes: {
|
|
from: 0,
|
|
to: view.state.doc.length,
|
|
insert: '',
|
|
},
|
|
});
|
|
|
|
view.scrollDOM.scrollTo(0, 0);
|
|
}
|
|
|
|
function setEditorDocument(
|
|
view: EditorView,
|
|
theme: Theme,
|
|
editable: boolean,
|
|
languageCompartment: Compartment,
|
|
autoFocus: boolean,
|
|
doc: TextEditorDocument,
|
|
) {
|
|
if (doc.value !== view.state.doc.toString()) {
|
|
view.dispatch({
|
|
selection: { anchor: 0 },
|
|
changes: {
|
|
from: 0,
|
|
to: view.state.doc.length,
|
|
insert: doc.value,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Check if the file is locked
|
|
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({
|
|
effects: [editableStateEffect.of(editable && !doc.isBinary && !locked)],
|
|
});
|
|
|
|
getLanguage(doc.filePath).then((languageSupport) => {
|
|
if (!languageSupport) {
|
|
return;
|
|
}
|
|
|
|
view.dispatch({
|
|
effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
|
|
});
|
|
|
|
requestAnimationFrame(() => {
|
|
const currentLeft = view.scrollDOM.scrollLeft;
|
|
const currentTop = view.scrollDOM.scrollTop;
|
|
const newLeft = doc.scroll?.left ?? 0;
|
|
const newTop = doc.scroll?.top ?? 0;
|
|
|
|
if (typeof doc.scroll?.line === 'number') {
|
|
const line = doc.scroll.line;
|
|
const column = doc.scroll.column ?? 0;
|
|
|
|
try {
|
|
// Check if the line number is valid for the current document
|
|
const totalLines = view.state.doc.lines;
|
|
|
|
// Only proceed if the line number is within the document's range
|
|
if (line < totalLines) {
|
|
const linePos = view.state.doc.line(line + 1).from + column;
|
|
view.dispatch({
|
|
selection: { anchor: linePos },
|
|
scrollIntoView: true,
|
|
});
|
|
view.focus();
|
|
} else {
|
|
logger.warn(`Invalid line number ${line + 1} in ${totalLines}-line document`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error scrolling to line:', error);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
|
|
|
|
if (autoFocus && editable) {
|
|
if (needsScrolling) {
|
|
view.scrollDOM.addEventListener(
|
|
'scroll',
|
|
() => {
|
|
view.focus();
|
|
},
|
|
{ once: true },
|
|
);
|
|
} else {
|
|
view.focus();
|
|
}
|
|
}
|
|
|
|
view.scrollDOM.scrollTo(newLeft, newTop);
|
|
});
|
|
});
|
|
}
|
|
|
|
function getReadOnlyTooltip(state: EditorState) {
|
|
if (!state.readOnly) {
|
|
return [];
|
|
}
|
|
|
|
// Get the current document from the module-level reference
|
|
const currentDoc = currentDocRef;
|
|
let tooltipMessage = 'Cannot edit file while AI response is being generated';
|
|
|
|
// If we have a current document, check if it's locked
|
|
if (currentDoc?.filePath) {
|
|
const currentChatId = getCurrentChatId();
|
|
const { locked } = isFileLocked(currentDoc.filePath, currentChatId);
|
|
|
|
if (locked) {
|
|
tooltipMessage = 'This file is locked and cannot be edited';
|
|
}
|
|
}
|
|
|
|
return state.selection.ranges
|
|
.filter((range) => {
|
|
return range.empty;
|
|
})
|
|
.map((range) => {
|
|
return {
|
|
pos: range.head,
|
|
above: true,
|
|
strictSide: true,
|
|
arrow: true,
|
|
create: () => {
|
|
const divElement = document.createElement('div');
|
|
divElement.className = 'cm-readonly-tooltip';
|
|
divElement.textContent = tooltipMessage;
|
|
|
|
return { dom: divElement };
|
|
},
|
|
};
|
|
});
|
|
}
|