import React, { useState, useRef, useEffect, useReducer } from 'react' // eslint-disable-line import Editor, { loader, Monaco } from '@monaco-editor/react' import { AlertModal } from '@remix-ui/app' import { reducerActions, reducerListener, initialState } from './actions/editor' import { solidityTokensProvider, solidityLanguageConfig } from './syntaxes/solidity' import { cairoTokensProvider, cairoLanguageConfig } from './syntaxes/cairo' import { zokratesTokensProvider, zokratesLanguageConfig } from './syntaxes/zokrates' import './remix-ui-editor.css' import { loadTypes } from './web-types' import monaco from '../types/monaco' import { IMarkdownString, IPosition, MarkerSeverity } from 'monaco-editor' import { RemixHoverProvider } from './providers/hoverProvider' import { RemixReferenceProvider } from './providers/referenceProvider' import { RemixCompletionProvider } from './providers/completionProvider' import { RemixHighLightProvider } from './providers/highlightProvider' import { RemixDefinitionProvider } from './providers/definitionProvider' type sourceAnnotation = { row: number, column: number, text: string, type: 'error' | 'warning' | 'info' hide: boolean from: string // plugin name } type sourceMarker = { position: { start: { line: number column: number }, end: { line: number column: number } }, from: string // plugin name hide: boolean } export type lineText = { position: { start: { line: number column: number }, end: { line: number column: number } }, from?: string // plugin name content: string className: string afterContentClassName: string hide: boolean, hoverMessage: IMarkdownString | IMarkdownString[] } type errorMarker = { message: string severity: MarkerSeverity | 'warning' | 'info' | 'error' | 'hint' position: { start: { line: number column: number }, end: { line: number column: number } }, file: string } loader.config({ paths: { vs: 'assets/js/monaco-editor/dev/vs' } }) export type DecorationsReturn = { currentDecorations: Array registeredDecorations?: Array } /* eslint-disable-next-line */ export interface EditorUIProps { contextualListener: any activated: boolean themeType: string currentFile: string events: { onBreakPointAdded: (file: string, line: number) => void onBreakPointCleared: (file: string, line: number) => void onDidChangeContent: (file: string) => void onEditorMounted: () => void } plugin: { on: (plugin: string, event: string, listener: any) => void call: (plugin: string, method: string, arg1?: any, arg2?: any, arg3?: any, arg4?: any) => any } editorAPI: { findMatches: (uri: string, value: string) => any getFontSize: () => number, getValue: (uri: string) => string getCursorPosition: (offset?: boolean) => number | IPosition getHoverPosition: (position: IPosition) => number addDecoration: (marker: sourceMarker, filePath: string, typeOfDecoration: string) => DecorationsReturn clearDecorationsByPlugin: (filePath: string, plugin: string, typeOfDecoration: string, registeredDecorations: any, currentDecorations: any) => DecorationsReturn keepDecorationsFor: (filePath: string, plugin: string, typeOfDecoration: string, registeredDecorations: any, currentDecorations: any) => DecorationsReturn addErrorMarker: (errors: errorMarker[], from: string) => void clearErrorMarkers: (sources: string[] | {[fileName: string]: any}, from: string) => void } } export const EditorUI = (props: EditorUIProps) => { const [, setCurrentBreakpoints] = useState({}) const defaultEditorValue = ` \t\t\t\t\t\t\t ____ _____ __ __ ___ __ __ ___ ____ _____ \t\t\t\t\t\t\t| _ \\ | ____| | \\/ | |_ _| \\ \\/ / |_ _| | _ \\ | ____| \t\t\t\t\t\t\t| |_) | | _| | |\\/| | | | \\ / | | | | | | | _| \t\t\t\t\t\t\t| _ < | |___ | | | | | | / \\ | | | |_| | | |___ \t\t\t\t\t\t\t|_| \\_\\ |_____| |_| |_| |___| /_/\\_\\ |___| |____/ |_____|\n\n \t\t\t\t\t\t\tKeyboard Shortcuts:\n \t\t\t\t\t\t\t\tCTRL + S: Compile the current contract\n \t\t\t\t\t\t\t\tCTRL + Shift + F : Open the File Explorer\n \t\t\t\t\t\t\t\tCTRL + Shift + A : Open the Plugin Manager\n \t\t\t\t\t\t\t\tCTRL + SHIFT + S: Compile the current contract & Run an associated script\n \t\t\t\t\t\t\tEditor Keyboard Shortcuts:\n \t\t\t\t\t\t\t\tCTRL + Alt + F : Format the code in the current file\n \t\t\t\t\t\t\tImportant Links:\n \t\t\t\t\t\t\t\tOfficial website about the Remix Project: https://remix-project.org/\n \t\t\t\t\t\t\t\tOfficial documentation: https://remix-ide.readthedocs.io/en/latest/\n \t\t\t\t\t\t\t\tGithub: https://github.com/ethereum/remix-project\n \t\t\t\t\t\t\t\tGitter: https://gitter.im/ethereum/remix\n \t\t\t\t\t\t\t\tMedium: https://medium.com/remix-ide\n \t\t\t\t\t\t\t\tTwitter: https://twitter.com/ethereumremix\n ` const editorRef = useRef(null) const monacoRef = useRef(null) const currentFileRef = useRef('') // const currentDecorations = useRef({ sourceAnnotationsPerFile: {}, markerPerFile: {} }) // decorations that are currently in use by the editor // const registeredDecorations = useRef({}) // registered decorations const [editorModelsState, dispatch] = useReducer(reducerActions, initialState) const formatColor = (name) => { let color = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim() if (color.length === 4) { color = color.concat(color.substr(1)) } return color } const defineAndSetTheme = (monaco) => { const themeType = props.themeType === 'dark' ? 'vs-dark' : 'vs' const themeName = props.themeType === 'dark' ? 'remix-dark' : 'remix-light' // see https://microsoft.github.io/monaco-editor/playground.html#customizing-the-appearence-exposed-colors const lightColor = formatColor('--light') const infoColor = formatColor('--info') const darkColor = formatColor('--dark') const secondaryColor = formatColor('--secondary') const primaryColor = formatColor('--primary') const textColor = formatColor('--text') || darkColor const textbackground = formatColor('--text-background') || lightColor const blueColor = formatColor('--blue') const successColor = formatColor('--success') const warningColor = formatColor('--warning') const yellowColor = formatColor('--yellow') const pinkColor = formatColor('--pink') const locationColor = '#9e7e08' // const purpleColor = formatColor('--purple') const dangerColor = formatColor('--danger') const greenColor = formatColor('--green') const orangeColor = formatColor('--orange') const grayColor = formatColor('--gray') monaco.editor.defineTheme(themeName, { base: themeType, inherit: true, // can also be false to completely replace the builtin rules rules: [ { background: darkColor.replace('#', '') }, { foreground: textColor.replace('#', '') }, // global variables { token: 'keyword.abi', foreground: blueColor }, { token: 'keyword.block', foreground: blueColor }, { token: 'keyword.bytes', foreground: blueColor }, { token: 'keyword.msg', foreground: blueColor }, { token: 'keyword.tx', foreground: blueColor }, // global functions { token: 'keyword.assert', foreground: blueColor }, { token: 'keyword.require', foreground: blueColor }, { token: 'keyword.revert', foreground: blueColor }, { token: 'keyword.blockhash', foreground: blueColor }, { token: 'keyword.keccak256', foreground: blueColor }, { token: 'keyword.sha256', foreground: blueColor }, { token: 'keyword.ripemd160', foreground: blueColor }, { token: 'keyword.ecrecover', foreground: blueColor }, { token: 'keyword.addmod', foreground: blueColor }, { token: 'keyword.mulmod', foreground: blueColor }, { token: 'keyword.selfdestruct', foreground: blueColor }, { token: 'keyword.type ', foreground: blueColor }, { token: 'keyword.gasleft', foreground: blueColor }, // specials { token: 'keyword.super', foreground: infoColor }, { token: 'keyword.this', foreground: infoColor }, { token: 'keyword.virtual', foreground: infoColor }, // for state variables { token: 'keyword.constants', foreground: grayColor }, { token: 'keyword.override', foreground: grayColor }, { token: 'keyword.immutable', foreground: grayColor }, // data location { token: 'keyword.memory', foreground: locationColor }, { token: 'keyword.storage', foreground: locationColor }, { token: 'keyword.calldata', foreground: locationColor }, // for Events { token: 'keyword.indexed', foreground: yellowColor }, { token: 'keyword.anonymous', foreground: yellowColor }, // for functions { token: 'keyword.external', foreground: successColor }, { token: 'keyword.internal', foreground: successColor }, { token: 'keyword.private', foreground: successColor }, { token: 'keyword.public', foreground: successColor }, { token: 'keyword.view', foreground: successColor }, { token: 'keyword.pure', foreground: successColor }, { token: 'keyword.payable', foreground: successColor }, { token: 'keyword.nonpayable', foreground: successColor }, // Errors { token: 'keyword.Error', foreground: dangerColor }, { token: 'keyword.Panic', foreground: dangerColor }, // special functions { token: 'keyword.fallback', foreground: pinkColor }, { token: 'keyword.receive', foreground: pinkColor }, { token: 'keyword.constructor', foreground: pinkColor }, // identifiers { token: 'keyword.identifier', foreground: warningColor }, { token: 'keyword.for', foreground: warningColor }, { token: 'keyword.break', foreground: warningColor }, { token: 'keyword.continue', foreground: warningColor }, { token: 'keyword.while', foreground: warningColor }, { token: 'keyword.do', foreground: warningColor }, { token: 'keyword.if', foreground: yellowColor }, { token: 'keyword.else', foreground: yellowColor }, { token: 'keyword.throw', foreground: orangeColor }, { token: 'keyword.catch', foreground: orangeColor }, { token: 'keyword.try', foreground: orangeColor }, // returns { token: 'keyword.returns', foreground: greenColor }, { token: 'keyword.return', foreground: greenColor } ], colors: { // see https://code.visualstudio.com/api/references/theme-color for more settings 'editor.background': textbackground, 'editorSuggestWidget.background': lightColor, 'editorSuggestWidget.selectedBackground': secondaryColor, 'editorSuggestWidget.selectedForeground': textColor, 'editorSuggestWidget.highlightForeground': primaryColor, 'editorSuggestWidget.focusHighlightForeground': infoColor, 'editor.lineHighlightBorder': secondaryColor, 'editor.lineHighlightBackground': textbackground === darkColor ? lightColor : secondaryColor, 'editorGutter.background': lightColor, //'editor.selectionHighlightBackground': secondaryColor, 'minimap.background': lightColor, 'menu.foreground': textColor, 'menu.background': textbackground, 'menu.selectionBackground': secondaryColor, 'menu.selectionForeground': textColor, 'menu.selectionBorder': secondaryColor } }) monacoRef.current.editor.setTheme(themeName) } useEffect(() => { if (!monacoRef.current) return defineAndSetTheme(monacoRef.current) }) useEffect(() => { if (!editorRef.current || !props.currentFile) return currentFileRef.current = props.currentFile const file = editorModelsState[props.currentFile] editorRef.current.setModel(file.model) editorRef.current.updateOptions({ readOnly: editorModelsState[props.currentFile].readOnly }) if (file.language === 'sol') { monacoRef.current.editor.setModelLanguage(file.model, 'remix-solidity') } else if (file.language === 'cairo') { monacoRef.current.editor.setModelLanguage(file.model, 'remix-cairo') } else if (file.language === 'zokrates') { monacoRef.current.editor.setModelLanguage(file.model, 'remix-zokrates') } }, [props.currentFile]) const convertToMonacoDecoration = (decoration: lineText | sourceAnnotation | sourceMarker, typeOfDecoration: string) => { if (typeOfDecoration === 'sourceAnnotationsPerFile') { decoration = decoration as sourceAnnotation return { type: typeOfDecoration, range: new monacoRef.current.Range(decoration.row + 1, 1, decoration.row + 1, 1), options: { isWholeLine: false, glyphMarginHoverMessage: { value: (decoration.from ? `from ${decoration.from}:\n` : '') + decoration.text }, glyphMarginClassName: `fal fa-exclamation-square text-${decoration.type === 'error' ? 'danger' : (decoration.type === 'warning' ? 'warning' : 'info')}` } } } if (typeOfDecoration === 'markerPerFile') { decoration = decoration as sourceMarker let isWholeLine = false if ((decoration.position.start.line === decoration.position.end.line && decoration.position.end.column - decoration.position.start.column < 2) || (decoration.position.start.line !== decoration.position.end.line)) { // in this case we force highlighting the whole line (doesn't make sense to highlight 2 chars) isWholeLine = true } return { type: typeOfDecoration, range: new monacoRef.current.Range(decoration.position.start.line + 1, decoration.position.start.column + 1, decoration.position.end.line + 1, decoration.position.end.column + 1), options: { isWholeLine, inlineClassName: `${isWholeLine ? 'alert-info' : 'inline-class'} border-0 highlightLine${decoration.position.start.line + 1}` } } } if (typeOfDecoration === 'lineTextPerFile') { const lineTextDecoration = decoration as lineText return { type: typeOfDecoration, range: new monacoRef.current.Range(lineTextDecoration.position.start.line + 1, lineTextDecoration.position.start.column + 1, lineTextDecoration.position.start.line + 1, 1024), options: { after: { content: ` ${lineTextDecoration.content}`, inlineClassName: `${lineTextDecoration.className}` }, afterContentClassName: `${lineTextDecoration.afterContentClassName}`, hoverMessage : lineTextDecoration.hoverMessage }, } } if (typeOfDecoration === 'lineTextPerFile') { const lineTextDecoration = decoration as lineText return { type: typeOfDecoration, range: new monacoRef.current.Range(lineTextDecoration.position.start.line + 1, lineTextDecoration.position.start.column + 1, lineTextDecoration.position.start.line + 1, 1024), options: { after: { content: ` ${lineTextDecoration.content}`, inlineClassName: `${lineTextDecoration.className}` }, afterContentClassName: `${lineTextDecoration.afterContentClassName}`, hoverMessage : lineTextDecoration.hoverMessage }, } } } props.editorAPI.clearDecorationsByPlugin = (filePath: string, plugin: string, typeOfDecoration: string, registeredDecorations: any, currentDecorations: any) => { const model = editorModelsState[filePath]?.model if (!model) return { currentDecorations: [], registeredDecorations: [] } const decorations = [] const newRegisteredDecorations = [] if (registeredDecorations) { for (const decoration of registeredDecorations) { if (decoration.type === typeOfDecoration && decoration.value.from !== plugin) { decorations.push(convertToMonacoDecoration(decoration.value, typeOfDecoration)) newRegisteredDecorations.push(decoration) } } } return { currentDecorations: model.deltaDecorations(currentDecorations, decorations), registeredDecorations: newRegisteredDecorations } } props.editorAPI.keepDecorationsFor = (filePath: string, plugin: string, typeOfDecoration: string, registeredDecorations: any, currentDecorations: any) => { const model = editorModelsState[filePath]?.model if (!model) return { currentDecorations: [] } const decorations = [] if (registeredDecorations) { for (const decoration of registeredDecorations) { if (decoration.value.from === plugin) { decorations.push(convertToMonacoDecoration(decoration.value, typeOfDecoration)) } } } return { currentDecorations: model.deltaDecorations(currentDecorations, decorations) } } const addDecoration = (decoration: sourceAnnotation | sourceMarker, filePath: string, typeOfDecoration: string) => { const model = editorModelsState[filePath]?.model if (!model) return { currentDecorations: [] } const monacoDecoration = convertToMonacoDecoration(decoration, typeOfDecoration) return { currentDecorations: model.deltaDecorations([], [monacoDecoration]), registeredDecorations: [{ value: decoration, type: typeOfDecoration }] } } props.editorAPI.addDecoration = (marker: sourceMarker, filePath: string, typeOfDecoration: string) => { return addDecoration(marker, filePath, typeOfDecoration) } props.editorAPI.addErrorMarker = async (errors: errorMarker[], from: string) => { const allMarkersPerfile: Record> = {} for (const error of errors) { let filePath = error.file if (!filePath) return const fileFromUrl = await props.plugin.call('fileManager', 'getPathFromUrl', filePath) filePath = fileFromUrl.file const model = editorModelsState[filePath]?.model const errorServerityMap = { 'error': MarkerSeverity.Error, 'warning': MarkerSeverity.Warning, 'info': MarkerSeverity.Info } if (model) { const markerData: monaco.editor.IMarkerData = { severity: (typeof error.severity === 'string') ? errorServerityMap[error.severity] : error.severity, startLineNumber: ((error.position.start && error.position.start.line) || 0), startColumn: ((error.position.start && error.position.start.column) || 0), endLineNumber: ((error.position.end && error.position.end.line) || 0), endColumn: ((error.position.end && error.position.end.column) || 0), message: error.message, } if (!allMarkersPerfile[filePath]) { allMarkersPerfile[filePath] = [] } allMarkersPerfile[filePath].push(markerData) } } for (const filePath in allMarkersPerfile) { const model = editorModelsState[filePath]?.model if (model) { monacoRef.current.editor.setModelMarkers(model, from, allMarkersPerfile[filePath]) } } } props.editorAPI.clearErrorMarkers = async (sources: string[] | {[fileName: string]: any}, from: string) => { if (sources) { for (const source of (Array.isArray(sources) ? sources : Object.keys(sources))) { const filePath = source const model = editorModelsState[filePath]?.model if (model) { monacoRef.current.editor.setModelMarkers(model, from, []) } } } } props.editorAPI.findMatches = (uri: string, value: string) => { if (!editorRef.current) return const model = editorModelsState[uri]?.model if (model) return model.findMatches(value) } props.editorAPI.getValue = (uri: string) => { if (!editorRef.current) return const model = editorModelsState[uri]?.model if (model) { return model.getValue() } } props.editorAPI.getCursorPosition = (offset:boolean = true) => { if (!monacoRef.current) return const model = editorModelsState[currentFileRef.current]?.model if (model) { return offset? model.getOffsetAt(editorRef.current.getPosition()): editorRef.current.getPosition() } } props.editorAPI.getHoverPosition = (position: monaco.Position) => { if (!monacoRef.current) return const model = editorModelsState[currentFileRef.current]?.model if (model) { return model.getOffsetAt(position) } else { return 0 } } props.editorAPI.getFontSize = () => { if (!editorRef.current) return return editorRef.current.getOption(43).fontSize } (window as any).addRemixBreakpoint = (position) => { // make it available from e2e testing... const model = editorRef.current.getModel() if (model) { setCurrentBreakpoints(prevState => { const currentFile = currentFileRef.current if (!prevState[currentFile]) prevState[currentFile] = {} const decoration = Object.keys(prevState[currentFile]).filter((line) => parseInt(line) === position.lineNumber) if (decoration.length) { props.events.onBreakPointCleared(currentFile, position.lineNumber) model.deltaDecorations([prevState[currentFile][position.lineNumber]], []) delete prevState[currentFile][position.lineNumber] } else { props.events.onBreakPointAdded(currentFile, position.lineNumber) const decorationIds = model.deltaDecorations([], [{ range: new monacoRef.current.Range(position.lineNumber, 1, position.lineNumber, 1), options: { isWholeLine: false, glyphMarginClassName: 'fas fa-circle text-info' } }]) prevState[currentFile][position.lineNumber] = decorationIds[0] } return prevState }) } } function handleEditorDidMount(editor) { editorRef.current = editor defineAndSetTheme(monacoRef.current) reducerListener(props.plugin, dispatch, monacoRef.current, editorRef.current, props.events) props.events.onEditorMounted() editor.onMouseUp((e) => { if (e && e.target && e.target.toString().startsWith('GUTTER')) { (window as any).addRemixBreakpoint(e.target.position) } }) editor.onDidPaste((e) => { if (e && e.range && e.range.startLineNumber >= 0 && e.range.endLineNumber >= 0 && e.range.endLineNumber - e.range.startLineNumber > 10) { const modalContent: AlertModal = { id: 'newCodePasted', title: 'New pasted code detected!', message: (
Remix detected you have just pasted a consequent code snippet or contract in the editor.
Please make sure you fully understand this new code before executing any transaction that uses it.
Your wallet might be exposed and at risk if you run untrusted code. In a worst-case scenario you could loose all your money.
If you don't fully understand it, please don't run this code.
If you are not a smart contract developer, please ask assistance to someone you trust and has the skills to determine whether this code is safe to use.
), } props.plugin.call('notification', 'alert', modalContent) } }) // zoomin zoomout editor.addCommand(monacoRef.current.KeyMod.CtrlCmd | (monacoRef.current.KeyCode as any).US_EQUAL, () => { editor.updateOptions({ fontSize: editor.getOption(43).fontSize + 1 }) }) editor.addCommand(monacoRef.current.KeyMod.CtrlCmd | (monacoRef.current.KeyCode as any).US_MINUS, () => { editor.updateOptions({ fontSize: editor.getOption(43).fontSize - 1 }) }) // add context menu items const zoominAction = { id: "zoomIn", label: "Zoom In", contextMenuOrder: 0, // choose the order contextMenuGroupId: "zooming", // create a new grouping keybindings: [ // eslint-disable-next-line no-bitwise monacoRef.current.KeyMod.CtrlCmd | monacoRef.current.KeyCode.Equal, ], run: () => { editor.updateOptions({ fontSize: editor.getOption(43).fontSize + 1 }) }, } const zoomOutAction = { id: "zoomOut", label: "Zoom Out", contextMenuOrder: 0, // choose the order contextMenuGroupId: "zooming", // create a new grouping keybindings: [ // eslint-disable-next-line no-bitwise monacoRef.current.KeyMod.CtrlCmd | monacoRef.current.KeyCode.Minus, ], run: () => { editor.updateOptions({ fontSize: editor.getOption(43).fontSize - 1 }) }, } const formatAction = { id: "autoFormat", label: "Format Code", contextMenuOrder: 0, // choose the order contextMenuGroupId: "formatting", // create a new grouping keybindings: [ // eslint-disable-next-line no-bitwise monacoRef.current.KeyMod.Shift | monacoRef.current.KeyMod.Alt | monacoRef.current.KeyCode.KeyF, ], run: async () => { const file = await props.plugin.call('fileManager', 'getCurrentFile') await props.plugin.call('codeFormatter', 'format', file) }, } editor.addAction(formatAction) editor.addAction(zoomOutAction) editor.addAction(zoominAction) const editorService = editor._codeEditorService; const openEditorBase = editorService.openCodeEditor.bind(editorService); editorService.openCodeEditor = async (input , source) => { const result = await openEditorBase(input, source) if (input && input.resource && input.resource.path) { try { await props.plugin.call('fileManager', 'open', input.resource.path) if (input.options && input.options.selection) { editor.revealRange(input.options.selection) editor.setPosition({ column: input.options.selection.startColumn, lineNumber: input.options.selection.startLineNumber }) } } catch (e) { console.log(e) } } return result } // just for e2e testing const loadedElement = document.createElement('span') loadedElement.setAttribute('data-id', 'editorloaded') document.body.appendChild(loadedElement) } function handleEditorWillMount(monaco) { monacoRef.current = monaco // Register a new language monacoRef.current.languages.register({ id: 'remix-solidity' }) monacoRef.current.languages.register({ id: 'remix-cairo' }) monacoRef.current.languages.register({ id: 'remix-zokrates' }) // Register a tokens provider for the language monacoRef.current.languages.setMonarchTokensProvider('remix-solidity', solidityTokensProvider as any) monacoRef.current.languages.setLanguageConfiguration('remix-solidity', solidityLanguageConfig as any ) monacoRef.current.languages.setMonarchTokensProvider('remix-cairo', cairoTokensProvider as any) monacoRef.current.languages.setLanguageConfiguration('remix-cairo', cairoLanguageConfig as any) monacoRef.current.languages.setMonarchTokensProvider('remix-zokrates', zokratesTokensProvider as any) monacoRef.current.languages.setLanguageConfiguration('remix-zokrates', zokratesLanguageConfig as any) monacoRef.current.languages.registerDefinitionProvider('remix-solidity', new RemixDefinitionProvider(props, monaco)) monacoRef.current.languages.registerDocumentHighlightProvider('remix-solidity', new RemixHighLightProvider(props, monaco)) monacoRef.current.languages.registerReferenceProvider('remix-solidity', new RemixReferenceProvider(props, monaco)) monacoRef.current.languages.registerHoverProvider('remix-solidity', new RemixHoverProvider(props, monaco)) monacoRef.current.languages.registerCompletionItemProvider('remix-solidity', new RemixCompletionProvider(props, monaco)) loadTypes(monacoRef.current) } return (
) } export default EditorUI