UNPKG

@finos/legend-application-studio

Version:
462 lines 29.7 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 { useState, useRef, useEffect, useMemo } from 'react'; import { observer } from 'mobx-react-lite'; import { editor as monacoEditorAPI, languages as monacoLanguagesAPI, Range, } from 'monaco-editor'; import { DEFAULT_TAB_SIZE, useApplicationStore, } from '@finos/legend-application'; import { ENTITY_CHANGE_CONFLICT_EDITOR_VIEW_MODE, } from '../../../../stores/editor/editor-state/entity-diff-editor-state/EntityChangeConflictEditorState.js'; import { IllegalStateError, shallowStringify, debounce, isNonNullable, hashObject, } from '@finos/legend-shared'; import { clsx, CustomSelectorInput, CompareIcon, ArrowDownIcon, ArrowUpIcon, } from '@finos/legend-art'; import { getBaseCodeEditorOptions, moveCursorToPosition, setErrorMarkers, resetLineNumberGutterWidth, getCodeEditorValue, normalizeLineEnding, clearMarkers, CODE_EDITOR_THEME, CODE_EDITOR_LANGUAGE, } from '@finos/legend-code-editor'; import { CodeDiffView, disposeCodeEditor, } from '@finos/legend-lego/code-editor'; import { getPrettyLabelForRevision } from '../../../../stores/editor/editor-state/entity-diff-editor-state/EntityDiffEditorState.js'; import { flowResult } from 'mobx'; const getConflictSummaryText = (conflictEditorState) => { // We will annotate each possible conflict using convension: [current change - incoming change] // for more deatils, refer to the extensive note in `ChangeDetectionState` if (!conflictEditorState.baseEntity && conflictEditorState.currentChangeEntity && conflictEditorState.incomingChangeEntity) { // [CREATE - CREATE] return `Entity is created in both your changes and their changes`; } else if (conflictEditorState.baseEntity) { if (!conflictEditorState.currentChangeEntity && conflictEditorState.incomingChangeEntity) { // [DELETE - MODIFY] return `Entity is deleted in your changes but modified in their changes`; } else if (conflictEditorState.currentChangeEntity && !conflictEditorState.incomingChangeEntity) { // [MODIFY - DELETE] return `Entity is modified in your changes but deleted in their changes`; } else if (conflictEditorState.currentChangeEntity && conflictEditorState.incomingChangeEntity) { // [MODIFY - MODIFY] if (hashObject(conflictEditorState.currentChangeEntity) === hashObject(conflictEditorState.incomingChangeEntity)) { return 'Entity contents are identical'; } return `Entity is modified in both your changes and their changes`; } } throw new IllegalStateError(`Detected unfeasible state while computing entity change conflict for entity '${conflictEditorState.entityPath}', ` + `with base entity: ${shallowStringify(conflictEditorState.baseEntity)}, ` + `current change entity: ${shallowStringify(conflictEditorState.currentChangeEntity)}, ` + `and incoming change entity: ${shallowStringify(conflictEditorState.incomingChangeEntity)}`); }; export const EntityChangeConflictSideBarItem = observer((props) => { const { conflict, isSelected, openConflict } = props; return (_jsxs("button", { className: clsx('side-bar__panel__item', { 'side-bar__panel__item--selected': isSelected, }), tabIndex: -1, title: `${conflict.entityPath} \u2022 Has merge conflict(s)\n${conflict.conflictReason}`, onClick: openConflict, children: [_jsxs("div", { className: "diff-panel__item__info", children: [_jsx("span", { className: "diff-panel__item__info-name diff-panel__item__info-name--conflict", children: conflict.entityName }), _jsx("span", { className: "diff-panel__item__info-path", children: conflict.entityPath })] }), _jsx("div", { className: "diff-panel__item__type diff-panel__item__type--conflict", children: "C" })] }, `conflict-${conflict.entityPath}`)); }); const MergeConflictEditor = observer((props) => { const { conflictEditorState } = props; const isReadOnly = conflictEditorState.isReadOnly; const [editor, setEditor] = useState(); const [hasInitializedTextValue, setInitializedTextValue] = useState(false); const value = conflictEditorState.mergedText ? normalizeLineEnding(conflictEditorState.mergedText) : ''; const error = conflictEditorState.mergeEditorParserError; const decorations = useRef(null); const mergeConflictResolutionCodeLensDisposer = useRef(undefined); const onDidChangeModelContentEventDisposer = useRef(undefined); const textInputRef = useRef(null); // cursor const onDidChangeCursorPositionEventDisposer = useRef(undefined); const onDidBlurEditorTextEventDisposer = useRef(undefined); const onDidFocusEditorTextEventDisposer = useRef(undefined); useEffect(() => { if (!editor && textInputRef.current) { const element = textInputRef.current; const _editor = monacoEditorAPI.create(element, { ...getBaseCodeEditorOptions(), theme: CODE_EDITOR_THEME.DEFAULT_DARK, language: CODE_EDITOR_LANGUAGE.PURE, minimap: { enabled: false }, formatOnType: true, formatOnPaste: true, }); _editor.focus(); // focus on the editor initially so we can correctly compute next/prev conflict chunks setEditor(_editor); } }, [editor]); if (editor) { // dispose the old editor content setter in case the `updateInput` handler changes // for a more extensive note on this, see `LambdaEditor` onDidChangeModelContentEventDisposer.current?.dispose(); onDidChangeModelContentEventDisposer.current = editor.onDidChangeModelContent(() => { conflictEditorState.setMergedText(getCodeEditorValue(editor)); conflictEditorState.clearMergeEditorError(); }); // sync cursor position with merge editor state to properly monitor conflict chunk navigation onDidChangeCursorPositionEventDisposer.current?.dispose(); onDidChangeCursorPositionEventDisposer.current = editor.onDidChangeCursorPosition((event) => { // this is done to avoid modifying the parent merge editor component from the child. // if this action is triggered on purpose or async or in an effect then we can ignore it // but when we first render this editor and update the merge editor line number // which in turn affect the parent component, we will get warnings: // "Cannot update a component from inside the function body of a different component." // See https://reactjs.org/blog/2020/02/26/react-v16.13.0.html#warnings-for-some-updates-during-render if (editor.hasTextFocus()) { conflictEditorState.setCurrentMergeEditorLine(event.position.lineNumber); } conflictEditorState.setCurrentMergeEditorConflict(undefined); // reset as we just moved the cursor }); // when the editor lose or gain focus, we will need to sync cursor position properly as well onDidBlurEditorTextEventDisposer.current?.dispose(); onDidBlurEditorTextEventDisposer.current = editor.onDidBlurEditorText(() => { conflictEditorState.setCurrentMergeEditorLine(undefined); conflictEditorState.setCurrentMergeEditorConflict(undefined); // reset as we just moved the cursor }); onDidFocusEditorTextEventDisposer.current?.dispose(); onDidFocusEditorTextEventDisposer.current = editor.onDidFocusEditorText(() => { conflictEditorState.setCurrentMergeEditorLine(editor.getPosition()?.lineNumber); conflictEditorState.setCurrentMergeEditorConflict(undefined); // reset as we just moved the cursor }); // NOTE: since `monaco-editor` does not expose a method to explicitly register commands, we have to use the key-binding command adder method // but we send in an invalid keycode to ensure no accidents. const selectCurrentChangeCommandId = editor.addCommand(-1, (baseArg, conflict) => conflictEditorState.acceptCurrentChange(conflict)); const selectIncomingChangeCommandId = editor.addCommand(-1, (baseArg, conflict) => conflictEditorState.acceptIncomingChange(conflict)); const selectBothChangesCommandId = editor.addCommand(-1, (baseArg, conflict) => conflictEditorState.acceptBothChanges(conflict)); const rejectBothChangesCommandId = editor.addCommand(-1, (baseArg, conflict) => conflictEditorState.rejectBothChanges(conflict)); const compareChangesCommandId = editor.addCommand(-1, () => conflictEditorState.setCurrentMode(ENTITY_CHANGE_CONFLICT_EDITOR_VIEW_MODE.CURRENT_INCOMING)); // CodeLens registration mergeConflictResolutionCodeLensDisposer.current?.dispose(); mergeConflictResolutionCodeLensDisposer.current = monacoLanguagesAPI.registerCodeLensProvider(CODE_EDITOR_LANGUAGE.PURE, { provideCodeLenses: (model, token) => ({ lenses: conflictEditorState.mergeConflicts.flatMap((conflict) => [ !isReadOnly && selectCurrentChangeCommandId ? { range: { startLineNumber: conflict.startHeader, endLineNumber: conflict.startHeader, startColumn: 1, endColumn: 1, }, command: { id: selectCurrentChangeCommandId, title: 'Accept Your Change', arguments: [conflict], }, } : undefined, !isReadOnly && selectIncomingChangeCommandId ? { range: { startLineNumber: conflict.startHeader, endLineNumber: conflict.startHeader, startColumn: 1, endColumn: 1, }, command: { id: selectIncomingChangeCommandId, title: 'Accept Their Change', arguments: [conflict], }, } : undefined, !isReadOnly && selectBothChangesCommandId ? { range: { startLineNumber: conflict.startHeader, endLineNumber: conflict.startHeader, startColumn: 1, endColumn: 1, }, command: { id: selectBothChangesCommandId, title: 'Accept Both Changes', arguments: [conflict], }, } : undefined, !isReadOnly && rejectBothChangesCommandId ? { range: { startLineNumber: conflict.startHeader, endLineNumber: conflict.startHeader, startColumn: 1, endColumn: 1, }, command: { id: rejectBothChangesCommandId, title: 'Reject Both Changes', arguments: [conflict], }, } : undefined, compareChangesCommandId ? { range: { startLineNumber: conflict.startHeader, endLineNumber: conflict.startHeader, startColumn: 1, endColumn: 1, }, command: { id: compareChangesCommandId, title: 'Compare Changes', }, } : undefined, ].filter(isNonNullable)), dispose: () => { /** no need to do anything on disposal */ }, }), resolveCodeLens: (model, codeLens, token) => codeLens, }); resetLineNumberGutterWidth(editor); editor.updateOptions({ readOnly: Boolean(isReadOnly) }); const editorModel = editor.getModel(); if (editorModel) { editorModel.updateOptions({ tabSize: DEFAULT_TAB_SIZE }); if (error?.sourceInformation) { setErrorMarkers(editorModel, [ { message: error.message, startLineNumber: error.sourceInformation.startLine, startColumn: error.sourceInformation.startColumn, endLineNumber: error.sourceInformation.endLine, endColumn: error.sourceInformation.endColumn, }, ]); } else { clearMarkers(); } } // decoration/highlighting for merge conflicts decorations.current = editor.createDecorationsCollection(conflictEditorState.mergeConflicts.flatMap((conflict) => { const currentChangeContentStartLine = conflict.startHeader + 1; const currentChangeContentEndLine = (conflict.commonBase ?? conflict.splitter) - 1; const baseContentStartLine = (conflict.commonBase ?? Number.MAX_SAFE_INTEGER) + 1; const baseContentEndLine = conflict.splitter - 1; const incomingChangeContentStartLine = conflict.splitter + 1; const incomingChangeContentEndLine = conflict.endFooter - 1; const currentChangeOverviewRulerColor = '#40c8ae80'; // opacity 50% const baseOverviewRulerColor = '#60606080'; // opacity 50% const incomingChangeOverviewRulerColor = '#40a6ff80'; // opacity 50% return [ { // current change range: new Range(conflict.startHeader, 1, conflict.startHeader, 1), options: { isWholeLine: true, overviewRuler: { color: currentChangeOverviewRulerColor, position: 7, // full }, className: 'merge-editor__current__header', }, }, currentChangeContentEndLine >= currentChangeContentStartLine ? { range: new Range(currentChangeContentStartLine, 1, currentChangeContentEndLine, 1), options: { isWholeLine: true, overviewRuler: { color: currentChangeOverviewRulerColor, position: 7, // full }, className: 'merge-editor__current__content', }, } : undefined, // base conflict.commonBase ? { range: new Range(conflict.commonBase, 1, conflict.commonBase, 1), options: { isWholeLine: true, overviewRuler: { color: baseOverviewRulerColor, position: 7, // full }, className: 'merge-editor__base__header', }, } : undefined, baseContentEndLine >= baseContentStartLine ? { range: new Range(baseContentStartLine, 1, baseContentEndLine, 1), options: { isWholeLine: true, overviewRuler: { color: baseOverviewRulerColor, position: 7, // full }, className: 'merge-editor__base__content', }, } : undefined, // incoming change incomingChangeContentEndLine >= incomingChangeContentStartLine ? { range: new Range(incomingChangeContentStartLine, 1, incomingChangeContentEndLine, 1), options: { isWholeLine: true, overviewRuler: { color: incomingChangeOverviewRulerColor, position: 7, // full }, className: 'merge-editor__incoming__content', }, } : undefined, { range: new Range(conflict.endFooter, 1, conflict.endFooter, 1), options: { isWholeLine: true, overviewRuler: { color: incomingChangeOverviewRulerColor, position: 7, // full }, className: 'merge-editor__incoming__header', }, }, ].filter(isNonNullable); })); } /** * Set the text value when the text value is set not by editing the text inside the editor, but as a reaction to a change in * value from the parent state. * NOTE: using `editor.setValue` is convenient, but it will make us lose the undo-redo stack, so we have to use `pushEditOperations` * Also, because we don't want to let the user undo to the initial blank text, we use a boolean flag to check if this is the very first * meaningful value set so we can block undo * Since `mergeText` can be undefined, We also use the fact that `value === undefined` to make decision on when to actually update the * value of the editor */ useEffect(() => { if (editor) { const editorModel = editor.getModel(); const currentValue = getCodeEditorValue(editor); if (editorModel && currentValue !== value) { if (!hasInitializedTextValue) { editor.setValue(value); setInitializedTextValue(true); } else { const lineCount = editorModel.getLineCount(); const lastLineLength = editorModel.getLineMaxColumn(lineCount); const range = new Range(1, 1, lineCount, lastLineLength); // ensure we push the previous text to the undo-redo stack, // otherwise, if after we set the text value using `pushEditOperations`, we try to undo, we will // undo to the previous state before the state we make this edit, i.e. missing 1 undo state. editorModel.pushStackElement(); editorModel.pushEditOperations([], [{ range: range, text: value, forceMoveMarkers: true }], () => null); } } } }, [editor, value, hasInitializedTextValue]); useEffect(() => { if (editor) { if (error?.sourceInformation) { moveCursorToPosition(editor, { lineNumber: error.sourceInformation.startLine, column: error.sourceInformation.startColumn, }); } } }, [editor, error, error?.sourceInformation]); useEffect(() => { if (editor && conflictEditorState.currentMergeEditorConflict !== undefined) { moveCursorToPosition(editor, { lineNumber: conflictEditorState.currentMergeEditorConflict.startHeader, column: 1, }); } }, [ editor, conflictEditorState, conflictEditorState.currentMergeEditorConflict, ]); // dispose editor useEffect(() => () => { if (editor) { disposeCodeEditor(editor); onDidChangeModelContentEventDisposer.current?.dispose(); onDidChangeCursorPositionEventDisposer.current?.dispose(); onDidBlurEditorTextEventDisposer.current?.dispose(); onDidFocusEditorTextEventDisposer.current?.dispose(); mergeConflictResolutionCodeLensDisposer.current?.dispose(); } }, [editor]); return (_jsx("div", { className: "code-editor__container", children: _jsx("div", { className: "code-editor__body", ref: textInputRef }) })); }); const getMergeEditorViewModeOption = (mode, modeComparisonViewInfo) => ({ value: mode, label: (_jsxs("div", { className: "entity-change-conflict-editor__header__action__view-dropdown__option", children: [_jsx("div", { className: `entity-change-conflict-editor__header__action__view-dropdown__option__label entity-change-conflict-editor__header__action__view-dropdown__option__label--${mode.toLowerCase()}`, children: modeComparisonViewInfo.label }), mode !== ENTITY_CHANGE_CONFLICT_EDITOR_VIEW_MODE.MERGE_VIEW && (_jsxs("div", { className: "entity-change-conflict-editor__header__action__view-dropdown__option__summary", children: [_jsx("div", { className: "entity-change-conflict-editor__header__action__view-dropdown__option__summary__revision", children: getPrettyLabelForRevision(modeComparisonViewInfo.fromRevision) }), _jsx("div", { className: "entity-change-conflict-editor__header__action__view-dropdown__option__summary__icon", children: _jsx(CompareIcon, {}) }), _jsx("div", { className: "entity-change-conflict-editor__header__action__view-dropdown__option__summary__revision", children: getPrettyLabelForRevision(modeComparisonViewInfo.toRevision) })] }))] })), }); export const EntityChangeConflictEditor = observer((props) => { const applicationStore = useApplicationStore(); const { conflictEditorState } = props; const isReadOnly = conflictEditorState.isReadOnly; // this call might be expensive so we debounce it const debouncedUpdateMergeConflicts = useMemo(() => debounce(() => conflictEditorState.refreshMergeConflict(), 20), [conflictEditorState]); // header actions const goToPreviousConflict = () => { if (conflictEditorState.previousConflict) { conflictEditorState.setCurrentMergeEditorConflict(conflictEditorState.previousConflict); } }; const goToNextConflict = () => { if (conflictEditorState.nextConflict) { conflictEditorState.setCurrentMergeEditorConflict(conflictEditorState.nextConflict); } }; // resolutions const markAsResolved = applicationStore.guardUnhandledError(() => flowResult(conflictEditorState.markAsResolved())); const useTheirs = applicationStore.guardUnhandledError(() => flowResult(conflictEditorState.useIncomingChanges())); const useYours = applicationStore.guardUnhandledError(() => flowResult(conflictEditorState.useCurrentChanges())); // mode const currentMode = conflictEditorState.currentMode; const currentModeComparisonViewInfo = conflictEditorState.getModeComparisonViewInfo(currentMode); const onModeChange = (val) => conflictEditorState.setCurrentMode(val.value); useEffect(() => { flowResult(conflictEditorState.refresh()).catch(applicationStore.alertUnhandledError); }, [applicationStore, conflictEditorState]); useEffect(() => { debouncedUpdateMergeConflicts.cancel(); debouncedUpdateMergeConflicts(); }, [ conflictEditorState, conflictEditorState.mergedText, debouncedUpdateMergeConflicts, ]); // reset transient merge editor states (e.g. cursor position, current merge conflict, etc.) as we exit the tab useEffect(() => () => conflictEditorState.resetMergeEditorStateOnLeave(), [conflictEditorState]); return (_jsxs("div", { className: "entity-change-conflict-editor", children: [_jsxs("div", { className: "entity-change-conflict-editor__header", children: [_jsxs("div", { className: "entity-change-conflict-editor__header__info", children: [_jsx("div", { className: "entity-change-conflict-editor__header__info__tag", children: _jsx("div", { className: "entity-change-conflict-editor__header__info__tag__text", children: isReadOnly ? 'Merge preview' : conflictEditorState.mergeSucceeded ? 'Merged successfully' : 'Merged with conflict(s)' }) }), _jsx("div", { className: "entity-change-conflict-editor__header__info__comparison-summary", children: getConflictSummaryText(conflictEditorState) })] }), _jsx("div", { className: "entity-change-conflict-editor__header__info__tag", children: _jsxs("div", { className: "entity-change-conflict-editor__header__info__tag__text", children: [conflictEditorState.mergeConflicts.length, " conflict(s)"] }) })] }), _jsx("div", { className: "entity-change-conflict-editor__header__actions", children: _jsxs("div", { className: "entity-change-conflict-editor__header__actions__main", children: [_jsx("button", { className: "btn--dark btn--sm entity-change-conflict-editor__header__action", disabled: !conflictEditorState.previousConflict, onClick: goToPreviousConflict, title: "Previous conflict", children: _jsx(ArrowUpIcon, {}) }), _jsx("button", { className: "btn--dark btn--sm entity-change-conflict-editor__header__action", disabled: !conflictEditorState.nextConflict, onClick: goToNextConflict, title: "Next conflict", children: _jsx(ArrowDownIcon, {}) }), _jsx(CustomSelectorInput, { className: "entity-change-conflict-editor__header__action__view-dropdown", options: Object.values(ENTITY_CHANGE_CONFLICT_EDITOR_VIEW_MODE).map((mode) => getMergeEditorViewModeOption(mode, conflictEditorState.getModeComparisonViewInfo(mode))), isSearchable: false, onChange: onModeChange, value: getMergeEditorViewModeOption(currentMode, conflictEditorState.getModeComparisonViewInfo(currentMode)), darkMode: !applicationStore.layoutService .TEMPORARY__isLightColorThemeEnabled })] }) }), _jsxs("div", { className: clsx('entity-change-conflict-editor__content', { 'entity-change-conflict-editor__content--read-only': isReadOnly, }), children: [currentMode === ENTITY_CHANGE_CONFLICT_EDITOR_VIEW_MODE.MERGE_VIEW && (_jsx(MergeConflictEditor, { conflictEditorState: conflictEditorState })), currentMode !== ENTITY_CHANGE_CONFLICT_EDITOR_VIEW_MODE.MERGE_VIEW && (_jsx(CodeDiffView, { language: CODE_EDITOR_LANGUAGE.PURE, from: currentModeComparisonViewInfo.fromGrammarText ?? '', to: currentModeComparisonViewInfo.toGrammarText ?? '' }))] }), !isReadOnly && (_jsxs("div", { className: "entity-change-conflict-editor__actions", children: [_jsx("button", { className: "btn--dark btn--important entity-change-conflict-editor__action entity-change-conflict-editor__action__use-yours-btn", disabled: !conflictEditorState.canUseYours, onClick: useYours, children: "Use yours" }), _jsx("button", { className: "btn--dark btn--important entity-change-conflict-editor__action entity-change-conflict-editor__action__use-theirs-btn", disabled: !conflictEditorState.canUseTheirs, onClick: useTheirs, children: "Use Theirs" }), _jsx("button", { className: "btn--dark btn--important entity-change-conflict-editor__action", disabled: !conflictEditorState.canMarkAsResolved, onClick: markAsResolved, children: "Mark as resolved" })] }))] })); }); //# sourceMappingURL=EntityChangeConflictEditor.js.map