UNPKG

@finos/legend-application-pure-ide

Version:
495 lines (492 loc) 29.3 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; /** * Copyright (c) 2020-present, Goldman Sachs * * 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 * * http://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, useRef, useState } from 'react'; import { observer } from 'mobx-react-lite'; import { editor as monacoEditorAPI, languages as monacoLanguagesAPI, } from 'monaco-editor'; import { useApplicationStore, useCommands } from '@finos/legend-application'; import { CODE_EDITOR_LANGUAGE, CODE_EDITOR_THEME, getInlineSnippetSuggestions, getParserElementSnippetSuggestions, getParserKeywordSuggestions, isTokenOneOf, PURE_GRAMMAR_TOKEN, getBaseCodeEditorOptions, moveCursorToPosition, PURE_CODE_EDITOR_WORD_SEPARATORS, } from '@finos/legend-code-editor'; import { clsx, Dialog, WordWrapIcon } from '@finos/legend-art'; import { usePureIDEStore } from '../PureIDEStoreProvider.js'; import { collectExtraInlineSnippetSuggestions, collectParserElementSnippetSuggestions, collectParserKeywordSuggestions, getArrowFunctionSuggestions, getAttributeSuggestions, getCastingClassSuggestions, getConstructorClassSuggestions, getCopyrightHeaderSuggestions, getIncompletePathSuggestions, getVariableSuggestions, } from '../../stores/PureFileEditorUtils.js'; import { guaranteeNonNullable, isNonNullable } from '@finos/legend-shared'; import { flowResult } from 'mobx'; import { FileCoordinate } from '../../server/models/File.js'; import { LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY } from '../../__lib__/LegendPureIDECommand.js'; import { GoToLinePrompt } from './GenericFileEditor.js'; const IDENTIFIER_PATTERN = /^\w[\w$]*$/; const RenameConceptPrompt = observer((props) => { const { renameConceptState } = props; const applicationStore = useApplicationStore(); const fileEditorState = renameConceptState.fileEditorState; const conceptName = renameConceptState.concept.pureName; const [value, setValue] = useState(conceptName); const inputRef = useRef(null); // validation const isValidValue = Boolean(value.match(IDENTIFIER_PATTERN)); const isSameValue = conceptName === value; const error = !isValidValue ? 'Invalid concept name' : undefined; // actions const closeModal = () => { flowResult(fileEditorState.setConceptToRenameState(undefined)).catch(applicationStore.alertUnhandledError); }; const onValueChange = (event) => setValue(event.target.value); const rename = (event) => { event.preventDefault(); if (isSameValue) { return; } fileEditorState .renameConcept(value) .catch(applicationStore.alertUnhandledError) .finally(() => closeModal()); }; const handleEnter = () => inputRef.current?.focus(); return (_jsx(Dialog, { open: true, onClose: closeModal, TransitionProps: { onEnter: handleEnter, }, classes: { container: 'command-modal__container' }, PaperProps: { classes: { root: 'command-modal__inner-container' } }, children: _jsxs("div", { className: "modal modal--dark command-modal", children: [_jsx("div", { className: "modal__title", children: "Rename concept" }), _jsxs("div", { className: "command-modal__content", children: [_jsx("form", { className: "command-modal__content__form", onSubmit: rename, children: _jsxs("div", { className: "input-group command-modal__content__input", children: [_jsx("input", { ref: inputRef, className: "input input--dark", onChange: onValueChange, value: value, spellCheck: false }), error && (_jsx("div", { className: "input-group__error-message", children: error }))] }) }), _jsx("button", { className: "command-modal__content__submit-btn btn--dark", disabled: Boolean(error), onClick: rename, children: "Rename" })] })] }) })); }); export const PureFileEditor = observer((props) => { const { editorState } = props; const definitionProviderDisposer = useRef(undefined); const hoverProviderDisposer = useRef(undefined); const pureConstructSuggestionProviderDisposer = useRef(undefined); const pureIdentifierSuggestionProviderDisposer = useRef(undefined); const textInputRef = useRef(null); const [editor, setEditor] = useState(); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const ideStore = usePureIDEStore(); const applicationStore = useApplicationStore(); useEffect(() => { if (!editor && textInputRef.current) { const element = textInputRef.current; const newEditor = monacoEditorAPI.create(element, { ...getBaseCodeEditorOptions(), language: CODE_EDITOR_LANGUAGE.PURE, theme: CODE_EDITOR_THEME.DEFAULT_DARK, wordSeparators: PURE_CODE_EDITOR_WORD_SEPARATORS, wordWrap: editorState.textEditorState.wrapText ? 'on' : 'off', readOnly: editorState.file.RO, contextmenu: true, }); // NOTE: (hacky) hijack the editor service so we can alternate the behavior of goto definition // since we cannot really override the editor service anymore, but must provide a full editor service // implementation in place, which is not practical for now // See https://github.com/microsoft/monaco-editor/issues/852 // See https://github.com/microsoft/monaco-editor/issues/2000#issuecomment-649622966 newEditor._codeEditorService.openCodeEditor = async () => { const currentPosition = newEditor.getPosition(); if (currentPosition) { flowResult(ideStore.executeNavigation(new FileCoordinate(editorState.filePath, currentPosition.lineNumber, currentPosition.column))).catch(applicationStore.alertUnhandledError); } }; // NOTE: with the way we create suggestion tokens, there's a problem // where for the definition coming from the same URI, the goto-definition // action will by default just go to the token, i.e. do nothing in our case // as such, we have to override `gotoLocation.alternativeDefinitionCommand` // in order for `editorService.openCodeEditor` to be called // See https://github.com/microsoft/vscode/issues/110060 // See https://github.com/microsoft/vscode/issues/107841 newEditor.updateOptions({ gotoLocation: { multiple: 'goto', multipleDefinitions: 'goto', alternativeDefinitionCommand: 'DUMMY', }, }); // NOTE: we need to find a way to remove some items in context-menu // but currently there's no API exposed by monaco-editor to do so // hence, we have to use this hack where we will hijack the mounted context-menu // and remove undesired DOM nodes // See https://github.com/microsoft/monaco-editor/issues/1567 // However, it's not enough to just do the DOM surgery in `onContextMenu` // since at this point, the context menu is not rendered yet, so we have to // make use of `useState` and `useEffect` to achieve this goal // as `useEffect` is called after DOM rendering occurs newEditor.onContextMenu(() => setIsContextMenuOpen(true)); newEditor.addAction({ id: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.FIND_USAGES, label: 'Find Usages', contextMenuGroupId: 'navigation', contextMenuOrder: 1000, run: function (_editor) { const currentPosition = _editor.getPosition(); if (currentPosition) { const coordinate = new FileCoordinate(editorState.filePath, currentPosition.lineNumber, currentPosition.column); editorState.findConceptUsages(coordinate); } }, }); newEditor.addAction({ id: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.REVEAL_CONCEPT_IN_TREE, label: 'Reveal Concept', contextMenuGroupId: 'navigation', contextMenuOrder: 1000, run: function (_editor) { const currentPosition = _editor.getPosition(); if (currentPosition) { ideStore .revealConceptInTree(new FileCoordinate(editorState.filePath, currentPosition.lineNumber, currentPosition.column)) .catch(applicationStore.alertUnhandledError); } }, }); newEditor.addAction({ id: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.RENAME_CONCEPT, label: 'Rename', contextMenuGroupId: '1_modification', contextMenuOrder: 1000, run: function (_editor) { const currentPosition = _editor.getPosition(); if (currentPosition) { const currentWord = editorState.textEditorState.model.getWordAtPosition(currentPosition); if (!currentWord) { return; } const coordinate = new FileCoordinate(editorState.filePath, currentPosition.lineNumber, currentPosition.column); flowResult(editorState.setConceptToRenameState(coordinate)).catch(ideStore.applicationStore.alertUnhandledError); } }, }); newEditor.addAction({ id: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.RENAME_CONCEPT, label: 'Run Test', contextMenuGroupId: 'navigation', contextMenuOrder: 1000, run: function (_editor) { const currentPosition = _editor.getPosition(); if (currentPosition) { const coordinate = new FileCoordinate(editorState.filePath, currentPosition.lineNumber, currentPosition.column); flowResult(editorState.runTest(coordinate)).catch(ideStore.applicationStore.alertUnhandledError); } }, }); newEditor.onDidChangeModelContent(() => { const currentVal = newEditor.getValue(); if (currentVal !== editorState.file.content) { // the assertion above is to ensure we don't accidentally clear error on initialization of the editor editorState.clearError(); // clear error on content change/typing } editorState.file.setContent(currentVal); }); // manual trigger to support cursor observability newEditor.onDidChangeCursorPosition(() => { editorState.textEditorState.notifyCursorObserver(); }); newEditor.onDidChangeCursorSelection(() => { editorState.textEditorState.notifyCursorObserver(); }); // Restore the editor model and view state newEditor.setModel(editorState.textEditorState.model); if (editorState.textEditorState.viewState) { newEditor.restoreViewState(editorState.textEditorState.viewState); } newEditor.focus(); // focus on the editor initially editorState.textEditorState.setEditor(newEditor); setEditor(newEditor); } }, [ideStore, applicationStore, editorState, editor]); if (editor) { definitionProviderDisposer.current?.dispose(); definitionProviderDisposer.current = monacoLanguagesAPI.registerDefinitionProvider(CODE_EDITOR_LANGUAGE.PURE, { provideDefinition: (model, position) => { // NOTE: there is a quirky problem with monaco-editor or our integration with it // where sometimes, hovering the mouse on the right half of the last character of a definition token // and then hitting Ctrl/Cmd key will not be trigger definition provider. We're not quite sure what // to do with that for the time being. const lineContent = model.getLineContent(position.lineNumber); const lineTokens = monacoEditorAPI.tokenize(lineContent, CODE_EDITOR_LANGUAGE.PURE)[0]; if (!lineTokens) { return []; } let currentToken = undefined; let currentTokenRange = undefined; for (let i = 1; i <= lineTokens.length; ++i) { // account for the fact that the last token // extends to the end of the line, and it could be a meaningful token const tokenOffset = i === lineTokens.length ? lineContent.length : guaranteeNonNullable(lineTokens[i]).offset; if (tokenOffset + 1 > position.column) { currentToken = guaranteeNonNullable(lineTokens[i - 1]); // since this is the selection of text from another file for peeking/preview the definition // We can't really do much here since we do goto-definition asynchronously, we will // show the token itself currentTokenRange = { startLineNumber: position.lineNumber, startColumn: currentToken.offset + 1, endLineNumber: position.lineNumber, endColumn: tokenOffset + 1, // NOTE: seems like this needs to be exclusive }; break; } } if (currentToken && currentTokenRange && // only allow goto definition for these tokens isTokenOneOf(currentToken.type, [ PURE_GRAMMAR_TOKEN.TYPE, PURE_GRAMMAR_TOKEN.VARIABLE, PURE_GRAMMAR_TOKEN.PROPERTY, PURE_GRAMMAR_TOKEN.PARAMETER, PURE_GRAMMAR_TOKEN.IDENTIFIER, ])) { return [ { uri: editorState.textEditorState.model.uri, range: currentTokenRange, }, ]; } return []; }, }); // hover hoverProviderDisposer.current?.dispose(); hoverProviderDisposer.current = monacoLanguagesAPI.registerHoverProvider(CODE_EDITOR_LANGUAGE.PURE, { provideHover: async (model, position) => { // NOTE: there is a quirky problem with monaco-editor or our integration with it // where sometimes, hovering the mouse on the right half of the last character of a definition token // and then hitting Ctrl/Cmd key will not be trigger definition provider. We're not quite sure what // to do with that for the time being. const lineContent = model.getLineContent(position.lineNumber); const lineTokens = monacoEditorAPI.tokenize(lineContent, CODE_EDITOR_LANGUAGE.PURE)[0]; if (!lineTokens) { return { contents: [] }; } let currentToken = undefined; let currentTokenRange = undefined; for (let i = 1; i <= lineTokens.length; ++i) { // here we have to account for the fact that the last token // extends to the end of the line, and it could be a meaningful token const tokenOffset = i === lineTokens.length ? lineContent.length : guaranteeNonNullable(lineTokens[i]).offset; if (tokenOffset + 1 > position.column) { currentToken = guaranteeNonNullable(lineTokens[i - 1]); currentTokenRange = { startLineNumber: position.lineNumber, startColumn: currentToken.offset + 1, endLineNumber: position.lineNumber, endColumn: tokenOffset + 1, // NOTE: seems like this needs to be exclusive }; break; } } if (currentToken && currentTokenRange && // only allow these tokens to show documentation isTokenOneOf(currentToken.type, [ PURE_GRAMMAR_TOKEN.TYPE, PURE_GRAMMAR_TOKEN.PROPERTY, PURE_GRAMMAR_TOKEN.PARAMETER, PURE_GRAMMAR_TOKEN.IDENTIFIER, ])) { const concept = await ideStore.getConceptInfo(new FileCoordinate(editorState.filePath, position.lineNumber, position.column), { silent: true, }); if (concept) { return { contents: [ concept.doc ? { value: concept.doc, } : undefined, concept.grammarChars ? { value: `**Syntax:** \`${concept.grammarChars}\``, } : undefined, concept.grammarDoc ? { value: `**Usage:** ${concept.grammarDoc}`, } : undefined, concept.signature ? { value: `**Signature:** \`\`\`${concept.signature}\`\`\``, } : undefined, ].filter(isNonNullable), range: currentTokenRange, }; } } return { contents: [] }; }, }); // suggestions pureConstructSuggestionProviderDisposer.current?.dispose(); pureConstructSuggestionProviderDisposer.current = monacoLanguagesAPI.registerCompletionItemProvider(CODE_EDITOR_LANGUAGE.PURE, { triggerCharacters: ['/', '#', ':', '>', '.', '@', '^', '$'], provideCompletionItems: async (model, position, context) => { let suggestions = []; if (context.triggerKind === monacoLanguagesAPI.CompletionTriggerKind.TriggerCharacter) { switch (context.triggerCharacter) { // special commands: copyright header, etc. case '/': { suggestions = suggestions.concat(getCopyrightHeaderSuggestions(position, model)); break; } // parser section header case '#': { suggestions = suggestions.concat(getParserKeywordSuggestions(position, model, collectParserKeywordSuggestions())); break; } // incomplete path (::) case ':': { suggestions = suggestions.concat(await getIncompletePathSuggestions(position, model, ideStore)); break; } // arrow function (->) // NOTE: we can't really do type matching on document being edited // since in order to get the semantics of these token, we need the // currently typed text to compile, but that's not always possible // especially mid typing an expression sequence case '>': { suggestions = suggestions.concat(await getArrowFunctionSuggestions(position, model, ideStore)); break; } // calling property, attribute, enum value, tag, stereotype, etc. // NOTE: we can't really do type matching on document being edited // since in order to get the semantics of these token, we need the // currently typed text to compile, but that's not always possible // especially mid typing an expression sequence case '.': { suggestions = suggestions.concat(await getAttributeSuggestions(position, model, ideStore)); break; } // constructing a new class instance case '^': { suggestions = suggestions.concat(await getConstructorClassSuggestions(position, model, ideStore)); break; } // casting to a class case '@': { suggestions = suggestions.concat(await getCastingClassSuggestions(position, model, ideStore)); break; } // variables case '$': { suggestions = suggestions.concat(await getVariableSuggestions(position, model, editorState.filePath, ideStore)); break; } default: break; } } return { suggestions }; }, }); pureIdentifierSuggestionProviderDisposer.current?.dispose(); pureIdentifierSuggestionProviderDisposer.current = monacoLanguagesAPI.registerCompletionItemProvider(CODE_EDITOR_LANGUAGE.PURE, { triggerCharacters: [], provideCompletionItems: async (model, position, context) => { let suggestions = []; if (context.triggerKind === monacoLanguagesAPI.CompletionTriggerKind.Invoke) { // copyright header suggestions = suggestions.concat(getCopyrightHeaderSuggestions(position, model)); // suggestions for parser element snippets suggestions = suggestions.concat(getParserElementSnippetSuggestions(position, model, (parserName) => collectParserElementSnippetSuggestions(parserName))); // code snippet suggestions suggestions = suggestions.concat(getInlineSnippetSuggestions(position, model, collectExtraInlineSnippetSuggestions())); // TODO: support contextual suggestions with just the identifier, i.e. auto-complete // which Pure IDE server has not supported at the moment } return { suggestions }; }, }); } useCommands(editorState); useEffect(() => { if (isContextMenuOpen) { const shadowRoot = editor ?.getDomNode() ?.querySelector('.shadow-root-host')?.shadowRoot; if (shadowRoot) { const shadowRootStyleSheet = document.createElement('style'); shadowRootStyleSheet.innerHTML = ` .monaco-scrollable-element { background: var(--color-dark-grey-100) !important; color: var(--color-light-grey-400); border-radius: 0.2rem !important; } .monaco-action-bar.vertical { padding: 0.5rem 0; } .action-label.separator { border-bottom: 1px solid var(--color-dark-grey-300) !important; border-bottom-color: var(--color-dark-grey-300) !important; } .action-item.focused:hover > a, .action-item.focused > a { background: var(--color-light-blue-450) !important; } `; shadowRoot.appendChild(shadowRootStyleSheet); // NOTE: we have tried to remove the DOM node, but since the context-menu height is computed // this causes a problem with the UI, so we just can disable the item until an official API // is supported and we can removed this hack // See https://github.com/microsoft/monaco-editor/issues/1567 // See https://github.com/microsoft/monaco-editor/issues/1280 const MENU_ITEMS_TO_DISABLE = ['Peek']; Array.from(shadowRoot.querySelectorAll('.action-label')) .filter((element) => MENU_ITEMS_TO_DISABLE.includes(element.innerHTML)) .forEach((element) => { const menuItem = element.parentElement?.parentElement; // NOTE: we must not remove this item since vscode would // still keep it in memory and calculate context menu height wrong if (menuItem) { menuItem.classList.add('disabled'); menuItem.style.opacity = '0.3'; menuItem.style.pointerEvents = 'none'; } }); setIsContextMenuOpen(false); } } }, [editor, isContextMenuOpen]); useEffect(() => { if (editor) { if (editorState.textEditorState.forcedCursorPosition) { moveCursorToPosition(editor, editorState.textEditorState.forcedCursorPosition); editorState.textEditorState.setForcedCursorPosition(undefined); } } }, [editor, editorState, editorState.textEditorState.forcedCursorPosition]); // clean up useEffect(() => () => { if (editor) { // persist editor view state (cursor, scroll, etc.) to restore on re-open editorState.textEditorState.setViewState(editor.saveViewState() ?? undefined); editor.dispose(); definitionProviderDisposer.current?.dispose(); hoverProviderDisposer.current?.dispose(); pureConstructSuggestionProviderDisposer.current?.dispose(); pureIdentifierSuggestionProviderDisposer.current?.dispose(); } }, [editorState, editor]); return (_jsxs("div", { className: "panel editor-group file-editor", children: [_jsx("div", { className: "panel__header file-editor__header", children: _jsxs("div", { className: "file-editor__header__actions", children: [_jsx("button", { className: clsx('file-editor__header__action', { 'file-editor__header__action--active': editorState.textEditorState.wrapText, }), tabIndex: -1, onClick: () => editorState.textEditorState.setWrapText(!editorState.textEditorState.wrapText), title: "Toggle Text Wrap", children: _jsx(WordWrapIcon, { className: "file-editor__icon--text-wrap" }) }), editorState.renameConceptState && (_jsx(RenameConceptPrompt, { renameConceptState: editorState.renameConceptState })), editorState.showGoToLinePrompt && (_jsx(GoToLinePrompt, { fileEditorState: editorState }))] }) }), _jsx("div", { className: "panel__content file-editor__content", children: _jsx("div", { className: "code-editor__container", children: _jsx("div", { className: "code-editor__body", ref: textInputRef }) }) })] })); }); //# sourceMappingURL=PureFileEditor.js.map