mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-05-05 12:44:38 +00:00
Introduce a new extension for CodeMirror that masks sensitive values in .env files. This ensures that sensitive information like API keys or passwords is not displayed in plain text within the editor. The extension dynamically applies masking to values in lines matching the KEY=VALUE format, improving security during development.
468 lines
12 KiB
TypeScript
468 lines
12 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 { 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');
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
themeRef.current = theme;
|
|
});
|
|
|
|
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,
|
|
);
|
|
}, [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,
|
|
},
|
|
});
|
|
}
|
|
|
|
view.dispatch({
|
|
effects: [editableStateEffect.of(editable && !doc.isBinary)],
|
|
});
|
|
|
|
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;
|
|
|
|
const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
|
|
|
|
if (autoFocus && editable) {
|
|
if (needsScrolling) {
|
|
// we have to wait until the scroll position was changed before we can set the focus
|
|
view.scrollDOM.addEventListener(
|
|
'scroll',
|
|
() => {
|
|
view.focus();
|
|
},
|
|
{ once: true },
|
|
);
|
|
} else {
|
|
// if the scroll position is still the same we can focus immediately
|
|
view.focus();
|
|
}
|
|
}
|
|
|
|
view.scrollDOM.scrollTo(newLeft, newTop);
|
|
});
|
|
});
|
|
}
|
|
|
|
function getReadOnlyTooltip(state: EditorState) {
|
|
if (!state.readOnly) {
|
|
return [];
|
|
}
|
|
|
|
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 = 'Cannot edit file while AI response is being generated';
|
|
|
|
return { dom: divElement };
|
|
},
|
|
};
|
|
});
|
|
}
|