mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
- Add support for `PREVIEW_CONSOLE_ERROR` in WebContainer error handling - Introduce new Search component for text search functionality - Extend `ScrollPosition` interface to include `line` and `column` - Implement scroll-to-line functionality in CodeMirrorEditor - Add tab-based navigation for files and search in EditorPanel This commit introduces several enhancements to the editor, including improved error handling, better scrolling capabilities, and a new search feature. The changes are focused on improving the user experience and adding new functionality to the editor components.
500 lines
13 KiB
TypeScript
500 lines
13 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;
|
|
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;
|
|
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;
|
|
const linePos = viewRef.current.state.doc.line(line + 1).from + column;
|
|
viewRef.current.dispatch({
|
|
selection: { anchor: linePos },
|
|
scrollIntoView: true,
|
|
});
|
|
viewRef.current.focus();
|
|
} 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,
|
|
);
|
|
}, [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;
|
|
|
|
if (typeof doc.scroll?.line === 'number') {
|
|
const line = doc.scroll.line;
|
|
const column = doc.scroll.column ?? 0;
|
|
const linePos = view.state.doc.line(line + 1).from + column;
|
|
view.dispatch({
|
|
selection: { anchor: linePos },
|
|
scrollIntoView: true,
|
|
});
|
|
view.focus();
|
|
|
|
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 [];
|
|
}
|
|
|
|
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 };
|
|
},
|
|
};
|
|
});
|
|
}
|