blob: a48e8cd57140dfbf11a07ec9a39ebd48a11e035d [file] [log] [blame]
// Copyright 2022 The Pigweed Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy of
// the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations under
// the License.
import {useEffect, useState} from "react";
import {Device} from "pigweedjs";
import {EditorView} from "codemirror"
import {basicSetup} from "./basicSetup";
import {javascript, javascriptLanguage} from "@codemirror/lang-javascript"
import {placeholder} from "@codemirror/view";
import {oneDark} from "@codemirror/theme-one-dark";
import {keymap} from "@codemirror/view"
import {Extension} from "@codemirror/state"
import {completeFromGlobalScope} from "./autocomplete";
import LocalStorageArray from "./localStorageArray";
import "xterm/css/xterm.css";
import styles from "../../styles/repl.module.css";
const isSSR = () => typeof window === 'undefined';
interface ReplProps {
device: Device | undefined
}
const globalJavaScriptCompletions = javascriptLanguage.data.of({
autocomplete: completeFromGlobalScope
})
const createTerminal = async (container: HTMLElement) => {
const {Terminal} = await import('xterm');
const {FitAddon} = await import('xterm-addon-fit');
const terminal = new Terminal({
// cursorBlink: true,
theme: {
background: '#2c313a'
}
});
terminal.open(container);
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
fitAddon.fit();
return terminal;
};
const createPlaceholderText = () => {
var div = document.createElement('div');
div.innerHTML = `Type code and hit Enter to run. See <b>[?]</b> for more info.`
return div;
}
const createEditor = (container: HTMLElement, enterKeyMap: Extension) => {
let view = new EditorView({
extensions: [basicSetup, javascript(), placeholder(createPlaceholderText()), oneDark, globalJavaScriptCompletions, enterKeyMap],
parent: container,
});
return view;
}
let currentCommandHistoryIndex = -1;
let historyStorage: LocalStorageArray;
if (typeof window !== 'undefined') {
historyStorage = new LocalStorageArray();
}
export default function Repl({device}: ReplProps) {
const [terminal, setTerminal] = useState<any>(null);
const [codeEditor, setCodeEditor] = useState<EditorView | null>(null);
useEffect(() => {
let cleanupFns: {(): void; (): void;}[] = [];
if (!terminal && !isSSR() && device) {
const futureTerm = createTerminal(document.querySelector('#repl-log-container')!);
futureTerm.then(async (term) => {
cleanupFns.push(() => {
term.dispose();
setTerminal(null);
});
setTerminal(term);
});
return () => {
cleanupFns.forEach(fn => fn());
}
}
else if (terminal && !device) {
terminal.dispose();
setTerminal(null);
}
}, [device]);
useEffect(() => {
if (!terminal) return;
const enterKeyMap = {
key: "Enter",
run(view: EditorView) {
if (view.state.doc.toString().trim().length === 0) return true;
try {
// To run eval() in global scope, we do (1, eval) here.
const cmdOutput = (1, eval)(view.state.doc.toString());
// Check if eval returned a promise
if (typeof cmdOutput === "object" && cmdOutput.then !== undefined) {
cmdOutput
.then((result: any) => {
terminal.write(`Promise { ${result} }\r\n`);
})
.catch((e: any) => {
if (e instanceof Error) {
terminal.write(`\x1b[31;1mUncaught (in promise) Error: ${e.message}\x1b[0m\r\n`)
}
else {
terminal.write(`\x1b[31;1mUncaught (in promise) ${e}\x1b[0m\r\n`)
}
});
}
else {
terminal.write(cmdOutput + "\r\n");
}
}
catch (e) {
if (e instanceof Error) terminal.write(`\x1b[31;1m${e.message}\x1b[0m\r\n`)
}
currentCommandHistoryIndex = -1;
historyStorage.unshift(view.state.doc.toString());
// Clear text editor
const transaction = view.state.update({changes: {from: 0, to: view.state.doc.length, insert: ""}});
view.dispatch(transaction);
return true;
}
};
const upKeyMap = {
key: "ArrowUp",
run(view: EditorView) {
currentCommandHistoryIndex++;
if (historyStorage.data[currentCommandHistoryIndex]) {
// set text editor
const transaction = view.state.update({changes: {from: 0, to: view.state.doc.length, insert: historyStorage.data[currentCommandHistoryIndex]}});
view.dispatch(transaction);
}
else {
currentCommandHistoryIndex = historyStorage.data.length - 1;
}
return true;
}
};
const downKeyMap = {
key: "ArrowDown",
run(view: EditorView) {
currentCommandHistoryIndex--;
if (currentCommandHistoryIndex <= -1) {
currentCommandHistoryIndex = -1;
const transaction = view.state.update({changes: {from: 0, to: view.state.doc.length, insert: ""}});
view.dispatch(transaction);
}
else if (historyStorage.data[currentCommandHistoryIndex]) {
// set text editor
const transaction = view.state.update({changes: {from: 0, to: view.state.doc.length, insert: historyStorage.data[currentCommandHistoryIndex]}});
view.dispatch(transaction);
}
return true;
}
};
const keymaps = keymap.of([enterKeyMap, upKeyMap, downKeyMap]);
let view = createEditor(document.querySelector('#repl-editor-container')!, keymaps);
return () => view.destroy();
}, [terminal]);
return (
<div className={styles.container}>
<div id="repl-log-container" className={styles.logs}></div>
<div className={styles.replWithCaret}>
<div>
<div className={styles.tooltip}>?
<span className={styles.tooltiptext}>
This REPL runs JavaScript.
You can navigate previous commands using <span>Up</span> and <span>Down</span> arrow keys.
<br /><br />
Call device RPCs using <span>device.rpcs.*</span> API.
</span>
</div>
<span className={styles.caret}>{`> `}</span>
</div>
<div id="repl-editor-container" className={styles.editor}></div>
</div>
</div>
)
}