Merge "Fix the icons of RUNNING chips"
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 9f58aaf..63e0b7a 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -88,8 +88,10 @@
       // If the latest approved patchset is the current patchset, no need to return anything.
       return "";
     }
-    String diff =
-        String.format("\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get());
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get()));
     PatchList patchList =
         getPatchList(
             notes.getProjectName(),
@@ -103,19 +105,19 @@
             .collect(Collectors.toList());
 
     if (patchListEntryList.isEmpty()) {
-      diff +=
-          "No files were changed between the latest approved patch-set and the submitted one.\n";
-      return diff;
+      diff.append(
+          "No files were changed between the latest approved patch-set and the submitted one.\n");
+      return diff.toString();
     }
 
-    diff += "The change was submitted with unreviewed changes in the following files:\n\n";
+    diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
 
     for (PatchListEntry patchListEntry : patchListEntryList) {
-      diff +=
+      diff.append(
           getDiffForFile(
-              notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser);
+              notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser));
     }
-    return diff;
+    return diff.toString();
   }
 
   private String getDiffForFile(
@@ -126,12 +128,13 @@
       CurrentUser currentUser)
       throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
-    String diff =
-        String.format(
-            "The name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
-            patchListEntry.getNewName(),
-            patchListEntry.getInsertions(),
-            patchListEntry.getDeletions());
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "The name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
+                patchListEntry.getNewName(),
+                patchListEntry.getInsertions(),
+                patchListEntry.getDeletions()));
     DiffPreferencesInfo diffPreferencesInfo = createDefaultDiffPreferencesInfo();
     PatchScriptFactory patchScriptFactory =
         patchScriptFactoryFactory.create(
@@ -145,66 +148,66 @@
     try {
       patchScript = patchScriptFactory.call();
     } catch (LargeObjectException exception) {
-      diff += "The file content is too large for showing the full diff. \n\n";
-      return diff;
+      diff.append("The file content is too large for showing the full diff. \n\n");
+      return diff.toString();
     }
     if (patchScript.getChangeType() == ChangeType.RENAMED) {
-      diff +=
+      diff.append(
           String.format(
               "The file %s was renamed to %s\n",
-              patchListEntry.getOldName(), patchListEntry.getNewName());
+              patchListEntry.getOldName(), patchListEntry.getNewName()));
     }
     SparseFileContent.Accessor fileA = patchScript.getA().createAccessor();
     SparseFileContent.Accessor fileB = patchScript.getB().createAccessor();
     boolean editsExist = false;
     if (patchScript.getEdits().stream().anyMatch(e -> e.getType() != Edit.Type.EMPTY)) {
-      diff += "```\n";
+      diff.append("```\n");
       editsExist = true;
     }
     for (Edit edit : patchScript.getEdits()) {
-      diff += getDiffForEdit(fileA, fileB, edit);
+      diff.append(getDiffForEdit(fileA, fileB, edit));
     }
     if (editsExist) {
-      diff += "```\n";
+      diff.append("```\n");
     }
-    return diff;
+    return diff.toString();
   }
 
   private String getDiffForEdit(Accessor fileA, Accessor fileB, Edit edit) {
-    String diff = "";
+    StringBuilder diff = new StringBuilder();
     Edit.Type type = edit.getType();
     switch (type) {
       case INSERT:
-        diff += String.format("@@ +%d:%d @@\n", edit.getBeginB(), edit.getEndB());
-        diff += getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+');
-        diff += "\n";
+        diff.append(String.format("@@ +%d:%d @@\n", edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
         break;
       case DELETE:
-        diff += String.format("@@ -%d:%d @@\n", edit.getBeginA(), edit.getEndA());
-        diff += getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-');
-        diff += "\n";
+        diff.append(String.format("@@ -%d:%d @@\n", edit.getBeginA(), edit.getEndA()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append("\n");
         break;
       case REPLACE:
-        diff +=
+        diff.append(
             String.format(
                 "@@ -%d:%d, +%d:%d @@\n",
-                edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB());
-        diff += getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-');
-        diff += getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+');
-        diff += "\n";
+                edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
         break;
       case EMPTY:
         // do nothing since there is no change here.
     }
-    return diff;
+    return diff.toString();
   }
 
   private String getModifiedLines(Accessor file, int begin, int end, char modificationType) {
-    String diff = "";
+    StringBuilder diff = new StringBuilder();
     for (int i = begin; i < end; i++) {
-      diff += String.format("%c  %s\n", modificationType, file.get(i));
+      diff.append(String.format("%c  %s\n", modificationType, file.get(i)));
     }
-    return diff;
+    return diff.toString();
   }
 
   private DiffPreferencesInfo createDefaultDiffPreferencesInfo() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index beec0a4..538fa4969 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -22,7 +22,8 @@
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   allRuns$,
-  aPluginHasRegistered,
+  aPluginHasRegistered$,
+  someProvidersAreLoading$,
 } from '../../../services/checks/checks-model';
 import {
   Category,
@@ -262,13 +263,17 @@
   @property()
   showChecksSummary = false;
 
+  @property()
+  someProvidersAreLoading = false;
+
   /** Is reset when rendering beings and decreases while chips are rendered. */
   private detailsQuota = DETAILS_QUOTA;
 
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
-    this.subscribe('showChecksSummary', aPluginHasRegistered);
+    this.subscribe('showChecksSummary', aPluginHasRegistered$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
   }
 
   static get styles() {
@@ -312,7 +317,8 @@
 
   renderChecksZeroState() {
     if (this.runs.some(isRunningOrHasCompleted)) return;
-    return html`<span class="font-small zeroState">No results</span>`;
+    const msg = this.someProvidersAreLoading ? 'Loading...' : 'No results';
+    return html`<span class="font-small zeroState">${msg}</span>`;
   }
 
   renderChecksChipForCategory(category: Category) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 3d7646f..a7f5ea7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -171,7 +171,7 @@
 import {fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {takeUntil} from 'rxjs/operators';
-import {aPluginHasRegistered} from '../../../services/checks/checks-model';
+import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
 import {Subject} from 'rxjs';
 import {GrRelatedChangesListExperimental} from '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
 
@@ -274,11 +274,13 @@
    * @event show-auth-required
    */
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
 
-  flagsService = appContext.flagsService;
+  private readonly flagsService = appContext.flagsService;
 
-  readonly jsAPI = appContext.jsApiService;
+  private readonly jsAPI = appContext.jsApiService;
+
+  private readonly changeService = appContext.changeService;
 
   /**
    * URL params passed from the router.
@@ -594,7 +596,7 @@
   /** @override */
   ready() {
     super.ready();
-    aPluginHasRegistered.pipe(takeUntil(this.disconnected$)).subscribe(b => {
+    aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
       this._showChecksTab = b;
     });
     this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
@@ -2022,6 +2024,7 @@
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
         this._change = change;
+        this.changeService.updateChange(change);
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 99e5356..ae446cd 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -105,6 +105,7 @@
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
+import {appContext} from '../../../services/app-context';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
@@ -2766,7 +2767,7 @@
     element._change = {...change};
     element._patchRange = {patchNum: 4 as PatchSetNum};
     element._mergeable = true;
-    const showStub = sinon.stub(element.jsAPI, 'handleEvent');
+    const showStub = sinon.stub(appContext.jsApiService, 'handleEvent');
     element._sendShowChangeEvent();
     assert.isTrue(showStub.calledOnce);
     assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
@@ -2968,11 +2969,11 @@
 
     test("don't report changeDisplayed on reply", done => {
       const changeDisplayStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeFullyLoaded'
       );
       element._handleReplySent();
@@ -2985,11 +2986,11 @@
 
     test('report changeDisplayed on _paramsChanged', done => {
       const changeDisplayStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeFullyLoaded'
       );
       element._paramsChanged({
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
index 99094d2..29b3752 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
@@ -25,7 +25,7 @@
       margin-bottom: var(--spacing-l);
     }
     .warningBeforeSubmit {
-      color: var(--error-text-color);
+      color: var(--warning-foreground);
       vertical-align: top;
       margin-right: var(--spacing-s);
     }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 002c8c3..ef2430c 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -24,7 +24,14 @@
   query,
 } from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
-import {Category, CheckRun, Link, RunStatus, Tag} from '../../api/checks';
+import {
+  Category,
+  CheckRun,
+  Link,
+  LinkIcon,
+  RunStatus,
+  Tag,
+} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {RunResult} from '../../services/checks/checks-model';
 import {
@@ -35,6 +42,7 @@
 } from '../../services/checks/checks-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {whenVisible} from '../../utils/dom-util';
+import {durationString} from '../../utils/date-util';
 
 @customElement('gr-result-row')
 class GrResultRow extends GrLitElement {
@@ -473,9 +481,26 @@
     const adaptedRun: RunResult = {
       category: Category.INFO, // will not be used, but is required
       summary: run.statusDescription ?? '',
-      message: 'Completed without results.',
       ...run,
     };
+    if (!run.statusDescription) {
+      const start = run.scheduledTimestamp ?? run.startedTimestamp;
+      const end = run.finishedTimestamp;
+      let duration = '';
+      if (start && end) {
+        duration = ` in ${durationString(start, end, true)}`;
+      }
+      adaptedRun.message = `Completed without results${duration}.`;
+    }
+    if (run.statusLink) {
+      adaptedRun.links = [
+        {
+          url: run.statusLink,
+          primary: true,
+          icon: LinkIcon.EXTERNAL,
+        },
+      ];
+    }
     return html`<gr-result-row .result="${adaptedRun}"></gr-result-row>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 3a76112..676ef7b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -308,15 +308,6 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly changeService = appContext.changeService;
-
-  constructor() {
-    super();
-    // TODO: This is just an artificical dependdency such that the service is
-    // instantiated and its observables subscribed. Remove this later.
-    this.changeService.dontDoAnything();
-  }
-
   start() {
     if (!this._app) {
       return;
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 09e0724..0369ccf 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -70,7 +70,7 @@
     eventEmitter: () => new EventEmitter(),
     authService: () => new Auth(appContext.eventEmitter),
     restApiService: () => new GrRestApiInterface(appContext.authService),
-    changeService: () => new ChangeService(appContext.restApiService),
+    changeService: () => new ChangeService(),
     checksService: () => new ChecksService(),
     jsApiService: () => new GrJsApiInterface(),
   });
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index 6a9a5e9..4524813 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -14,28 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {routerChangeNum$} from '../router/router-model';
 import {updateState} from './change-model';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {switchMap, tap} from 'rxjs/operators';
-import {of, from} from 'rxjs';
+import {tap} from 'rxjs/operators';
+import {ParsedChangeInfo} from '../../types/types';
 
 export class ChangeService {
+  // TODO: In the future we will want to make restApiService.getChangeDetail()
+  // calls from a switchMap() here. For now just make sure to invalidate the
+  // change when no changeNum is set.
   private routerChangeNumEffect = routerChangeNum$.pipe(
-    switchMap(changeNum => {
-      if (!changeNum) return of(undefined);
-      return from(this.restApiService.getChangeDetail(changeNum));
-    }),
-    tap(change => {
-      updateState(change ?? undefined);
+    tap(changeNum => {
+      if (!changeNum) updateState(undefined);
     })
   );
 
-  constructor(private readonly restApiService: RestApiService) {
+  constructor() {
     this.routerChangeNumEffect.subscribe();
   }
 
-  // TODO: Remove.
-  dontDoAnything() {}
+  /**
+   * This is a temporary indirection between change-view, which currently
+   * manages what the current change is, and the change-model, which will
+   * become the source of truth in the future. We will extract a substantial
+   * amount of code from change-view and move it into this change-service. This
+   * will take some time ...
+   */
+  updateChange(change: ParsedChangeInfo) {
+    updateState(change);
+  }
 }
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 6705a85..28aca73 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -34,6 +34,7 @@
 
 interface ChecksProviderState {
   pluginName: string;
+  loading: boolean;
   config?: ChecksApiConfig;
   runs: CheckRun[];
   actions: Action[];
@@ -50,11 +51,18 @@
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const checksState$: Observable<ChecksState> = privateState$;
 
-export const aPluginHasRegistered = checksState$.pipe(
+export const aPluginHasRegistered$ = checksState$.pipe(
   map(state => Object.keys(state).length > 0),
   distinctUntilChanged()
 );
 
+export const someProvidersAreLoading$ = checksState$.pipe(
+  map(state => {
+    return Object.values(state).some(providerState => providerState.loading);
+  }),
+  distinctUntilChanged()
+);
+
 export const allActions$ = checksState$.pipe(
   map(state => {
     return Object.values(state).reduce(
@@ -106,6 +114,7 @@
   const nextState = {...privateState$.getValue()};
   nextState[pluginName] = {
     pluginName,
+    loading: false,
     config,
     runs: [],
     actions: [],
@@ -177,6 +186,15 @@
   status: RunStatus.COMPLETED,
 };
 
+export function updateStateSetLoading(pluginName: string) {
+  const nextState = {...privateState$.getValue()};
+  nextState[pluginName] = {
+    ...nextState[pluginName],
+    loading: true,
+  };
+  privateState$.next(nextState);
+}
+
 export function updateStateSetResults(
   pluginName: string,
   runs: CheckRun[],
@@ -185,6 +203,7 @@
   const nextState = {...privateState$.getValue()};
   nextState[pluginName] = {
     ...nextState[pluginName],
+    loading: false,
     runs: [...runs],
     actions: [...actions],
   };
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index 2e63f98..b11eada 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -30,7 +30,11 @@
   ResponseCode,
 } from '../../api/checks';
 import {change$, currentPatchNum$} from '../change/change-model';
-import {updateStateSetProvider, updateStateSetResults} from './checks-model';
+import {
+  updateStateSetLoading,
+  updateStateSetProvider,
+  updateStateSetResults,
+} from './checks-model';
 import {
   BehaviorSubject,
   combineLatest,
@@ -83,6 +87,7 @@
               patchsetNumber: patchNum,
               repo: change.project,
             };
+            updateStateSetLoading(pluginName);
             return from(this.providers[pluginName].fetch(data));
           }
         ),
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index fad5041..3af8c59 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -37,9 +37,13 @@
 
 // similar to fromNow from moment.js
 export function fromNow(date: Date, noAgo = false) {
-  const now = new Date();
+  return durationString(date, new Date(), noAgo);
+}
+
+// similar to fromNow from moment.js
+export function durationString(from: Date, to: Date, noAgo = false) {
   const ago = noAgo ? '' : ' ago';
-  const secondsAgo = Math.floor((now.valueOf() - date.valueOf()) / 1000);
+  const secondsAgo = Math.floor((to.valueOf() - from.valueOf()) / 1000);
   if (secondsAgo <= 59) return 'just now';
   if (secondsAgo <= 119) return `1 minute${ago}`;
   const minutesAgo = Math.floor(secondsAgo / 60);