Revert "Revert "Move reviewed files into model""

This reverts commit ba40029b1e8000224bc84720d4f6215a76a2d586.

Reason for revert: Integrate fix in Change 326058

Change-Id: Iff1409930a2b2732125a1a97fea38af291adcc37
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 1918972..441c233 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -221,7 +221,7 @@
   _loggedIn = false;
 
   @property({type: Array})
-  _reviewed?: string[] = [];
+  reviewed?: string[] = [];
 
   @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
   diffPrefs?: DiffPreferencesInfo;
@@ -318,6 +318,8 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  private readonly changeModel = getAppContext().changeModel;
+
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getBrowserModel = resolve(this, browserModelToken);
@@ -394,6 +396,9 @@
       ).subscribe(sizeBarInChangeTable => {
         this._showSizeBars = sizeBarInChangeTable;
       }),
+      this.changeModel.reviewedFiles$.subscribe(reviewedFiles => {
+        this.reviewed = reviewedFiles ?? [];
+      }),
     ];
 
     getPluginLoader()
@@ -470,7 +475,7 @@
     this._loading = true;
 
     this.collapseAllDiffs();
-    const promises = [];
+    const promises: Promise<boolean | void>[] = [];
 
     promises.push(
       this.restApiService
@@ -481,19 +486,7 @@
     );
 
     promises.push(
-      this._getLoggedIn()
-        .then(loggedIn => (this._loggedIn = loggedIn))
-        .then(loggedIn => {
-          if (!loggedIn) {
-            return;
-          }
-
-          return this._getReviewedFiles(changeNum, patchRange).then(
-            reviewed => {
-              this._reviewed = reviewed;
-            }
-          );
-        })
+      this._getLoggedIn().then(loggedIn => (this._loggedIn = loggedIn))
     );
 
     return Promise.all(promises).then(() => {
@@ -755,7 +748,7 @@
       throw new Error('changeNum and patchRange must be set');
     }
 
-    return this.restApiService.saveFileReviewed(
+    return this.changeModel.setReviewedFilesStatus(
       this.changeNum,
       this.patchRange.patchNum,
       path,
@@ -780,8 +773,7 @@
     const paths = Object.keys(response).sort(specialFilePathCompare);
     const files: NormalizedFileInfo[] = [];
     for (let i = 0; i < paths.length; i++) {
-      // TODO(TS): make copy instead of as NormalizedFileInfo
-      const info = response[paths[i]] as NormalizedFileInfo;
+      const info = {...response[paths[i]]} as NormalizedFileInfo;
       info.__path = paths[i];
       info.lines_inserted = info.lines_inserted || 0;
       info.lines_deleted = info.lines_deleted || 0;
@@ -1133,7 +1125,7 @@
     '_filesByPath',
     'changeComments',
     'patchRange',
-    '_reviewed',
+    'reviewed',
     '_loading'
   )
   _computeFiles(
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index e212bcb..bd12eae 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -662,7 +662,7 @@
     });
 
     test('file review status', () => {
-      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
       element._filesByPath = {
         '/COMMIT_MSG': {},
         'file_added_in_rev2.txt': {},
@@ -1509,7 +1509,7 @@
           size: 100,
         },
       };
-      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
       element._loggedIn = true;
       element.changeNum = '42';
       element.patchRange = {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 459b696..2fdb650 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -112,8 +112,8 @@
 import {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
 import {isFalse, throttleWrap, until} from '../../../utils/async-util';
-import {filter, take} from 'rxjs/operators';
-import {Subscription, combineLatest} from 'rxjs';
+import {filter, take, switchMap} from 'rxjs/operators';
+import {combineLatest, Subscription} from 'rxjs';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
 import {LoadingStatus} from '../../../services/change/change-model';
 import {DisplayLine} from '../../../api/diff';
@@ -123,7 +123,6 @@
 import {resolve, DIPolymerElement} from '../../../models/dependency';
 import {BehaviorSubject} from 'rxjs';
 
-const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const LOADING_BLAME = 'Loading blame...';
 const LOADED_BLAME = 'Blame loaded';
 
@@ -273,20 +272,14 @@
   @property({type: Object, computed: '_getRevisionInfo(_change)'})
   _revisionInfo?: RevisionInfoObj;
 
-  @property({type: Object})
-  _reviewedFiles = new Set<string>();
-
   @property({type: Number})
   _focusLineNum?: number;
 
-  private getReviewedParams: {
-    changeNum?: NumericChangeId;
-    patchNum?: PatchSetNum;
-  } = {};
-
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
+  private reviewedFiles = new Set<string>();
+
   override keyboardShortcuts(): ShortcutListener[] {
     return [
       listen(Shortcut.LEFT_PANE, _ => this.cursor.moveLeft()),
@@ -422,29 +415,53 @@
       })
     );
 
+    this.subscriptions.push(
+      this.changeModel.reviewedFiles$.subscribe(reviewedFiles => {
+        this.reviewedFiles = new Set(reviewedFiles) ?? new Set();
+      })
+    );
+
+    this.subscriptions.push(
+      this.changeModel.diffPath$.subscribe(path => (this._path = path))
+    );
+
+    this.subscriptions.push(
+      combineLatest(
+        this.changeModel.diffPath$,
+        this.changeModel.reviewedFiles$
+      ).subscribe(([path, files]) => {
+        this.$.reviewed.checked = !!path && !!files && files.includes(path);
+      })
+    );
+
     // When user initially loads the diff view, we want to autmatically mark
     // the file as reviewed if they have it enabled. We can't observe these
     // properties since the method will be called anytime a property updates
     // but we only want to call this on the initial load.
     this.subscriptions.push(
-      combineLatest([
-        this.changeModel.currentPatchNum$,
-        this.routerModel.routerView$,
-        this.changeModel.diffPath$,
-        this.userModel.diffPreferences$,
-      ])
+      this.changeModel.diffPath$
         .pipe(
-          filter(
-            ([currentPatchNum, routerView, path, diffPrefs]) =>
-              !!currentPatchNum &&
-              routerView === GerritView.DIFF &&
-              !!path &&
-              !!diffPrefs
-          ),
-          take(1)
+          filter(diffPath => !!diffPath),
+          switchMap(() =>
+            combineLatest(
+              this.changeModel.currentPatchNum$,
+              this.routerModel.routerView$,
+              this.userModel.diffPreferences$,
+              this.changeModel.reviewedFiles$
+            ).pipe(
+              filter(
+                ([currentPatchNum, routerView, diffPrefs, reviewedFiles]) =>
+                  !!currentPatchNum &&
+                  routerView === GerritView.DIFF &&
+                  !!diffPrefs &&
+                  !!reviewedFiles
+              ),
+              take(1)
+            )
+          )
         )
-        .subscribe(([currentPatchNum, _routerView, path, diffPrefs]) => {
-          this.setReviewedStatus(currentPatchNum!, path!, diffPrefs);
+        .subscribe(([currentPatchNum, _routerView, diffPrefs]) => {
+          this.setReviewedStatus(currentPatchNum!, diffPrefs);
         })
     );
     this.subscriptions.push(
@@ -479,17 +496,18 @@
     super.disconnectedCallback();
   }
 
+  /**
+   * Set initial review status of the file.
+   * automatically mark the file as reviewed if manual review is not set.
+   */
+
   async setReviewedStatus(
     currentPatchNum: PatchSetNum,
-    path: string,
     diffPrefs: DiffPreferencesInfo
   ) {
     const loggedIn = await this._getLoggedIn();
     if (!loggedIn) return;
-    await this._getReviewedFiles();
-    if (diffPrefs.manual_review) {
-      this.$.reviewed.checked = this._getReviewedStatus(path!);
-    } else {
+    if (!diffPrefs.manual_review) {
       this._setReviewed(true, currentPatchNum as RevisionPatchSetNum);
     }
   }
@@ -590,32 +608,14 @@
     patchNum: RevisionPatchSetNum | undefined = this._patchRange?.patchNum
   ) {
     if (this._editMode) return;
-    this.$.reviewed.checked = reviewed;
-    if (!patchNum || !this._path) return;
+    if (!patchNum || !this._path || !this._changeNum) return;
     const path = this._path;
     // if file is already reviewed then do not make a saveReview request
-    if (this._reviewedFiles.has(path) && reviewed) return;
-    if (reviewed) this._reviewedFiles.add(path);
-    else this._reviewedFiles.delete(path);
-    this._saveReviewedState(reviewed, patchNum).catch(err => {
-      if (this._reviewedFiles.has(path)) this._reviewedFiles.delete(path);
-      else this._reviewedFiles.add(path);
-      fireAlert(this, ERR_REVIEW_STATUS);
-      throw err;
-    });
-  }
-
-  _saveReviewedState(
-    reviewed: boolean,
-    patchNum?: RevisionPatchSetNum
-  ): Promise<Response | undefined> {
-    if (!this._changeNum) return Promise.resolve(undefined);
-    if (!patchNum) return Promise.resolve(undefined);
-    if (!this._path) return Promise.resolve(undefined);
-    return this.restApiService.saveFileReviewed(
+    if (this.reviewedFiles.has(path) && reviewed) return;
+    this.changeModel.setReviewedFilesStatus(
       this._changeNum,
       patchNum,
-      this._path,
+      path,
       reviewed
     );
   }
@@ -736,11 +736,11 @@
   private navigateToUnreviewedFile(direction: string) {
     if (!this._path) return;
     if (!this._fileList) return;
-    if (!this._reviewedFiles) return;
+    if (!this.reviewedFiles) return;
     // Ensure that the currently viewed file always appears in unreviewedFiles
     // so we resolve the right "next" file.
     const unreviewedFiles = this._fileList.filter(
-      file => file === this._path || !this._reviewedFiles.has(file)
+      file => file === this._path || !this.reviewedFiles.has(file)
     );
 
     this._navToFile(this._path, unreviewedFiles, direction === 'next' ? 1 : -1);
@@ -940,30 +940,6 @@
     return {path: fileList[idx]};
   }
 
-  _getReviewedFiles(changeNum?: NumericChangeId, patchNum?: PatchSetNum) {
-    if (!changeNum || !patchNum) return;
-    if (
-      this.getReviewedParams.changeNum === changeNum &&
-      this.getReviewedParams.patchNum === patchNum
-    ) {
-      return Promise.resolve();
-    }
-    this.getReviewedParams = {
-      changeNum,
-      patchNum,
-    };
-    return this.restApiService
-      .getReviewedFiles(changeNum, patchNum)
-      .then(files => {
-        this._reviewedFiles = new Set(files);
-      });
-  }
-
-  _getReviewedStatus(path: string) {
-    if (this._editMode) return false;
-    return this._reviewedFiles.has(path);
-  }
-
   _initLineOfInterestAndCursor(leftSide: boolean) {
     this.$.diffHost.lineOfInterest = this._getLineOfInterest(leftSide);
     this._initCursor(leftSide);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index b590625..77bbded 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -19,7 +19,7 @@
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {ChangeStatus, DiffViewMode, createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {stubRestApi, stubUsers, waitUntil} from '../../../test/test-utils.js';
+import {stubRestApi, stubUsers, waitUntil, stubChange} from '../../../test/test-utils.js';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {
@@ -1190,10 +1190,10 @@
 
     test('_prefs.manual_review true means set reviewed is not ' +
       'automatically called', async () => {
-      const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
+      const setReviewedFileStatusStub = stubChange('setReviewedFilesStatus')
           .callsFake(() => Promise.resolve());
-      const getReviewedStub = sinon.stub(element, '_getReviewedStatus')
-          .returns(false);
+
+      const setReviewedStatusStub = sinon.spy(element, 'setReviewedStatus');
 
       sinon.stub(element.$.diffHost, 'reload');
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
@@ -1204,7 +1204,9 @@
       element.userModel.setDiffPreferences(diffPreferences);
       element.changeModel.setState({
         change: createChange(),
-        diffPath: '/COMMIT_MSG'});
+        diffPath: '/COMMIT_MSG',
+        reviewedFiles: [],
+      });
 
       element.routerModel.setState({
         changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
@@ -1214,25 +1216,21 @@
         basePatchNum: 1,
       };
 
-      await waitUntil(() => getReviewedStub.called);
+      await waitUntil(() => setReviewedStatusStub.called);
 
-      assert.isFalse(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.called);
+      assert.isFalse(setReviewedFileStatusStub.called);
 
       // if prefs are updated then the reviewed status should not be set again
       element.userModel.setDiffPreferences(createDefaultDiffPrefs());
 
       await flush();
-      assert.isFalse(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.calledOnce);
+      assert.isFalse(setReviewedFileStatusStub.called);
     });
 
     test('_prefs.manual_review false means set reviewed is called',
         async () => {
-          const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
+          const setReviewedFileStatusStub = stubChange('setReviewedFilesStatus')
               .callsFake(() => Promise.resolve());
-          const getReviewedStub = sinon.stub(element, '_getReviewedStatus')
-              .returns(false);
 
           sinon.stub(element.$.diffHost, 'reload');
           sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
@@ -1243,7 +1241,9 @@
           element.userModel.setDiffPreferences(diffPreferences);
           element.changeModel.setState({
             change: createChange(),
-            diffPath: '/COMMIT_MSG'});
+            diffPath: '/COMMIT_MSG',
+            reviewedFiles: [],
+          });
 
           element.routerModel.setState({
             changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF,
@@ -1254,22 +1254,23 @@
             basePatchNum: 1,
           };
 
-          await waitUntil(() => saveReviewedStub.called);
+          await waitUntil(() => setReviewedFileStatusStub.called);
 
-          assert.isTrue(saveReviewedStub.called);
-          assert.isFalse(getReviewedStub.called);
+          assert.isTrue(setReviewedFileStatusStub.called);
         });
 
     test('file review status', async () => {
+      element.changeModel.setState({
+        change: createChange(),
+        diffPath: '/COMMIT_MSG',
+        reviewedFiles: [],
+      });
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
+      const saveReviewedStub = stubChange('setReviewedFilesStatus')
           .callsFake(() => Promise.resolve());
       sinon.stub(element.$.diffHost, 'reload');
 
       element.userModel.setDiffPreferences(createDefaultDiffPrefs());
-      element.changeModel.setState({
-        change: createChange(),
-        diffPath: '/COMMIT_MSG'});
 
       element.routerModel.setState({
         changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
@@ -1282,19 +1283,28 @@
 
       await waitUntil(() => saveReviewedStub.called);
 
+      element.changeModel.updateStateFileReviewed('/COMMIT_MSG', true);
+      await flush();
+
       const reviewedStatusCheckBox = element.root.querySelector(
           'input[type="checkbox"]');
 
       assert.isTrue(reviewedStatusCheckBox.checked);
-      assert.deepEqual(saveReviewedStub.lastCall.args, [true, 2]);
+      assert.deepEqual(saveReviewedStub.lastCall.args,
+          ['42', 2, '/COMMIT_MSG', true]);
 
       MockInteractions.tap(reviewedStatusCheckBox);
       assert.isFalse(reviewedStatusCheckBox.checked);
-      assert.deepEqual(saveReviewedStub.lastCall.args, [false, 2]);
+      assert.deepEqual(saveReviewedStub.lastCall.args,
+          ['42', 2, '/COMMIT_MSG', false]);
+
+      element.changeModel.updateStateFileReviewed('/COMMIT_MSG', false);
+      await flush();
 
       MockInteractions.tap(reviewedStatusCheckBox);
       assert.isTrue(reviewedStatusCheckBox.checked);
-      assert.deepEqual(saveReviewedStub.lastCall.args, [true, 2]);
+      assert.deepEqual(saveReviewedStub.lastCall.args,
+          ['42', 2, '/COMMIT_MSG', true]);
 
       const callCount = saveReviewedStub.callCount;
 
@@ -1307,7 +1317,7 @@
     });
 
     test('file review status with edit loaded', () => {
-      const saveReviewedStub = sinon.stub(element, '_saveReviewedState');
+      const saveReviewedStub = stubChange('setReviewedFilesStatus');
 
       element._patchRange = {patchNum: EditPatchSetNum};
       flush();
@@ -1796,7 +1806,7 @@
         isAtEndStub.returns(true);
 
         element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-        element._reviewedFiles = new Set(['file2']);
+        element.reviewedFiles = new Set(['file2']);
         element._path = 'file1';
 
         nowStub.returns(5);
@@ -1840,7 +1850,7 @@
         isAtStartStub.returns(true);
 
         element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-        element._reviewedFiles = new Set(['file2']);
+        element.reviewedFiles = new Set(['file2']);
         element._path = 'file3';
 
         nowStub.returns(5);
@@ -1887,7 +1897,7 @@
 
     test('shift+m navigates to next unreviewed file', () => {
       element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      element._reviewedFiles = new Set(['file1', 'file2']);
+      element.reviewedFiles = new Set(['file1', 'file2']);
       element._path = 'file1';
       const reviewedStub = sinon.stub(element, '_setReviewed');
       const navStub = sinon.stub(element, '_navToFile');
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 0ba1e6c..9634147 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -28,6 +28,7 @@
   Observable,
   Subscription,
   forkJoin,
+  of,
 } from 'rxjs';
 import {
   map,
@@ -43,6 +44,7 @@
   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 '../gr-rest-api/gr-rest-api';
@@ -58,6 +60,8 @@
   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.
@@ -71,6 +75,12 @@
    * 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[];
 }
 
 /**
@@ -112,6 +122,10 @@
 };
 
 export class ChangeModel extends Model<ChangeState> implements Finalizable {
+  private change?: ParsedChangeInfo;
+
+  private currentPatchNum?: PatchSetNum;
+
   public readonly change$ = select(
     this.state$,
     changeState => changeState.change
@@ -127,6 +141,11 @@
     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);
@@ -205,6 +224,21 @@
           // helps with that.
           this.updateStateChange(change ?? undefined);
         }),
+      this.change$.subscribe(change => (this.change = change)),
+      this.currentPatchNum$.subscribe(
+        currentPatchNum => (this.currentPatchNum = currentPatchNum)
+      ),
+      combineLatest([this.currentPatchNum$, this.changeNum$])
+        .pipe(
+          switchMap(([currentPatchNum, changeNum]) => {
+            if (!changeNum || !currentPatchNum) {
+              this.updateStateReviewedFiles([]);
+              return of(undefined);
+            }
+            return from(this.fetchReviewedFiles(currentPatchNum!, changeNum!));
+          })
+        )
+        .subscribe(),
     ];
   }
 
@@ -221,6 +255,71 @@
     this.setState({...current, diffPath});
   }
 
+  updateStateReviewedFiles(reviewedFiles: string[]) {
+    const current = this.subject$.getValue();
+    this.setState({...current, reviewedFiles});
+  }
+
+  updateStateFileReviewed(file: string, reviewed: boolean) {
+    const current = this.subject$.getValue();
+    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.setState({...current, reviewedFiles});
+  }
+
+  fetchReviewedFiles(currentPatchNum: PatchSetNum, changeNum: NumericChangeId) {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) return;
+      this.restApiService
+        .getReviewedFiles(changeNum, currentPatchNum)
+        .then(files => {
+          if (
+            changeNum !== this.change?._number ||
+            currentPatchNum !== this.currentPatchNum
+          )
+            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.currentPatchNum
+        )
+          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
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index fbc2433..88167e0 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -28,6 +28,7 @@
 import {queryAndAssert, query} from '../utils/common-util';
 import {FlagsService} from '../services/flags/flags';
 import {Key, Modifier} from '../utils/dom-util';
+import {ChangeModel} from '../services/change/change-model';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
 export interface MockPromise<T> extends Promise<T> {
@@ -111,6 +112,10 @@
   return sinon.spy(getAppContext().restApiService, method);
 }
 
+export function stubChange<K extends keyof ChangeModel>(method: K) {
+  return sinon.stub(getAppContext().changeModel, method);
+}
+
 export function stubUsers<K extends keyof UserModel>(method: K) {
   return sinon.stub(getAppContext().userModel, method);
 }