1// Copyright 2022 The Pigweed Authors 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); you may not 4// use this file except in compliance with the License. You may obtain a copy of 5// the License at 6// 7// https://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12// License for the specific language governing permissions and limitations under 13// the License. 14 15import {useEffect, useState} from "react"; 16import {Device} from "pigweedjs"; 17import {EditorView} from "codemirror" 18import {basicSetup} from "./basicSetup"; 19import {javascript, javascriptLanguage} from "@codemirror/lang-javascript" 20import {placeholder} from "@codemirror/view"; 21import {oneDark} from "@codemirror/theme-one-dark"; 22import {keymap} from "@codemirror/view" 23import {Extension} from "@codemirror/state" 24import {completeFromGlobalScope} from "./autocomplete"; 25import LocalStorageArray from "./localStorageArray"; 26import "xterm/css/xterm.css"; 27import styles from "../../styles/repl.module.css"; 28 29const isSSR = () => typeof window === 'undefined'; 30 31interface ReplProps { 32 device: Device | undefined 33} 34 35const globalJavaScriptCompletions = javascriptLanguage.data.of({ 36 autocomplete: completeFromGlobalScope 37}) 38 39const createTerminal = async (container: HTMLElement) => { 40 const {Terminal} = await import('xterm'); 41 const {FitAddon} = await import('xterm-addon-fit'); 42 const terminal = new Terminal({ 43 // cursorBlink: true, 44 theme: { 45 background: '#2c313a' 46 } 47 }); 48 terminal.open(container); 49 50 const fitAddon = new FitAddon(); 51 terminal.loadAddon(fitAddon); 52 fitAddon.fit(); 53 return terminal; 54}; 55 56const createPlaceholderText = () => { 57 var div = document.createElement('div'); 58 div.innerHTML = `Type code and hit Enter to run. See <b>[?]</b> for more info.` 59 return div; 60} 61 62const createEditor = (container: HTMLElement, enterKeyMap: Extension) => { 63 let view = new EditorView({ 64 extensions: [basicSetup, javascript(), placeholder(createPlaceholderText()), oneDark, globalJavaScriptCompletions, enterKeyMap], 65 parent: container, 66 }); 67 return view; 68} 69 70let currentCommandHistoryIndex = -1; 71let historyStorage: LocalStorageArray; 72if (typeof window !== 'undefined') { 73 historyStorage = new LocalStorageArray(); 74} 75 76export default function Repl({device}: ReplProps) { 77 const [terminal, setTerminal] = useState<any>(null); 78 const [codeEditor, setCodeEditor] = useState<EditorView | null>(null); 79 80 useEffect(() => { 81 let cleanupFns: {(): void; (): void;}[] = []; 82 if (!terminal && !isSSR() && device) { 83 const futureTerm = createTerminal(document.querySelector('#repl-log-container')!); 84 futureTerm.then(async (term) => { 85 cleanupFns.push(() => { 86 term.dispose(); 87 setTerminal(null); 88 }); 89 setTerminal(term); 90 }); 91 92 return () => { 93 cleanupFns.forEach(fn => fn()); 94 } 95 } 96 else if (terminal && !device) { 97 terminal.dispose(); 98 setTerminal(null); 99 } 100 }, [device]); 101 102 useEffect(() => { 103 if (!terminal) return; 104 const enterKeyMap = { 105 key: "Enter", 106 run(view: EditorView) { 107 if (view.state.doc.toString().trim().length === 0) return true; 108 try { 109 // To run eval() in global scope, we do (1, eval) here. 110 const cmdOutput = (1, eval)(view.state.doc.toString()); 111 // Check if eval returned a promise 112 if (typeof cmdOutput === "object" && cmdOutput.then !== undefined) { 113 cmdOutput 114 .then((result: any) => { 115 terminal.write(`Promise { ${result} }\r\n`); 116 }) 117 .catch((e: any) => { 118 if (e instanceof Error) { 119 terminal.write(`\x1b[31;1mUncaught (in promise) Error: ${e.message}\x1b[0m\r\n`) 120 } 121 else { 122 terminal.write(`\x1b[31;1mUncaught (in promise) ${e}\x1b[0m\r\n`) 123 } 124 }); 125 } 126 else { 127 terminal.write(cmdOutput + "\r\n"); 128 } 129 } 130 catch (e) { 131 if (e instanceof Error) terminal.write(`\x1b[31;1m${e.message}\x1b[0m\r\n`) 132 } 133 134 currentCommandHistoryIndex = -1; 135 historyStorage.unshift(view.state.doc.toString()); 136 137 // Clear text editor 138 const transaction = view.state.update({changes: {from: 0, to: view.state.doc.length, insert: ""}}); 139 view.dispatch(transaction); 140 return true; 141 } 142 }; 143 144 const upKeyMap = { 145 key: "ArrowUp", 146 run(view: EditorView) { 147 currentCommandHistoryIndex++; 148 if (historyStorage.data[currentCommandHistoryIndex]) { 149 // set text editor 150 const transaction = view.state.update({changes: {from: 0, to: view.state.doc.length, insert: historyStorage.data[currentCommandHistoryIndex]}}); 151 view.dispatch(transaction); 152 } 153 else { 154 currentCommandHistoryIndex = historyStorage.data.length - 1; 155 } 156 return true; 157 } 158 }; 159 160 const downKeyMap = { 161 key: "ArrowDown", 162 run(view: EditorView) { 163 currentCommandHistoryIndex--; 164 if (currentCommandHistoryIndex <= -1) { 165 currentCommandHistoryIndex = -1; 166 const transaction = view.state.update({changes: {from: 0, to: view.state.doc.length, insert: ""}}); 167 view.dispatch(transaction); 168 } 169 else if (historyStorage.data[currentCommandHistoryIndex]) { 170 // set text editor 171 const transaction = view.state.update({changes: {from: 0, to: view.state.doc.length, insert: historyStorage.data[currentCommandHistoryIndex]}}); 172 view.dispatch(transaction); 173 } 174 return true; 175 } 176 }; 177 178 const keymaps = keymap.of([enterKeyMap, upKeyMap, downKeyMap]); 179 let view = createEditor(document.querySelector('#repl-editor-container')!, keymaps); 180 return () => view.destroy(); 181 }, [terminal]); 182 183 return ( 184 <div className={styles.container}> 185 <div id="repl-log-container" className={styles.logs}></div> 186 <div className={styles.replWithCaret}> 187 <div> 188 <div className={styles.tooltip}>? 189 <span className={styles.tooltiptext}> 190 This REPL runs JavaScript. 191 You can navigate previous commands using <span>Up</span> and <span>Down</span> arrow keys. 192 <br /><br /> 193 Call device RPCs using <span>device.rpcs.*</span> API. 194 </span> 195 </div> 196 <span className={styles.caret}>{`> `}</span> 197 </div> 198 <div id="repl-editor-container" className={styles.editor}></div> 199 </div> 200 </div> 201 ) 202} 203