Merge "Fix malformed change weblinks generation"
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 2e2f565..eed0896 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -161,7 +161,8 @@
 
     Base base = rebaseUtil.parseBase(rsrc, str);
     if (base == null) {
-      throw new ResourceConflictException("base revision is missing: " + str);
+      throw new ResourceConflictException(
+          "base revision is missing from the destination branch: " + str);
     }
     PatchSet.Id baseId = base.patchSet().getId();
     if (change.getId().equals(baseId.getParentKey())) {
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index d1fdf2f..af982cf 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -165,6 +165,7 @@
     PREV_FILE: 'PREV_FILE',
     NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
     PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
+    NEXT_UNREVIEWED_FILE: 'NEXT_UNREVIEWED_FILE',
     CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
     CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
     OPEN_FILE: 'OPEN_FILE',
@@ -255,6 +256,8 @@
       'Mark/unmark file as reviewed');
   _describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
       'Toggle unified/side-by-side diff');
+  _describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
+      'Mark file as reviewed and go to next unreviewed file');
 
   _describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Select next file');
   _describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
index ad12a44..987b63d 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -55,7 +55,7 @@
           padding: 0 .15em;
         }
       }
-      .hideBranch {
+      .hide {
         display: none;
       }
     </style>
@@ -108,7 +108,7 @@
             </iron-autogrow-textarea>
           </span>
         </section>
-        <section>
+        <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
           <label
               class="title"
               for="privateChangeCheckBox">Private change</label>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index 826a6dc..8e15755 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -44,6 +44,7 @@
         notify: true,
         value: false,
       },
+      _privateChangesEnabled: Boolean,
     },
 
     behaviors: [
@@ -52,10 +53,23 @@
     ],
 
     attached() {
-      if (!this.repoName) { return; }
-      this.$.restAPI.getProjectConfig(this.repoName).then(config => {
-        this.privateByDefault = config.private_by_default;
-      });
+      if (!this.repoName) { return Promise.resolve(); }
+
+      const promises = [];
+
+      promises.push(this.$.restAPI.getProjectConfig(this.repoName)
+          .then(config => {
+            this.privateByDefault = config.private_by_default;
+          }));
+
+      promises.push(this.$.restAPI.getConfig().then(config => {
+        if (!config) { return; }
+
+        this._privateConfig = config && config.change &&
+            config.change.disable_private_changes;
+      }));
+
+      return Promise.all(promises);
     },
 
     observers: [
@@ -63,7 +77,7 @@
     ],
 
     _computeBranchClass(baseChange) {
-      return baseChange ? 'hideBranch' : '';
+      return baseChange ? 'hide' : '';
     },
 
     _allowCreate(branch, subject) {
@@ -120,5 +134,9 @@
         return false;
       }
     },
+
+    _computePrivateSectionClass(config) {
+      return config ? 'hide' : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
index 08c569c..aa4da68 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
@@ -158,5 +158,15 @@
         done();
       });
     });
+
+    test('_computeBranchClass', () => {
+      assert.equal(element._computeBranchClass(true), 'hide');
+      assert.equal(element._computeBranchClass(false), '');
+    });
+
+    test('_computePrivateSectionClass', () => {
+      assert.equal(element._computePrivateSectionClass(true), 'hide');
+      assert.equal(element._computePrivateSectionClass(false), '');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index 799b831..fe043d5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -133,6 +133,8 @@
      * @return {!Promise}
      */
     _repoChanged(repo) {
+      this._loading = true;
+
       if (!repo) { return Promise.resolve(); }
 
       return this._reload(repo);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
index 36a7d76..1d49db9 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
@@ -53,7 +53,7 @@
           </tr>
           <template is="dom-repeat" items="[[item.dashboards]]">
             <tr class="table">
-              <td class="name"><a href$="[[_getUrl(item.project, item.sections)]]">[[item.path]]</a></td>
+              <td class="name"><a href$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a></td>
               <td class="title">[[item.title]]</td>
               <td class="desc">[[item.description]]</td>
               <td class="inherited">[[_computeInheritedFrom(item.project, item.defining_project)]]</td>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
index c0fc0cb..7dea7f4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
@@ -43,24 +43,24 @@
       this.$.restAPI.getRepoDashboards(this.repo, errFn).then(res => {
         if (!res) { return Promise.resolve(); }
 
-        // Flatten 2 dimenional array, and sort by id.
+        // Group by ref and sort by id.
         const dashboards = res.concat.apply([], res).sort((a, b) =>
-            a.id > b.id);
-        const customList = dashboards.filter(a => a.ref === 'custom');
-        const defaultList = dashboards.filter(a => a.ref === 'default');
+            a.id < b.id ? -1 : 1);
+        const dashboardsByRef = {};
+        dashboards.forEach(d => {
+          if (!dashboardsByRef[d.ref]) {
+            dashboardsByRef[d.ref] = [];
+          }
+          dashboardsByRef[d.ref].push(d);
+        });
+
         const dashboardBuilder = [];
-        if (customList.length) {
+        Object.keys(dashboardsByRef).sort().forEach(ref => {
           dashboardBuilder.push({
-            section: 'Custom',
-            dashboards: customList,
+            section: ref,
+            dashboards: dashboardsByRef[ref],
           });
-        }
-        if (defaultList.length) {
-          dashboardBuilder.push({
-            section: 'Default',
-            dashboards: defaultList,
-          });
-        }
+        });
 
         this._dashboards = dashboardBuilder;
         this._loading = false;
@@ -68,10 +68,10 @@
       });
     },
 
-    _getUrl(project, sections) {
-      if (!project || !sections) { return ''; }
+    _getUrl(project, id) {
+      if (!project || !id) { return ''; }
 
-      return Gerrit.Nav.getUrlForCustomDashboard(project, sections);
+      return Gerrit.Nav.getUrlForRepoDashboard(project, id);
     },
 
     _computeLoadingClass(loading) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
index 4d86a0c..94bf5e0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
@@ -46,54 +46,61 @@
       sandbox.restore();
     });
 
-    suite('with default only', () => {
+    suite('dashboard table', () => {
       setup(() => {
         sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
             Promise.resolve([
-              [
-                {
-                  id: 'default:contributor',
-                  project: 'gerrit',
-                  defining_project: 'gerrit',
-                  ref: 'default',
-                  path: 'contributor',
-                  description: 'Own contributions.',
-                  foreach: 'owner:self',
-                  url: '/dashboard/?params',
-                  title: 'Contributor Dashboard',
-                  sections: [
-                    {
-                      name: 'Mine To Rebase',
-                      query: 'is:open -is:mergeable',
-                    },
-                    {
-                      name: 'My Recently Merged',
-                      query: 'is:merged limit:10',
-                    },
-                  ],
-                },
-              ],
-              [
-                {
-                  id: 'default:open',
-                  project: 'gerrit',
-                  defining_project: 'Public-Projects',
-                  ref: 'default',
-                  path: 'open',
-                  description: 'Recent open changes.',
-                  url: '/dashboard/?params',
-                  title: 'Open Changes',
-                  sections: [
-                    {
-                      name: 'Open Changes',
-                      query: 'status:open project:${project} -age:7w',
-                    },
-                  ],
-                },
-              ],
+              {
+                id: 'default:contributor',
+                project: 'gerrit',
+                defining_project: 'gerrit',
+                ref: 'default',
+                path: 'contributor',
+                description: 'Own contributions.',
+                foreach: 'owner:self',
+                url: '/dashboard/?params',
+                title: 'Contributor Dashboard',
+                sections: [
+                  {
+                    name: 'Mine To Rebase',
+                    query: 'is:open -is:mergeable',
+                  },
+                  {
+                    name: 'My Recently Merged',
+                    query: 'is:merged limit:10',
+                  },
+                ],
+              },
+              {
+                id: 'custom:custom2',
+                project: 'gerrit',
+                defining_project: 'Public-Projects',
+                ref: 'custom',
+                path: 'open',
+                description: 'Recent open changes.',
+                url: '/dashboard/?params',
+                title: 'Open Changes',
+                sections: [
+                  {
+                    name: 'Open Changes',
+                    query: 'status:open project:${project} -age:7w',
+                  },
+                ],
+              },
+              {
+                id: 'default:abc',
+                project: 'gerrit',
+                ref: 'default',
+              },
+              {
+                id: 'custom:custom1',
+                project: 'gerrit',
+                ref: 'custom',
+              },
             ]));
       });
-      test('loading', done => {
+
+      test('loading, sections, and ordering', done => {
         assert.isTrue(element._loading);
         assert.notEqual(getComputedStyle(element.$.loadingContainer).display,
             'none');
@@ -101,143 +108,20 @@
             'none');
         element.repo = 'test';
         flush(() => {
-          assert.equal(element._dashboards.length, 1);
-          assert.equal(element._dashboards[0].section, 'Default');
-          assert.equal(element._dashboards[0].dashboards.length, 2);
           assert.equal(getComputedStyle(element.$.loadingContainer).display,
               'none');
           assert.notEqual(getComputedStyle(element.$.dashboards).display,
               'none');
-          done();
-        });
-      });
 
-      test('dispatched command-tap on button tap', done => {
-        element.repo = 'test';
-        flush(() => {
-          assert.equal(element._dashboards.length, 1);
-          assert.equal(element._dashboards[0].section, 'Default');
-          assert.equal(element._dashboards[0].dashboards.length, 2);
-          done();
-        });
-      });
-    });
-
-    suite('with custom only', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
-            Promise.resolve([
-              [
-                {
-                  id: 'custom:custom1',
-                  project: 'gerrit',
-                  defining_project: 'gerrit',
-                  ref: 'custom',
-                  path: 'contributor',
-                  description: 'Own contributions.',
-                  foreach: 'owner:self',
-                  url: '/dashboard/?params',
-                  title: 'Contributor Dashboard',
-                  sections: [
-                    {
-                      name: 'Mine To Rebase',
-                      query: 'is:open -is:mergeable',
-                    },
-                    {
-                      name: 'My Recently Merged',
-                      query: 'is:merged limit:10',
-                    },
-                  ],
-                },
-              ],
-              [
-                {
-                  id: 'custom:custom2',
-                  project: 'gerrit',
-                  defining_project: 'Public-Projects',
-                  ref: 'custom',
-                  path: 'open',
-                  description: 'Recent open changes.',
-                  url: '/dashboard/?params',
-                  title: 'Open Changes',
-                  sections: [
-                    {
-                      name: 'Open Changes',
-                      query: 'status:open project:${project} -age:7w',
-                    },
-                  ],
-                },
-              ],
-            ]));
-      });
-
-      test('dispatched command-tap on button tap', done => {
-        element.repo = 'test';
-        flush(() => {
-          assert.equal(element._dashboards.length, 1);
-          assert.equal(element._dashboards[0].section, 'Custom');
-          assert.equal(element._dashboards[0].dashboards.length, 2);
-          done();
-        });
-      });
-    });
-
-    suite('with custom and default', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
-            Promise.resolve([
-              [
-                {
-                  id: 'default:contributor',
-                  project: 'gerrit',
-                  defining_project: 'gerrit',
-                  ref: 'default',
-                  path: 'contributor',
-                  description: 'Own contributions.',
-                  foreach: 'owner:self',
-                  url: '/dashboard/?params',
-                  title: 'Contributor Dashboard',
-                  sections: [
-                    {
-                      name: 'Mine To Rebase',
-                      query: 'is:open -is:mergeable',
-                    },
-                    {
-                      name: 'My Recently Merged',
-                      query: 'is:merged limit:10',
-                    },
-                  ],
-                },
-              ],
-              [
-                {
-                  id: 'custom:custom2',
-                  project: 'gerrit',
-                  defining_project: 'Public-Projects',
-                  ref: 'custom',
-                  path: 'open',
-                  description: 'Recent open changes.',
-                  url: '/dashboard/?params',
-                  title: 'Open Changes',
-                  sections: [
-                    {
-                      name: 'Open Changes',
-                      query: 'status:open project:${project} -age:7w',
-                    },
-                  ],
-                },
-              ],
-            ]));
-      });
-
-      test('dispatched command-tap on button tap', done => {
-        element.repo = 'test';
-        flush(() => {
           assert.equal(element._dashboards.length, 2);
-          assert.equal(element._dashboards[0].section, 'Custom');
-          assert.equal(element._dashboards[1].section, 'Default');
-          assert.equal(element._dashboards[0].dashboards.length, 1);
-          assert.equal(element._dashboards[1].dashboards.length, 1);
+          assert.equal(element._dashboards[0].section, 'custom');
+          assert.equal(element._dashboards[1].section, 'default');
+
+          const dashboards = element._dashboards[0].dashboards;
+          assert.equal(dashboards.length, 2);
+          assert.equal(dashboards[0].id, 'custom:custom1');
+          assert.equal(dashboards[1].id, 'custom:custom2');
+
           done();
         });
       });
@@ -245,7 +129,7 @@
 
     suite('test url', () => {
       test('_getUrl', () => {
-        sandbox.stub(Gerrit.Nav, 'getUrlForCustomDashboard',
+        sandbox.stub(Gerrit.Nav, 'getUrlForRepoDashboard',
             () => '/r/dashboard/test');
 
         assert.equal(element._getUrl('/dashboard/test', {}), '/r/dashboard/test');
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
index 99aa265..b0ba8a2b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
@@ -87,7 +87,7 @@
     <div hidden$="[[_loading]]" hidden>
       <gr-user-header
           user-id="[[params.user]]"
-          class$="[[_computeUserHeaderClass(params.user)]]"></gr-user-header>
+          class$="[[_computeUserHeaderClass(params)]]"></gr-user-header>
       <gr-change-list
           show-star
           show-reviewed-state
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 3fc4089..0625bbe 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -37,7 +37,7 @@
       /** @type {{ selectedChangeIndex: number }} */
       viewState: Object,
 
-      /** @type {{ user: string }} */
+      /** @type {{ project: string, user: string }} */
       params: {
         type: Object,
       },
@@ -217,8 +217,12 @@
           });
     },
 
-    _computeUserHeaderClass(userParam) {
-      return userParam === 'self' ? 'hide' : '';
+    _computeUserHeaderClass(params) {
+      if (!params || !!params.project || !params.user
+          || params.user === 'self') {
+        return 'hide';
+      }
+      return '';
     },
 
     _handleToggleStar(e) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index 78fdee6..618ec65 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -314,10 +314,13 @@
     });
 
     test('_computeUserHeaderClass', () => {
-      assert.equal(element._computeUserHeaderClass(undefined), '');
-      assert.equal(element._computeUserHeaderClass(''), '');
-      assert.equal(element._computeUserHeaderClass('self'), 'hide');
-      assert.equal(element._computeUserHeaderClass('user'), '');
+      assert.equal(element._computeUserHeaderClass(undefined), 'hide');
+      assert.equal(element._computeUserHeaderClass({}), 'hide');
+      assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
+      assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
+      assert.equal(
+          element._computeUserHeaderClass({project: 'p', user: 'user'}),
+          'hide');
     });
 
     test('404 page', done => {
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index 7278c5a..b1433a3 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -522,14 +522,15 @@
 
       /**
        * @param {string} repo The name of the repo.
-       * @param {!Array} sections The sections to display in the dashboard
+       * @param {string} dashboard The ID of the dashboard, in the form of
+       *     '<ref>:<path>'.
        * @return {string}
        */
-      getUrlForCustomDashboard(repo, sections) {
+      getUrlForRepoDashboard(repo, dashboard) {
         return this._getUrlFor({
-          repo,
           view: Gerrit.Nav.View.DASHBOARD,
-          sections,
+          repo,
+          dashboard,
         });
       },
 
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 2d019ae..8ae30ad 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -423,7 +423,8 @@
         return `/dashboard/${user}?${queryParams.join('&')}`;
       } else if (repoName) {
         // Project dashboard.
-        return `/p/${repoName}/+/dashboard/${params.dashboard}`;
+        const encodedRepo = this.encodeURL(repoName, true);
+        return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
       } else {
         // User dashboard.
         return `/dashboard/${params.user || 'self'}`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index e26201a..e77eb57 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -29,7 +29,7 @@
     </div>
     <gr-ranged-comment-layer
         id="rangeLayer"
-        comments="[[comments]]"></gr-ranged-comment-layer>
+        comment-ranges="[[commentRanges]]"></gr-ranged-comment-layer>
     <gr-syntax-layer
         id="syntaxLayer"
         diff="[[diff]]"></gr-syntax-layer>
@@ -109,7 +109,6 @@
           changeNum: String,
           patchNum: String,
           viewMode: String,
-          comments: Object,
           isImageDiff: Boolean,
           baseImage: Object,
           revisionImage: Object,
@@ -125,6 +124,10 @@
           _groups: Array,
           _layers: Array,
           _showTabs: Boolean,
+          /** @type {!Array<!Gerrit.HoveredRange>} */
+          commentRanges: {
+            type: Array,
+          },
         },
 
         get diffElement() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index a855833..c277f34 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -389,7 +389,7 @@
     test('_handlePreferenceError called with invalid preference', () => {
       sandbox.stub(element, '_handlePreferenceError');
       const prefs = {tab_size: 0};
-      element._getDiffBuilder(element.diff, element.comments, prefs);
+      element._getDiffBuilder(element.diff, undefined, prefs);
       assert.isTrue(element._handlePreferenceError.lastCall
           .calledWithExactly('tab size'));
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index f3e3249..a2439d7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -48,11 +48,12 @@
       *     version is the one whose line number column is further to the left.
       *
       * range:
-      *     The range of text that the comment refers to (startLine, startChar,
-      *     endLine, endChar), serialized as JSON. If set, range's startLine
-      *     will have the same value as line-num. Line numbers are 1-based,
-      *     char numbers are 0-based. The start position (startLine, startChar)
-      *     is inclusive, and the end position (endLine, endChar) is exclusive.
+      *     The range of text that the comment refers to (start_line,
+      *     start_character, end_line, end_character), serialized as JSON. If
+      *     set, range's end_line will have the same value as line-num. Line
+      *     numbers are 1-based, char numbers are 0-based. The start position
+      *     (start_line, start_character) is inclusive, and the end position
+      *     (end_line, end_character) is exclusive.
       */
     properties: {
       changeNum: String,
@@ -61,8 +62,8 @@
         value() { return []; },
       },
       /**
-       * @type {?{startLine: number, startChar: number, endLine: number,
-       *          endChar: number}}
+       * @type {?{start_line: number, start_character: number, end_line: number,
+       *          end_character: number}}
        */
       range: {
         type: Object,
@@ -390,12 +391,7 @@
         d.line = opt_lineNum;
       }
       if (opt_range) {
-        d.range = {
-          start_line: opt_range.startLine,
-          start_character: opt_range.startChar,
-          end_line: opt_range.endLine,
-          end_character: opt_range.endChar,
-        };
+        d.range = opt_range;
       }
       if (this.parentIndex) {
         d.parent = this.parentIndex;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index 58648bf..1881497 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -724,15 +724,25 @@
     });
 
     test('reflects range to JSON serialized attribute if set', () => {
-      element.range = {startLine: 4, endLine: 5, startChar: 6, endChar: 7};
+      element.range = {
+        start_line: 4,
+        end_line: 5,
+        start_character: 6,
+        end_character: 7,
+      };
 
       assert.deepEqual(
           JSON.parse(element.getAttribute('range')),
-          {startLine: 4, endLine: 5, startChar: 6, endChar: 7});
+          {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
     });
 
     test('removes range attribute if range is unset', () => {
-      element.range = {startLine: 4, endLine: 5, startChar: 6, endChar: 7};
+      element.range = {
+        start_line: 4,
+        end_line: 5,
+        start_character: 6,
+        end_character: 7,
+      };
       element.range = undefined;
 
       assert.notOk(element.hasAttribute('range'));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index 577eec6..85ba202 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -21,7 +21,11 @@
     is: 'gr-diff-highlight',
 
     properties: {
-      comments: Object,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: {
+        type: Array,
+        notify: true,
+      },
       loggedIn: Boolean,
       /**
        * querySelector can return null, so needs to be nullable.
@@ -71,35 +75,44 @@
     },
 
     _handleCommentMouseOver(e) {
-      const comment = e.detail.comment;
-      if (!comment.range) { return; }
-      const lineEl = this.diffBuilder.getLineElByChild(e.target);
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
-      const index = this._indexOfComment(side, comment);
+      const threadEl = Polymer.dom(e).localTarget;
+      const index = this._indexForThreadEl(threadEl);
+
       if (index !== undefined) {
-        this.set(['comments', side, index, '__hovering'], true);
+        this.set(['commentRanges', index, 'hovering'], true);
       }
     },
 
     _handleCommentMouseOut(e) {
-      const comment = e.detail.comment;
-      if (!comment.range) { return; }
-      const lineEl = this.diffBuilder.getLineElByChild(e.target);
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
-      const index = this._indexOfComment(side, comment);
+      const threadEl = Polymer.dom(e).localTarget;
+      const index = this._indexForThreadEl(threadEl);
+
       if (index !== undefined) {
-        this.set(['comments', side, index, '__hovering'], false);
+        this.set(['commentRanges', index, 'hovering'], false);
       }
     },
 
-    _indexOfComment(side, comment) {
-      const idProp = comment.id ? 'id' : '__draftID';
-      for (let i = 0; i < this.comments[side].length; i++) {
-        if (comment[idProp] &&
-            this.comments[side][i][idProp] === comment[idProp]) {
-          return i;
-        }
+    _indexForThreadEl(threadEl) {
+      const side = threadEl.getAttribute('comment-side');
+      const range = JSON.parse(threadEl.getAttribute('range'));
+
+      if (!range) return undefined;
+
+      return this._indexOfCommentRange(side, range);
+    },
+
+    _indexOfCommentRange(side, range) {
+      function rangesEqual(a, b) {
+        if (!a && !b) { return true; }
+        if (!a || !b) { return false; }
+        return a.start_line === b.start_line &&
+            a.start_character === b.start_character &&
+            a.end_line === b.end_line &&
+            a.end_character === b.end_character;
       }
+
+      return this.commentRanges.findIndex(commentRange =>
+          commentRange.side === side && rangesEqual(commentRange.range, range));
     },
 
     /**
@@ -295,10 +308,10 @@
       const root = Polymer.dom(this.root);
       root.insertBefore(actionBox, root.firstElementChild);
       actionBox.range = {
-        startLine: start.line,
-        startChar: start.column,
-        endLine: end.line,
-        endChar: end.column,
+        start_line: start.line,
+        start_character: start.column,
+        end_line: end.line,
+        end_character: end.column,
       };
       actionBox.side = start.side;
       if (start.line === end.line) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index 98d55c0..23de407 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -205,7 +205,7 @@
 
       test('comment-mouse-over from ranged comment causes set', () => {
         sandbox.stub(element, 'set');
-        sandbox.stub(element, '_indexOfComment').returns(0);
+        sandbox.stub(element, '_indexForThreadEl').returns(0);
         element.fire('comment-mouse-over', {comment: {range: {}}});
         assert.isTrue(element.set.called);
       });
@@ -318,10 +318,10 @@
         const actionBox = element.$$('gr-selection-action-box');
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 138,
-          startChar: 5,
-          endLine: 138,
-          endChar: 12,
+          start_line: 138,
+          start_character: 5,
+          end_line: 138,
+          end_character: 12,
         });
         assert.equal(getActionSide(), 'left');
         assert.notOk(actionBox.positionBelow);
@@ -337,10 +337,10 @@
         const actionBox = element.$$('gr-selection-action-box');
 
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 36,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 36,
         });
         assert.equal(getActionSide(), 'right');
         assert.notOk(actionBox.positionBelow);
@@ -370,10 +370,10 @@
         element._handleSelection();
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 36,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 36,
         });
       });
 
@@ -383,10 +383,10 @@
         emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 2,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 2,
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -404,10 +404,10 @@
         emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 8,
-          endLine: 140,
-          endChar: 23,
+          start_line: 140,
+          start_character: 8,
+          end_line: 140,
+          end_character: 23,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -418,10 +418,10 @@
         emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 18,
-          endLine: 140,
-          endChar: 27,
+          start_line: 140,
+          start_character: 18,
+          end_line: 140,
+          end_character: 27,
         });
       });
 
@@ -431,10 +431,10 @@
         emulateSelection(content.firstChild, 2, hl.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 2,
-          endLine: 140,
-          endChar: 61,
+          start_line: 140,
+          start_character: 2,
+          end_line: 140,
+          end_character: 61,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -470,10 +470,10 @@
         emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 83,
-          endLine: 141,
-          endChar: 4,
+          start_line: 140,
+          start_character: 83,
+          end_line: 141,
+          end_character: 4,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -485,10 +485,10 @@
         emulateSelection(content.firstChild, 4, comment.firstChild, 1);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 4,
-          endLine: 140,
-          endChar: 83,
+          start_line: 140,
+          start_character: 4,
+          end_line: 140,
+          end_character: 83,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -517,10 +517,10 @@
         emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 130,
-          startChar: 3,
-          endLine: 146,
-          endChar: 14,
+          start_line: 130,
+          start_character: 3,
+          end_line: 146,
+          end_character: 14,
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -531,10 +531,10 @@
             content.firstChild, 1, content.querySelector('span'), 0);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 1,
-          endLine: 140,
-          endChar: 51,
+          start_line: 140,
+          start_character: 1,
+          end_line: 140,
+          end_character: 51,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -546,10 +546,10 @@
             content.querySelectorAll('span')[1].nextSibling, 1);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 51,
-          endLine: 140,
-          endChar: 71,
+          start_line: 140,
+          start_character: 51,
+          end_line: 140,
+          end_character: 71,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -582,10 +582,10 @@
         emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 0,
-          endLine: 119,
-          endChar: element._getLength(startContent),
+          start_line: 119,
+          start_character: 0,
+          end_line: 119,
+          end_character: element._getLength(startContent),
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -597,10 +597,10 @@
             endContent.parentElement.previousElementSibling, 0);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 146,
-          startChar: 0,
-          endLine: 146,
-          endChar: 84,
+          start_line: 146,
+          start_character: 0,
+          end_line: 146,
+          end_character: 84,
         });
         assert.equal(getActionSide(), 'right');
       });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index 5e4a3fd..814c7268 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -45,11 +45,6 @@
     return !!(diff.binary && (isA || isB));
   }
 
-  /** @typedef {{startLine: number, startChar: number,
-   *             endLine: number, endChar: number}} */
-  Gerrit.Range;
-
-
   /**
    * Compare two ranges. Either argument may be falsy, but will only return
    * true if both are falsy or if neither are falsy and have the same position
@@ -62,10 +57,10 @@
   function rangesEqual(a, b) {
     if (!a && !b) { return true; }
     if (!a || !b) { return false; }
-    return a.startLine === b.startLine &&
-        a.startChar === b.startChar &&
-        a.endLine === b.endLine &&
-        a.endChar === b.endChar;
+    return a.start_line === b.start_line &&
+        a.start_character === b.start_character &&
+        a.end_line === b.end_line &&
+        a.end_character === b.end_character;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
index c7ee1c2..423bdc6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -966,10 +966,10 @@
 
       // Try to fetch a thread with a different range.
       range = {
-        startLine: 1,
-        startChar: 1,
-        endLine: 1,
-        endChar: 3,
+        start_line: 1,
+        start_character: 1,
+        end_line: 1,
+        end_character: 3,
       };
 
       assert.isOk(element._getOrCreateThread(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 59c5b1f..fd54af4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -169,6 +169,10 @@
         type: Object,
         computed: '_getRevisionInfo(_change)',
       },
+      _reviewedFiles: {
+        type: Object,
+        value: () => new Set(),
+      },
     },
 
     behaviors: [
@@ -215,6 +219,7 @@
         [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
         [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
         [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+        [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
 
         // Final two are actually handled by gr-diff-comment-thread.
         [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
@@ -564,10 +569,18 @@
       return {path: fileList[idx]};
     },
 
+    _getReviewedFiles(changeNum, patchNum) {
+      return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
+          .then(files => {
+            this._reviewedFiles = new Set(files);
+            return this._reviewedFiles;
+          });
+    },
+
     _getReviewedStatus(editMode, changeNum, patchNum, path) {
       if (editMode) { return Promise.resolve(false); }
-      return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
-          .then(files => files.includes(path));
+      return this._getReviewedFiles(changeNum, patchNum)
+          .then(files => files.has(path));
     },
 
     _paramsChanged(value) {
@@ -1025,5 +1038,15 @@
     _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
       return disableDiffPrefs || !loggedIn;
     },
+
+    _handleNextUnreviewedFile(e) {
+      this._setReviewed(true);
+      // Ensure that the currently viewed file always appears in unreviewedFiles
+      // so we resolve the right "next" file.
+      const unreviewedFiles = this._fileList
+          .filter(file =>
+          (file === this._path || !this._reviewedFiles.has(file)));
+      this._navToFile(this._path, unreviewedFiles, 1);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index 3a5ca51..0274330 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -67,6 +67,7 @@
     kb.bindShortcut(kb.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
     kb.bindShortcut(kb.Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
     kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
+    kb.bindShortcut(kb.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
 
     let element;
     let sandbox;
@@ -1127,5 +1128,22 @@
       assert.isTrue(setStub.calledOnce);
       assert.isTrue(setStub.calledWith(101, 'test-project'));
     });
+
+    test('shift+m navigates to next unreviewed file', () => {
+      element._fileList = ['file1', 'file2', 'file3'];
+      element._reviewedFiles = new Set(['file1', 'file2']);
+      element._path = 'file1';
+      const reviewedStub = sandbox.stub(element, '_setReviewed');
+      const navStub = sandbox.stub(element, '_navToFile');
+      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+      flushAsynchronousOperations();
+
+      assert.isTrue(reviewedStub.lastCall.args[0]);
+      assert.deepEqual(navStub.lastCall.args, [
+        'file1',
+        ['file1', 'file3'],
+        1,
+      ]);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 7f7ec72..862db10 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -280,10 +280,10 @@
         <gr-diff-highlight
             id="highlights"
             logged-in="[[loggedIn]]"
-            comments="{{comments}}">
+            comment-ranges="{{_commentRanges}}">
           <gr-diff-builder
               id="diffBuilder"
-              comments="[[comments]]"
+              comment-ranges="[[_commentRanges]]"
               project-name="[[projectName]]"
               diff="[[diff]]"
               diff-path="[[path]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 1c6c8cf..f87e46f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -39,6 +39,15 @@
   const FULL_CONTEXT = -1;
   const LIMITED_CONTEXT = 10;
 
+  /** @typedef {{start_line: number, start_character: number,
+   *             end_line: number, end_character: number}} */
+  Gerrit.Range;
+
+  function isThreadEl(node) {
+    return node.nodeType === Node.ELEMENT_NODE &&
+        node.classList.contains('comment-thread');
+  }
+
   Polymer({
     is: 'gr-diff',
 
@@ -96,6 +105,11 @@
         type: Object,
         value: {left: [], right: []},
       },
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      _commentRanges: {
+        type: Array,
+        value: [],
+      },
       lineWrapping: {
         type: Boolean,
         value: false,
@@ -181,7 +195,19 @@
 
       _diffLength: Number,
 
-      /** @type {?PolymerDomApi.ObserveHandle} */
+      /**
+       * Observes comment nodes added or removed after the initial render.
+       * Can be used to unregister when the entire diff is (re-)rendered or upon
+       * detachment.
+       * @type {?PolymerDomApi.ObserveHandle}
+       */
+      _incrementalNodeObserver: Object,
+
+      /**
+       * Observes comment nodes added or removed at any point.
+       * Can be used to unregister upon detachment.
+       * @type {?PolymerDomApi.ObserveHandle}
+       */
       _nodeObserver: Object,
     },
 
@@ -197,10 +223,36 @@
       'render-content': '_handleRenderContent',
     },
 
+    attached() {
+      this._updateRangesWhenNodesChange();
+    },
+
     detached() {
+      this._unobserveIncrementalNodes();
       this._unobserveNodes();
     },
 
+    _updateRangesWhenNodesChange() {
+      function commentRangeFromThreadEl(threadEl) {
+        const side = threadEl.getAttribute('comment-side');
+        const range = JSON.parse(threadEl.getAttribute('range'));
+        return {side, range, hovering: false};
+      }
+
+      this._nodeObserver = Polymer.dom(this).observeNodes(info => {
+        const addedThreadEls = info.addedNodes.filter(isThreadEl);
+        const addedCommentRanges = addedThreadEls
+            .map(commentRangeFromThreadEl)
+            .filter(({range}) => range);
+        this.push('_commentRanges', ...addedCommentRanges);
+        // In principal we should also handle removed nodes, but I have not
+        // figured out how to do that yet without also catching all the removals
+        // caused by further redistribution. Right now, comments are never
+        // removed by no longer slotting them in, so I decided to not handle
+        // this situation until it occurs.
+      });
+    },
+
     /** Cancel any remaining diff builder rendering work. */
     cancel() {
       this.$.diffBuilder.cancel();
@@ -305,7 +357,7 @@
     _handleCreateRangeComment(e) {
       const range = e.detail.range;
       const side = e.detail.side;
-      const lineNum = range.endLine;
+      const lineNum = range.end_line;
       const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
 
       if (this._isValidElForComment(lineEl)) {
@@ -573,7 +625,7 @@
     },
 
     _renderDiffTable() {
-      this._unobserveNodes();
+      this._unobserveIncrementalNodes();
       if (!this.prefs) {
         this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
         return;
@@ -591,9 +643,8 @@
     },
 
     _handleRenderContent() {
-      this._nodeObserver = Polymer.dom(this).observeNodes(info => {
-        const addedThreadEls = info.addedNodes.filter(
-            node => node.nodeType === Node.ELEMENT_NODE);
+      this._incrementalNodeObserver = Polymer.dom(this).observeNodes(info => {
+        const addedThreadEls = info.addedNodes.filter(isThreadEl);
         // In principal we should also handle removed nodes, but I have not
         // figured out how to do that yet without also catching all the removals
         // caused by further redistribution. Right now, comments are never
@@ -612,6 +663,12 @@
       });
     },
 
+    _unobserveIncrementalNodes() {
+      if (this._incrementalNodeObserver) {
+        Polymer.dom(this).unobserveNodes(this._incrementalNodeObserver);
+      }
+    },
+
     _unobserveNodes() {
       if (this._nodeObserver) {
         Polymer.dom(this).unobserveNodes(this._nodeObserver);
@@ -629,7 +686,7 @@
     },
 
     clearDiffContent() {
-      this._unobserveNodes();
+      this._unobserveIncrementalNodes();
       this.$.diffTable.innerHTML = null;
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index db14fc8..fa488f0 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -17,31 +17,34 @@
 (function() {
   'use strict';
 
-  const HOVER_PATH_PATTERN = /^comments\.(left|right)\.\#(\d+)\.__hovering$/;
-  const SPLICE_PATH_PATTERN = /^comments\.(left|right)\.splices$/;
+  const HOVER_PATH_PATTERN = /^commentRanges\.\#(\d+)\.hovering$/;
 
   const RANGE_HIGHLIGHT = 'range';
   const HOVER_HIGHLIGHT = 'rangeHighlight';
 
   const NORMALIZE_RANGE_EVENT = 'normalize-range';
 
+  /** @typedef {{side: string, range: Gerrit.Range, hovering: boolean}} */
+  Gerrit.HoveredRange;
+
   Polymer({
     is: 'gr-ranged-comment-layer',
 
     properties: {
-      comments: Object,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: Array,
       _listeners: {
         type: Array,
         value() { return []; },
       },
-      _commentMap: {
+      _rangesMap: {
         type: Object,
-        value() { return {left: [], right: []}; },
+        value() { return {left: {}, right: {}}; },
       },
     },
 
     observers: [
-      '_handleCommentChange(comments.*)',
+      '_handleCommentRangesChange(commentRanges.*)',
     ],
 
     /**
@@ -93,97 +96,78 @@
     },
 
     /**
-     * Handle change in the comments by updating the comment maps and by
+     * Handle change in the ranges by updating the ranges maps and by
      * emitting appropriate update notifications.
      * @param {Object} record The change record.
      */
-    _handleCommentChange(record) {
-      if (!record.path) { return; }
+    _handleCommentRangesChange(record) {
+      if (!record) return;
 
       // If the entire set of comments was changed.
-      if (record.path === 'comments') {
-        this._commentMap.left = this._computeCommentMap(this.comments.left);
-        this._commentMap.right = this._computeCommentMap(this.comments.right);
-        return;
+      if (record.path === 'commentRanges') {
+        this._rangesMap = {left: {}, right: {}};
+        for (const {side, range, hovering} of record.value) {
+          this._updateRangesMap(
+              side, range, hovering, (forLine, start, end, hovering) => {
+                forLine.push({start, end, hovering});
+              });
+        }
       }
 
       // If the change only changed the `hovering` property of a comment.
-      let match = record.path.match(HOVER_PATH_PATTERN);
-      let side;
-
+      const match = record.path.match(HOVER_PATH_PATTERN);
       if (match) {
-        side = match[1];
-        const index = match[2];
-        const comment = this.comments[side][index];
-        if (comment && comment.range) {
-          this._commentMap[side] = this._computeCommentMap(this.comments[side]);
-          this._notifyUpdateRange(
-              comment.range.start_line, comment.range.end_line, side);
-        }
-        return;
+        const commentRangesIndex = match[1];
+        const {side, range, hovering} = this.commentRanges[commentRangesIndex];
+        this._updateRangesMap(
+            side, range, hovering, (forLine, start, end, hovering) => {
+              const index = forLine.findIndex(lineRange =>
+                  lineRange.start === start && lineRange.end === end);
+              forLine[index].hovering = hovering;
+            });
       }
 
       // If comments were spliced in or out.
-      match = record.path.match(SPLICE_PATH_PATTERN);
-      if (match) {
-        side = match[1];
-        this._commentMap[side] = this._computeCommentMap(this.comments[side]);
-        this._handleCommentSplice(record.value, side);
+      if (record.path === 'commentRanges.splices') {
+        for (const indexSplice of record.value.indexSplices) {
+          const removed = indexSplice.removed;
+          for (const {side, range, hovering} of removed) {
+            this._updateRangesMap(
+                side, range, hovering, (forLine, start, end) => {
+                  const index = forLine.findIndex(lineRange =>
+                      lineRange.start === start && lineRange.end === end);
+                  forLine.splice(index, 1);
+                });
+          }
+          const added = indexSplice.object.slice(
+              indexSplice.index, indexSplice.index + indexSplice.addedCount);
+          for (const {side, range, hovering} of added) {
+            this._updateRangesMap(
+                side, range, hovering, (forLine, start, end, hovering) => {
+                  forLine.push({start, end, hovering});
+                });
+          }
+        }
       }
     },
 
-    /**
-     * Take a list of comments and return a sparse list mapping line numbers to
-     * partial ranges. Uses an end-character-index of -1 to indicate the end of
-     * the line.
-     * @param {?} commentList The list of comments.
-     *    Getting this param to match closure requirements caused problems.
-     * @return {!Object} The sparse list.
-     */
-    _computeCommentMap(commentList) {
-      const result = {};
-      for (const comment of commentList) {
-        if (!comment.range) { continue; }
-        const range = comment.range;
-        for (let line = range.start_line; line <= range.end_line; line++) {
-          if (!result[line]) { result[line] = []; }
-          result[line].push({
-            comment,
-            start: line === range.start_line ? range.start_character : 0,
-            end: line === range.end_line ? range.end_character : -1,
-          });
-        }
+    _updateRangesMap(side, range, hovering, operation) {
+      const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
+      for (let line = range.start_line; line <= range.end_line; line++) {
+        const forLine = forSide[line] || (forSide[line] = []);
+        const start = line === range.start_line ? range.start_character : 0;
+        const end = line === range.end_line ? range.end_character : -1;
+        operation(forLine, start, end, hovering);
       }
-      return result;
-    },
-
-    /**
-     * Translate a splice record into range update notifications.
-     */
-    _handleCommentSplice(record, side) {
-      if (!record || !record.indexSplices) { return; }
-
-      for (const splice of record.indexSplices) {
-        const ranges = splice.removed.length ?
-            splice.removed.map(c => { return c.range; }) :
-            [splice.object[splice.index].range];
-        for (const range of ranges) {
-          if (!range) { continue; }
-          this._notifyUpdateRange(range.start_line, range.end_line, side);
-        }
-      }
+      this._notifyUpdateRange(range.start_line, range.end_line, side);
     },
 
     _getRangesForLine(line, side) {
       const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
-      const ranges = this.get(['_commentMap', side, lineNum]) || [];
+      const ranges = this.get(['_rangesMap', side, lineNum]) || [];
       return ranges
           .map(range => {
-            range = {
-              start: range.start,
-              end: range.end === -1 ? line.text.length : range.end,
-              hovering: !!range.comment.__hovering,
-            };
+            range.end = range.end === -1 ? line.text.length : range.end;
 
             // Normalize invalid ranges where the start is after the end but the
             // start still makes sense. Set the end to the end of the line.
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index c541e26..c198ace 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -40,62 +40,48 @@
     let sandbox;
 
     setup(() => {
-      const initialComments = {
-        left: [
-          {
-            id: '12345',
-            line: 39,
-            message: 'range comment',
-            range: {
-              end_character: 9,
-              end_line: 39,
-              start_character: 6,
-              start_line: 36,
-            },
-          }, {
-            id: '23456',
-            line: 100,
-            message: 'non range comment',
+      const initialCommentRanges = [
+        {
+          side: 'left',
+          range: {
+            end_character: 9,
+            end_line: 39,
+            start_character: 6,
+            start_line: 36,
           },
-        ],
-        right: [
-          {
-            id: '34567',
-            line: 10,
-            message: 'range comment',
-            range: {
-              end_character: 22,
-              end_line: 12,
-              start_character: 10,
-              start_line: 10,
-            },
-          }, {
-            id: '45678',
-            line: 100,
-            message: 'single line range comment',
-            range: {
-              end_character: 15,
-              end_line: 100,
-              start_character: 5,
-              start_line: 100,
-            },
-          }, {
-            id: '8675309',
-            line: 55,
-            message: 'nonsense range',
-            range: {
-              end_character: 2,
-              end_line: 55,
-              start_character: 32,
-              start_line: 55,
-            },
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 22,
+            end_line: 12,
+            start_character: 10,
+            start_line: 10,
           },
-        ],
-      };
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 15,
+            end_line: 100,
+            start_character: 5,
+            start_line: 100,
+          },
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 2,
+            end_line: 55,
+            start_character: 32,
+            start_line: 55,
+          },
+        },
+      ];
 
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      element.comments = initialComments;
+      element.commentRanges = initialCommentRanges;
     });
 
     teardown(() => {
@@ -149,7 +135,7 @@
       test('type=Remove has-comment hovering', () => {
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 36;
-        element.set(['comments', 'left', 0, '__hovering'], true);
+        element.set(['commentRanges', 0, 'hovering'], true);
 
         const expectedStart = 6;
         const expectedLength = line.text.length - expectedStart;
@@ -210,29 +196,18 @@
       });
     });
 
-    test('_handleCommentChange overwrite', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange overwrite', () => {
+      element.set('commentRanges', []);
 
-      element.set('comments', {left: [], right: []});
-
-      assert.isTrue(handlerSpy.called);
-      assert.equal(mapSpy.callCount, 2);
-
-      assert.equal(Object.keys(element._commentMap.left).length, 0);
-      assert.equal(Object.keys(element._commentMap.right).length, 0);
+      assert.equal(Object.keys(element._rangesMap.left).length, 0);
+      assert.equal(Object.keys(element._rangesMap.right).length, 0);
     });
 
-    test('_handleCommentChange hovering', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange hovering', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.set(['comments', 'right', 0, '__hovering'], true);
-
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
+      element.set(['commentRanges', 1, 'hovering'], true);
 
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
@@ -241,16 +216,11 @@
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice out', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange splice out', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.splice('comments.right', 0, 1);
-
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
+      element.splice('commentRanges', 1, 1);
 
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
@@ -259,16 +229,12 @@
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice in', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange splice in', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.splice('comments.left', element.comments.left.length, 0, {
-        id: '56123',
-        line: 250,
-        message: 'new range comment',
+      element.splice('commentRanges', 1, 0, {
+        side: 'left',
         range: {
           end_character: 15,
           end_line: 275,
@@ -277,9 +243,6 @@
         },
       });
 
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
-
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
       assert.equal(lastCall.args[0], 250);
@@ -291,48 +254,48 @@
       // There is only one ranged comment on the left, but it spans ll.36-39.
       const leftKeys = [];
       for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
-      assert.deepEqual(Object.keys(element._commentMap.left).sort(),
+      assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
           leftKeys.sort());
 
-      assert.equal(element._commentMap.left[36].length, 1);
-      assert.equal(element._commentMap.left[36][0].start, 6);
-      assert.equal(element._commentMap.left[36][0].end, -1);
+      assert.equal(element._rangesMap.left[36].length, 1);
+      assert.equal(element._rangesMap.left[36][0].start, 6);
+      assert.equal(element._rangesMap.left[36][0].end, -1);
 
-      assert.equal(element._commentMap.left[37].length, 1);
-      assert.equal(element._commentMap.left[37][0].start, 0);
-      assert.equal(element._commentMap.left[37][0].end, -1);
+      assert.equal(element._rangesMap.left[37].length, 1);
+      assert.equal(element._rangesMap.left[37][0].start, 0);
+      assert.equal(element._rangesMap.left[37][0].end, -1);
 
-      assert.equal(element._commentMap.left[38].length, 1);
-      assert.equal(element._commentMap.left[38][0].start, 0);
-      assert.equal(element._commentMap.left[38][0].end, -1);
+      assert.equal(element._rangesMap.left[38].length, 1);
+      assert.equal(element._rangesMap.left[38][0].start, 0);
+      assert.equal(element._rangesMap.left[38][0].end, -1);
 
-      assert.equal(element._commentMap.left[39].length, 1);
-      assert.equal(element._commentMap.left[39][0].start, 0);
-      assert.equal(element._commentMap.left[39][0].end, 9);
+      assert.equal(element._rangesMap.left[39].length, 1);
+      assert.equal(element._rangesMap.left[39][0].start, 0);
+      assert.equal(element._rangesMap.left[39][0].end, 9);
 
       // The right has two ranged comments, one spanning ll.10-12 and the other
       // on line 100.
       const rightKeys = [];
       for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
       rightKeys.push('55', '100');
-      assert.deepEqual(Object.keys(element._commentMap.right).sort(),
+      assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
           rightKeys.sort());
 
-      assert.equal(element._commentMap.right[10].length, 1);
-      assert.equal(element._commentMap.right[10][0].start, 10);
-      assert.equal(element._commentMap.right[10][0].end, -1);
+      assert.equal(element._rangesMap.right[10].length, 1);
+      assert.equal(element._rangesMap.right[10][0].start, 10);
+      assert.equal(element._rangesMap.right[10][0].end, -1);
 
-      assert.equal(element._commentMap.right[11].length, 1);
-      assert.equal(element._commentMap.right[11][0].start, 0);
-      assert.equal(element._commentMap.right[11][0].end, -1);
+      assert.equal(element._rangesMap.right[11].length, 1);
+      assert.equal(element._rangesMap.right[11][0].start, 0);
+      assert.equal(element._rangesMap.right[11][0].end, -1);
 
-      assert.equal(element._commentMap.right[12].length, 1);
-      assert.equal(element._commentMap.right[12][0].start, 0);
-      assert.equal(element._commentMap.right[12][0].end, 22);
+      assert.equal(element._rangesMap.right[12].length, 1);
+      assert.equal(element._rangesMap.right[12][0].start, 0);
+      assert.equal(element._rangesMap.right[12][0].end, 22);
 
-      assert.equal(element._commentMap.right[100].length, 1);
-      assert.equal(element._commentMap.right[100][0].start, 5);
-      assert.equal(element._commentMap.right[100][0].end, 15);
+      assert.equal(element._rangesMap.right[100].length, 1);
+      assert.equal(element._rangesMap.right[100][0].start, 5);
+      assert.equal(element._rangesMap.right[100][0].end, 15);
     });
 
     test('_getRangesForLine normalizes invalid ranges', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index 0f84877..fa5c810 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -34,10 +34,10 @@
       range: {
         type: Object,
         value: {
-          startLine: NaN,
-          startChar: NaN,
-          endLine: NaN,
-          endChar: NaN,
+          start_line: NaN,
+          start_character: NaN,
+          end_line: NaN,
+          end_character: NaN,
         },
       },
       positionBelow: Boolean,
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index 4f1065a..dece366 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -89,10 +89,10 @@
     test('event fired contains playload', () => {
       const side = 'left';
       const range = {
-        startLine: 1,
-        startChar: 11,
-        endLine: 2,
-        endChar: 42,
+        start_line: 1,
+        start_character: 11,
+        end_line: 2,
+        end_character: 42,
       };
       element.side = 'left';
       element.range = range;
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 0cf517d..321dc58 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -275,6 +275,8 @@
       this.bindShortcut(
           this.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
       this.bindShortcut(
+          this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+      this.bindShortcut(
           this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
       this.bindShortcut(
           this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');