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'; 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; const readOnlyTooltipStateEffect = StateEffect.define(); const editableTooltipField = StateField.define({ 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(); const editableStateField = StateField.define({ 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()); const containerRef = useRef(null); const viewRef = useRef(); const themeRef = useRef(); const docRef = useRef(); const editorStatesRef = useRef(); 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(); }, [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([]), ]); 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([]), ]); editorStates.set(doc.filePath, state); } view.setState(state); setEditorDocument( view, theme, editable, languageCompartment, autoFocusOnDocumentChange, doc as TextEditorDocument, ); }, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]); return (
{doc?.isBinary && }
); }, ); export default CodeMirrorEditor; CodeMirrorEditor.displayName = 'CodeMirrorEditor'; function newEditorState( content: string, theme: Theme, settings: EditorSettings | undefined, onScrollRef: MutableRefObject, debounceScroll: number, onFileSaveRef: MutableRefObject, 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 }; }, }; }); }