xref: /aosp_15_r20/external/pigweed/pw_web/log-viewer/src/components/repl/code-editor.ts (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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>&#xe037;</md-icon>
231      </md-icon-button>
232    </div>`;
233  }
234}
235