UNPKG

@finos/legend-application-studio

Version:
542 lines 28.4 kB
/** * 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 { action, makeObservable, flowResult, flow, observable, computed, } from 'mobx'; import { LogEvent, assertErrorThrown, downloadFileUsingDataURI, guaranteeNonNullable, ContentType, NetworkClientError, HttpStatus, deleteEntry, assertTrue, readFileAsText, ActionState, formatDate, } from '@finos/legend-shared'; import { DEFAULT_TAB_SIZE, ActionAlertType, ActionAlertActionType, DEFAULT_DATE_TIME_FORMAT, } from '@finos/legend-application'; import { EntityDiffViewState } from '../editor-state/entity-diff-editor-state/EntityDiffViewState.js'; import { SPECIAL_REVISION_ALIAS } from '../editor-state/entity-diff-editor-state/EntityDiffEditorState.js'; import { EntityDiff, EntityChange, Revision, EntityChangeType, } from '@finos/legend-server-sdlc'; import { LEGEND_STUDIO_APP_EVENT } from '../../../__lib__/LegendStudioEvent.js'; import { WorkspaceSyncState } from './WorkspaceSyncState.js'; import { ACTIVITY_MODE } from '../EditorConfig.js'; import { EntityChangeConflictEditorState } from '../editor-state/entity-diff-editor-state/EntityChangeConflictEditorState.js'; class PatchLoaderState { editorStore; sdlcState; changes; currentChanges = []; isLoadingChanges = false; showModal = false; isValidPatch = false; constructor(editorStore, sdlcState) { makeObservable(this, { changes: observable, currentChanges: observable, isLoadingChanges: observable, showModal: observable, isValidPatch: observable, overiddingChanges: computed, openModal: action, closeModal: action, setIsValidPatch: action, setPatchChanges: action, deleteChange: action, loadPatchFile: flow, applyChanges: flow, }); this.editorStore = editorStore; this.sdlcState = sdlcState; } get overiddingChanges() { if (this.changes?.length) { return this.changes.filter((change) => this.currentChanges.find((local) => local.entityPath === change.entityPath)); } return []; } openModal(localChanges) { this.currentChanges = localChanges; this.showModal = true; } closeModal() { this.currentChanges = []; this.setPatchChanges(undefined); this.showModal = false; } setIsValidPatch(val) { this.isValidPatch = val; } setPatchChanges(changes) { this.changes = changes; } deleteChange(change) { if (this.changes) { deleteEntry(this.changes, change); } } *loadPatchFile(file) { try { this.setPatchChanges(undefined); assertTrue(file.type === ContentType.APPLICATION_JSON, `Patch file expected to be of type 'JSON'`); const fileText = (yield readFileAsText(file)); const entityChanges = JSON.parse(fileText); const changes = entityChanges.entityChanges.map((e) => EntityChange.serialization.fromJson(e)); this.setPatchChanges(changes); this.setIsValidPatch(true); } catch (error) { assertErrorThrown(error); this.setIsValidPatch(false); this.editorStore.applicationStore.notificationService.notifyError(`Can't load patch: Error: ${error.message}`); } } *applyChanges() { if (this.changes?.length) { try { const changes = this.changes; this.closeModal(); yield flowResult(this.editorStore.graphState.loadEntityChangesToGraph(changes, undefined)); } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.notificationService.notifyError(`Can't apply patch changes: Error: ${error.message}`); } } } } export class LocalChangesState { editorStore; sdlcState; workspaceSyncState; pushChangesState = ActionState.create(); refreshLocalChangesDetectorState = ActionState.create(); refreshWorkspaceSyncStatusState = ActionState.create(); patchLoaderState; constructor(editorStore, sdlcState) { makeObservable(this, { hasUnpushedChanges: computed, refreshWorkspaceSyncStatus: flow, refreshLocalChanges: flow, pushLocalChanges: flow, processConflicts: flow, restartChangeDetection: flow, }); this.editorStore = editorStore; this.sdlcState = sdlcState; this.patchLoaderState = new PatchLoaderState(editorStore, sdlcState); this.workspaceSyncState = new WorkspaceSyncState(editorStore, sdlcState); } get hasUnpushedChanges() { return Boolean(this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState .changes.length); } downloadLocalChanges() { const fileName = `entityChanges_(${this.sdlcState.currentProject?.name}_${this.sdlcState.activeWorkspace.workspaceId})_${formatDate(new Date(Date.now()), DEFAULT_DATE_TIME_FORMAT)}.json`; const content = JSON.stringify({ message: '', // TODO? entityChanges: this.computeLocalEntityChanges(), revisionId: this.sdlcState.activeRevision.id, }, undefined, DEFAULT_TAB_SIZE); downloadFileUsingDataURI(fileName, content, ContentType.APPLICATION_JSON); } alertUnsavedChanges(onProceed) { if (this.hasUnpushedChanges) { this.editorStore.applicationStore.alertService.setActionAlertInfo({ message: 'Unsaved changes will be lost if you continue. Do you still want to proceed?', type: ActionAlertType.CAUTION, actions: [ { label: 'Proceed', type: ActionAlertActionType.PROCEED_WITH_CAUTION, handler: () => onProceed(), }, { label: 'Abort', type: ActionAlertActionType.PROCEED, default: true, }, ], }); } else { onProceed(); } } *refreshWorkspaceSyncStatus() { try { this.refreshWorkspaceSyncStatusState.inProgress(); const currentRemoteRevision = this.sdlcState.activeRemoteWorkspaceRevision; yield flowResult(this.sdlcState.fetchRemoteWorkspaceRevision(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace)); if (currentRemoteRevision.id !== this.sdlcState.activeRemoteWorkspaceRevision.id) { if (this.sdlcState.isWorkspaceOutOfSync) { this.workspaceSyncState.fetchIncomingRevisions(); const remoteWorkspaceEntities = (yield this.editorStore.sdlcServerClient.getEntitiesByRevision(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace, this.sdlcState.activeRemoteWorkspaceRevision.id)); this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.setEntities(remoteWorkspaceEntities); yield flowResult(this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.buildEntityHashesIndex(remoteWorkspaceEntities, LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_BUILD_LOCAL_HASHES_INDEX__SUCCESS))); yield flowResult(this.editorStore.changeDetectionState.computeAggregatedWorkspaceRemoteChanges()); } else { this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.setEntities([]); this.editorStore.changeDetectionState.setPotentialWorkspacePullConflicts([]); this.editorStore.changeDetectionState.setAggregatedWorkspaceRemoteChanges([]); } } } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.logService.error(LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE), error); } finally { this.refreshWorkspaceSyncStatusState.complete(); } } *pushLocalChanges(pushMessage) { if (this.pushChangesState.isInProgress || this.editorStore.workspaceUpdaterState.isUpdatingWorkspace) { return; } // check if the workspace is in conflict resolution mode yield flowResult(this.processConflicts()); this.pushChangesState.inProgress(); const startTime = Date.now(); const localChanges = this.computeLocalEntityChanges(); if (!localChanges.length) { this.pushChangesState.complete(); return; } yield flowResult(this.sdlcState.fetchRemoteWorkspaceRevision(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace)); if (this.sdlcState.isWorkspaceOutOfSync) { // ensure changes/conflicts have been computed for latest remote version const remoteWorkspaceEntities = (yield this.editorStore.sdlcServerClient.getEntitiesByRevision(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace, this.sdlcState.activeRemoteWorkspaceRevision.id)); this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.setEntities(remoteWorkspaceEntities); yield flowResult(this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.buildEntityHashesIndex(remoteWorkspaceEntities, LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_BUILD_LOCAL_HASHES_INDEX__SUCCESS))); yield flowResult(this.editorStore.changeDetectionState.computeAggregatedWorkspaceRemoteChanges()); this.editorStore.applicationStore.alertService.setActionAlertInfo({ message: 'Local workspace is out-of-sync', prompt: 'Please pull remote changes before pushing your local changes', type: ActionAlertType.CAUTION, actions: [ { label: 'Pull remote changes', type: ActionAlertActionType.STANDARD, default: true, handler: () => { this.editorStore.setActiveActivity(ACTIVITY_MODE.LOCAL_CHANGES); flowResult(this.workspaceSyncState.pullChanges()).catch(this.editorStore.applicationStore.alertUnhandledError); }, }, { label: 'Cancel', type: ActionAlertActionType.PROCEED_WITH_CAUTION, }, ], }); this.pushChangesState.complete(); return; } const currentHashesIndex = this.getCurrentHashIndexes(); try { const nullableRevisionChange = (yield this.editorStore.sdlcServerClient.performEntityChanges(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace, { message: pushMessage ?? `pushed new changes from ${this.editorStore.applicationStore.config.appName} [potentially affected ${localChanges.length === 1 ? '1 entity' : `${localChanges.length} entities`}]`, entityChanges: localChanges, revisionId: this.sdlcState.activeRevision.id, })); const revisionChange = guaranteeNonNullable(nullableRevisionChange, `Can't push an empty change set. This may be due to an error with change detection`); const latestRevision = Revision.serialization.fromJson(revisionChange); this.sdlcState.setCurrentRevision(latestRevision); // update current revision to the latest this.sdlcState.setWorkspaceLatestRevision(latestRevision); const syncFinishedTime = Date.now(); this.editorStore.applicationStore.logService.info(LogEvent.create(LEGEND_STUDIO_APP_EVENT.PUSH_LOCAL_CHANGES__SUCCESS), syncFinishedTime - startTime, 'ms'); // ======= (RE)START CHANGE DETECTION ======= this.stopChangeDetection(); try { /** * Here we try to rebuild local hash index. If failed, we will use local hash index, but for veracity, it's best to use entities * coming from the server. */ const entities = (yield this.editorStore.sdlcServerClient.getEntitiesByRevision(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace, latestRevision.id)); this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.setEntities(entities); yield flowResult(this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.buildEntityHashesIndex(entities, LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_BUILD_LOCAL_HASHES_INDEX__SUCCESS))); this.editorStore.tabManagerState.refreshCurrentEntityDiffViewer(); } catch (error) { assertErrorThrown(error); /** * NOTE: there is a known problem with the SDLC server where if we try to fetch the entities right after syncing, there is a chance * that we get entities from the older commit (i.e. potentially some caching issue). As such, to account for this case, we will * not try to get entities for the workspace HEAD, but for the revision returned from the syncing call (i.e. this must be the latest revision) * if we get a 404, we will do a refresh and warn user about this. Otherwise, if we get other types of error, we will assume this is a network * failure and use local workspace hashes index */ if (error instanceof NetworkClientError) { if (error.response.status === HttpStatus.NOT_FOUND) { this.editorStore.applicationStore.logService.error(LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE), `Can't fetch entities for the latest workspace revision immediately after syncing`, error); } this.editorStore.applicationStore.alertService.setActionAlertInfo({ message: `Change detection engine failed to build hashes index for workspace after syncing`, prompt: 'To fix this, you can either try to keep refreshing local changes until success or trust and reuse current workspace hashes index', type: ActionAlertType.CAUTION, actions: [ { label: 'Use local hashes index', type: ActionAlertActionType.PROCEED_WITH_CAUTION, handler: () => { this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.setEntityHashesIndex(currentHashesIndex); this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.setIsBuildingEntityHashesIndex(false); }, }, { label: 'Refresh changes', type: ActionAlertActionType.STANDARD, default: true, handler: this.editorStore.applicationStore.guardUnhandledError(() => flowResult(this.refreshLocalChanges())), }, ], }); } else { throw error; } } yield flowResult(this.restartChangeDetection()); this.editorStore.applicationStore.logService.info(LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_RESTART__SUCCESS), Date.now() - syncFinishedTime, 'ms'); // ======= FINISHED (RE)START CHANGE DETECTION ======= } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.logService.error(LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE), error); if (error instanceof NetworkClientError && error.response.status === HttpStatus.CONFLICT) { // NOTE: a confict here indicates that the reference revision ID sent along with update call // does not match the HEAD of the workspace, therefore, we need to prompt user to refresh the application this.editorStore.applicationStore.notificationService.notifyWarning('Syncing failed. Current workspace revision is not the latest. Please backup your work and refresh the application'); // TODO: maybe we should do more here, e.g. prompt the user to download the patch, but that is for later } else { this.editorStore.applicationStore.notificationService.notifyError(error); } } finally { this.pushChangesState.complete(); } } } export class FormLocalChangesState extends LocalChangesState { constructor(editorStore, sdlcState) { super(editorStore, sdlcState); makeObservable(this, { openPotentialWorkspacePullConflict: action, }); } openLocalChange(diff) { const fromEntityGetter = (entityPath) => { if (entityPath) { return this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.entities.find((e) => e.path === entityPath); } return undefined; }; const toEntityGetter = (entityPath) => { if (!entityPath) { return undefined; } const element = this.editorStore.graphManagerState.graph.getNullableElement(entityPath); if (!element) { return undefined; } const entity = this.editorStore.graphManagerState.graphManager.elementToEntity(element, { pruneSourceInformation: true, }); return entity; }; const fromEntity = EntityDiff.shouldOldEntityExist(diff) ? guaranteeNonNullable(fromEntityGetter(diff.getValidatedOldPath()), `Can't find entity with path '${diff.oldPath}'`) : undefined; const toEntity = EntityDiff.shouldNewEntityExist(diff) ? guaranteeNonNullable(toEntityGetter(diff.getValidatedNewPath()), `Can't find entity with path '${diff.newPath}'`) : undefined; this.editorStore.tabManagerState.openTab(new EntityDiffViewState(this.editorStore, SPECIAL_REVISION_ALIAS.WORKSPACE_HEAD, SPECIAL_REVISION_ALIAS.LOCAL, diff.oldPath, diff.newPath, fromEntity, toEntity, fromEntityGetter, toEntityGetter)); } openWorkspacePullChange(diff) { const fromEntityGetter = (entityPath) => { if (entityPath) { return this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.entities.find((e) => e.path === entityPath); } return undefined; }; const toEntityGetter = (entityPath) => { if (entityPath) { return this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.entities.find((e) => e.path === entityPath); } return 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(toEntityGetter(diff.getValidatedNewPath()), `Can't find entity with path '${diff.newPath}'`) : undefined; this.editorStore.tabManagerState.openTab(new EntityDiffViewState(this.editorStore, SPECIAL_REVISION_ALIAS.LOCAL, SPECIAL_REVISION_ALIAS.WORKSPACE_HEAD, diff.oldPath, diff.newPath, fromEntity, toEntity, fromEntityGetter, toEntityGetter)); } openPotentialWorkspacePullConflict(conflict) { const baseEntityGetter = (entityPath) => entityPath ? this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.entities.find((e) => e.path === entityPath) : undefined; const currentChangeEntityGetter = (entityPath) => entityPath ? this.editorStore.graphManagerState.graph.allOwnElements .map((element) => this.editorStore.graphManagerState.graphManager.elementToEntity(element)) .find((e) => e.path === entityPath) : undefined; const incomingChangeEntityGetter = (entityPath) => entityPath ? this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.entities.find((e) => e.path === entityPath) : undefined; const conflictEditorState = new EntityChangeConflictEditorState(this.editorStore, this.editorStore.conflictResolutionState, 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); conflictEditorState.setReadOnly(true); this.editorStore.tabManagerState.openTab(conflictEditorState); } *refreshLocalChanges() { const startTime = Date.now(); this.refreshLocalChangesDetectorState.inProgress(); try { // ======= (RE)START CHANGE DETECTION ======= this.editorStore.changeDetectionState.stop(); yield Promise.all([ this.sdlcState.buildWorkspaceLatestRevisionEntityHashesIndex(), this.editorStore.changeDetectionState.preComputeGraphElementHashes(), ]); this.editorStore.changeDetectionState.start(); this.editorStore.applicationStore.logService.info(LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_RESTART__SUCCESS), Date.now() - startTime, 'ms'); // ======= FINISHED (RE)START CHANGE DETECTION ======= } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.logService.error(LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE), error); this.editorStore.applicationStore.notificationService.notifyError(error); this.sdlcState.handleChangeDetectionRefreshIssue(error); } finally { this.refreshLocalChangesDetectorState.complete(); } } *processConflicts() { try { const isInConflictResolutionMode = (yield flowResult(this.sdlcState.checkIfCurrentWorkspaceIsInConflictResolutionMode())); if (isInConflictResolutionMode) { this.editorStore.applicationStore.alertService.setBlockingAlert({ message: 'Workspace is in conflict resolution mode', prompt: 'Please refresh the application', }); return; } } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.notificationService.notifyWarning('Failed to check if current workspace is in conflict resolution mode'); return; } } /** * Get entitiy changes to prepare for syncing */ computeLocalEntityChanges() { const baseHashesIndex = this.editorStore.isInConflictResolutionMode ? this.editorStore.changeDetectionState .conflictResolutionHeadRevisionState.entityHashesIndex : this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState .entityHashesIndex; const originalPaths = new Set(Array.from(baseHashesIndex.keys())); const entityChanges = []; this.editorStore.graphManagerState.graph.allOwnElements.forEach((element) => { const elementPath = element.path; if (baseHashesIndex.get(elementPath) !== element.hashCode) { const entity = this.editorStore.graphManagerState.graphManager.elementToEntity(element, { pruneSourceInformation: true, }); entityChanges.push({ classifierPath: entity.classifierPath, entityPath: element.path, content: entity.content, type: baseHashesIndex.get(elementPath) !== undefined ? EntityChangeType.MODIFY : EntityChangeType.CREATE, }); } originalPaths.delete(elementPath); }); Array.from(originalPaths).forEach((path) => { entityChanges.push({ type: EntityChangeType.DELETE, entityPath: path, }); }); return entityChanges; } getCurrentHashIndexes() { return this.editorStore.changeDetectionState.snapshotLocalEntityHashesIndex(); } stopChangeDetection() { this.editorStore.changeDetectionState.stop(); } *restartChangeDetection() { yield this.editorStore.changeDetectionState.preComputeGraphElementHashes(); this.editorStore.changeDetectionState.start(); yield Promise.all([ this.editorStore.changeDetectionState.computeAggregatedWorkspaceChanges(true), ]); } } export class TextLocalChangesState extends LocalChangesState { localChanges = []; constructor(editorStore, sdlcState) { super(editorStore, sdlcState); makeObservable(this, { setLocalChanges: action, }); } setLocalChanges(val) { this.localChanges = val; } *refreshLocalChanges() { this.refreshLocalChangesDetectorState.inProgress(); try { yield Promise.all([ this.sdlcState.buildWorkspaceLatestRevisionEntityHashesIndex(), this.editorStore.changeDetectionState.preComputeGraphElementHashes(), ]); this.editorStore.changeDetectionState.computeLocalChangesInTextMode(this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState .entities); } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.logService.error(LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE), error); this.editorStore.applicationStore.notificationService.notifyError(error); this.sdlcState.handleChangeDetectionRefreshIssue(error); } finally { this.refreshLocalChangesDetectorState.complete(); } } *processConflicts() { return; } getCurrentHashIndexes() { return this.editorStore.changeDetectionState .workspaceLocalLatestRevisionState.currentEntityHashesIndex; } stopChangeDetection() { this.localChanges = []; } computeLocalEntityChanges() { return this.localChanges; } *restartChangeDetection() { this.editorStore.changeDetectionState.computeLocalChangesInTextMode(this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState .entities); } } //# sourceMappingURL=LocalChangesState.js.map