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  }