Merge "Make IllegalLabelException a checked Exception"
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index 5110a28..9936730 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -46,6 +46,17 @@
     patchNumEquals(a, b) {
       return a + '' === b + '';
     },
+
+    /**
+     * Whether the given patch is a numbered parent of a merge (i.e. a negative
+     * number).
+     * @param  {string|number} n
+     * @return {Boolean}
+     */
+    isMergeParent(n) {
+      return (n + '')[0] === '-';
+    },
+
     /**
      * Given an object of revisions, get a particular revision based on patch
      * num.
@@ -192,6 +203,13 @@
       return allPatchSets[allPatchSets.length - 1].num;
     },
 
+    /** @return {Boolean} */
+    hasEditBasedOnCurrentPatchSet(allPatchSets) {
+      if (!allPatchSets || !allPatchSets.length) { return false; }
+      return allPatchSets[allPatchSets.length - 1].num ===
+          Gerrit.PatchSetBehavior.EDIT_NAME;
+    },
+
     /**
      * Check whether there is no newer patch than the latest patch that was
      * available when this change was loaded.
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index c2ccccd..54c1355 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -174,6 +174,20 @@
       assert.isTrue(equals('PARENT', 'PARENT'));
     });
 
+    test('isMergeParent', () => {
+      const isParent = Gerrit.PatchSetBehavior.isMergeParent;
+      assert.isFalse(isParent(1));
+      assert.isFalse(isParent(4321));
+      assert.isFalse(isParent('52'));
+      assert.isFalse(isParent('edit'));
+      assert.isFalse(isParent('PARENT'));
+      assert.isFalse(isParent(0));
+
+      assert.isTrue(isParent(-23));
+      assert.isTrue(isParent(-1));
+      assert.isTrue(isParent('-42'));
+    });
+
     test('findEditParentRevision', () => {
       const findParent = Gerrit.PatchSetBehavior.findEditParentRevision;
       let revisions = [
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index f2ed33a..b7a048b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -157,6 +157,19 @@
           Do you really want to delete the change?
         </div>
       </gr-confirm-dialog>
+      <gr-confirm-dialog
+          id="confirmDeleteEditDialog"
+          class="confirmDialog"
+          confirm-label="Delete"
+          on-cancel="_handleConfirmDialogCancel"
+          on-confirm="_handleDeleteEditConfirm">
+        <div class="header">
+          Delete Change Edit
+        </div>
+        <div class="main">
+          Do you really want to delete the edit?
+        </div>
+      </gr-confirm-dialog>
     </gr-overlay>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index ff77142..5657cbe 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -51,11 +51,14 @@
   const ChangeActions = {
     ABANDON: 'abandon',
     DELETE: '/',
+    DELETE_EDIT: 'deleteEdit',
     IGNORE: 'ignore',
     MOVE: 'move',
     MUTE: 'mute',
     PRIVATE: 'private',
     PRIVATE_DELETE: 'private.delete',
+    PUBLISH_EDIT: 'publishEdit',
+    REBASE_EDIT: 'rebaseEdit',
     RESTORE: 'restore',
     REVERT: 'revert',
     UNIGNORE: 'unignore',
@@ -118,6 +121,36 @@
     __type: 'revision',
   };
 
+  const REBASE_EDIT = {
+    enabled: true,
+    label: 'Rebase Edit',
+    title: 'Rebase change edit',
+    __key: 'rebaseEdit',
+    __primary: false,
+    __type: 'change',
+    method: 'POST',
+  };
+
+  const PUBLISH_EDIT = {
+    enabled: true,
+    label: 'Publish Edit',
+    title: 'Publish change edit',
+    __key: 'publishEdit',
+    __primary: false,
+    __type: 'change',
+    method: 'POST',
+  };
+
+  const DELETE_EDIT = {
+    enabled: true,
+    label: 'Delete Edit',
+    title: 'Delete change edit',
+    __key: 'deleteEdit',
+    __primary: false,
+    __type: 'change',
+    method: 'DELETE',
+  };
+
   const AWAIT_CHANGE_ATTEMPTS = 5;
   const AWAIT_CHANGE_TIMEOUT_MS = 1000;
 
@@ -276,6 +309,14 @@
         type: Array,
         value() { return []; },
       },
+      editLoaded: {
+        type: Boolean,
+        value: false,
+      },
+      editBasedOnCurrentPatchSet: {
+        type: Boolean,
+        value: true,
+      },
     },
 
     ActionType,
@@ -288,7 +329,8 @@
     ],
 
     observers: [
-      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
+      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*, ' +
+          'editLoaded, editBasedOnCurrentPatchSet, change)',
     ],
 
     listeners: {
@@ -422,7 +464,8 @@
     },
 
     _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
-        additionalActionsChangeRecord) {
+        additionalActionsChangeRecord, editLoaded, editBasedOnCurrentPatchSet,
+        change) {
       const additionalActions = (additionalActionsChangeRecord &&
           additionalActionsChangeRecord.base) || [];
       this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
@@ -436,6 +479,47 @@
           !revisionActions.download) {
         this.set('revisionActions.download', DOWNLOAD_ACTION);
       }
+
+      const changeActions = actionsChangeRecord.base || {};
+      if (Object.keys(changeActions).length !== 0) {
+        if (editLoaded) {
+          if (this.changeIsOpen(change.status)) {
+            if (editBasedOnCurrentPatchSet) {
+              if (!changeActions.publishEdit) {
+                this.set('actions.publishEdit', PUBLISH_EDIT);
+              }
+              if (changeActions.rebaseEdit) {
+                delete this.actions.rebaseEdit;
+                this.notifyPath('actions.rebaseEdit');
+              }
+            } else {
+              if (!changeActions.rebasEdit) {
+                this.set('actions.rebaseEdit', REBASE_EDIT);
+              }
+              if (changeActions.publishEdit) {
+                delete this.actions.publishEdit;
+                this.notifyPath('actions.publishEdit');
+              }
+            }
+          }
+          if (!changeActions.deleteEdit) {
+            this.set('actions.deleteEdit', DELETE_EDIT);
+          }
+        } else {
+          if (changeActions.publishEdit) {
+            delete this.actions.publishEdit;
+            this.notifyPath('actions.publishEdit');
+          }
+          if (changeActions.rebaseEdit) {
+            delete this.actions.rebaseEdit;
+            this.notifyPath('actions.rebaseEdit');
+          }
+          if (changeActions.deleteEdit) {
+            delete this.actions.deleteEdit;
+            this.notifyPath('actions.deleteEdit');
+          }
+        }
+      }
     },
 
     _getValuesFor(obj) {
@@ -638,12 +722,21 @@
         case ChangeActions.DELETE:
           this._handleDeleteTap();
           break;
+        case ChangeActions.DELETE_EDIT:
+          this._handleDeleteEditTap();
+          break;
         case ChangeActions.WIP:
           this._handleWipTap();
           break;
         case ChangeActions.MOVE:
           this._handleMoveTap();
           break;
+        case ChangeActions.PUBLISH_EDIT:
+          this._handlePublishEditTap();
+          break;
+        case ChangeActions.REBASE_EDIT:
+          this._handleRebaseEditTap();
+          break;
         default:
           this._fireAction(this._prependSlash(key), this.actions[key], false);
       }
@@ -775,6 +868,12 @@
       this._fireAction('/', this.actions[ChangeActions.DELETE], false);
     },
 
+    _handleDeleteEditConfirm() {
+      this._hideAllDialogs();
+
+      this._fireAction('/edit', this.actions.deleteEdit, false);
+    },
+
     _getActionOverflowIndex(type, key) {
       return this._overflowActions.findIndex(action => {
         return action.type === type && action.key === key;
@@ -862,6 +961,9 @@
             }
             break;
           case ChangeActions.WIP:
+          case ChangeActions.DELETE_EDIT:
+          case ChangeActions.PUBLISH_EDIT:
+          case ChangeActions.REBASE_EDIT:
             page.show(this.changePath(this.changeNum));
             break;
           default:
@@ -949,10 +1051,22 @@
       this._showActionDialog(this.$.confirmDeleteDialog);
     },
 
+    _handleDeleteEditTap() {
+      this._showActionDialog(this.$.confirmDeleteEditDialog);
+    },
+
     _handleWipTap() {
       this._fireAction('/wip', this.actions.wip, false);
     },
 
+    _handlePublishEditTap() {
+      this._fireAction('/edit:publish', this.actions.publishEdit, false);
+    },
+
+    _handleRebaseEditTap() {
+      this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
+    },
+
     _handleHideBackgroundContent() {
       this.$.mainContent.classList.add('overlayOpen');
     },
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 11a2569..2c252eb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -360,6 +360,137 @@
       assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
     });
 
+    suite('change edits', () => {
+      let fireActionStub;
+      const deleteEditAction = {
+        enabled: true,
+        label: 'Delete Edit',
+        title: 'Delete change edit',
+        __key: 'deleteEdit',
+        __primary: false,
+        __type: 'change',
+        method: 'DELETE',
+      };
+      const publishEditAction = {
+        enabled: true,
+        label: 'Publish Edit',
+        title: 'Publish change edit',
+        __key: 'publishEdit',
+        __primary: false,
+        __type: 'change',
+        method: 'POST',
+      };
+      const rebaseEditAction = {
+        enabled: true,
+        label: 'Rebase Edit',
+        title: 'Rebase change edit',
+        __key: 'rebaseEdit',
+        __primary: false,
+        __type: 'change',
+        method: 'POST',
+      };
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        element.patchNum = 'edit';
+        element.editLoaded = true;
+      });
+
+      test('does not delete edit on action', () => {
+        element._handleDeleteEditTap();
+        assert.isFalse(fireActionStub.called);
+      });
+
+      test('shows confirm dialog for delete edit', () => {
+        element._handleDeleteEditTap();
+        assert.isFalse(element.$$('#confirmDeleteEditDialog').hidden);
+        assert.ok(element.$$('gr-button[data-action-key="deleteEdit"]'));
+        MockInteractions.tap(
+            element.$$('#confirmDeleteEditDialog').$$('gr-button[primary]'));
+        flushAsynchronousOperations();
+        assert.isTrue(
+            fireActionStub.calledWith('/edit', deleteEditAction, false));
+      });
+
+      test('show publish edit but rebaseEdit is hidden', () => {
+        element.change = {
+          status: 'NEW',
+        };
+        const rebaseEditButton =
+            element.$$('gr-button[data-action-key="rebaseEdit"]');
+        assert.isNotOk(rebaseEditButton);
+
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.ok(publishEditButton);
+        MockInteractions.tap(publishEditButton);
+        element._handlePublishEditTap();
+        flushAsynchronousOperations();
+
+        assert.isTrue(
+            fireActionStub.calledWith('/edit:publish', publishEditAction, false));
+      });
+
+      test('show rebase edit but publishEdit is hidden', () => {
+        element.change = {
+          status: 'NEW',
+        };
+        element.editBasedOnCurrentPatchSet = false;
+
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.isNotOk(publishEditButton);
+
+        const rebaseEditButton =
+            element.$$('gr-button[data-action-key="rebaseEdit"]');
+        assert.ok(rebaseEditButton);
+        MockInteractions.tap(rebaseEditButton);
+        element._handleRebaseEditTap();
+        flushAsynchronousOperations();
+
+        assert.isTrue(
+            fireActionStub.calledWith('/edit:rebase', rebaseEditAction, false));
+      });
+
+      test('hide publishEdit and rebaseEdit if change is not open', () => {
+        element.change = {
+          status: 'MERGED',
+        };
+        flushAsynchronousOperations();
+
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.isNotOk(publishEditButton);
+
+        const rebaseEditButton =
+            element.$$('gr-button[data-action-key="rebaseEdit"]');
+        assert.isNotOk(rebaseEditButton);
+
+        const deleteEditButton =
+            element.$$('gr-button[data-action-key="deleteEdit"]');
+        assert.ok(deleteEditButton);
+      });
+
+      test('do not show delete edit on a non change edit', () => {
+        element.editLoaded = false;
+        flushAsynchronousOperations();
+        const deleteEditButton =
+            element.$$('gr-button[data-action-key="deleteEdit"]');
+        assert.isNotOk(deleteEditButton);
+      });
+
+      test('do not show publish edit on a non change edit', () => {
+        element.change = {
+          status: 'NEW',
+        };
+        element.editLoaded = false;
+        flushAsynchronousOperations();
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.isNotOk(publishEditButton);
+      });
+    });
+
     suite('cherry-pick', () => {
       let fireActionStub;
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index dbba7ad..031acda 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -351,6 +351,8 @@
                 commit-num="[[_commitInfo.commit]]"
                 patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
                 commit-message="[[_latestCommitMessage]]"
+                edit-loaded="[[_editLoaded]]"
+                edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
                 on-reload-change="_handleReloadChange"
                 on-download-tap="_handleOpenDownloadDialog"></gr-change-actions>
           </div>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index b4ae93f..5c2422d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -44,13 +44,15 @@
         display: initial;
       }
       .patchInfo-header {
-        padding: .5em calc(var(--default-horizontal-margin) / 2);
-      }
-      .patchInfo-header {
         background-color: #f6f6f6;
         border-bottom: 1px solid #ebebeb;
         display: flex;
-        justify-content: space-between;
+        padding: .5em calc(var(--default-horizontal-margin) / 2);
+      }
+      .patchInfo-header-wrapper {
+        align-items: center;
+        display: flex;
+        width: 100%;
       }
       .latestPatchContainer {
         display: none;
@@ -63,11 +65,9 @@
       }
       #diffPrefsContainer,
       .rightControls {
+        align-self: flex-end;
         margin: auto 0 auto auto;
       }
-      .patchInfo-header-wrapper {
-        width: 100%;
-      }
       .showOnEdit {
         display: none;
       }
@@ -78,12 +78,13 @@
         display: initial;
       }
       .fileList-header {
+        align-items: center;
         display: flex;
         font-weight: bold;
-        justify-content: space-between;
-        margin: .5em calc(var(--default-horizontal-margin) / 2);
+        margin: .5em calc(var(--default-horizontal-margin) / 2) 0;
       }
       .rightControls {
+        align-items: center;
         display: flex;
         flex-wrap: wrap;
         font-weight: normal;
@@ -109,40 +110,42 @@
     </style>
     <div class$="patchInfo-header [[_computeEditLoadedClass(editLoaded)]] [[_computePatchInfoClass(patchRange.patchNum, allPatchSets)]]">
       <div class="patchInfo-header-wrapper">
-        <gr-patch-range-select
-            id="rangeSelect"
-            comments="[[comments]]"
-            change-num="[[changeNum]]"
-            patch-range="[[patchRange]]"
-            available-patches="[[allPatchSets]]"
-            revisions="[[change.revisions]]"
-            on-patch-range-change="_handlePatchChange">
-        </gr-patch-range-select>
-        /
-        <gr-commit-info
-            change="[[change]]"
-            server-config="[[serverConfig]]"
-            commit-info="[[commitInfo]]"></gr-commit-info>
-        <span class="latestPatchContainer">
+        <div>
+          <gr-patch-range-select
+              id="rangeSelect"
+              comments="[[comments]]"
+              change-num="[[changeNum]]"
+              patch-range="[[patchRange]]"
+              available-patches="[[allPatchSets]]"
+              revisions="[[change.revisions]]"
+              on-patch-range-change="_handlePatchChange">
+          </gr-patch-range-select>
           /
-          <a href$="[[changeUrl]]">Go to latest patch set</a>
-        </span>
-        <span class="downloadContainer desktop">
-          /
-          <gr-button link
-              class="download"
-              on-tap="_handleDownloadTap">Download</gr-button>
-        </span>
-        <span class="descriptionContainer hideOnEdit">
-          /
-          <gr-editable-label
-              id="descriptionLabel"
-              class="descriptionLabel"
-              value="[[_computePatchSetDescription(change, patchRange.patchNum)]]"
-              placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
-              read-only="[[_descriptionReadOnly]]"
-              on-changed="_handleDescriptionChanged"></gr-editable-label>
-        </span>
+          <gr-commit-info
+              change="[[change]]"
+              server-config="[[serverConfig]]"
+              commit-info="[[commitInfo]]"></gr-commit-info>
+          <span class="latestPatchContainer">
+            /
+            <a href$="[[changeUrl]]">Go to latest patch set</a>
+          </span>
+          <span class="downloadContainer desktop">
+            /
+            <gr-button link
+                class="download"
+                on-tap="_handleDownloadTap">Download</gr-button>
+          </span>
+          <span class="descriptionContainer hideOnEdit">
+            /
+            <gr-editable-label
+                id="descriptionLabel"
+                class="descriptionLabel"
+                value="[[_computePatchSetDescription(change, patchRange.patchNum)]]"
+                placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
+                read-only="[[_descriptionReadOnly]]"
+                on-changed="_handleDescriptionChanged"></gr-editable-label>
+          </span>
+        </div>
         <span id="diffPrefsContainer"
             class="hideOnEdit"
             hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 0a2b974..ac74ee5 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -126,7 +126,9 @@
      */
     _showAlert(text, opt_actionText, opt_actionCallback,
         opt_dismissOnNavigation) {
-      if (this._alertElement) { return; }
+      if (this._alertElement) {
+        this._hideAlert();
+      }
 
       this._clearHideAlertHandle();
       if (opt_dismissOnNavigation) {
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 5d1c461..17fa746 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -272,20 +272,11 @@
       });
     });
 
-    test('dismissOnNavigation respected', () => {
-      const asyncStub = sandbox.stub(element, 'async');
-      const hideSpy = sandbox.spy(element, '_hideAlert');
-      // No async call when dismissOnNavigation supplied.
-      element._showAlert('test', null, null, true);
-      assert.isFalse(asyncStub.called);
-
-      // When page nav happens, clear alert.
-      document.dispatchEvent(new CustomEvent('location-change'));
-      assert.isTrue(hideSpy.called);
-
-      // When timeout is not supplied, use HIDE_ALERT_TIMEOUT_MS.
-      element._showAlert('test');
-      assert.isTrue(asyncStub.called);
+    test('_showAlert hides existing alerts', () => {
+      element._alertElement = element._createToastAlert();
+      const hideStub = sandbox.stub(element, '_hideAlert');
+      element._showAlert();
+      assert.isTrue(hideStub.calledOnce);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index a2861c6..acc9d4f 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -91,7 +91,7 @@
     QUERY_OFFSET: '/q/:query,:offset',
 
     // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
-    CHANGE_LEGACY: /^\/c\/(\d+)\/?(((\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+    CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
     CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
 
     // Matches
@@ -100,15 +100,15 @@
     // TODO(kaspern): Migrate completely to project based URLs, with backwards
     // compatibility for change-only.
     // eslint-disable-next-line max-len
-    CHANGE_OR_DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/?((\d+|edit)(\.\.(\d+|edit))?(\/(.+))?))?\/?$/,
+    CHANGE_OR_DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))?))?\/?$/,
 
     // Matches
     //     /c/<project>/+/<changeNum>/[<basePatchNum>..]<patchNum>/<path>,edit
     // eslint-disable-next-line max-len
-    DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(((\d+|edit)\.\.)?(edit)(\/(.+)))),edit$/,
+    DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(((-?\d+|edit)\.\.)?(edit)(\/(.+)))),edit$/,
 
     // Matches /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
-    DIFF_LEGACY: /^\/c\/(\d+)\/((\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
+    DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
 
     SETTINGS: /^\/settings\/?/,
     SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
@@ -298,8 +298,17 @@
       const hasPatchNum = params.patchNum !== null &&
           params.patchNum !== undefined;
       let needsRedirect = false;
+
+      // Diffing a patch against itself is invalid, so if the base and revision
+      // patches are equal clear the base.
+      // NOTE: while selecting numbered parents of a merge is not yet
+      // implemented, normalize parent base patches to be un-selected parents in
+      // the same way.
+      // TODO(issue 4760): Remove the isMergeParent check when PG supports
+      // diffing against numbered parents of a merge.
       if (hasBasePatchNum &&
-          this.patchNumEquals(params.basePatchNum, params.patchNum)) {
+          (this.patchNumEquals(params.basePatchNum, params.patchNum) ||
+              this.isMergeParent(params.basePatchNum))) {
         needsRedirect = true;
         params.basePatchNum = null;
       } else if (hasBasePatchNum && !hasPatchNum) {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index df46a38..a9231b6 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -376,6 +376,16 @@
           assert.isNotOk(params.basePatchNum);
           assert.equal(params.patchNum, 'edit');
         });
+
+        // TODO(issue 4760): Remove when PG supports diffing against numbered
+        // parents of a merge.
+        test('range -n..m normalizes to m', () => {
+          const params = {basePatchNum: -2, patchNum: 4};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.isNotOk(params.basePatchNum);
+          assert.equal(params.patchNum, 4);
+        });
       });
     });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index 4f0ebcc..bda9a35 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -15,6 +15,12 @@
   'use strict';
 
   const STORAGE_DEBOUNCE_INTERVAL = 400;
+  const TOAST_DEBOUNCE_INTERVAL = 200;
+
+  const SAVING_MESSAGE = 'Saving';
+  const DRAFT_SINGULAR = 'draft...';
+  const DRAFT_PLURAL = 'drafts...';
+  const SAVED_MESSAGE = 'All changes saved';
 
   Polymer({
     is: 'gr-diff-comment',
@@ -109,6 +115,11 @@
         type: Boolean,
         observer: '_toggleResolved',
       },
+
+      _numPendingDiffRequests: {
+        type: Object,
+        value: {number: 0}, // Intentional to share the object across instances.
+      },
     },
 
     observers: [
@@ -424,13 +435,52 @@
       });
     },
 
+    _getSavingMessage(numPending) {
+      if (numPending === 0) { return SAVED_MESSAGE; }
+      return [
+        SAVING_MESSAGE,
+        numPending,
+        numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
+      ].join(' ');
+    },
+
+    _showStartRequest() {
+      const numPending = ++this._numPendingDiffRequests.number;
+      this._updateRequestToast(numPending);
+    },
+
+    _showEndRequest() {
+      const numPending = --this._numPendingDiffRequests.number;
+      this._updateRequestToast(numPending);
+    },
+
+    _updateRequestToast(numPending) {
+      const message = this._getSavingMessage(numPending);
+      this.debounce('draft-toast', () => {
+        // Note: the event is fired on the body rather than this element because
+        // this element may not be attached by the time this executes, in which
+        // case the event would not bubble.
+        document.body.dispatchEvent(new CustomEvent('show-alert',
+            {detail: {message}, bubbles: true}));
+      }, TOAST_DEBOUNCE_INTERVAL);
+    },
+
     _saveDraft(draft) {
-      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft);
+      this._showStartRequest();
+      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
+          .then(result => {
+            this._showEndRequest();
+            return result;
+          });
     },
 
     _deleteDraft(draft) {
+      this._showStartRequest();
       return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
-          draft);
+          draft).then(result => {
+            this._showEndRequest();
+            return result;
+          });
     },
 
     _getPatchNum() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index bdbf4f9..2816647 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -602,5 +602,34 @@
       element.comment = {unresolved: true};
       assert.isFalse(element.$$('.resolve input').checked);
     });
+
+    suite('draft saving messages', () => {
+      test('_getSavingMessage', () => {
+        assert.equal(element._getSavingMessage(0), 'All changes saved');
+        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
+        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
+        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
+      });
+
+      test('_show{Start,End}Request', () => {
+        const updateStub = sandbox.stub(element, '_updateRequestToast');
+        element._numPendingDiffRequests.number = 1;
+
+        element._showStartRequest();
+        assert.isTrue(updateStub.calledOnce);
+        assert.equal(updateStub.lastCall.args[0], 2);
+        assert.equal(element._numPendingDiffRequests.number, 2);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledTwice);
+        assert.equal(updateStub.lastCall.args[0], 1);
+        assert.equal(element._numPendingDiffRequests.number, 1);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledThrice);
+        assert.equal(updateStub.lastCall.args[0], 0);
+        assert.equal(element._numPendingDiffRequests.number, 0);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 5c56826..c3332b3 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -29,6 +29,8 @@
   const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
       'Documentation';
   const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
+  const ABSOLUTE_URL_PATTERN = /^https?:/;
+  const TRAILING_SLASH_PATTERN = /\/$/;
 
   Polymer({
     is: 'gr-settings-view',
@@ -366,9 +368,13 @@
 
     _getFilterDocsLink(docsBaseUrl) {
       let base = docsBaseUrl;
-      if (!base || !base.startsWith('http')) {
+      if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
         base = GERRIT_DOCS_BASE_URL;
       }
+
+      // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
+      base = base.replace(TRAILING_SLASH_PATTERN, '');
+
       return base + GERRIT_DOCS_FILTER_PATH;
     },
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index a8585da..e1182c1 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -386,6 +386,39 @@
       assert.isTrue(element.$.emailEditor.loadData.calledOnce);
     });
 
+    suite('_getFilterDocsLink', () => {
+      test('with http: docs base URL', () => {
+        const base = 'http://example.com/';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'http://example.com/user-notify.html');
+      });
+
+      test('with http: docs base URL without slash', () => {
+        const base = 'http://example.com';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'http://example.com/user-notify.html');
+      });
+
+      test('with https: docs base URL', () => {
+        const base = 'https://example.com/';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'https://example.com/user-notify.html');
+      });
+
+      test('without docs base URL', () => {
+        const result = element._getFilterDocsLink(null);
+        assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+            'Documentation/user-notify.html');
+      });
+
+      test('ignores non HTTP links', () => {
+        const base = 'javascript://alert("evil");';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+            'Documentation/user-notify.html');
+      });
+    });
+
     suite('when email verification token is provided', () => {
       let resolveConfirm;