/**
* 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 {
type IDisposable,
editor as monacoEditorAPI,
languages as monacoLanguagesAPI,
Range,
} from 'monaco-editor';
import {
DEFAULT_TAB_SIZE,
useApplicationStore,
} from '@finos/legend-application';
import {
type MergeEditorComparisonViewInfo,
type MergeConflict,
type EntityChangeConflictEditorState,
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,
getCodeEditorThemeForAppTheme,
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';
import type { EntityChangeConflict } from '@finos/legend-server-sdlc';
const getConflictSummaryText = (
conflictEditorState: EntityChangeConflictEditorState,
): string => {
// 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: {
conflict: EntityChangeConflict;
isSelected: boolean;
openConflict: () => void;
}) => {
const { conflict, isSelected, openConflict } = props;
return (
);
},
);
const MergeConflictEditor = observer(
(props: { conflictEditorState: EntityChangeConflictEditorState }) => {
const { conflictEditorState } = props;
const applicationStore = useApplicationStore();
const isReadOnly = conflictEditorState.isReadOnly;
const [editor, setEditor] = useState<
monacoEditorAPI.IStandaloneCodeEditor | undefined
>();
const [hasInitializedTextValue, setInitializedTextValue] = useState(false);
const value = conflictEditorState.mergedText
? normalizeLineEnding(conflictEditorState.mergedText)
: '';
const error = conflictEditorState.mergeEditorParserError;
const decorations =
useRef(null);
const mergeConflictResolutionCodeLensDisposer = useRef<
IDisposable | undefined
>(undefined);
const onDidChangeModelContentEventDisposer = useRef<
IDisposable | undefined
>(undefined);
const textInputRef = useRef(null);
// cursor
const onDidChangeCursorPositionEventDisposer = useRef<
IDisposable | undefined
>(undefined);
const onDidBlurEditorTextEventDisposer = useRef(
undefined,
);
const onDidFocusEditorTextEventDisposer = useRef(
undefined,
);
useEffect(() => {
if (!editor && textInputRef.current) {
const element = textInputRef.current;
const _editor = monacoEditorAPI.create(element, {
...getBaseCodeEditorOptions(),
// Match the active app theme — see GrammarTextEditor for the rationale.
theme: getCodeEditorThemeForAppTheme(
applicationStore.layoutService.TEMPORARY__isLightColorThemeEnabled,
),
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,
applicationStore.layoutService.TEMPORARY__isLightColorThemeEnabled,
]);
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: MergeConflict) =>
conflictEditorState.acceptCurrentChange(conflict),
);
const selectIncomingChangeCommandId = editor.addCommand(
-1,
(baseArg, conflict: MergeConflict) =>
conflictEditorState.acceptIncomingChange(conflict),
);
const selectBothChangesCommandId = editor.addCommand(
-1,
(baseArg, conflict: MergeConflict) =>
conflictEditorState.acceptBothChanges(conflict),
);
const rejectBothChangesCommandId = editor.addCommand(
-1,
(baseArg, conflict: MergeConflict) =>
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: (): void => {
/** 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(
() => (): void => {
if (editor) {
disposeCodeEditor(editor);
onDidChangeModelContentEventDisposer.current?.dispose();
onDidChangeCursorPositionEventDisposer.current?.dispose();
onDidBlurEditorTextEventDisposer.current?.dispose();
onDidFocusEditorTextEventDisposer.current?.dispose();
mergeConflictResolutionCodeLensDisposer.current?.dispose();
}
},
[editor],
);
return (