@finos/legend-application-studio
Version:
Legend Studio application core
579 lines (544 loc) • 20 kB
text/typescript
/**
* 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 { flowResult, action, flow, makeObservable, observable } from 'mobx';
import type { EditorStore } from '../EditorStore.js';
import type { EditorSDLCState } from '../EditorSDLCState.js';
import type { Entity } from '@finos/legend-storage';
import {
type GeneratorFn,
type PlainObject,
assertTrue,
LogEvent,
hashObject,
isNonNullable,
guaranteeNonNullable,
assertErrorThrown,
deleteEntry,
ActionState,
} from '@finos/legend-shared';
import { LEGEND_STUDIO_APP_EVENT } from '../../../__lib__/LegendStudioEvent.js';
import { EntityChangeConflictEditorState } from '../editor-state/entity-diff-editor-state/EntityChangeConflictEditorState.js';
import {
type EntityDiffViewerState,
SPECIAL_REVISION_ALIAS,
} from '../editor-state/entity-diff-editor-state/EntityDiffEditorState.js';
import { EntityDiffViewState } from '../editor-state/entity-diff-editor-state/EntityDiffViewState.js';
import {
type EntityChangeConflict,
type EntityChangeConflictResolution,
type EntityChange,
EntityDiff,
EntityChangeType,
Revision,
convertEntityDiffsToEntityChanges,
} from '@finos/legend-server-sdlc';
import {
ActionAlertActionType,
ActionAlertType,
} from '@finos/legend-application';
import { AbstractConflictResolutionState } from '../AbstractConflictResolutionState.js';
class WorkspaceSyncConflictResolutionState extends AbstractConflictResolutionState {
showModal = false;
conflicts: EntityChangeConflict[] = [];
openMergedEditorStates: EntityDiffViewerState[] = [];
currentDiffEditorState: EntityDiffViewerState | undefined;
resolutions: EntityChangeConflictResolution[] = [];
baseToLocalChanges: EntityDiff[] = [];
constructor(editorStore: EditorStore, sdlcState: EditorSDLCState) {
super(editorStore, sdlcState);
makeObservable<WorkspaceSyncConflictResolutionState>(this, {
editorStore: false,
sdlcState: false,
conflicts: observable,
resolutions: observable,
mergeEditorStates: observable,
currentDiffEditorState: observable,
openMergedEditorStates: observable,
openConflict: action,
openConflictState: action,
closeConflict: action,
resolveConflict: action,
openState: action,
markConflictAsResolved: flow,
showModal: observable,
setShowModal: action,
openEntityChangeConflict: action,
openConflictResolutionChange: action,
});
}
get baseEntities(): Entity[] {
return this.editorStore.changeDetectionState
.workspaceLocalLatestRevisionState.entities;
}
get currentEntities(): Entity[] {
return this.editorStore.graphManagerState.graph.allOwnElements.map(
(element) =>
this.editorStore.graphManagerState.graphManager.elementToEntity(
element,
),
);
}
get incomingEntities(): Entity[] {
return this.editorStore.changeDetectionState
.workspaceRemoteLatestRevisionState.entities;
}
get resolvedChanges(): EntityDiff[] {
return this.resolutions
.map((resolution) => {
const path = resolution.entityPath;
const fromEntity = this.baseEntities.find((e) => e.path === path);
const toEntity = resolution.resolvedEntity;
if (!fromEntity && !toEntity) {
return undefined;
} else if (!fromEntity) {
return new EntityDiff(undefined, path, EntityChangeType.CREATE);
} else if (!toEntity) {
return new EntityDiff(path, undefined, EntityChangeType.DELETE);
}
return hashObject(toEntity.content) === hashObject(fromEntity.content)
? undefined
: new EntityDiff(path, path, EntityChangeType.MODIFY);
})
.filter(isNonNullable);
}
get pendingConflicts(): EntityChangeConflict[] {
return this.conflicts.filter(
(conflict) =>
!this.resolutions
.map((resolution) => resolution.entityPath)
.includes(conflict.entityPath),
);
}
get changes(): EntityDiff[] {
return this.baseToLocalChanges
.filter(
(change) =>
!this.pendingConflicts
.map((conflict) => conflict.entityPath)
.includes(change.entityPath),
)
.filter(
(change) =>
!this.resolutions
.map((resolution) => resolution.entityPath)
.includes(change.entityPath),
)
.filter(
(change) =>
!this.resolvedChanges
.map((resolvedChange) => resolvedChange.entityPath)
.includes(change.entityPath),
)
.concat(this.resolvedChanges);
}
openState(entityDiffEditorState: EntityDiffViewerState): void {
if (entityDiffEditorState instanceof EntityChangeConflictEditorState) {
this.openConflictState(entityDiffEditorState);
}
if (entityDiffEditorState instanceof EntityDiffViewState) {
this.openDiff(entityDiffEditorState);
}
}
openConflict(conflict: EntityChangeConflict): void {
const existingMergeEditorState = this.mergeEditorStates.find(
(state) => state.entityPath === conflict.entityPath,
);
if (existingMergeEditorState) {
this.openEntityChangeConflict(existingMergeEditorState);
return;
}
const baseEntityGetter = (
entityPath: string | undefined,
): Entity | undefined =>
entityPath
? this.baseEntities.find((e) => e.path === entityPath)
: undefined;
const currentChangeEntityGetter = (
entityPath: string | undefined,
): Entity | undefined =>
entityPath
? this.currentEntities.find((e) => e.path === entityPath)
: undefined;
const incomingChangeEntityGetter = (
entityPath: string | undefined,
): Entity | undefined =>
entityPath
? this.incomingEntities.find((e) => e.path === entityPath)
: undefined;
const mergeEditorState = new EntityChangeConflictEditorState(
this.editorStore,
this,
conflict.entityPath,
SPECIAL_REVISION_ALIAS.WORKSPACE_BASE,
SPECIAL_REVISION_ALIAS.LOCAL,
SPECIAL_REVISION_ALIAS.WORKSPACE_HEAD,
baseEntityGetter(conflict.entityPath),
currentChangeEntityGetter(conflict.entityPath),
incomingChangeEntityGetter(conflict.entityPath),
baseEntityGetter,
currentChangeEntityGetter,
incomingChangeEntityGetter,
);
this.mergeEditorStates.push(mergeEditorState);
this.openEntityChangeConflict(mergeEditorState);
}
closeConflict(conflictState: EntityDiffViewerState): void {
const conflictIndex = this.openMergedEditorStates.findIndex(
(e) => e === conflictState,
);
assertTrue(conflictIndex !== -1, `Can't close a tab which is not opened`);
this.openMergedEditorStates.splice(conflictIndex, 1);
if (this.currentDiffEditorState === conflictState) {
if (this.openMergedEditorStates.length) {
const openIndex = conflictIndex - 1;
this.setCurrentMergeEditorState(
openIndex >= 0
? this.openMergedEditorStates[openIndex]
: this.openMergedEditorStates[0],
);
} else {
this.setCurrentMergeEditorState(undefined);
}
}
}
openConflictState(conflictState: EntityChangeConflictEditorState): void {
const existingEditorState = this.openMergedEditorStates.find(
(editorState) =>
editorState instanceof EntityChangeConflictEditorState &&
editorState.entityPath === conflictState.entityPath,
);
const conflictEditorState = existingEditorState ?? conflictState;
if (!existingEditorState) {
this.openMergedEditorStates.push(conflictEditorState);
}
this.setCurrentMergeEditorState(conflictEditorState);
}
resolveConflict(resolution: EntityChangeConflictResolution): void {
this.resolutions.push(resolution);
}
*markConflictAsResolved(
conflictState: EntityChangeConflictEditorState,
): GeneratorFn<void> {
// swap out the current conflict editor with a normal diff editor
const resolvedChange = this.resolvedChanges.find(
(change) => change.entityPath === conflictState.entityPath,
);
if (resolvedChange) {
this.openConflictResolutionChange(resolvedChange);
}
this.closeConflict(conflictState);
deleteEntry(this.mergeEditorStates, conflictState);
}
get toEntityGetter(): (entityPath: string | undefined) => Entity | undefined {
return (entityPath: string | undefined): Entity | undefined => {
if (!entityPath) {
return undefined;
}
// if the editor is still in conflict resolution phase (i.e. graph is not built yet), we will get entity from change detection or conflict resolutions
const existingResolution = this.resolutions.find(
(resolution) => resolution.entityPath === entityPath,
);
if (existingResolution) {
return existingResolution.resolvedEntity;
}
// if the change is not from a conflict resolution, it must be from the list of entities of the latest revision in the workspace
return this.incomingEntities.find((entity) => entity.path === entityPath);
};
}
openConflictResolutionChange(diff: EntityDiff): void {
const fromEntityGetter = (
entityPath: string | undefined,
): Entity | undefined =>
entityPath
? this.baseEntities.find((entity) => entity.path === entityPath)
: undefined;
const fromEntity = EntityDiff.shouldOldEntityExist(diff)
? guaranteeNonNullable(
fromEntityGetter(diff.getValidatedOldPath()),
`Can't find entity with path '${diff.oldPath}'`,
)
: undefined;
const toEntity = EntityDiff.shouldNewEntityExist(diff)
? guaranteeNonNullable(
this.toEntityGetter(diff.getValidatedNewPath()),
`Can't find entity with path '${diff.newPath}'`,
)
: undefined;
this.openDiff(
new EntityDiffViewState(
this.editorStore,
SPECIAL_REVISION_ALIAS.WORKSPACE_BASE,
SPECIAL_REVISION_ALIAS.LOCAL,
diff.oldPath,
diff.newPath,
fromEntity,
toEntity,
fromEntityGetter,
this.toEntityGetter,
),
);
}
initialize(conflicts: EntityChangeConflict[], changes: EntityDiff[]): void {
this.conflicts = conflicts;
this.baseToLocalChanges = changes;
this.setShowModal(true);
}
teardown(): void {
this.setShowModal(false);
this.openMergedEditorStates = [];
this.mergeEditorStates = [];
this.setCurrentMergeEditorState(undefined);
this.conflicts = [];
this.baseToLocalChanges = [];
}
setShowModal(val: boolean): void {
this.showModal = val;
}
setCurrentMergeEditorState(val: EntityDiffViewerState | undefined): void {
this.currentDiffEditorState = val;
}
openEntityChangeConflict(
entityChangeConflictEditorState: EntityChangeConflictEditorState,
): void {
const existingEditorState = this.openMergedEditorStates.find(
(editorState) =>
editorState instanceof EntityChangeConflictEditorState &&
editorState.entityPath === entityChangeConflictEditorState.entityPath,
);
const conflictEditorState =
existingEditorState ?? entityChangeConflictEditorState;
if (!existingEditorState) {
this.openMergedEditorStates.push(conflictEditorState);
}
this.setCurrentMergeEditorState(conflictEditorState);
}
openDiff(entityDiffEditorState: EntityDiffViewState): void {
const existingEditorState = this.openMergedEditorStates.find(
(editorState) =>
editorState instanceof EntityDiffViewState &&
editorState.fromEntityPath === entityDiffEditorState.fromEntityPath &&
editorState.toEntityPath === entityDiffEditorState.toEntityPath &&
editorState.fromRevision === entityDiffEditorState.fromRevision &&
editorState.toRevision === entityDiffEditorState.toRevision,
);
const diffEditorState = existingEditorState ?? entityDiffEditorState;
if (!existingEditorState) {
this.openMergedEditorStates.push(diffEditorState);
}
this.setCurrentMergeEditorState(diffEditorState);
}
}
export class WorkspaceSyncState {
readonly editorStore: EditorStore;
readonly sdlcState: EditorSDLCState;
pullChangesState = ActionState.create();
incomingRevisions: Revision[] = [];
workspaceSyncConflictResolutionState: WorkspaceSyncConflictResolutionState;
constructor(editorStore: EditorStore, sdlcState: EditorSDLCState) {
makeObservable(this, {
pullChangesState: observable,
incomingRevisions: observable,
workspaceSyncConflictResolutionState: observable,
resetConflictState: action,
setIncomingRevisions: action,
fetchIncomingRevisions: flow,
pullChanges: flow,
loadChanges: flow,
forcePull: flow,
applyResolutionChanges: flow,
});
this.editorStore = editorStore;
this.sdlcState = sdlcState;
this.workspaceSyncConflictResolutionState =
new WorkspaceSyncConflictResolutionState(editorStore, sdlcState);
}
resetConflictState(): void {
this.workspaceSyncConflictResolutionState.teardown();
this.workspaceSyncConflictResolutionState =
new WorkspaceSyncConflictResolutionState(
this.editorStore,
this.sdlcState,
);
}
setIncomingRevisions(revisions: Revision[]): void {
this.incomingRevisions = revisions;
}
*fetchIncomingRevisions(): GeneratorFn<void> {
try {
assertTrue(this.sdlcState.isWorkspaceOutOfSync);
const revisions = (
(yield this.editorStore.sdlcServerClient.getRevisions(
this.sdlcState.activeProject.projectId,
this.sdlcState.activeWorkspace,
this.sdlcState.activeRevision.committedAt,
this.sdlcState.activeRemoteWorkspaceRevision.committedAt,
)) as PlainObject<Revision>[]
).map((v) => Revision.serialization.fromJson(v));
this.setIncomingRevisions(
revisions.filter((r) => r.id !== this.sdlcState.activeRevision.id),
);
} catch (error) {
this.setIncomingRevisions([]);
assertErrorThrown(error);
}
}
*pullChanges(): GeneratorFn<void> {
try {
assertTrue(this.sdlcState.isWorkspaceOutOfSync);
this.editorStore.applicationStore.alertService.setBlockingAlert({
message: `Pulling latest changes...`,
showLoading: true,
});
this.pullChangesState.inProgress();
const changes =
this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState
.changes;
let conflicts: EntityChangeConflict[] = [];
if (changes.length) {
yield flowResult(
this.editorStore.changeDetectionState.computeAggregatedWorkspaceRemoteChanges(),
);
conflicts =
this.editorStore.changeDetectionState.potentialWorkspacePullConflicts;
}
if (conflicts.length) {
this.editorStore.applicationStore.alertService.setBlockingAlert(
undefined,
);
this.editorStore.applicationStore.alertService.setActionAlertInfo({
message: 'Conflicts found while pulling changes',
prompt:
'You can either force-pull (override local changes) or resolve these conflicts manually',
type: ActionAlertType.CAUTION,
actions: [
{
label: 'Resolve merge conflicts',
default: true,
handler: (): void =>
this.workspaceSyncConflictResolutionState.initialize(
conflicts,
this.editorStore.changeDetectionState
.aggregatedWorkspaceRemoteChanges,
),
type: ActionAlertActionType.STANDARD,
},
{
label: 'Force pull',
type: ActionAlertActionType.PROCEED_WITH_CAUTION,
handler: (): void => {
flowResult(this.forcePull()).catch(
this.editorStore.applicationStore.alertUnhandledError,
);
},
},
],
});
return;
}
const localChanges =
this.editorStore.localChangesState.computeLocalEntityChanges();
yield flowResult(this.loadChanges(localChanges));
} catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.notificationService.notifyError(
`Can't pull changes. Error: ${error.message}`,
);
} finally {
this.pullChangesState.complete();
}
}
*loadChanges(changes: EntityChange[]): GeneratorFn<void> {
this.editorStore.sdlcState.setCurrentRevision(
this.sdlcState.activeRemoteWorkspaceRevision,
);
const entities =
this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState
.entities;
this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.setEntities(
entities,
);
this.resetConflictState();
yield flowResult(
this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.buildEntityHashesIndex(
entities,
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_BUILD_LOCAL_HASHES_INDEX__SUCCESS,
),
),
);
this.setIncomingRevisions([]);
this.editorStore.changeDetectionState.setAggregatedWorkspaceRemoteChanges(
[],
);
this.editorStore.changeDetectionState.setPotentialWorkspacePullConflicts(
[],
);
yield flowResult(
this.editorStore.graphState.loadEntityChangesToGraph(
changes,
// we create new entities to not override the initial entities on `workspaceLatestRevisionState` used for change detection
entities.map((e) => ({
classifierPath: e.classifierPath,
path: e.path,
content: e.content,
})),
),
);
}
*forcePull(): GeneratorFn<void> {
try {
const changes =
this.editorStore.localChangesState.computeLocalEntityChanges();
yield flowResult(this.loadChanges(changes));
this.editorStore.applicationStore.notificationService.notifySuccess(
'Workspace changes were force-pulled',
);
} catch (error) {
assertErrorThrown(error);
this.resetConflictState();
this.editorStore.applicationStore.notificationService.notifyError(
`Can't force-pull remote workspace changes. Error: ${error.message}`,
);
} finally {
this.editorStore.applicationStore.alertService.setBlockingAlert(
undefined,
);
}
}
*applyResolutionChanges(): GeneratorFn<void> {
try {
this.editorStore.applicationStore.alertService.setBlockingAlert({
message: `Applying resolutions and reloading graph...`,
showLoading: true,
});
const changes = convertEntityDiffsToEntityChanges(
this.workspaceSyncConflictResolutionState.changes,
this.workspaceSyncConflictResolutionState.toEntityGetter,
);
yield flowResult(this.loadChanges(changes));
} catch (error) {
assertErrorThrown(error);
this.resetConflictState();
this.editorStore.applicationStore.notificationService.notifyError(
`Can't apply resolutions to local workspace. Error: ${error.message}`,
);
} finally {
this.editorStore.applicationStore.alertService.setBlockingAlert(
undefined,
);
}
}
}