/** * Copyright (c) 2021 GraphQL Contributors. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import React from 'react'; import { isEqual, escapeRegExp } from 'lodash'; import { getEnvironmentVariables } from 'utils/collections'; import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; import StyledWrapper from './StyledWrapper'; import jsonlint from 'jsonlint'; import { JSHINT } from 'jshint'; import stripJsonComments from 'strip-json-comments'; let CodeMirror; const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; const TAB_SIZE = 2; if (!SERVER_RENDERED) { CodeMirror = require('codemirror'); window.jsonlint = jsonlint; window.JSHINT = JSHINT; //This should be done dynamically if possible const hintWords = [ 'res', 'res.status', 'res.statusText', 'res.headers', 'res.body', 'res.responseTime', 'res.getStatus()', 'res.getHeader(name)', 'res.getHeaders()', 'res.getBody()', 'res.getResponseTime()', 'req', 'req.url', 'req.method', 'req.headers', 'req.body', 'req.timeout', 'req.getUrl()', 'req.setUrl(url)', 'req.getMethod()', 'req.getAuthMode()', 'req.setMethod(method)', 'req.getHeader(name)', 'req.getHeaders()', 'req.setHeader(name, value)', 'req.setHeaders(data)', 'req.getBody()', 'req.setBody(data)', 'req.setMaxRedirects(maxRedirects)', 'req.getTimeout()', 'req.setTimeout(timeout)', 'req.getExecutionMode()', 'bru', 'bru.cwd()', 'bru.getEnvName(key)', 'bru.getProcessEnv(key)', 'bru.hasEnvVar(key)', 'bru.getEnvVar(key)', 'bru.getFolderVar(key)', 'bru.getCollectionVar(key)', 'bru.setEnvVar(key,value)', 'bru.hasVar(key)', 'bru.getVar(key)', 'bru.setVar(key,value)', 'bru.deleteVar(key)', 'bru.setNextRequest(requestName)', 'req.disableParsingResponseJson()', 'bru.getRequestVar(key)', 'bru.sleep(ms)' ]; CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => { const cursor = editor.getCursor(); const currentLine = editor.getLine(cursor.line); let startBru = cursor.ch; let endBru = startBru; while (endBru < currentLine.length && /[\w.]/.test(currentLine.charAt(endBru))) ++endBru; while (startBru && /[\w.]/.test(currentLine.charAt(startBru - 1))) --startBru; let curWordBru = startBru != endBru && currentLine.slice(startBru, endBru); let start = cursor.ch; let end = start; while (end < currentLine.length && /[\w]/.test(currentLine.charAt(end))) ++end; while (start && /[\w]/.test(currentLine.charAt(start - 1))) --start; const jsHinter = CodeMirror.hint.javascript; let result = jsHinter(editor) || { list: [] }; result.to = CodeMirror.Pos(cursor.line, end); result.from = CodeMirror.Pos(cursor.line, start); if (curWordBru) { hintWords.forEach((h) => { if (h.includes('.') == curWordBru.includes('.') && h.startsWith(curWordBru)) { result.list.push(curWordBru.includes('.') ? h.split('.')[1] : h); } }); result.list?.sort(); } return result; }); CodeMirror.commands.autocomplete = (cm, hint, options) => { cm.showHint({ hint, ...options }); }; } export default class CodeEditor extends React.Component { constructor(props) { super(props); // Keep a cached version of the value, this cache will be updated when the // editor is updated, which can later be used to protect the editor from // unnecessary updates during the update lifecycle. this.cachedValue = props.value || ''; this.variables = {}; this.searchResultsCountElementId = 'search-results-count'; this.lintOptions = { esversion: 11, expr: true, asi: true }; } componentDidMount() { const editor = (this.editor = CodeMirror(this._node, { value: this.props.value || '', lineNumbers: true, lineWrapping: true, tabSize: TAB_SIZE, mode: this.props.mode || 'application/ld+json', keyMap: 'sublime', autoCloseBrackets: true, matchBrackets: true, showCursorWhenSelecting: true, foldGutter: true, gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'], lint: this.lintOptions, readOnly: this.props.readOnly, scrollbarStyle: 'overlay', theme: this.props.theme === 'dark' ? 'monokai' : 'default', extraKeys: { 'Cmd-Enter': () => { if (this.props.onRun) { this.props.onRun(); } }, 'Ctrl-Enter': () => { if (this.props.onRun) { this.props.onRun(); } }, 'Cmd-S': () => { if (this.props.onSave) { this.props.onSave(); } }, 'Ctrl-S': () => { if (this.props.onSave) { this.props.onSave(); } }, 'Cmd-F': (cm) => { cm.execCommand('findPersistent'); this._bindSearchHandler(); this._appendSearchResultsCount(); }, 'Ctrl-F': (cm) => { cm.execCommand('findPersistent'); this._bindSearchHandler(); this._appendSearchResultsCount(); }, 'Cmd-H': 'replace', 'Ctrl-H': 'replace', Tab: function (cm) { cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection() ? cm.execCommand('indentMore') : cm.replaceSelection(' ', 'end'); }, 'Shift-Tab': 'indentLess', 'Ctrl-Space': 'autocomplete', 'Cmd-Space': 'autocomplete', 'Ctrl-Y': 'foldAll', 'Cmd-Y': 'foldAll', 'Ctrl-I': 'unfoldAll', 'Cmd-I': 'unfoldAll', 'Cmd-/': (cm) => { // comment/uncomment every selected line(s) const selections = cm.listSelections(); selections.forEach((range) => { for (let i = range.from().line; i <= range.to().line; i++) { const selectedLine = cm.getLine(i); // if commented line, remove comment if (selectedLine.trim().startsWith('//')) { cm.replaceRange( selectedLine.replace(/^(\s*)\/\/\s?/, '$1'), { line: i, ch: 0 }, { line: i, ch: selectedLine.length } ); continue; } // otherwise add comment cm.replaceRange( selectedLine.search(/\S|$/) >= TAB_SIZE ? ' '.repeat(TAB_SIZE) + '// ' + selectedLine.trim() : '// ' + selectedLine, { line: i, ch: 0 }, { line: i, ch: selectedLine.length } ); } }); } }, foldOptions: { widget: (from, to) => { var count = undefined; var internal = this.editor.getRange(from, to); if (this.props.mode == 'application/ld+json') { if (this.editor.getLine(from.line).endsWith('[')) { var toParse = '[' + internal + ']'; } else var toParse = '{' + internal + '}'; try { count = Object.keys(JSON.parse(toParse)).length; } catch (e) {} } else if (this.props.mode == 'application/xml') { var doc = new DOMParser(); try { //add header element and remove prefix namespaces for DOMParser var dcm = doc.parseFromString( ' ' + internal.replace(/(?<=\<|<\/)\w+:/g, '') + '', 'application/xml' ); count = dcm.documentElement.children.length; } catch (e) {} } return count ? `\u21A4${count}\u21A6` : '\u2194'; } } })); CodeMirror.registerHelper('lint', 'json', function (text) { let found = []; if (!window.jsonlint) { if (window.console) { window.console.error('Error: window.jsonlint not defined, CodeMirror JSON linting cannot run.'); } return found; } let jsonlint = window.jsonlint.parser || window.jsonlint; jsonlint.parseError = function (str, hash) { let loc = hash.loc; found.push({ from: CodeMirror.Pos(loc.first_line - 1, loc.first_column), to: CodeMirror.Pos(loc.last_line - 1, loc.last_column), message: str }); }; try { jsonlint.parse(stripJsonComments(text.replace(/(? 0 ? this.lintOptions : false); editor.on('change', this._onEdit); this.addOverlay(); } if (this.props.mode == 'javascript') { editor.on('keyup', function (cm, event) { const cursor = editor.getCursor(); const currentLine = editor.getLine(cursor.line); let start = cursor.ch; let end = start; while (end < currentLine.length && /[^{}();\s\[\]\,]/.test(currentLine.charAt(end))) ++end; while (start && /[^{}();\s\[\]\,]/.test(currentLine.charAt(start - 1))) --start; let curWord = start != end && currentLine.slice(start, end); //Qualify if autocomplete will be shown if ( /^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|\s)\w*/.test(event.key) && curWord.length > 0 && !/\/\/|\/\*|.*{{|`[^$]*{|`[^{]*$/.test(currentLine.slice(0, end)) && /(? { this._node = node; }} /> ); } addOverlay = () => { const mode = this.props.mode || 'application/ld+json'; let variables = getEnvironmentVariables(this.props.collection); this.variables = variables; defineCodeMirrorBrunoVariablesMode(variables, mode); this.editor.setOption('mode', 'brunovariables'); }; _onEdit = () => { if (!this.ignoreChangeEvent && this.editor) { this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false); this.cachedValue = this.editor.getValue(); if (this.props.onEdit) { this.props.onEdit(this.cachedValue); } } }; /** * Bind handler to search input to count number of search results */ _bindSearchHandler = () => { const searchInput = document.querySelector('.CodeMirror-search-field'); if (searchInput) { searchInput.addEventListener('input', this._countSearchResults); } }; /** * Unbind handler to search input to count number of search results */ _unbindSearchHandler = () => { const searchInput = document.querySelector('.CodeMirror-search-field'); if (searchInput) { searchInput.removeEventListener('input', this._countSearchResults); } }; /** * Append search results count to search dialog */ _appendSearchResultsCount = () => { const dialog = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top'); if (dialog) { const searchResultsCount = document.createElement('span'); searchResultsCount.id = this.searchResultsCountElementId; dialog.appendChild(searchResultsCount); this._countSearchResults(); } }; /** * Count search results and update state */ _countSearchResults = () => { let count = 0; const searchInput = document.querySelector('.CodeMirror-search-field'); if (searchInput && searchInput.value.length > 0) { // Escape special characters in search input to prevent RegExp crashes. Fixes #3051 const text = new RegExp(escapeRegExp(searchInput.value), 'gi'); const matches = this.editor.getValue().match(text); count = matches ? matches.length : 0; } const searchResultsCountElement = document.querySelector(`#${this.searchResultsCountElementId}`); if (searchResultsCountElement) { searchResultsCountElement.innerText = `${count} results`; } }; }