|  | 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<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()); | 
					
						
						|  |  | 
					
						
						|  | 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); | 
					
						
						|  |  | 
					
						
						|  |  | 
					
						
						|  |  | 
					
						
						|  |  | 
					
						
						|  |  | 
					
						
						|  | 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([]), | 
					
						
						|  | ]); | 
					
						
						|  |  | 
					
						
						|  | 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 ( | 
					
						
						|  | <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) { | 
					
						
						|  |  | 
					
						
						|  | 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 }; | 
					
						
						|  | }, | 
					
						
						|  | }; | 
					
						
						|  | }); | 
					
						
						|  | } | 
					
						
						|  |  |