import { undo, redo } from '@codemirror/commands' import ReactCodeMirror from '@uiw/react-codemirror' import { TEST } from 'env' import { useCommandsContext } from 'hooks/useCommandsContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useConvertToVariable } from 'hooks/useToolbarGuards' import { Themes, getSystemTheme } from 'lib/theme' import { useEffect, useMemo, useRef } from 'react' import { useStore } from 'useStore' import { processCodeMirrorRanges } from 'lib/selections' import { highlightSelectionMatches, searchKeymap } from '@codemirror/search' import { lineHighlightField } from 'editor/highlightextension' import { roundOff } from 'lib/utils' import { lineNumbers, rectangularSelection, highlightActiveLineGutter, highlightSpecialChars, highlightActiveLine, keymap, EditorView, dropCursor, drawSelection, ViewUpdate, } from '@codemirror/view' import { indentWithTab, defaultKeymap, historyKeymap, history, } from '@codemirror/commands' import { lintGutter, lintKeymap, linter } from '@codemirror/lint' import { foldGutter, foldKeymap, bracketMatching, indentOnInput, codeFolding, syntaxHighlighting, defaultHighlightStyle, } from '@codemirror/language' import { useModelingContext } from 'hooks/useModelingContext' import interact from '@replit/codemirror-interact' import { engineCommandManager, sceneInfra, kclManager } from 'lib/singletons' import { useKclContext } from 'lang/KclProvider' import { ModelingMachineEvent } from 'machines/modelingMachine' import { useHotkeys } from 'react-hotkeys-hook' import { isTauri } from 'lib/isTauri' import { useNavigate } from 'react-router-dom' import { paths } from 'lib/paths' import makeUrlPathRelative from 'lib/makeUrlPathRelative' import { useLspContext } from 'components/LspProvider' import { Prec, EditorState, Extension, SelectionRange } from '@codemirror/state' import { closeBrackets, closeBracketsKeymap, completionKeymap, hasNextSnippetField, } from '@codemirror/autocomplete' import { NetworkHealthState, useNetworkStatus, } from 'components/NetworkHealthIndicator' import { kclErrorsToDiagnostics } from 'lang/errors' export const editorShortcutMeta = { formatCode: { display: 'Alt + Shift + F', }, convertToVariable: { codeMirror: 'Ctrl-Shift-c', display: 'Ctrl + Shift + C', }, } export const KclEditorPane = () => { const { settings: { context }, } = useSettingsAuthContext() const theme = context.app.theme.current === Themes.System ? getSystemTheme() : context.app.theme.current const { editorView, setEditorView, isShiftDown } = useStore((s) => ({ editorView: s.editorView, setEditorView: s.setEditorView, isShiftDown: s.isShiftDown, })) const { code, errors } = useKclContext() const lastEvent = useRef({ event: '', time: Date.now() }) const { copilotLSP, kclLSP } = useLspContext() const { overallState } = useNetworkStatus() const isNetworkOkay = overallState === NetworkHealthState.Ok const navigate = useNavigate() useEffect(() => { if (typeof window === 'undefined') return const onlineCallback = () => kclManager.setCodeAndExecute(kclManager.code) window.addEventListener('online', onlineCallback) return () => window.removeEventListener('online', onlineCallback) }, []) useHotkeys('mod+z', (e) => { e.preventDefault() if (editorView) { undo(editorView) } }) useHotkeys('mod+shift+z', (e) => { e.preventDefault() if (editorView) { redo(editorView) } }) const { context: { selectionRanges }, send, state, } = useModelingContext() const { settings } = useSettingsAuthContext() const textWrapping = settings.context.textEditor.textWrapping const cursorBlinking = settings.context.textEditor.blinkingCursor const { commandBarSend } = useCommandsContext() const { enable: convertEnabled, handleClick: convertCallback } = useConvertToVariable() const onChange = async (newCode: string) => { // If we are just fucking around in a snippet, return early and don't // trigger stuff below that might cause the component to re-render. // Otherwise we will not be able to tab thru the snippet portions. // We explicitly dont check HasPrevSnippetField because we always add // a ${} to the end of the function so that's fine. if (editorView && hasNextSnippetField(editorView.state)) { return } if (isNetworkOkay) kclManager.setCodeAndExecute(newCode) else kclManager.setCode(newCode) } const lastSelection = useRef('') const onUpdate = (viewUpdate: ViewUpdate) => { // If we are just fucking around in a snippet, return early and don't // trigger stuff below that might cause the component to re-render. // Otherwise we will not be able to tab thru the snippet portions. // We explicitly dont check HasPrevSnippetField because we always add // a ${} to the end of the function so that's fine. if (hasNextSnippetField(viewUpdate.view.state)) { return } if (!editorView) { setEditorView(viewUpdate.view) } const selString = stringifyRanges( viewUpdate?.state?.selection?.ranges || [] ) if (selString === lastSelection.current) { // onUpdate is noisy and is fired a lot by extensions // since we're only interested in selections changes we can ignore most of these. return } lastSelection.current = selString if ( // TODO find a less lazy way of getting the last Date.now() - useStore.getState().lastCodeMirrorSelectionUpdatedFromScene < 150 ) return // update triggered by scene selection if (sceneInfra.selected) return // mid drag const ignoreEvents: ModelingMachineEvent['type'][] = [ 'Equip Line tool', 'Equip tangential arc to', ] if (ignoreEvents.includes(state.event.type)) return const eventInfo = processCodeMirrorRanges({ codeMirrorRanges: viewUpdate.state.selection.ranges, selectionRanges, isShiftDown, }) if (!eventInfo) return const deterministicEventInfo = { ...eventInfo, engineEvents: eventInfo.engineEvents.map((e) => ({ ...e, cmd_id: 'static', })), } const stringEvent = JSON.stringify(deterministicEventInfo) if ( stringEvent === lastEvent.current.event && Date.now() - lastEvent.current.time < 500 ) return // don't repeat events lastEvent.current = { event: stringEvent, time: Date.now() } send(eventInfo.modelingEvent) eventInfo.engineEvents.forEach((event) => engineCommandManager.sendSceneCommand(event) ) } const editorExtensions = useMemo(() => { const extensions = [ drawSelection({ cursorBlinkRate: cursorBlinking.current ? 1200 : 0, }), lineHighlightField, history(), closeBrackets(), codeFolding(), keymap.of([ ...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap, ...lintKeymap, indentWithTab, { key: 'Meta-k', run: () => { commandBarSend({ type: 'Open' }) return false }, }, { key: isTauri() ? 'Meta-,' : 'Meta-Shift-,', run: () => { navigate(makeUrlPathRelative(paths.SETTINGS)) return false }, }, { key: editorShortcutMeta.convertToVariable.codeMirror, run: () => { if (convertEnabled) { convertCallback() return true } return false }, }, ]), ] as Extension[] if (kclLSP) extensions.push(Prec.highest(kclLSP)) if (copilotLSP) extensions.push(copilotLSP) // These extensions have proven to mess with vitest if (!TEST) { extensions.push( lintGutter(), linter((_view: EditorView) => { return kclErrorsToDiagnostics(errors) }), lineNumbers(), highlightActiveLineGutter(), highlightSpecialChars(), history(), foldGutter(), EditorState.allowMultipleSelections.of(true), indentOnInput(), bracketMatching(), closeBrackets(), highlightActiveLine(), highlightSelectionMatches(), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), rectangularSelection(), drawSelection(), dropCursor(), interact({ rules: [ // a rule for a number dragger { // the regexp matching the value regexp: /-?\b\d+\.?\d*\b/g, // set cursor to "ew-resize" on hover cursor: 'ew-resize', // change number value based on mouse X movement on drag onDrag: (text, setText, e) => { const multiplier = e.shiftKey && e.metaKey ? 0.01 : e.metaKey ? 0.1 : e.shiftKey ? 10 : 1 const delta = e.movementX * multiplier const newVal = roundOff( Number(text) + delta, multiplier === 0.01 ? 2 : multiplier === 0.1 ? 1 : 0 ) if (isNaN(newVal)) return setText(newVal.toString()) }, }, ], }) ) if (textWrapping.current) extensions.push(EditorView.lineWrapping) } return extensions }, [kclLSP, textWrapping.current, cursorBlinking.current, convertCallback]) return (