Show extended file status information when comparing patchsets

Screenshots https://imgur.com/a/tklPSAb

Release-Notes: skip
Google-Bug-Id: b/216343564
Change-Id: If9763ca9104fafdc7f172ce0a5af4ad7e7fc5a69
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 d42a40e..cfde4b0 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
@@ -77,6 +77,7 @@
 import {ValueChangedEvent} from '../../../types/events';
 import {subscribe} from '../../lit/subscription-controller';
 import {when} from 'lit/directives/when';
+import {classMap} from 'lit/directives/class-map';
 import {incrementalRepeat} from '../../lit/incremental-repeat';
 import {ifDefined} from 'lit/directives/if-defined';
 import {KnownExperimentId} from '../../../services/flags/flags';
@@ -218,6 +219,9 @@
   files: NormalizedFileInfo[] = [];
 
   // Private but used in tests.
+  @state() filesLeftBase: NormalizedFileInfo[] = [];
+
+  // Private but used in tests.
   @state()
   loggedIn = false;
 
@@ -397,6 +401,15 @@
         }
         .status {
           margin-right: var(--spacing-s);
+          display: flex;
+          width: 20px;
+          justify-content: flex-end;
+        }
+        .status.extended {
+          width: 60px;
+        }
+        .status > * {
+          display: block;
         }
         .path {
           cursor: pointer;
@@ -694,6 +707,13 @@
     );
     subscribe(
       this,
+      () => this.getFilesModel().filesLeftBase$,
+      files => {
+        this.filesLeftBase = [...files];
+      }
+    );
+    subscribe(
+      this,
       () => this.getBrowserModel().diffViewMode$,
       diffView => {
         this.diffViewMode = diffView;
@@ -1012,16 +1032,55 @@
 
   private renderFileStatus(file?: NormalizedFileInfo) {
     if (!this.flagsService.isEnabled(KnownExperimentId.MORE_FILES_INFO)) return;
-    let status: FileInfoStatus | undefined = undefined;
-    if (file && !isMagicPath(file?.__path)) {
-      status = file.status ?? FileInfoStatus.MODIFIED;
-    }
-    // TODO: Add support for showing more file info when comparing two patchsets.
-    return html`<div class="status" role="gridcell">
-      <gr-file-status .status=${status}></gr-file-status>
+    const hasExtendedStatus = this.filesLeftBase.length > 0;
+    const leftStatus = this.renderFileStatusLeft(file?.__path);
+    const rightStatus = this.renderFileStatusRight(file);
+    return html`<div
+      class=${classMap({status: true, extended: hasExtendedStatus})}
+      role="gridcell"
+    >
+      ${leftStatus}${rightStatus}
     </div>`;
   }
 
+  private renderFileStatusRight(file?: NormalizedFileInfo) {
+    if (!file) return nothing;
+    if (isMagicPath(file.__path)) return nothing;
+
+    const hasExtendedStatus = this.filesLeftBase.length > 0;
+    const fileWasAlreadyChanged = this.filesLeftBase.some(
+      info => info.__path === file?.__path
+    );
+    const newlyChanged = hasExtendedStatus && !fileWasAlreadyChanged;
+
+    const status = file?.status ?? FileInfoStatus.MODIFIED;
+    const postfix = ` in patchset ${this.patchRange?.patchNum}`;
+
+    return html`<gr-file-status
+      .status=${status}
+      .labelPostfix=${postfix}
+      ?newlyChanged=${newlyChanged}
+    ></gr-file-status>`;
+  }
+
+  private renderFileStatusLeft(path?: string) {
+    if (this.filesLeftBase.length === 0) return nothing;
+    if (isMagicPath(path)) return nothing;
+    const file = this.filesLeftBase.find(info => info.__path === path);
+    if (!file) return nothing;
+
+    const status = file.status ?? FileInfoStatus.MODIFIED;
+    const postfix = ` in patchset ${this.patchRange?.basePatchNum}`;
+
+    return html`
+      <gr-file-status
+        .status=${status}
+        .labelPostfix=${postfix}
+      ></gr-file-status>
+      <iron-icon icon="gr-icons:arrow-right"></iron-icon>
+    `;
+  }
+
   private renderFilePath(file: NormalizedFileInfo) {
     return html` <span class="path" role="gridcell">
       <a class="pathLink" href=${ifDefined(this.computeDiffURL(file.__path))}>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 8d9afb1..045f9b6 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -15,6 +15,7 @@
   stubRestApi,
   waitUntil,
   pressKey,
+  stubFlags,
 } from '../../../test/test-utils';
 import {
   BasePatchSetNum,
@@ -254,6 +255,22 @@
       </div>`);
     });
 
+    test('renders file status column', async () => {
+      stubFlags('isEnabled').returns(true);
+      element.files = createFiles(1, {lines_inserted: 9});
+      element.filesLeftBase = createFiles(1, {lines_inserted: 9});
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+      const statusCol = queryAndAssert(fileRows?.[0], '.status');
+      expect(statusCol).dom.equal(/* HTML */ `
+        <div class="extended status" role="gridcell">
+          <gr-file-status></gr-file-status>
+          <iron-icon icon="gr-icons:arrow-right"></iron-icon>
+          <gr-file-status></gr-file-status>
+        </div>
+      `);
+    });
+
     test('correct number of files are shown', async () => {
       element.fileListIncrement = 300;
       element.files = createFiles(500);
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status.ts
index ab30138..33bbaa0 100644
--- a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status.ts
@@ -10,12 +10,34 @@
 import '@polymer/iron-icon/iron-icon';
 import {iconStyles} from '../../../styles/gr-icon-styles';
 
+function statusString(status: FileInfoStatus) {
+  if (!status) return '';
+  switch (status) {
+    case FileInfoStatus.ADDED:
+      return 'Added';
+    case FileInfoStatus.COPIED:
+      return 'Copied';
+    case FileInfoStatus.DELETED:
+      return 'Deleted';
+    case FileInfoStatus.MODIFIED:
+      return 'Modified';
+    case FileInfoStatus.RENAMED:
+      return 'Renamed';
+    case FileInfoStatus.REWRITTEN:
+      return 'Rewritten';
+    case FileInfoStatus.UNMODIFIED:
+      return 'Unchanged';
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
 /**
  * This is a square colored box with a single letter, which can be used as a
  * prefix column before file names.
  *
  * It can also show an additional "new" icon for indicating that a file was
- * newly added in a patchset.
+ * newly changed in a patchset.
  *
  * `gr-file-status-chip` was a chip for being shown after a file name. It will
  * become obsolete.
@@ -26,18 +48,25 @@
   status?: FileInfoStatus;
 
   /**
-   * Show an additional "new" icon for indicating that a file was newly added
+   * Show an additional "new" icon for indicating that a file was newly changed
    * in a patchset.
    */
   @property({type: Boolean})
-  new = false;
+  newlyChanged = false;
+
+  /**
+   * What postfix should the tooltip have? For example you can set
+   * ' in ps 5', such that the 'Added' tooltip becomes 'Added in ps 5'.
+   */
+  @property({type: String})
+  labelPostfix = '';
 
   static override get styles() {
     return [
       iconStyles,
       css`
         :host {
-          display: block;
+          display: inline-block;
         }
         iron-icon.new {
           margin-right: var(--spacing-xs);
@@ -70,51 +99,34 @@
   }
 
   override render() {
-    return html`${this.renderNew()}${this.renderStatus()}`;
+    return html`${this.renderNewlyChanged()}${this.renderStatus()}`;
   }
 
   private renderStatus() {
     const classes = ['status', this.status];
-    const label = this.computeLabel();
-    const text = this.status ?? ' ';
     return html`<div
       class=${classes.join(' ')}
       tabindex="0"
-      title=${label}
-      aria-label=${label}
+      title=${this.computeLabel()}
+      aria-label=${this.computeLabel()}
     >
-      ${text}
+      ${this.status ?? ''}
     </div>`;
   }
 
-  private renderNew() {
-    if (!this.new) return;
+  private renderNewlyChanged() {
+    if (!this.newlyChanged) return;
     return html`<iron-icon
       class="size-16 new"
       icon="gr-icons:new"
+      title=${this.computeLabel()}
     ></iron-icon>`;
   }
 
   private computeLabel() {
     if (!this.status) return '';
-    switch (this.status) {
-      case FileInfoStatus.ADDED:
-        return 'Added';
-      case FileInfoStatus.COPIED:
-        return 'Copied';
-      case FileInfoStatus.DELETED:
-        return 'Deleted';
-      case FileInfoStatus.MODIFIED:
-        return 'Modified';
-      case FileInfoStatus.RENAMED:
-        return 'Renamed';
-      case FileInfoStatus.REWRITTEN:
-        return 'Rewritten';
-      case FileInfoStatus.UNMODIFIED:
-        return 'Unchanged';
-      default:
-        assertNever(this.status, `Unsupported status: ${this.status}`);
-    }
+    const prefix = this.newlyChanged ? 'Newly ' : '';
+    return `${prefix}${statusString(this.status)}${this.labelPostfix}`;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status_test.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status_test.ts
index 614b97c8..682f4e9 100644
--- a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status_test.ts
@@ -19,7 +19,7 @@
 
   const setStatus = async (status?: FileInfoStatus, newly = false) => {
     element.status = status;
-    element.new = newly;
+    element.newlyChanged = newly;
     await element.updateComplete;
   };
 
@@ -42,8 +42,17 @@
     test('newly added', async () => {
       await setStatus(FileInfoStatus.ADDED, true);
       expect(element).shadowDom.to.equal(/* HTML */ `
-        <iron-icon class="new size-16" icon="gr-icons:new"></iron-icon>
-        <div class="A status" aria-label="Added" tabindex="0" title="Added">
+        <iron-icon
+          class="new size-16"
+          icon="gr-icons:new"
+          title="Newly Added"
+        ></iron-icon>
+        <div
+          class="A status"
+          aria-label="Newly Added"
+          tabindex="0"
+          title="Newly Added"
+        >
           A
         </div>
       `);
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index fc38332..3176f61 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -166,7 +166,7 @@
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:new_releases -->
       <g id="new"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M23 12l-2.44-2.78.34-3.68-3.61-.82-1.89-3.18L12 3 8.6 1.54 6.71 4.72l-3.61.81.34 3.68L1 12l2.44 2.78-.34 3.69 3.61.82 1.89 3.18L12 21l3.4 1.46 1.89-3.18 3.61-.82-.34-3.68L23 12zm-4.51 2.11l.26 2.79-2.74.62-1.43 2.41L12 18.82l-2.58 1.11-1.43-2.41-2.74-.62.26-2.8L3.66 12l1.85-2.12-.26-2.78 2.74-.61 1.43-2.41L12 5.18l2.58-1.11 1.43 2.41 2.74.62-.26 2.79L20.34 12l-1.85 2.11zM11 15h2v2h-2zm0-8h2v6h-2z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:arrow_right_alt -->
-      <g id="arrow_right"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14 6l-1.41 1.41L16.17 11H4v2h12.17l-3.58 3.59L14 18l6-6z"/></g>
+      <g id="arrow-right"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14 6l-1.41 1.41L16.17 11H4v2h12.17l-3.58 3.59L14 18l6-6z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index 139a5a9..e26167c 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -121,6 +121,16 @@
     ([files, commentedPaths]) => addUnmodified(files, commentedPaths)
   );
 
+  public readonly filesLeftBase$ = select(
+    this.state$,
+    state => state.filesLeftBase
+  );
+
+  public readonly filesRightBase$ = select(
+    this.state$,
+    state => state.filesRightBase
+  );
+
   private subscriptions: Subscription[] = [];
 
   constructor(