go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/web/rpcexplorer/src/components/request_editor.tsx (about) 1 // Copyright 2023 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://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, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import { 16 useEffect, useRef, forwardRef, useImperativeHandle, ForwardedRef, 17 } from 'react'; 18 19 import Box from '@mui/material/Box'; 20 21 import { Ace, Range } from 'ace-builds'; 22 import * as ace from '../ace'; 23 import * as prpc from '../data/prpc'; 24 import * as autocomplete from '../data/autocomplete'; 25 26 27 export interface Props { 28 requestType: prpc.Message | undefined; 29 defaultValue: string; 30 readOnly: boolean; 31 onInvokeMethod: () => void; 32 } 33 34 export interface RequestEditorRef { 35 prepareRequest(): object; 36 } 37 38 export const RequestEditor = forwardRef(( 39 props: Props, ref: ForwardedRef<RequestEditorRef>) => { 40 const editorRef = useRef<ace.AceEditor>(null); 41 42 // Configure the ACE editor on initial load. 43 useEffect(() => { 44 if (!editorRef.current) { 45 return; 46 } 47 const editor = editorRef.current.editor; 48 49 // Send the request when hitting Shift+Enter. 50 editor.commands.addCommand({ 51 name: 'execute-request', 52 bindKey: { win: 'Shift-Enter', mac: 'Shift-Enter' }, 53 exec: props.onInvokeMethod, 54 }); 55 56 // Auto-complete JSON property names based on the protobuf schema. 57 if (props.requestType) { 58 editor.completers = [new Completer(props.requestType)]; 59 if (editor.getValue() == '{}') { 60 editor.focus(); 61 editor.moveCursorTo(0, 1); 62 } 63 } 64 }, [props.onInvokeMethod, props.requestType]); 65 66 // Expose some methods on the ref assigned to RequestEditor. We do it this 67 // way to avoid using AceEditor in a "managed" mode, since it can get slow 68 // with large requests. 69 useImperativeHandle(ref, () => ({ 70 prepareRequest: () => { 71 if (!editorRef.current) { 72 throw new Error('Uninitialized RequestEditor'); 73 } 74 const editor = editorRef.current.editor; 75 // Default to an empty request. 76 let requestBody = editor.getValue().trim(); 77 if (!requestBody) { 78 requestBody = '{}'; 79 } 80 // The request must be a valid JSON. Verify locally by parsing. 81 return JSON.parse(requestBody); 82 }, 83 })); 84 85 return ( 86 <Box 87 component='div' 88 sx={{ border: '1px solid #e0e0e0', borderRadius: '2px' }} 89 > 90 <ace.AceEditor 91 ref={editorRef} 92 mode={ace.mode} 93 theme={ace.theme} 94 name='request-editor' 95 width='100%' 96 height='200px' 97 defaultValue={props.defaultValue} 98 setOptions={{ 99 readOnly: props.readOnly, 100 useWorker: false, 101 tabSize: 2, 102 dragEnabled: false, 103 showPrintMargin: false, 104 enableBasicAutocompletion: true, 105 enableLiveAutocompletion: true, 106 }} 107 /> 108 </Box> 109 ); 110 }); 111 112 // Eslint wants this. 113 RequestEditor.displayName = 'RequestEditor'; 114 115 116 // JSONType => tokens surrounding values of that type in JSON syntax. 117 const jsonWrappers = { 118 [prpc.JSONType.Object]: ['{', '}'], 119 [prpc.JSONType.List]: ['[', ']'], 120 [prpc.JSONType.String]: ['"', '"'], 121 [prpc.JSONType.Scalar]: ['', ''], 122 }; 123 124 125 // Auto-completes message field names and surrounding syntax. 126 // 127 // Returns completions for every field in the message. 128 const fieldCompletion = (comp: autocomplete.Completion): Ace.Completion[] => { 129 return comp.fields.map((field) => { 130 // If the value is e.g. a list of strings, we auto-complete `["` and `"]`. 131 let leftWrappers = ''; 132 let rightWrappers = ''; 133 const typ = field.jsonType; 134 leftWrappers += jsonWrappers[typ][0]; 135 if (typ == prpc.JSONType.List) { 136 const elem = field.jsonElementType; 137 leftWrappers += jsonWrappers[elem][0]; 138 rightWrappers += jsonWrappers[elem][1]; 139 } 140 rightWrappers += jsonWrappers[typ][1]; 141 return { 142 snippet: `"${field.jsonName}": ${leftWrappers}\${0}${rightWrappers}`, 143 caption: field.jsonName, 144 meta: field.type, 145 docText: field.doc, 146 }; 147 }); 148 }; 149 150 151 // Auto-completes message field names with no extra syntax. 152 // 153 // Returns completions only for fields starting with the given prefix. 154 const fieldCompletionForPrefix = ( 155 comp: autocomplete.Completion, 156 pfx: string): Ace.Completion[] => { 157 return comp.fields. 158 filter((field) => field.jsonName.startsWith(pfx)). 159 map((field) => { 160 return { 161 value: field.jsonName, 162 caption: field.jsonName, 163 meta: field.type, 164 docText: field.doc, 165 }; 166 }); 167 }; 168 169 170 // Auto-completes field values with surrounding syntax. 171 // 172 // Returns completions for all possible values of the field if they can be 173 // enumerated. 174 const valueCompletion = ( 175 comp: autocomplete.Completion, 176 field: string): Ace.Completion[] => { 177 return comp.values(field).map((val) => { 178 return { 179 value: `"${val.value}"`, 180 caption: val.value, 181 docText: val.doc, 182 }; 183 }); 184 }; 185 186 187 // Auto-completes field values with no extra syntax. 188 // 189 // Returns completions only for values starting with the given prefix. 190 const valueCompletionForPrefix = ( 191 comp: autocomplete.Completion, 192 field: string, 193 pfx: string): Ace.Completion[] => { 194 return comp.values(field). 195 filter((val) => val.value.startsWith(pfx)). 196 map((val) => { 197 return { 198 value: val.value, 199 caption: val.value, 200 docText: val.doc, 201 }; 202 }); 203 }; 204 205 206 // Completer knows how to auto-complete JSON fields based on a protobuf schema. 207 class Completer implements Ace.Completer { 208 readonly id: string = 'prpc-request-completer'; 209 readonly triggerCharacters: string[] = ['{', '"', ' ']; 210 211 constructor(readonly requestType: prpc.Message) {} 212 213 getCompletions( 214 _editor: Ace.Editor, 215 session: Ace.EditSession, 216 pos: Ace.Point, 217 _prefix: string, 218 callback: Ace.CompleterCallback) { 219 // Get all the text before the current position. It is some JSON fragment, 220 // e.g. `{"foo": {"bar": [{"ba`. Extract the current JSON path and, perhaps, 221 // a partially typed key. 222 const ctx = autocomplete.getContext( 223 session.getTextRange(new Range(0, 0, pos.row, pos.column)), 224 ); 225 if (ctx == undefined) { 226 return; 227 } 228 229 // The last path element is what is being edited now. Extract proto message 230 // key and value from there (they may be incomplete). 231 const last = ctx.path.pop(); 232 if (!last || last.kind == 'root') { 233 return; 234 } 235 const field = last.key?.val || ''; 236 const value = last.value?.val || ''; 237 238 // All preceding path elements define the parent object of the currently 239 // edited field. 240 const comp = autocomplete.completionForPath(this.requestType, ctx.path); 241 if (comp == undefined) { 242 return; 243 } 244 245 // Figure out what syntactic element needs to be auto-completed. 246 switch (ctx.state) { 247 case autocomplete.State.BeforeKey: 248 // E.g. `{`. List snippets with all available fields. 249 callback(null, fieldCompletion(comp)); 250 break; 251 case autocomplete.State.InsideKey: 252 // E.g. `{ "zz`. List only fields matching the typed prefix. 253 callback(null, fieldCompletionForPrefix(comp, field)); 254 break; 255 case autocomplete.State.AfterKey: 256 // E.g. `{ "zzz"`. Do nothing. Popping auto-complete box for ':' is 257 // silly and inserting it unconditionally via ACE API appears to be 258 // buggy. Perhaps this can be improved by using "live auto completion" 259 // feature. 260 break; 261 case autocomplete.State.BeforeValue: 262 // E.g. `{ "zzz": ` or `[`. List snippets with all possible values. 263 callback(null, valueCompletion(comp, field)); 264 break; 265 case autocomplete.State.InsideValue: 266 // E.g. `{ "zzz": "xx` or `["xx`. List only values matching the prefix. 267 callback(null, valueCompletionForPrefix(comp, field, value)); 268 break; 269 case autocomplete.State.AfterValue: 270 // E.g. `{ "zzz": "xxx"` or `["xxx"`. Do nothing. We don't know if this 271 // is the end of the message or the user wants to type another field or 272 // value. 273 break; 274 } 275 } 276 }