| /** |
| * @license |
| * Copyright 2020 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import { |
| BasePatchSetNum, |
| EditInfo, |
| EDIT, |
| PARENT, |
| NumericChangeId, |
| PatchSetNum, |
| PreferencesInfo, |
| RevisionPatchSetNum, |
| } from '../../types/common'; |
| import {DefaultBase} from '../../constants/constants'; |
| import { |
| combineLatest, |
| from, |
| fromEvent, |
| Observable, |
| Subscription, |
| forkJoin, |
| of, |
| } from 'rxjs'; |
| import { |
| map, |
| filter, |
| withLatestFrom, |
| distinctUntilChanged, |
| startWith, |
| switchMap, |
| } from 'rxjs/operators'; |
| import {RouterModel} from '../../services/router/router-model'; |
| import { |
| computeAllPatchSets, |
| computeLatestPatchNum, |
| } from '../../utils/patch-set-util'; |
| import {ParsedChangeInfo} from '../../types/types'; |
| import {fireAlert} from '../../utils/event-util'; |
| |
| import {ChangeInfo} from '../../types/common'; |
| import {RestApiService} from '../../services/gr-rest-api/gr-rest-api'; |
| import {Finalizable} from '../../services/registry'; |
| import {select} from '../../utils/observable-util'; |
| import {assertIsDefined} from '../../utils/common-util'; |
| import {Model} from '../model'; |
| import {UserModel} from '../user/user-model'; |
| import {define} from '../dependency'; |
| import {isOwner} from '../../utils/change-util'; |
| |
| export enum LoadingStatus { |
| NOT_LOADED = 'NOT_LOADED', |
| LOADING = 'LOADING', |
| RELOADING = 'RELOADING', |
| LOADED = 'LOADED', |
| } |
| |
| const ERR_REVIEW_STATUS = 'Couldn’t change file review status.'; |
| |
| export interface ChangeState { |
| /** |
| * If `change` is undefined, this must be either NOT_LOADED or LOADING. |
| * If `change` is defined, this must be either LOADED or RELOADING. |
| */ |
| loadingStatus: LoadingStatus; |
| change?: ParsedChangeInfo; |
| /** |
| * The name of the file user is viewing in the diff view mode. File path is |
| * specified in the url or derived from the commentId. |
| * Does not apply to change-view or edit-view. |
| */ |
| diffPath?: string; |
| /** |
| * The list of reviewed files, kept in the model because we want changes made |
| * in one view to reflect on other views without re-rendering the other views. |
| * Undefined means it's still loading and empty set means no files reviewed. |
| */ |
| reviewedFiles?: string[]; |
| } |
| |
| /** |
| * Updates the change object with information from the saved `edit` patchset. |
| */ |
| // visible for testing |
| export function updateChangeWithEdit( |
| change?: ParsedChangeInfo, |
| edit?: EditInfo, |
| routerPatchNum?: PatchSetNum |
| ): ParsedChangeInfo | undefined { |
| if (!change || !edit) return change; |
| assertIsDefined(edit.commit.commit, 'edit.commit.commit'); |
| if (!change.revisions) change.revisions = {}; |
| change.revisions[edit.commit.commit] = { |
| _number: EDIT, |
| basePatchNum: edit.base_patch_set_number, |
| commit: edit.commit, |
| fetch: edit.fetch, |
| }; |
| // If the change was loaded without a specific patchset, then this normally |
| // means that the *latest* patchset should be loaded. But if there is an |
| // active edit, then automatically switch to that edit as the current |
| // patchset. |
| // TODO: This goes together with `_patchRange.patchNum' being set to `edit`, |
| // which is still done in change-view. `_patchRange.patchNum` should |
| // eventually also be model managed, so we can reconcile these two code |
| // snippets into one location. |
| if (routerPatchNum === undefined) { |
| change.current_revision = edit.commit.commit; |
| } |
| return change; |
| } |
| |
| /** |
| * Derives the base patchset number from all the data that can potentially |
| * influence it. Mostly just returns `routerBasePatchNum` or PARENT, but has |
| * some special logic when looking at merge commits. |
| * |
| * NOTE: At the moment this returns just `routerBasePatchNum ?? PARENT`, see |
| * TODO below. |
| */ |
| function computeBase( |
| routerBasePatchNum: BasePatchSetNum | undefined, |
| patchNum: RevisionPatchSetNum | undefined, |
| change: ParsedChangeInfo | undefined, |
| preferences: PreferencesInfo |
| ): BasePatchSetNum { |
| if (routerBasePatchNum && routerBasePatchNum !== PARENT) { |
| return routerBasePatchNum; |
| } |
| if (!change || !patchNum) return PARENT; |
| |
| const preferFirst = |
| preferences.default_base_for_merges === DefaultBase.FIRST_PARENT; |
| if (!preferFirst) return PARENT; |
| |
| // TODO: Re-enable respecting the default_base_for_merges preference. |
| // For the Polygerrit UI this was originally implemented in change 214432, |
| // but we are not sure whether this was ever 100% working correctly. A |
| // major challenge is being able to select PARENT explicitly even if your |
| // preference for the default choice is FIRST_PARENT. <gr-file-list-header> |
| // just uses `navigation.setUrl()` and the router does not have any |
| // way of forcing the basePatchSetNum to stick to PARENT without being |
| // altered back to FIRST_PARENT here. |
| // See also corresponding TODO in gr-settings-view. |
| return PARENT; |
| // const revisionInfo = new RevisionInfo(change); |
| // const isMergeCommit = revisionInfo.isMergeCommit(patchNum); |
| // return isMergeCommit ? (-1 as PatchSetNumber) : PARENT; |
| } |
| |
| // TODO: Figure out how to best enforce immutability of all states. Use Immer? |
| // Use DeepReadOnly? |
| const initialState: ChangeState = { |
| loadingStatus: LoadingStatus.NOT_LOADED, |
| }; |
| |
| export const changeModelToken = define<ChangeModel>('change-model'); |
| |
| export class ChangeModel extends Model<ChangeState> implements Finalizable { |
| private change?: ParsedChangeInfo; |
| |
| private patchNum?: PatchSetNum; |
| |
| public readonly change$ = select( |
| this.state$, |
| changeState => changeState.change |
| ); |
| |
| public readonly changeLoadingStatus$ = select( |
| this.state$, |
| changeState => changeState.loadingStatus |
| ); |
| |
| public readonly diffPath$ = select( |
| this.state$, |
| changeState => changeState?.diffPath |
| ); |
| |
| public readonly reviewedFiles$ = select( |
| this.state$, |
| changeState => changeState?.reviewedFiles |
| ); |
| |
| public readonly changeNum$ = select(this.change$, change => change?._number); |
| |
| public readonly repo$ = select(this.change$, change => change?.project); |
| |
| public readonly labels$ = select(this.change$, change => change?.labels); |
| |
| public readonly latestPatchNum$ = select(this.change$, change => |
| computeLatestPatchNum(computeAllPatchSets(change)) |
| ); |
| |
| /** |
| * Emits the current patchset number. If the route does not define the current |
| * patchset num, then this selector waits for the change to be defined and |
| * returns the number of the latest patchset. |
| * |
| * Note that this selector can emit without the change being available! |
| */ |
| public readonly patchNum$: Observable<RevisionPatchSetNum | undefined> = |
| select( |
| combineLatest([ |
| this.routerModel.state$, |
| this.state$, |
| this.routerModel.routerPatchNum$, |
| this.latestPatchNum$, |
| ]).pipe( |
| /** |
| * If you depend on both, router and change state, then you want to filter |
| * out inconsistent state, e.g. router changeNum already updated, change not |
| * yet reset to undefined. |
| */ |
| filter(([routerState, changeState, _routerPatchN, _latestPatchN]) => { |
| const changeNum = changeState.change?._number; |
| const routerChangeNum = routerState.changeNum; |
| return changeNum === undefined || changeNum === routerChangeNum; |
| }) |
| ), |
| ([_routerState, _changeState, routerPatchN, latestPatchN]) => |
| routerPatchN || latestPatchN |
| ); |
| |
| /** |
| * Emits the base patchset number. This is identical to the |
| * `routerBasePatchNum$`, but has some special logic for merges. |
| * |
| * Note that this selector can emit without the change being available! |
| */ |
| public readonly basePatchNum$: Observable<BasePatchSetNum> = |
| /** |
| * If you depend on both, router and change state, then you want to filter |
| * out inconsistent state, e.g. router changeNum already updated, change not |
| * yet reset to undefined. |
| */ |
| combineLatest([this.routerModel.state$, this.state$, this.userModel.state$]) |
| .pipe( |
| filter(([routerState, changeState, _]) => { |
| const changeNum = changeState.change?._number; |
| const routerChangeNum = routerState.changeNum; |
| return changeNum === undefined || changeNum === routerChangeNum; |
| }), |
| distinctUntilChanged() |
| ) |
| .pipe( |
| withLatestFrom( |
| this.routerModel.routerBasePatchNum$, |
| this.patchNum$, |
| this.change$, |
| this.userModel.preferences$ |
| ), |
| map(([_, routerBasePatchNum, patchNum, change, preferences]) => |
| computeBase(routerBasePatchNum, patchNum, change, preferences) |
| ), |
| distinctUntilChanged() |
| ); |
| |
| public readonly isOwner$: Observable<boolean> = select( |
| combineLatest([this.change$, this.userModel.account$]), |
| ([change, account]) => isOwner(change, account) |
| ); |
| |
| private subscriptions: Subscription[] = []; |
| |
| // For usage in `combineLatest` we need `startWith` such that reload$ has an |
| // initial value. |
| readonly reload$: Observable<unknown> = fromEvent(document, 'reload').pipe( |
| startWith(undefined) |
| ); |
| |
| constructor( |
| readonly routerModel: RouterModel, |
| readonly restApiService: RestApiService, |
| readonly userModel: UserModel |
| ) { |
| super(initialState); |
| this.subscriptions = [ |
| combineLatest([this.routerModel.routerChangeNum$, this.reload$]) |
| .pipe( |
| map(([changeNum, _]) => changeNum), |
| switchMap(changeNum => { |
| if (changeNum !== undefined) this.updateStateLoading(changeNum); |
| const change = from(this.restApiService.getChangeDetail(changeNum)); |
| const edit = from(this.restApiService.getChangeEdit(changeNum)); |
| return forkJoin([change, edit]); |
| }), |
| withLatestFrom(this.routerModel.routerPatchNum$), |
| map(([[change, edit], patchNum]) => |
| updateChangeWithEdit(change, edit, patchNum) |
| ) |
| ) |
| .subscribe(change => { |
| // The change service is currently a singleton, so we have to be |
| // careful to avoid situations where the application state is |
| // partially set for the old change where the user is coming from, |
| // and partially for the new change where the user is navigating to. |
| // So setting the change explicitly to undefined when the user |
| // moves away from diff and change pages (changeNum === undefined) |
| // helps with that. |
| this.updateStateChange(change ?? undefined); |
| }), |
| this.change$.subscribe(change => (this.change = change)), |
| this.patchNum$.subscribe(patchNum => (this.patchNum = patchNum)), |
| combineLatest([this.patchNum$, this.changeNum$, this.userModel.loggedIn$]) |
| .pipe( |
| switchMap(([patchNum, changeNum, loggedIn]) => { |
| if (!changeNum || !patchNum || !loggedIn) { |
| this.updateStateReviewedFiles([]); |
| return of(undefined); |
| } |
| return from(this.fetchReviewedFiles(patchNum, changeNum)); |
| }) |
| ) |
| .subscribe(), |
| ]; |
| } |
| |
| override finalize() { |
| for (const s of this.subscriptions) { |
| s.unsubscribe(); |
| } |
| this.subscriptions = []; |
| } |
| |
| // Temporary workaround until path is derived in the model itself. |
| updatePath(diffPath?: string) { |
| this.updateState({diffPath}); |
| } |
| |
| updateStateReviewedFiles(reviewedFiles: string[]) { |
| this.updateState({reviewedFiles}); |
| } |
| |
| updateStateFileReviewed(file: string, reviewed: boolean) { |
| const current = this.getState(); |
| if (current.reviewedFiles === undefined) { |
| // Reviewed files haven't loaded yet. |
| // TODO(dhruvsri): disable updating status if reviewed files are not loaded. |
| fireAlert( |
| document, |
| 'Updating status failed. Reviewed files not loaded yet.' |
| ); |
| return; |
| } |
| const reviewedFiles = [...current.reviewedFiles]; |
| |
| // File is already reviewed and is being marked reviewed |
| if (reviewedFiles.includes(file) && reviewed) return; |
| // File is not reviewed and is being marked not reviewed |
| if (!reviewedFiles.includes(file) && !reviewed) return; |
| |
| if (reviewed) reviewedFiles.push(file); |
| else reviewedFiles.splice(reviewedFiles.indexOf(file), 1); |
| this.updateState({reviewedFiles}); |
| } |
| |
| fetchReviewedFiles(patchNum: PatchSetNum, changeNum: NumericChangeId) { |
| return this.restApiService |
| .getReviewedFiles(changeNum, patchNum) |
| .then(files => { |
| if (changeNum !== this.change?._number || patchNum !== this.patchNum) |
| return; |
| this.updateStateReviewedFiles(files ?? []); |
| }); |
| } |
| |
| setReviewedFilesStatus( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| file: string, |
| reviewed: boolean |
| ) { |
| return this.restApiService |
| .saveFileReviewed(changeNum, patchNum, file, reviewed) |
| .then(() => { |
| if (changeNum !== this.change?._number || patchNum !== this.patchNum) |
| return; |
| this.updateStateFileReviewed(file, reviewed); |
| }) |
| .catch(() => { |
| fireAlert(document, ERR_REVIEW_STATUS); |
| }); |
| } |
| |
| /** |
| * Typically you would just subscribe to change$ yourself to get updates. But |
| * sometimes it is nice to also be able to get the current ChangeInfo on |
| * demand. So here it is for your convenience. |
| */ |
| getChange() { |
| return this.getState().change; |
| } |
| |
| /** |
| * Check whether there is no newer patch than the latest patch that was |
| * available when this change was loaded. |
| * |
| * @return A promise that yields true if the latest patch |
| * has been loaded, and false if a newer patch has been uploaded in the |
| * meantime. The promise is rejected on network error. |
| */ |
| fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) { |
| const knownLatest = computeLatestPatchNum(computeAllPatchSets(change)); |
| return this.restApiService.getChangeDetail(change._number).then(detail => { |
| if (!detail) { |
| const error = new Error('Change detail not found.'); |
| return Promise.reject(error); |
| } |
| const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail)); |
| if (!actualLatest || !knownLatest) { |
| const error = new Error('Unable to check for latest patchset.'); |
| return Promise.reject(error); |
| } |
| return { |
| isLatest: actualLatest <= knownLatest, |
| newStatus: change.status !== detail.status ? detail.status : null, |
| newMessages: |
| (change.messages || []).length < (detail.messages || []).length |
| ? detail.messages![detail.messages!.length - 1] |
| : undefined, |
| }; |
| }); |
| } |
| |
| /** |
| * Called when change detail loading is initiated. |
| * |
| * If the change number matches the current change in the state, then |
| * this is a reload. If not, then we not just want to set the state to |
| * LOADING instead of RELOADING, but we also want to set the change to |
| * undefined right away. Otherwise components could see inconsistent state: |
| * a new change number, but an old change. |
| */ |
| private updateStateLoading(changeNum: NumericChangeId) { |
| const current = this.getState(); |
| const reloading = current.change?._number === changeNum; |
| this.updateState({ |
| change: reloading ? current.change : undefined, |
| loadingStatus: reloading |
| ? LoadingStatus.RELOADING |
| : LoadingStatus.LOADING, |
| }); |
| } |
| |
| // Private but used in tests. |
| updateStateChange(change?: ParsedChangeInfo) { |
| this.updateState({ |
| change, |
| loadingStatus: |
| change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED, |
| }); |
| } |
| } |