Merge changes I27a6d786,I59d2b5e7

* changes:
  Migrate gr-download-commands to lit
  Convert gr-download-dialog to lit
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 5fc7bc8..a3f7374 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -246,7 +246,10 @@
             )}
             .schemes=${this.schemes}
             .selectedScheme=${this.selectedScheme}
-            @selected-scheme-changed=${this.handleSelectedSchemeValueChanged}
+            @selected-scheme-changed=${(e: BindValueChangeEvent) => {
+              if (this.loading) return;
+              this.selectedScheme = e.detail.value;
+            }}
           ></gr-download-commands>
         </fieldset>
       </div>
@@ -1121,12 +1124,7 @@
     }
   }
 
-  private handleSelectedSchemeValueChanged(e: CustomEvent) {
-    if (this.loading) return;
-    this.selectedScheme = e.detail.value;
-  }
-
-  private handleDescriptionTextChanged(e: CustomEvent) {
+  private handleDescriptionTextChanged(e: BindValueChangeEvent) {
     if (!this.repoConfig || this.loading) return;
     this.repoConfig = {
       ...this.repoConfig,
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 92f4a87..a72749b 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -14,46 +14,37 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-font-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-download-commands/gr-download-commands';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-download-dialog_html';
 import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
-import {customElement, property, computed, observe} from '@polymer/decorators';
-import {
-  ChangeInfo,
-  DownloadInfo,
-  PatchSetNum,
-  RevisionInfo,
-} from '../../../types/common';
+import {ChangeInfo, DownloadInfo, PatchSetNum} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {hasOwnProperty, queryAndAssert} from '../../../utils/common-util';
 import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {addShortcut} from '../../../utils/dom-util';
-
-export interface GrDownloadDialog {
-  $: {
-    download: HTMLAnchorElement;
-    downloadCommands: GrDownloadCommands;
-    closeButton: GrButton;
-  };
-}
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators';
+import {assertIsDefined} from '../../../utils/common-util';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {BindValueChangeEvent} from '../../../types/events';
 
 @customElement('gr-download-dialog')
-export class GrDownloadDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDownloadDialog extends LitElement {
   /**
    * Fired when the user presses the close button.
    *
    * @event close
    */
 
+  @query('#download') protected download?: HTMLAnchorElement;
+
+  @query('#downloadCommands') protected downloadCommands?: GrDownloadCommands;
+
+  @query('#closeButton') protected closeButton?: GrButton;
+
   @property({type: Object})
   change: ChangeInfo | undefined;
 
@@ -63,8 +54,7 @@
   @property({type: String})
   patchNum: PatchSetNum | undefined;
 
-  @property({type: String})
-  _selectedScheme?: string;
+  @state() private selectedScheme?: string;
 
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
@@ -79,14 +69,159 @@
     super.connectedCallback();
     for (const key of ['1', '2', '3', '4', '5']) {
       this.cleanups.push(
-        addShortcut(this, {key}, e => this._handleNumberKey(e))
+        addShortcut(this, {key}, e => this.handleNumberKey(e))
       );
     }
   }
 
-  @computed('change', 'patchNum')
-  get _schemes() {
-    // Polymer 2: check for undefined
+  static override get styles() {
+    return [
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          padding: var(--spacing-m) 0;
+        }
+        section {
+          display: flex;
+          padding: var(--spacing-m) var(--spacing-xl);
+        }
+        .flexContainer {
+          display: flex;
+          justify-content: space-between;
+          padding-top: var(--spacing-m);
+        }
+        .footer {
+          justify-content: flex-end;
+        }
+        .closeButtonContainer {
+          align-items: flex-end;
+          display: flex;
+          flex: 0;
+          justify-content: flex-end;
+        }
+        .patchFiles,
+        .archivesContainer {
+          padding-bottom: var(--spacing-m);
+        }
+        .patchFiles {
+          margin-right: var(--spacing-xxl);
+        }
+        .patchFiles a,
+        .archives a {
+          display: inline-block;
+          margin-right: var(--spacing-l);
+        }
+        .patchFiles a:last-of-type,
+        .archives a:last-of-type {
+          margin-right: 0;
+        }
+        gr-download-commands {
+          width: min(80vw, 1200px);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const revisions = this.change?.revisions;
+    return html`
+      <section>
+        <h3 class="heading-3">
+          Patch set ${this.patchNum} of
+          ${revisions ? Object.keys(revisions).length : 0}
+        </h3>
+      </section>
+      ${this.renderDownloadCommands()}
+      <section class="flexContainer">
+        ${this.renderPatchFiles()} ${this.renderArchives()}
+      </section>
+      <section class="footer">
+        <span class="closeButtonContainer">
+          <gr-button
+            id="closeButton"
+            link
+            @click=${(e: Event) => {
+              this.handleCloseTap(e);
+            }}
+            >Close</gr-button
+          >
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDownloadCommands() {
+    if (!this.schemes.length) return;
+
+    return html`
+      <section>
+        <gr-download-commands
+          id="downloadCommands"
+          .commands=${this.computeDownloadCommands()}
+          .schemes=${this.schemes}
+          .selectedScheme=${this.selectedScheme}
+          show-keyboard-shortcut-tooltips
+          @selected-scheme-changed=${(e: BindValueChangeEvent) => {
+            this.selectedScheme = e.detail.value;
+          }}
+        ></gr-download-commands>
+      </section>
+    `;
+  }
+
+  private renderPatchFiles() {
+    if (this.computeHidePatchFile()) return;
+
+    return html`
+      <div class="patchFiles">
+        <label>Patch file</label>
+        <div>
+          <a id="download" .href="${this.computeDownloadLink()}" download>
+            ${this.computeDownloadFilename()}
+          </a>
+          <a .href="${this.computeDownloadLink(true)}" download>
+            ${this.computeDownloadFilename(true)}
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderArchives() {
+    if (!this.config?.archives.length) return;
+
+    return html`
+      <div class="archivesContainer">
+        <label>Archive</label>
+        <div id="archives" class="archives">
+          ${this.config.archives.map(format => this.renderArchivesLink(format))}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderArchivesLink(format: string) {
+    return html`
+      <a .href=${this.computeArchiveDownloadLink(format)} download>
+        ${format}
+      </a>
+    `;
+  }
+
+  override firstUpdated(changedProperties: PropertyValues) {
+    super.firstUpdated(changedProperties);
+    if (!this.getAttribute('role')) this.setAttribute('role', 'dialog');
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('schemes')) {
+      this.schemesChanged();
+    }
+  }
+
+  get schemes() {
     if (this.change === undefined || this.patchNum === undefined) {
       return [];
     }
@@ -103,13 +238,9 @@
     return [];
   }
 
-  _handleNumberKey(e: KeyboardEvent) {
+  private handleNumberKey(e: KeyboardEvent) {
     const index = Number(e.key) - 1;
-    const commands = this._computeDownloadCommands(
-      this.change,
-      this.patchNum,
-      this._selectedScheme
-    );
+    const commands = this.computeDownloadCommands();
     if (index > commands.length) return;
     navigator.clipboard.writeText(commands[index].command).then(() => {
       fireAlert(this, `${commands[index].title} command copied to clipboard`);
@@ -117,41 +248,40 @@
     });
   }
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
-  }
-
   override focus() {
-    if (this._schemes.length) {
-      this.$.downloadCommands.focusOnCopy();
+    if (this.schemes.length) {
+      assertIsDefined(this.downloadCommands, 'downloadCommands');
+      this.downloadCommands.focusOnCopy();
     } else {
-      this.$.download.focus();
+      assertIsDefined(this.download, 'download');
+      this.download.focus();
     }
   }
 
   getFocusStops(): GrOverlayStops {
+    assertIsDefined(this.downloadCommands, 'downloadCommands');
+    assertIsDefined(this.closeButton, 'closeButton');
+    const downloadTabs = queryAndAssert<PaperTabsElement>(
+      this.downloadCommands,
+      '#downloadTabs'
+    );
     return {
-      start: this.$.downloadCommands.$.downloadTabs,
-      end: this.$.closeButton,
+      start: downloadTabs,
+      end: this.closeButton,
     };
   }
 
-  _computeDownloadCommands(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    selectedScheme?: string
-  ) {
+  private computeDownloadCommands() {
     let commandObj;
-    if (!change || !selectedScheme) return [];
-    for (const rev of Object.values(change.revisions || {})) {
+    if (!this.change || !this.selectedScheme) return [];
+    for (const rev of Object.values(this.change.revisions || {})) {
       if (
-        rev._number === patchNum &&
+        rev._number === this.patchNum &&
         rev &&
         rev.fetch &&
-        hasOwnProperty(rev.fetch, selectedScheme)
+        hasOwnProperty(rev.fetch, this.selectedScheme)
       ) {
-        commandObj = rev.fetch[selectedScheme].commands;
+        commandObj = rev.fetch[this.selectedScheme].commands;
         break;
       }
     }
@@ -162,53 +292,35 @@
     return commands;
   }
 
-  _computeZipDownloadLink(change?: ChangeInfo, patchNum?: PatchSetNum) {
-    return this._computeDownloadLink(change, patchNum, true);
-  }
-
-  _computeZipDownloadFilename(change?: ChangeInfo, patchNum?: PatchSetNum) {
-    return this._computeDownloadFilename(change, patchNum, true);
-  }
-
-  _computeDownloadLink(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    zip?: boolean
-  ) {
-    // Polymer 2: check for undefined
-    if (change === undefined || patchNum === undefined) {
+  private computeDownloadLink(zip?: boolean) {
+    if (this.change === undefined || this.patchNum === undefined) {
       return '';
     }
     return (
-      changeBaseURL(change.project, change._number, patchNum) +
+      changeBaseURL(this.change.project, this.change._number, this.patchNum) +
       '/patch?' +
       (zip ? 'zip' : 'download')
     );
   }
 
-  _computeDownloadFilename(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    zip?: boolean
-  ) {
-    // Polymer 2: check for undefined
-    if (change === undefined || patchNum === undefined) {
+  private computeDownloadFilename(zip?: boolean) {
+    if (this.change === undefined || this.patchNum === undefined) {
       return '';
     }
 
-    const rev = getRevisionKey(change, patchNum) ?? '';
+    const rev = getRevisionKey(this.change, this.patchNum) ?? '';
     const shortRev = rev.substr(0, 7);
 
     return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
   }
 
-  _computeHidePatchFile(change?: ChangeInfo, patchNum?: PatchSetNum) {
-    // Polymer 2: check for undefined
-    if (change === undefined || patchNum === undefined) {
+  // private but used in test
+  computeHidePatchFile() {
+    if (this.change === undefined || this.patchNum === undefined) {
       return false;
     }
-    for (const rev of Object.values(change.revisions || {})) {
-      if (rev._number === patchNum) {
+    for (const rev of Object.values(this.change.revisions || {})) {
+      if (rev._number === this.patchNum) {
         const parentLength =
           rev.commit && rev.commit.parents ? rev.commit.parents.length : 0;
         return parentLength === 0 || parentLength > 1;
@@ -217,52 +329,36 @@
     return false;
   }
 
-  _computeArchiveDownloadLink(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    format?: string
-  ) {
-    // Polymer 2: check for undefined
+  // private but used in test
+  computeArchiveDownloadLink(format?: string) {
     if (
-      change === undefined ||
-      patchNum === undefined ||
+      this.change === undefined ||
+      this.patchNum === undefined ||
       format === undefined
     ) {
       return '';
     }
     return (
-      changeBaseURL(change.project, change._number, patchNum) +
+      changeBaseURL(this.change.project, this.change._number, this.patchNum) +
       '/archive?format=' +
       format
     );
   }
 
-  _computePatchSetQuantity(revisions?: {[revisionId: string]: RevisionInfo}) {
-    if (!revisions) {
-      return 0;
-    }
-    return Object.keys(revisions).length;
-  }
-
-  _handleCloseTap(e: Event) {
+  private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     fireEvent(this, 'close');
   }
 
-  @observe('_schemes')
-  _schemesChanged(schemes: string[]) {
-    if (schemes.length === 0) {
+  private schemesChanged() {
+    if (this.schemes.length === 0) {
       return;
     }
-    if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
-      this._selectedScheme = schemes.sort()[0];
+    if (!this.selectedScheme || !this.schemes.includes(this.selectedScheme)) {
+      this.selectedScheme = this.schemes.sort()[0];
     }
   }
-
-  _computeShowDownloadCommands(schemes: string[]) {
-    return schemes.length ? '' : 'hidden';
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
deleted file mode 100644
index 097fb0e..0000000
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-      padding: var(--spacing-m) 0;
-    }
-    section {
-      display: flex;
-      padding: var(--spacing-m) var(--spacing-xl);
-    }
-    .flexContainer {
-      display: flex;
-      justify-content: space-between;
-      padding-top: var(--spacing-m);
-    }
-    .footer {
-      justify-content: flex-end;
-    }
-    .closeButtonContainer {
-      align-items: flex-end;
-      display: flex;
-      flex: 0;
-      justify-content: flex-end;
-    }
-    .patchFiles,
-    .archivesContainer {
-      padding-bottom: var(--spacing-m);
-    }
-    .patchFiles {
-      margin-right: var(--spacing-xxl);
-    }
-    .patchFiles a,
-    .archives a {
-      display: inline-block;
-      margin-right: var(--spacing-l);
-    }
-    .patchFiles a:last-of-type,
-    .archives a:last-of-type {
-      margin-right: 0;
-    }
-    .hidden {
-      display: none;
-    }
-    gr-download-commands {
-      width: min(80vw, 1200px);
-    }
-  </style>
-  <section>
-    <h3 class="heading-3">
-      Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
-    </h3>
-  </section>
-  <section class$="[[_computeShowDownloadCommands(_schemes)]]">
-    <gr-download-commands
-      id="downloadCommands"
-      commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
-      schemes="[[_schemes]]"
-      selected-scheme="{{_selectedScheme}}"
-      show-keyboard-shortcut-tooltips
-    ></gr-download-commands>
-  </section>
-  <section class="flexContainer">
-    <div
-      class="patchFiles"
-      hidden="[[_computeHidePatchFile(change, patchNum)]]"
-    >
-      <label>Patch file</label>
-      <div>
-        <a
-          id="download"
-          href$="[[_computeDownloadLink(change, patchNum)]]"
-          download=""
-        >
-          [[_computeDownloadFilename(change, patchNum)]]
-        </a>
-        <a href$="[[_computeZipDownloadLink(change, patchNum)]]" download="">
-          [[_computeZipDownloadFilename(change, patchNum)]]
-        </a>
-      </div>
-    </div>
-    <div
-      class="archivesContainer"
-      hidden$="[[!config.archives.length]]"
-      hidden=""
-    >
-      <label>Archive</label>
-      <div id="archives" class="archives">
-        <template is="dom-repeat" items="[[config.archives]]" as="format">
-          <a
-            href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
-            download=""
-          >
-            [[format]]
-          </a>
-        </template>
-      </div>
-    </div>
-  </section>
-  <section class="footer">
-    <span class="closeButtonContainer">
-      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
-        >Close</gr-button
-      >
-    </span>
-  </section>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index f61bb68..4c5a3ee 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -22,7 +22,6 @@
   createCommit,
   createDownloadInfo,
   createRevision,
-  createRevisions,
 } from '../../../test/test-data-generators';
 import {
   CommitId,
@@ -30,8 +29,10 @@
   PatchSetNum,
   RepoName,
 } from '../../../types/common';
+import './gr-download-dialog';
 import {GrDownloadDialog} from './gr-download-dialog';
-import {mockPromise} from '../../../test/test-utils';
+import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 
 const basicFixture = fixtureFromElement('gr-download-dialog');
 
@@ -103,37 +104,43 @@
   };
 }
 
-function getChangeObjectNoFetch() {
-  return {
-    ...createChange(),
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72' as CommitId,
-    revisions: createRevisions(1),
-  };
-}
-
 suite('gr-download-dialog', () => {
   let element: GrDownloadDialog;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
     element.patchNum = 1 as PatchSetNum;
     element.config = createDownloadInfo();
-    flush();
+    await element.updateComplete;
   });
 
   test('anchors use download attribute', () => {
-    const anchors = Array.from(element.root!.querySelectorAll('a'));
+    const anchors = Array.from(queryAll(element, 'a'));
     assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
   });
 
   suite('gr-download-dialog tests with no fetch options', () => {
-    setup(() => {
-      element.change = getChangeObjectNoFetch();
-      flush();
+    setup(async () => {
+      element.change = {
+        ...createChange(),
+        revisions: {
+          r1: {
+            ...createRevision(),
+            commit: {
+              ...createCommit(),
+              parents: [{commit: 'p1' as CommitId, subject: 'subject1'}],
+            },
+          },
+        },
+      };
+      await element.updateComplete;
     });
 
     test('focuses on first download link if no copy links', () => {
-      const focusStub = sinon.stub(element.$.download, 'focus');
+      const focusStub = sinon.stub(
+        queryAndAssert<HTMLAnchorElement>(element, '#download'),
+        'focus'
+      );
       element.focus();
       assert.isTrue(focusStub.called);
       focusStub.restore();
@@ -141,30 +148,31 @@
   });
 
   suite('gr-download-dialog with fetch options', () => {
-    setup(() => {
+    setup(async () => {
       element.change = getChangeObject();
-      flush();
+      await element.updateComplete;
     });
 
-    test('focuses on first copy link', () => {
-      const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
+    test('focuses on first copy link', async () => {
+      const focusStub = sinon.stub(
+        queryAndAssert<GrDownloadCommands>(element, '#downloadCommands'),
+        'focusOnCopy'
+      );
       element.focus();
-      flush();
+      await element.updateComplete;
       assert.isTrue(focusStub.called);
       focusStub.restore();
     });
 
     test('computed fields', () => {
+      element.change = {
+        ...createChange(),
+        project: 'test/project' as RepoName,
+        _number: 123 as NumericChangeId,
+      };
+      element.patchNum = 2 as PatchSetNum;
       assert.equal(
-        element._computeArchiveDownloadLink(
-          {
-            ...createChange(),
-            project: 'test/project' as RepoName,
-            _number: 123 as NumericChangeId,
-          },
-          2 as PatchSetNum,
-          'tgz'
-        ),
+        element.computeArchiveDownloadLink('tgz'),
         '/changes/test%2Fproject~123/revisions/2/archive?format=tgz'
       );
     });
@@ -174,7 +182,8 @@
       element.addEventListener('close', () => {
         closeCalled.resolve();
       });
-      const closeButton = element.shadowRoot!.querySelector(
+      const closeButton = queryAndAssert(
+        element,
         '.closeButtonContainer gr-button'
       );
       tap(closeButton!);
@@ -182,23 +191,18 @@
     });
   });
 
-  test('_computeShowDownloadCommands', () => {
-    assert.equal(element._computeShowDownloadCommands([]), 'hidden');
-    assert.equal(element._computeShowDownloadCommands(['test']), '');
-  });
+  test('computeHidePatchFile', () => {
+    element.patchNum = 1 as PatchSetNum;
 
-  test('_computeHidePatchFile', () => {
-    const patchNum = 1 as PatchSetNum;
-
-    const changeWithNoParent = {
+    element.change = {
       ...createChange(),
       revisions: {
         r1: {...createRevision(), commit: createCommit()},
       },
     };
-    assert.isTrue(element._computeHidePatchFile(changeWithNoParent, patchNum));
+    assert.isTrue(element.computeHidePatchFile());
 
-    const changeWithOneParent = {
+    element.change = {
       ...createChange(),
       revisions: {
         r1: {
@@ -210,11 +214,9 @@
         },
       },
     };
-    assert.isFalse(
-      element._computeHidePatchFile(changeWithOneParent, patchNum)
-    );
+    assert.isFalse(element.computeHidePatchFile());
 
-    const changeWithMultipleParents = {
+    element.change = {
       ...createChange(),
       revisions: {
         r1: {
@@ -229,8 +231,6 @@
         },
       },
     };
-    assert.isTrue(
-      element._computeHidePatchFile(changeWithMultipleParents, patchNum)
-    );
+    assert.isTrue(element.computeHidePatchFile());
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 6eb19da..64f97ca 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -18,53 +18,44 @@
 import '@polymer/paper-tabs/paper-tab';
 import '@polymer/paper-tabs/paper-tabs';
 import '../gr-shell-command/gr-shell-command';
-import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-download-commands_html';
-import {customElement, property} from '@polymer/decorators';
-import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {getAppContext} from '../../../services/app-context';
 import {queryAndAssert} from '../../../utils/common-util';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {fire} from '../../../utils/event-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementEventMap {
     'selected-changed': CustomEvent<{value: number}>;
+    'selected-scheme-changed': BindValueChangeEvent;
   }
   interface HTMLElementTagNameMap {
     'gr-download-commands': GrDownloadCommands;
   }
 }
 
-export interface GrDownloadCommands {
-  $: {
-    downloadTabs: PaperTabsElement;
-  };
-}
-
 export interface Command {
   title: string;
   command: string;
 }
 
 @customElement('gr-download-commands')
-export class GrDownloadCommands extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDownloadCommands extends LitElement {
   // TODO(TS): maybe default to [] as only used in dom-repeat
   @property({type: Array})
   commands?: Command[];
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  // private but used in test
+  @state() loggedIn = false;
 
   @property({type: Array})
   schemes: string[] = [];
 
-  @property({type: String, notify: true})
+  @property({type: String})
   selectedScheme?: string;
 
   @property({type: Boolean})
@@ -79,14 +70,15 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
+    this.restApiService.getLoggedIn().then(loggedIn => {
+      this.loggedIn = loggedIn;
     });
     this.subscriptions.push(
       this.userModel.preferences$.subscribe(prefs => {
         if (prefs?.download_scheme) {
           // Note (issue 5180): normalize the download scheme with lower-case.
           this.selectedScheme = prefs.download_scheme.toLowerCase();
+          fire(this, 'selected-scheme-changed', {value: this.selectedScheme});
         }
       })
     );
@@ -100,42 +92,121 @@
     super.disconnectedCallback();
   }
 
+  static override get styles() {
+    return [
+      paperStyles,
+      sharedStyles,
+      css`
+        paper-tabs {
+          height: 3rem;
+          margin-bottom: var(--spacing-m);
+          --paper-tabs-selection-bar-color: var(--link-color);
+        }
+        paper-tab {
+          max-width: 15rem;
+          text-transform: uppercase;
+          --paper-tab-ink: var(--link-color);
+        }
+        label,
+        input {
+          display: block;
+        }
+        label {
+          font-weight: var(--font-weight-bold);
+        }
+        .schemes {
+          display: flex;
+          justify-content: space-between;
+        }
+        .commands {
+          display: flex;
+          flex-direction: column;
+        }
+        gr-shell-command {
+          margin-bottom: var(--spacing-m);
+        }
+        .hidden {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="schemes">${this.renderDownloadTabs()}</div>
+      ${this.renderCommands()}
+    `;
+  }
+
+  private renderDownloadTabs() {
+    if (this.schemes.length <= 1) return;
+
+    const selectedIndex =
+      this.schemes.findIndex(scheme => scheme === this.selectedScheme) || 0;
+    return html`
+      <paper-tabs
+        id="downloadTabs"
+        .selected=${selectedIndex}
+        @selected-changed=${this.handleTabChange}
+      >
+        ${this.schemes.map(scheme => this.renderPaperTab(scheme))}
+      </paper-tabs>
+    `;
+  }
+
+  private renderPaperTab(scheme: string) {
+    return html` <paper-tab data-scheme=${scheme}>${scheme}</paper-tab> `;
+  }
+
+  private renderCommands() {
+    if (!this.schemes.length) return;
+
+    return html`
+      <div class="commands">
+        ${this.commands?.map((command, index) =>
+          this.renderShellCommand(command, index)
+        )}
+      </div>
+    `;
+  }
+
+  private renderShellCommand(command: Command, index: number) {
+    return html`
+      <gr-shell-command
+        class="${this.computeClass(command.title)}"
+        .label=${command.title}
+        .command=${command.command}
+        .tooltip=${this.computeTooltip(index)}
+      ></gr-shell-command>
+    `;
+  }
+
   focusOnCopy() {
     queryAndAssert<GrShellCommand>(this, 'gr-shell-command').focusOnCopy();
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _handleTabChange(e: CustomEvent<{value: number}>) {
+  private handleTabChange = (e: CustomEvent<{value: number}>) => {
     const scheme = this.schemes[e.detail.value];
     if (scheme && scheme !== this.selectedScheme) {
-      this.set('selectedScheme', scheme);
-      if (this._loggedIn) {
+      this.selectedScheme = scheme;
+      fire(this, 'selected-scheme-changed', {value: scheme});
+      if (this.loggedIn) {
         this.userModel.updatePreferences({
           download_scheme: this.selectedScheme,
         });
       }
     }
-  }
+  };
 
-  _computeSelected(schemes: string[], selectedScheme?: string) {
-    return `${schemes.findIndex(scheme => scheme === selectedScheme) || 0}`;
-  }
-
-  _computeShowTabs(schemes: string[]) {
-    return schemes.length > 1 ? '' : 'hidden';
-  }
-
-  _computeTooltip(showKeyboardShortcutTooltips: boolean, index: number) {
-    return index <= 4 && showKeyboardShortcutTooltips
+  private computeTooltip(index: number) {
+    return index <= 4 && this.showKeyboardShortcutTooltips
       ? `Keyboard shortcut: ${index + 1}`
       : '';
   }
 
   // TODO: maybe unify with strToClassName from dom-util
-  _computeClass(title: string) {
+  private computeClass(title: string) {
     // Only retain [a-z] chars, so "Cherry Pick" becomes "cherrypick".
     return '_label_' + title.replace(/[^a-z]+/gi, '').toLowerCase();
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
deleted file mode 100644
index f9c08ba..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    paper-tabs {
-      height: 3rem;
-      margin-bottom: var(--spacing-m);
-      --paper-tabs-selection-bar-color: var(--link-color);
-    }
-    paper-tab {
-      max-width: 15rem;
-      text-transform: uppercase;
-      --paper-tab-ink: var(--link-color);
-    }
-    label,
-    input {
-      display: block;
-    }
-    label {
-      font-weight: var(--font-weight-bold);
-    }
-    .schemes {
-      display: flex;
-      justify-content: space-between;
-    }
-    .commands {
-      display: flex;
-      flex-direction: column;
-    }
-    gr-shell-command {
-      margin-bottom: var(--spacing-m);
-    }
-    .hidden {
-      display: none;
-    }
-  </style>
-  <div class="schemes">
-    <paper-tabs
-      id="downloadTabs"
-      class$="[[_computeShowTabs(schemes)]]"
-      selected="[[_computeSelected(schemes, selectedScheme)]]"
-      on-selected-changed="_handleTabChange"
-    >
-      <template is="dom-repeat" items="[[schemes]]" as="scheme">
-        <paper-tab data-scheme$="[[scheme]]">[[scheme]]</paper-tab>
-      </template>
-    </paper-tabs>
-  </div>
-  <div class="commands" hidden$="[[!schemes.length]]" hidden="">
-    <template is="dom-repeat" items="[[commands]]" as="command" indexAs="index">
-      <gr-shell-command
-        class$="[[_computeClass(command.title)]]"
-        label="[[command.title]]"
-        command="[[command.command]]"
-        tooltip="[[_computeTooltip(showKeyboardShortcutTooltips, index)]]"
-      ></gr-shell-command>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index bd0ca70..eb9490f 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -18,11 +18,12 @@
 import '../../../test/common-test-setup-karma';
 import './gr-download-commands';
 import {GrDownloadCommands} from './gr-download-commands';
-import {isHidden, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {createPreferences} from '../../../test/test-data-generators';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
 import {createDefaultPreferences} from '../../../constants/constants';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 
 const basicFixture = fixtureFromElement('gr-download-commands');
 
@@ -63,7 +64,7 @@
       element.schemes = SCHEMES;
       element.commands = COMMANDS;
       element.selectedScheme = SELECTED_SCHEME;
-      await flush();
+      await element.updateComplete;
     });
 
     test('focusOnCopy', () => {
@@ -75,30 +76,37 @@
       assert.isTrue(focusStub.called);
     });
 
-    test('element visibility', () => {
-      assert.isFalse(isHidden(queryAndAssert(element, 'paper-tabs')));
-      assert.isFalse(isHidden(queryAndAssert(element, '.commands')));
+    test('element visibility', async () => {
+      assert.isTrue(Boolean(query(element, 'paper-tabs')));
+      assert.isTrue(Boolean(query(element, '.commands')));
 
       element.schemes = [];
-      assert.isTrue(isHidden(queryAndAssert(element, 'paper-tabs')));
-      assert.isTrue(isHidden(queryAndAssert(element, '.commands')));
+      await element.updateComplete;
+      assert.isFalse(Boolean(query(element, 'paper-tabs')));
+      assert.isFalse(Boolean(query(element, '.commands')));
     });
 
-    test('tab selection', () => {
-      assert.equal(element.$.downloadTabs.selected, '0');
+    test('tab selection', async () => {
+      assert.equal(
+        queryAndAssert<PaperTabsElement>(element, '#downloadTabs').selected,
+        '0'
+      );
       MockInteractions.tap(queryAndAssert(element, '[data-scheme="ssh"]'));
-      flush();
+      await element.updateComplete;
       assert.equal(element.selectedScheme, 'ssh');
-      assert.equal(element.$.downloadTabs.selected, '2');
+      assert.equal(
+        queryAndAssert<PaperTabsElement>(element, '#downloadTabs').selected,
+        '2'
+      );
     });
 
-    test('saves scheme to preferences', () => {
-      element._loggedIn = true;
+    test('saves scheme to preferences', async () => {
+      element.loggedIn = true;
       const savePrefsStub = stubRestApi('savePreferences').returns(
         Promise.resolve(createDefaultPreferences())
       );
 
-      flush();
+      await element.updateComplete;
 
       const repoTab = queryAndAssert(element, 'paper-tab[data-scheme="repo"]');
 
@@ -114,7 +122,7 @@
   suite('authenticated', () => {
     test('loads scheme from preferences', async () => {
       const element = basicFixture.instantiate();
-      await flush();
+      await element.updateComplete;
       element.userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'repo',
@@ -124,7 +132,7 @@
 
     test('normalize scheme from preferences', async () => {
       const element = basicFixture.instantiate();
-      await flush();
+      await element.updateComplete;
       element.userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'REPO',