@finos/legend-application-pure-ide
Version:
Legend Pure IDE application core
500 lines (497 loc) • 29.4 kB
JavaScript
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, classes: { container: 'command-modal__container' }, slotProps: {
transition: {
onEnter: handleEnter,
},
paper: {
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