1// Copyright 2024 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 { LitElement, html } from 'lit'; 16import { customElement, state } from 'lit/decorators.js'; 17import { basicSetup, EditorView } from 'codemirror'; 18import { keymap } from '@codemirror/view'; 19import { python } from '@codemirror/lang-python'; 20import { CompletionContext, autocompletion } from '@codemirror/autocomplete'; 21import { EditorState, Prec } from '@codemirror/state'; 22import { oneDark } from '@codemirror/theme-one-dark'; 23import { AutocompleteSuggestion } from '../../repl-kernel'; 24import { styles } from './code-editor.styles'; 25import { themeDark } from '../../themes/dark'; 26import { themeLight } from '../../themes/light'; 27import { insertNewlineAndIndent } from '@codemirror/commands'; 28 29type AutocompleteHandler = ( 30 code: string, 31 cursor: number, 32) => Promise<AutocompleteSuggestion[]>; 33type EvalHandler = (code: string) => Promise<void>; 34 35const LOCALSTORAGE_KEY = 'replHistory'; 36@customElement('code-editor') 37export class CodeEditor extends LitElement { 38 private view: EditorView; 39 private history: string[] = this.getHistory(); 40 private currentHistoryItem = -1; 41 static styles = [themeDark, themeLight, styles]; 42 43 @state() 44 _enableRun = true; 45 46 constructor( 47 private initialCode: string, 48 private getAutocompleteSuggestions: AutocompleteHandler, 49 private onEvalHandler: EvalHandler, 50 ) { 51 super(); 52 } 53 54 firstUpdated() { 55 const editorContainer = this.shadowRoot!.querySelector('.editor')!; 56 const startState = EditorState.create({ 57 doc: this.initialCode, 58 extensions: [ 59 basicSetup, 60 python(), 61 autocompletion({ 62 override: [this.myCompletions.bind(this)], 63 }), 64 oneDark, 65 Prec.highest( 66 keymap.of([ 67 { 68 key: 'Enter', 69 shift: (view) => { 70 insertNewlineAndIndent(view); 71 return true; 72 }, 73 run: () => { 74 // Shift key is not pressed, evaluate 75 this.handleEval(); 76 return true; 77 }, 78 }, 79 { 80 key: 'ArrowUp', 81 run: (view) => { 82 if (this.history.length === 0 || this.currentHistoryItem === 0) 83 return true; 84 if (this.currentHistoryItem === -1) 85 this.currentHistoryItem = this.history.length; 86 const state = view.state; 87 const selection = state.selection.main; 88 89 // Check if cursor is at the beginning of line 1 90 if ( 91 state.doc.lines > 1 && 92 selection.from !== state.doc.line(1).from 93 ) { 94 return false; 95 } 96 this.currentHistoryItem -= 1; 97 view.dispatch({ 98 changes: { 99 from: 0, 100 to: this.view.state.doc.length, 101 insert: this.history[this.currentHistoryItem], 102 }, 103 selection: { 104 anchor: this.history[this.currentHistoryItem].length, 105 head: this.history[this.currentHistoryItem].length, 106 }, 107 }); 108 return true; 109 }, 110 }, 111 { 112 key: 'ArrowDown', 113 run: (view) => { 114 if (this.history.length === 0 || this.currentHistoryItem === -1) 115 return true; 116 const state = view.state; 117 const selection = state.selection.main; 118 119 // Check if cursor is at the end of the last line 120 if ( 121 state.doc.lines > 1 && 122 selection.to !== state.doc.line(state.doc.lines).to 123 ) { 124 return false; 125 } 126 this.currentHistoryItem += 1; 127 if (this.currentHistoryItem === this.history.length) { 128 this.currentHistoryItem = -1; 129 view.dispatch({ 130 changes: { 131 from: 0, 132 to: this.view.state.doc.length, 133 insert: '', 134 }, 135 }); 136 return true; 137 } 138 139 view.dispatch({ 140 changes: { 141 from: 0, 142 to: this.view.state.doc.length, 143 insert: this.history[this.currentHistoryItem], 144 }, 145 selection: { 146 anchor: this.history[this.currentHistoryItem].length, 147 head: this.history[this.currentHistoryItem].length, 148 }, 149 }); 150 151 return true; 152 }, 153 }, 154 ]), 155 ), 156 EditorView.updateListener.of((update) => { 157 if (update.docChanged) { 158 const isEmpty = this.view.state.doc.length === 0; 159 if (isEmpty) { 160 this._enableRun = true; 161 this.currentHistoryItem = -1; 162 } else { 163 this._enableRun = false; 164 } 165 } 166 }), 167 ], 168 }); 169 170 this.view = new EditorView({ 171 state: startState, 172 parent: editorContainer, 173 }); 174 } 175 176 private handleEval() { 177 const code = this.view.state.doc.toString().trim(); 178 if (!code) return; 179 this.onEvalHandler(code); 180 this.addToHistory(code); 181 this.view.dispatch({ 182 changes: { from: 0, to: this.view.state.doc.length, insert: '' }, 183 }); 184 this.currentHistoryItem = -1; 185 } 186 187 private getHistory(): string[] { 188 let historyStore = []; 189 if (localStorage.getItem(LOCALSTORAGE_KEY)) { 190 try { 191 historyStore = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY)!); 192 } catch (e) { 193 console.error('Repl history failed to parse.'); 194 } 195 } 196 return historyStore; 197 } 198 199 private addToHistory(command: string) { 200 this.history.push(command); 201 const historyStore = this.getHistory(); 202 historyStore.push(command); 203 localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(historyStore)); 204 } 205 206 async myCompletions(context: CompletionContext) { 207 const word = context.matchBefore(/\w*/); 208 const cursor = context.pos; 209 const code = context.state.doc.toString(); 210 211 try { 212 const suggestions = await this.getAutocompleteSuggestions(code, cursor)!; 213 return { 214 from: word?.from || 0, 215 options: suggestions.map((suggestion) => ({ 216 label: suggestion.text, 217 type: suggestion.type || 'text', 218 })), 219 }; 220 } catch (error) { 221 console.error('Error fetching autocomplete suggestions:', error); 222 return null; 223 } 224 } 225 226 render() { 227 return html` <div class="editor-and-run"> 228 <div class="editor"></div> 229 <md-icon-button ?disabled=${this._enableRun} @click=${this.handleEval}> 230 <md-icon></md-icon> 231 </md-icon-button> 232 </div>`; 233 } 234} 235