Merge changes I1d7420de,I819f24cd,I6e8ecbe9,I2e01c81b

* changes:
  Get rid of a special test-only request context and related methods.
  Remove SshSession from test Context.
  Remove unused code from the AcceptanceTestRequestScope
  Remove unused classes and no-op methods from tests
diff --git a/Documentation/cmd-ls-projects.txt b/Documentation/cmd-ls-projects.txt
index ebd365a..72e7630 100644
--- a/Documentation/cmd-ls-projects.txt
+++ b/Documentation/cmd-ls-projects.txt
@@ -16,6 +16,10 @@
   [--limit <N>]
   [--prefix | -p <prefix>]
   [--has-acl-for GROUP]
+  [--match | -m]
+  [-r REGEX]
+  [--start | -S]
+  [--state | -s ]
 --
 
 == DESCRIPTION
@@ -58,6 +62,22 @@
 	Displays project inheritance in a tree-like format.
 	This option does not work together with the show-branch option.
 
+--match::
+-m
+  Match project substring
+
+-r::
+  Match project regex
+
+--start::
+-S::
+  Number of projects to skip
+
+--state::
+-s::
+  Filter by project state. [ACTIVE | READON_ONLY | HIDDEN]
+
+
 [NOTE]
 If the calling user does not meet any of the following criteria:
 
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index c1e8093..1e76737 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -1012,6 +1012,14 @@
         implicitMergesRoots.remove(entry.getKey());
         continue;
       }
+      if (entry.getValue() == null) {
+        logger.atSevere().log("The entry value is null for the key %s", entry.getKey());
+      }
+      rw.parseBody(entry.getValue());
+      if (entry.getValue().getParents() == null) {
+        logger.atSevere().log(
+            "The entry value has null parents. The value is: %s", entry.getValue());
+      }
       for (RevCommit parent : entry.getValue().getParents()) {
         reachableCommits.push(Map.entry(entry.getKey(), parent));
       }
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index cc35a32..af7d22b 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -77,6 +77,7 @@
 import org.apache.sshd.common.forward.DefaultForwarderFactory;
 import org.apache.sshd.common.future.CloseFuture;
 import org.apache.sshd.common.future.SshFutureListener;
+import org.apache.sshd.common.global.KeepAliveHandler;
 import org.apache.sshd.common.io.AbstractIoServiceFactory;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoServiceFactory;
@@ -109,7 +110,6 @@
 import org.apache.sshd.server.command.CommandFactory;
 import org.apache.sshd.server.forward.ForwardingFilter;
 import org.apache.sshd.server.global.CancelTcpipForwardHandler;
-import org.apache.sshd.server.global.KeepAliveHandler;
 import org.apache.sshd.server.global.NoMoreSessionsHandler;
 import org.apache.sshd.server.global.TcpipForwardHandler;
 import org.apache.sshd.server.session.ServerSessionImpl;
diff --git a/plugins/delete-project b/plugins/delete-project
index f046ac6..75b79c1 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit f046ac6773ea1c6e10b5e95b763ae685c24ec7f1
+Subproject commit 75b79c115ba3dd03aa56a3165e6c319fa30c6c4a
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 24110ed..092375a 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -255,6 +255,11 @@
   show_newline_warning_left?: boolean;
   show_newline_warning_right?: boolean;
   use_new_image_diff_ui?: boolean;
+  /**
+   * Temporary flag for switching over to a simplified version of the diff
+   * processor.
+   */
+  use_simplified_processor?: boolean;
 }
 
 /**
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index ebc1f88..dd79542 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -517,31 +517,6 @@
 }
 
 /**
- * The CommentInfo entity contains information about an inline comment.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
- */
-export interface CommentInfo {
-  id: UrlEncodedCommentId;
-  updated: Timestamp;
-  // TODO(TS): Make this required. Every comment must have patch_set set.
-  patch_set?: RevisionPatchSetNum;
-  path?: string;
-  side?: CommentSide;
-  parent?: number;
-  line?: number;
-  range?: CommentRange;
-  in_reply_to?: UrlEncodedCommentId;
-  message?: string;
-  author?: AccountInfo;
-  tag?: string;
-  unresolved?: boolean;
-  change_message_id?: string;
-  commit_id?: string;
-  context_lines?: ContextLine[];
-  source_content_type?: string;
-}
-
-/**
  * The CommentRange entity describes the range of an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
  *
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 00f7639..6770d1c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -326,6 +326,12 @@
       resolve(this, highlightServiceToken),
       () => getAppContext().reportingService
     );
+    this.renderPrefs = {
+      ...this.renderPrefs,
+      use_simplified_processor: this.flags.isEnabled(
+        KnownExperimentId.SIMPLIFIED_DIFF_PROCESSOR
+      ),
+    };
     this.addEventListener(
       // These are named inconsistently for a reason:
       // The create-comment event is fired to indicate that we should
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index d553130..05ac69b 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -660,6 +660,7 @@
             ${this.renderHumanActions()} ${this.renderRobotActions()}
           </div>
           ${this.renderGeneratedSuggestionPreview()}
+          ${this.renderFixSuggestionPreview()}
         </div>
       </gr-endpoint-decorator>
       ${this.renderConfirmDialog()}
@@ -995,6 +996,13 @@
     `;
   }
 
+  private renderFixSuggestionPreview() {
+    if (!this.comment?.fix_suggestions) return nothing;
+    return html`<gr-suggestion-diff-preview
+      .fixReplacementInfos=${this.comment?.fix_suggestions?.[0].replacements}
+    ></gr-suggestion-diff-preview>`;
+  }
+
   private showGeneratedSuggestion() {
     return (
       (this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
@@ -1416,7 +1424,11 @@
         ],
       };
     }
-    if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) {
+    if (
+      isRobot(this.comment) &&
+      this.comment.fix_suggestions &&
+      this.comment.fix_suggestions.length > 0
+    ) {
       const id = this.comment.robot_id;
       return {
         fixSuggestions: this.comment.fix_suggestions.map(s => {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 03d8e32..d3df6c2 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -239,6 +239,7 @@
                   </gr-button>
                 </div>
               </div>
+              <gr-suggestion-diff-preview></gr-suggestion-diff-preview>
             </div>
           </gr-endpoint-decorator>
           <dialog id="confirmDeleteModal" tabindex="-1">
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
index fc37a0e..42fa984 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -3,7 +3,7 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Observable, combineLatest, from} from 'rxjs';
+import {Observable, combineLatest, from, of} from 'rxjs';
 import {debounceTime, filter, switchMap, withLatestFrom} from 'rxjs/operators';
 import {
   CreateCommentEventDetail,
@@ -36,6 +36,10 @@
   GrDiffProcessor,
   ProcessingOptions,
 } from '../gr-diff-processor/gr-diff-processor';
+import {
+  GrDiffProcessorSimplified,
+  ProcessingOptions as ProcessingOptionsSimplified,
+} from '../gr-diff-processor/gr-diff-processor-simplified';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {assert} from '../../../utils/common-util';
 import {countLines, isImageDiff} from '../../../utils/diff-util';
@@ -237,7 +241,7 @@
         withLatestFrom(this.keyLocations$),
         debounceTime(1),
         switchMap(([[diff, context, renderPrefs], keyLocations]) => {
-          const options: ProcessingOptions = {
+          const options: ProcessingOptions | ProcessingOptionsSimplified = {
             context,
             keyLocations,
             isBinary: !!(isImageDiff(diff) || diff.binary),
@@ -245,8 +249,16 @@
           if (renderPrefs?.num_lines_rendered_at_once) {
             options.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
           }
-          const processor = new GrDiffProcessor(options);
-          return from(processor.process(diff.content));
+
+          // TODO: When switching to the simplified processor unconditionally,
+          // then we can use map() instead of switchMap().
+          if (renderPrefs?.use_simplified_processor) {
+            const processor = new GrDiffProcessorSimplified(options);
+            return of(processor.process(diff.content));
+          } else {
+            const processor = new GrDiffProcessor(options);
+            return from(processor.process(diff.content));
+          }
         })
       )
       .subscribe(groups => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified.ts
new file mode 100644
index 0000000..a306de8
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified.ts
@@ -0,0 +1,550 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrDiffLine, Highlights} from '../gr-diff/gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from '../gr-diff/gr-diff-group';
+import {DiffContent} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {getStringLength} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLineType, LineNumber} from '../../../api/diff';
+import {FULL_CONTEXT, KeyLocations} from '../gr-diff/gr-diff-utils';
+
+// visible for testing
+export interface State {
+  lineNums: {
+    left: number;
+    right: number;
+  };
+  chunkIndex: number;
+}
+
+interface ChunkEnd {
+  offset: number;
+  keyLocation: boolean;
+}
+
+/** Interface for listening to the output of the processor. */
+export interface GroupConsumer {
+  addGroup(group: GrDiffGroup): void;
+  clearGroups(): void;
+}
+
+/** Interface for listening to the output of the processor. */
+export interface ProcessingOptions {
+  context: number;
+  keyLocations?: KeyLocations;
+  asyncThreshold?: number;
+  isBinary?: boolean;
+}
+
+/**
+ * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
+ *
+ * Glossary:
+ * - "chunk": A single `DiffContent` as returned by the API.
+ * - "group": A single `GrDiffGroup` as used for rendering.
+ * - "common" chunk/group: A chunk/group that should be considered unchanged
+ *   for diffing purposes. This can mean its either actually unchanged, or it
+ *   has only whitespace changes.
+ * - "key location": A line number and side of the diff that should not be
+ *   collapsed e.g. because a comment is attached to it, or because it was
+ *   provided in the URL and thus should be visible
+ * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
+ *   or cannot be collapsed because it contains a key location
+ *
+ * Here a a number of tasks this processor performs:
+ *  - splitting large chunks to allow more granular async rendering
+ *  - adding a group for the "File" pseudo line that file-level comments can
+ *    be attached to
+ *  - replacing common parts of the diff that are outside the user's
+ *    context setting and do not have comments with a group representing the
+ *    "expand context" widget. This may require splitting a chunk/group so
+ *    that the part that is within the context or has comments is shown, while
+ *    the rest is not.
+ */
+export class GrDiffProcessorSimplified {
+  // visible for testing
+  context: number;
+
+  // visible for testing
+  keyLocations: KeyLocations;
+
+  private isBinary = false;
+
+  private groups: GrDiffGroup[] = [];
+
+  constructor(options: ProcessingOptions) {
+    this.context = options.context;
+    this.keyLocations = options.keyLocations ?? {left: {}, right: {}};
+    this.isBinary = options.isBinary ?? false;
+  }
+
+  /**
+   * Process the diff chunks into GrDiffGroups.
+   *
+   * @return an array of GrDiffGroups
+   */
+  process(chunks: DiffContent[]): GrDiffGroup[] {
+    this.groups = [];
+    this.groups.push(this.makeGroup('LOST'));
+    this.groups.push(this.makeGroup('FILE'));
+
+    this.processChunks(chunks);
+    return this.groups;
+  }
+
+  processChunks(chunks: DiffContent[]) {
+    if (this.isBinary) return;
+
+    const state = {
+      lineNums: {left: 0, right: 0},
+      chunkIndex: 0,
+    };
+    chunks = this.splitCommonChunksWithKeyLocations(chunks);
+
+    while (state.chunkIndex < chunks.length) {
+      const stateUpdate = this.processNext(state, chunks);
+      for (const group of stateUpdate.groups) {
+        this.groups.push(group);
+      }
+      state.lineNums.left += stateUpdate.lineDelta.left;
+      state.lineNums.right += stateUpdate.lineDelta.right;
+      state.chunkIndex = stateUpdate.newChunkIndex;
+    }
+  }
+
+  /**
+   * Process the next uncollapsible chunk, or the next collapsible chunks.
+   */
+  // visible for testing
+  processNext(state: State, chunks: DiffContent[]) {
+    const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
+      chunks,
+      state.chunkIndex
+    );
+    if (firstUncollapsibleChunkIndex === state.chunkIndex) {
+      const chunk = chunks[state.chunkIndex];
+      return {
+        lineDelta: {
+          left: this.linesLeft(chunk).length,
+          right: this.linesRight(chunk).length,
+        },
+        groups: [
+          this.chunkToGroup(
+            chunk,
+            state.lineNums.left + 1,
+            state.lineNums.right + 1
+          ),
+        ],
+        newChunkIndex: state.chunkIndex + 1,
+      };
+    }
+
+    return this.processCollapsibleChunks(
+      state,
+      chunks,
+      firstUncollapsibleChunkIndex
+    );
+  }
+
+  private linesLeft(chunk: DiffContent) {
+    return chunk.ab || chunk.a || [];
+  }
+
+  private linesRight(chunk: DiffContent) {
+    return chunk.ab || chunk.b || [];
+  }
+
+  private firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
+    let chunkIndex = offset;
+    while (
+      chunkIndex < chunks.length &&
+      this.isCollapsibleChunk(chunks[chunkIndex])
+    ) {
+      chunkIndex++;
+    }
+    return chunkIndex;
+  }
+
+  private isCollapsibleChunk(chunk: DiffContent) {
+    return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
+  }
+
+  /**
+   * Process a stretch of collapsible chunks.
+   *
+   * Outputs up to three groups:
+   * 1) Visible context before the hidden common code, unless it's the
+   * very beginning of the file.
+   * 2) Context hidden behind a context bar, unless empty.
+   * 3) Visible context after the hidden common code, unless it's the very
+   * end of the file.
+   */
+  private processCollapsibleChunks(
+    state: State,
+    chunks: DiffContent[],
+    firstUncollapsibleChunkIndex: number
+  ) {
+    const collapsibleChunks = chunks.slice(
+      state.chunkIndex,
+      firstUncollapsibleChunkIndex
+    );
+    const lineCount = collapsibleChunks.reduce(
+      (sum, chunk) => sum + this.commonChunkLength(chunk),
+      0
+    );
+
+    let groups = this.chunksToGroups(
+      collapsibleChunks,
+      state.lineNums.left + 1,
+      state.lineNums.right + 1
+    );
+
+    const hasSkippedGroup = !!groups.find(g => g.skip);
+    if (this.context !== FULL_CONTEXT || hasSkippedGroup) {
+      const contextNumLines = this.context > 0 ? this.context : 0;
+      const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
+      const hiddenEnd =
+        lineCount -
+        (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
+      groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
+    }
+
+    return {
+      lineDelta: {
+        left: lineCount,
+        right: lineCount,
+      },
+      groups,
+      newChunkIndex: firstUncollapsibleChunkIndex,
+    };
+  }
+
+  private commonChunkLength(chunk: DiffContent) {
+    if (chunk.skip) {
+      return chunk.skip;
+    }
+    console.assert(!!chunk.ab || !!chunk.common);
+
+    console.assert(
+      !chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
+      'common chunk needs same number of a and b lines: ',
+      chunk
+    );
+    return this.linesLeft(chunk).length;
+  }
+
+  private chunksToGroups(
+    chunks: DiffContent[],
+    offsetLeft: number,
+    offsetRight: number
+  ): GrDiffGroup[] {
+    return chunks.map(chunk => {
+      const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
+      const chunkLength = this.commonChunkLength(chunk);
+      offsetLeft += chunkLength;
+      offsetRight += chunkLength;
+      return group;
+    });
+  }
+
+  private chunkToGroup(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ): GrDiffGroup {
+    const type =
+      chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
+    const lines = this.linesFromChunk(chunk, offsetLeft, offsetRight);
+    const options = {
+      moveDetails: chunk.move_details,
+      dueToRebase: !!chunk.due_to_rebase,
+      ignoredWhitespaceOnly: !!chunk.common,
+      keyLocation: !!chunk.keyLocation,
+    };
+    if (chunk.skip !== undefined) {
+      return new GrDiffGroup({
+        type,
+        skip: chunk.skip,
+        offsetLeft,
+        offsetRight,
+        ...options,
+      });
+    } else {
+      return new GrDiffGroup({
+        type,
+        lines,
+        ...options,
+      });
+    }
+  }
+
+  private linesFromChunk(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ) {
+    if (chunk.ab) {
+      return chunk.ab.map((row, i) =>
+        this.lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
+      );
+    }
+    let lines: GrDiffLine[] = [];
+    if (chunk.a) {
+      // Avoiding a.push(...b) because that causes callstack overflows for
+      // large b, which can occur when large files are added removed.
+      lines = lines.concat(
+        this.linesFromRows(
+          GrDiffLineType.REMOVE,
+          chunk.a,
+          offsetLeft,
+          chunk.edit_a
+        )
+      );
+    }
+    if (chunk.b) {
+      // Avoiding a.push(...b) because that causes callstack overflows for
+      // large b, which can occur when large files are added removed.
+      lines = lines.concat(
+        this.linesFromRows(
+          GrDiffLineType.ADD,
+          chunk.b,
+          offsetRight,
+          chunk.edit_b
+        )
+      );
+    }
+    return lines;
+  }
+
+  // visible for testing
+  linesFromRows(
+    lineType: GrDiffLineType,
+    rows: string[],
+    offset: number,
+    intralineInfos?: number[][]
+  ): GrDiffLine[] {
+    const grDiffHighlights = intralineInfos
+      ? this.convertIntralineInfos(rows, intralineInfos)
+      : undefined;
+    return rows.map((row, i) =>
+      this.lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
+    );
+  }
+
+  private lineFromRow(
+    type: GrDiffLineType,
+    offsetLeft: number,
+    offsetRight: number,
+    row: string,
+    i: number,
+    highlights?: Highlights[]
+  ): GrDiffLine {
+    const line = new GrDiffLine(type);
+    line.text = row;
+    if (type !== GrDiffLineType.ADD) line.beforeNumber = offsetLeft + i;
+    if (type !== GrDiffLineType.REMOVE) line.afterNumber = offsetRight + i;
+    if (highlights) {
+      line.hasIntralineInfo = true;
+      line.highlights = highlights.filter(hl => hl.contentIndex === i);
+    } else {
+      line.hasIntralineInfo = false;
+    }
+    return line;
+  }
+
+  private makeGroup(number: LineNumber) {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.beforeNumber = number;
+    line.afterNumber = number;
+    return new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [line]});
+  }
+
+  /**
+   * In order to show key locations, such as comments, out of the bounds of
+   * the selected context, treat them as separate chunks within the model so
+   * that the content (and context surrounding it) renders correctly.
+   *
+   * @param chunks DiffContents as returned from server.
+   * @return Finer grained DiffContents.
+   */
+  // visible for testing
+  splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
+    const result = [];
+    let leftLineNum = 1;
+    let rightLineNum = 1;
+
+    for (const chunk of chunks) {
+      // If it isn't a common chunk, append it as-is and update line numbers.
+      if (!chunk.ab && !chunk.skip && !chunk.common) {
+        if (chunk.a) {
+          leftLineNum += chunk.a.length;
+        }
+        if (chunk.b) {
+          rightLineNum += chunk.b.length;
+        }
+        result.push(chunk);
+        continue;
+      }
+
+      if (chunk.common && chunk.a!.length !== chunk.b!.length) {
+        throw new Error(
+          'DiffContent with common=true must always have equal length'
+        );
+      }
+      const numLines = this.commonChunkLength(chunk);
+      const chunkEnds = this.findChunkEndsAtKeyLocations(
+        numLines,
+        leftLineNum,
+        rightLineNum
+      );
+      leftLineNum += numLines;
+      rightLineNum += numLines;
+
+      if (chunk.skip) {
+        result.push({
+          ...chunk,
+          skip: chunk.skip,
+          keyLocation: false,
+        });
+      } else if (chunk.ab) {
+        result.push(
+          ...this.splitAtChunkEnds(chunk.ab, chunkEnds).map(
+            ({lines, keyLocation}) => {
+              return {
+                ...chunk,
+                ab: lines,
+                keyLocation,
+              };
+            }
+          )
+        );
+      } else if (chunk.common) {
+        const aChunks = this.splitAtChunkEnds(chunk.a!, chunkEnds);
+        const bChunks = this.splitAtChunkEnds(chunk.b!, chunkEnds);
+        result.push(
+          ...aChunks.map(({lines, keyLocation}, i) => {
+            return {
+              ...chunk,
+              a: lines,
+              b: bChunks[i].lines,
+              keyLocation,
+            };
+          })
+        );
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * @return Offsets of the new chunk ends, including whether it's a key
+   * location.
+   */
+  private findChunkEndsAtKeyLocations(
+    numLines: number,
+    leftOffset: number,
+    rightOffset: number
+  ): ChunkEnd[] {
+    const result = [];
+    let lastChunkEnd = 0;
+    for (let i = 0; i < numLines; i++) {
+      // If this line should not be collapsed.
+      if (
+        this.keyLocations[Side.LEFT][leftOffset + i] ||
+        this.keyLocations[Side.RIGHT][rightOffset + i]
+      ) {
+        // If any lines have been accumulated into the chunk leading up to
+        // this non-collapse line, then add them as a chunk and start a new
+        // one.
+        if (i > lastChunkEnd) {
+          result.push({offset: i, keyLocation: false});
+          lastChunkEnd = i;
+        }
+
+        // Add the non-collapse line as its own chunk.
+        result.push({offset: i + 1, keyLocation: true});
+      }
+    }
+
+    if (numLines > lastChunkEnd) {
+      result.push({offset: numLines, keyLocation: false});
+    }
+
+    return result;
+  }
+
+  private splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
+    const result = [];
+    let lastChunkEndOffset = 0;
+    for (const {offset, keyLocation} of chunkEnds) {
+      if (lastChunkEndOffset === offset) continue;
+      result.push({
+        lines: lines.slice(lastChunkEndOffset, offset),
+        keyLocation,
+      });
+      lastChunkEndOffset = offset;
+    }
+    return result;
+  }
+
+  /**
+   * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
+   * for rendering.
+   */
+  // visible for testing
+  convertIntralineInfos(
+    rows: string[],
+    intralineInfos: number[][]
+  ): Highlights[] {
+    // +1 to account for the \n that is not part of the rows passed here
+    const lineLengths = rows.map(r => getStringLength(r) + 1);
+
+    let rowIndex = 0;
+    let idx = 0;
+    const normalized = [];
+    for (const [skipLength, markLength] of intralineInfos) {
+      let lineLength = lineLengths[rowIndex];
+      let j = 0;
+      while (j < skipLength) {
+        if (idx === lineLength) {
+          idx = 0;
+          lineLength = lineLengths[++rowIndex];
+          continue;
+        }
+        idx++;
+        j++;
+      }
+      let lineHighlight: Highlights = {
+        contentIndex: rowIndex,
+        startIndex: idx,
+      };
+
+      j = 0;
+      while (lineLength && j < markLength) {
+        if (idx === lineLength) {
+          idx = 0;
+          lineLength = lineLengths[++rowIndex];
+          normalized.push(lineHighlight);
+          lineHighlight = {
+            contentIndex: rowIndex,
+            startIndex: idx,
+          };
+          continue;
+        }
+        idx++;
+        j++;
+      }
+      lineHighlight.endIndex = idx;
+      normalized.push(lineHighlight);
+    }
+    return normalized;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified_test.ts
new file mode 100644
index 0000000..abd224f
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified_test.ts
@@ -0,0 +1,987 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-processor';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {
+  GrDiffProcessorSimplified,
+  ProcessingOptions,
+  State,
+} from './gr-diff-processor-simplified';
+import {DiffContent} from '../../../types/diff';
+import {assert} from '@open-wc/testing';
+import {FILE, GrDiffLineType} from '../../../api/diff';
+import {FULL_CONTEXT} from '../gr-diff/gr-diff-utils';
+
+suite('gr-diff-processor tests', () => {
+  const loremIpsum =
+    'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+    'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
+    'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+    'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+    'fugit assum per.';
+
+  let processor: GrDiffProcessorSimplified;
+  let options: ProcessingOptions = {
+    context: 4,
+  };
+
+  setup(() => {});
+
+  suite('not logged in', () => {
+    setup(() => {
+      options = {context: 4};
+      processor = new GrDiffProcessorSimplified(options);
+    });
+
+    test('process loaded content', async () => {
+      const content: DiffContent[] = [
+        {
+          ab: ['<!DOCTYPE html>', '<meta charset="utf-8">'],
+        },
+        {
+          a: ['  Welcome ', '  to the wooorld of tomorrow!'],
+          b: ['  Hello, world!'],
+        },
+        {
+          ab: [
+            'Leela: This is the only place the ship can’t hear us, so ',
+            'everyone pretend to shower.',
+            'Fry: Same as every day. Got it.',
+          ],
+        },
+      ];
+
+      const groups = await processor.process(content);
+      groups.shift(); // remove portedThreadsWithoutRangeGroup
+      assert.equal(groups.length, 4);
+
+      let group = groups[0];
+      assert.equal(group.type, GrDiffGroupType.BOTH);
+      assert.equal(group.lines.length, 1);
+      assert.equal(group.lines[0].text, '');
+      assert.equal(group.lines[0].beforeNumber, FILE);
+      assert.equal(group.lines[0].afterNumber, FILE);
+
+      group = groups[1];
+      assert.equal(group.type, GrDiffGroupType.BOTH);
+      assert.equal(group.lines.length, 2);
+
+      function beforeNumberFn(l: GrDiffLine) {
+        return l.beforeNumber;
+      }
+      function afterNumberFn(l: GrDiffLine) {
+        return l.afterNumber;
+      }
+      function textFn(l: GrDiffLine) {
+        return l.text;
+      }
+
+      assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+      assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+      assert.deepEqual(group.lines.map(textFn), [
+        '<!DOCTYPE html>',
+        '<meta charset="utf-8">',
+      ]);
+
+      group = groups[2];
+      assert.equal(group.type, GrDiffGroupType.DELTA);
+      assert.equal(group.lines.length, 3);
+      assert.equal(group.adds.length, 1);
+      assert.equal(group.removes.length, 2);
+      assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+      assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+      assert.deepEqual(group.removes.map(textFn), [
+        '  Welcome ',
+        '  to the wooorld of tomorrow!',
+      ]);
+      assert.deepEqual(group.adds.map(textFn), ['  Hello, world!']);
+
+      group = groups[3];
+      assert.equal(group.type, GrDiffGroupType.BOTH);
+      assert.equal(group.lines.length, 3);
+      assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+      assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+      assert.deepEqual(group.lines.map(textFn), [
+        'Leela: This is the only place the ship can’t hear us, so ',
+        'everyone pretend to shower.',
+        'Fry: Same as every day. Got it.',
+      ]);
+    });
+
+    test('first group is for file', async () => {
+      const content = [{b: ['foo']}];
+
+      const groups = await processor.process(content);
+      groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+      assert.equal(groups[0].type, GrDiffGroupType.BOTH);
+      assert.equal(groups[0].lines.length, 1);
+      assert.equal(groups[0].lines[0].text, '');
+      assert.equal(groups[0].lines[0].beforeNumber, FILE);
+      assert.equal(groups[0].lines[0].afterNumber, FILE);
+    });
+
+    suite('context groups', async () => {
+      test('at the beginning, larger than context', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessorSimplified(options);
+        const content = [
+          {
+            ab: Array.from<string>({length: 100}).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        const groups = await processor.process(content);
+        // group[0] is the LOST group
+        // group[1] is the FILE group
+
+        assert.equal(groups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(groups[2].contextGroups[0], GrDiffGroup);
+        assert.equal(groups[2].contextGroups[0].lines.length, 90);
+        for (const l of groups[2].contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
+
+        assert.equal(groups[3].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[3].lines.length, 10);
+        for (const l of groups[3].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
+      });
+
+      test('at the beginning with skip chunks', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessorSimplified(options);
+        const content = [
+          {
+            ab: Array.from<string>({length: 20}).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
+          {skip: 43900},
+          {ab: Array.from<string>({length: 30}).fill('some other content')},
+          {a: ['some other content']},
+        ];
+
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        // group[0] is the file group
+
+        const commonGroup = groups[1];
+
+        // Hidden context before
+        assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+        assert.equal(commonGroup.contextGroups[0].lines.length, 20);
+        for (const l of commonGroup.contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
+
+        // Skipped group
+        const skipGroup = commonGroup.contextGroups[1];
+        assert.equal(skipGroup.skip, 43900);
+        const expectedRange = {
+          left: {start_line: 21, end_line: 43920},
+          right: {start_line: 21, end_line: 43920},
+        };
+        assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+        // Hidden context after
+        assert.equal(commonGroup.contextGroups[2].lines.length, 20);
+        for (const l of commonGroup.contextGroups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+
+        // Displayed lines
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 10);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+      });
+
+      test('at the beginning, smaller than context', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessorSimplified(options);
+        const content = [
+          {
+            ab: Array.from<string>({length: 5}).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        // group[0] is the file group
+
+        assert.equal(groups[1].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[1].lines.length, 5);
+        for (const l of groups[1].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
+      });
+
+      test('at the end, larger than context', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessorSimplified(options);
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: Array.from<string>({length: 100}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+        ];
+
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        // group[0] is the file group
+        // group[1] is the "a" group
+
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 10);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+
+        assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+        assert.equal(groups[3].contextGroups[0].lines.length, 90);
+        for (const l of groups[3].contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+      });
+
+      test('at the end, smaller than context', async () => {
+        options.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: Array.from<string>({length: 5}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+        ];
+
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        // group[0] is the file group
+        // group[1] is the "a" group
+
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 5);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+      });
+
+      test('for interleaved ab and common: true chunks', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessorSimplified(options);
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {
+            a: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+            b: Array.from<string>({length: 3}).fill(
+              '  all work and no play make jill a dull girl'
+            ),
+            common: true,
+          },
+          {
+            ab: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {
+            a: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+            b: Array.from<string>({length: 3}).fill(
+              '  all work and no play make jill a dull girl'
+            ),
+            common: true,
+          },
+          {
+            ab: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+        ];
+
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        // group[0] is the file group
+        // group[1] is the "a" group
+
+        // The first three interleaved chunks are completely shown because
+        // they are part of the context (3 * 3 <= 10)
+
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 3);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+
+        assert.equal(groups[3].type, GrDiffGroupType.DELTA);
+        assert.equal(groups[3].lines.length, 6);
+        assert.equal(groups[3].adds.length, 3);
+        assert.equal(groups[3].removes.length, 3);
+        for (const l of groups[3].removes) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+        for (const l of groups[3].adds) {
+          assert.equal(l.text, '  all work and no play make jill a dull girl');
+        }
+
+        assert.equal(groups[4].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[4].lines.length, 3);
+        for (const l of groups[4].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+
+        // The next chunk is partially shown, so it results in two groups
+
+        assert.equal(groups[5].type, GrDiffGroupType.DELTA);
+        assert.equal(groups[5].lines.length, 2);
+        assert.equal(groups[5].adds.length, 1);
+        assert.equal(groups[5].removes.length, 1);
+        for (const l of groups[5].removes) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+        for (const l of groups[5].adds) {
+          assert.equal(l.text, '  all work and no play make jill a dull girl');
+        }
+
+        assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.equal(groups[6].contextGroups.length, 2);
+
+        assert.equal(groups[6].contextGroups[0].lines.length, 4);
+        assert.equal(groups[6].contextGroups[0].removes.length, 2);
+        assert.equal(groups[6].contextGroups[0].adds.length, 2);
+        for (const l of groups[6].contextGroups[0].removes) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+        for (const l of groups[6].contextGroups[0].adds) {
+          assert.equal(l.text, '  all work and no play make jill a dull girl');
+        }
+
+        // The final chunk is completely hidden
+        assert.equal(groups[6].contextGroups[1].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[6].contextGroups[1].lines.length, 3);
+        for (const l of groups[6].contextGroups[1].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+      });
+
+      test('in the middle, larger than context', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessorSimplified(options);
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: Array.from<string>({length: 100}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        // group[0] is the file group
+        // group[1] is the "a" group
+
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 10);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+
+        assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+        assert.equal(groups[3].contextGroups[0].lines.length, 80);
+        for (const l of groups[3].contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+
+        assert.equal(groups[4].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[4].lines.length, 10);
+        for (const l of groups[4].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+      });
+
+      test('in the middle, smaller than context', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessorSimplified(options);
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: Array.from<string>({length: 5}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        // group[0] is the file group
+        // group[1] is the "a" group
+
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 5);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+      });
+    });
+
+    test('in the middle with skip chunks', async () => {
+      options.context = 10;
+      processor = new GrDiffProcessorSimplified(options);
+      const content = [
+        {a: ['all work and no play make andybons a dull boy']},
+        {
+          ab: Array.from<string>({length: 20}).fill(
+            'all work and no play make jill a dull girl'
+          ),
+        },
+        {skip: 60},
+        {
+          ab: Array.from<string>({length: 20}).fill(
+            'all work and no play make jill a dull girl'
+          ),
+        },
+        {a: ['all work and no play make andybons a dull boy']},
+      ];
+
+      const groups = await processor.process(content);
+      groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+      // group[0] is the file group
+      // group[1] is the chunk with a
+      // group[2] is the displayed part of ab before
+
+      const commonGroup = groups[3];
+
+      // Hidden context before
+      assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+      assert.equal(commonGroup.contextGroups[0].lines.length, 10);
+      for (const l of commonGroup.contextGroups[0].lines) {
+        assert.equal(l.text, 'all work and no play make jill a dull girl');
+      }
+
+      // Skipped group
+      const skipGroup = commonGroup.contextGroups[1];
+      assert.equal(skipGroup.skip, 60);
+      const expectedRange = {
+        left: {start_line: 22, end_line: 81},
+        right: {start_line: 21, end_line: 80},
+      };
+      assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+      // Hidden context after
+      assert.equal(commonGroup.contextGroups[2].lines.length, 10);
+      for (const l of commonGroup.contextGroups[2].lines) {
+        assert.equal(l.text, 'all work and no play make jill a dull girl');
+      }
+      // group[4] is the displayed part of the second ab
+    });
+
+    test('works with skip === 0', async () => {
+      options.context = 3;
+      processor = new GrDiffProcessorSimplified(options);
+      const content = [
+        {
+          skip: 0,
+        },
+        {
+          b: [
+            '/**',
+            ' * @license',
+            ' * Copyright 2015 Google LLC',
+            ' * SPDX-License-Identifier: Apache-2.0',
+            ' */',
+            "import '../../../test/common-test-setup';",
+          ],
+        },
+      ];
+      await processor.process(content);
+    });
+
+    test('break up common diff chunks', () => {
+      options.keyLocations = {
+        left: {1: true},
+        right: {10: true},
+      };
+      processor = new GrDiffProcessorSimplified(options);
+
+      const content = [
+        {
+          ab: [
+            'copy',
+            '',
+            'asdf',
+            'qwer',
+            'zxcv',
+            '',
+            'http',
+            '',
+            'vbnm',
+            'dfgh',
+            'yuio',
+            'sdfg',
+            '1234',
+          ],
+        },
+      ];
+      const result = processor.splitCommonChunksWithKeyLocations(content);
+      assert.deepEqual(result, [
+        {
+          ab: ['copy'],
+          keyLocation: true,
+        },
+        {
+          ab: ['', 'asdf', 'qwer', 'zxcv', '', 'http', '', 'vbnm'],
+          keyLocation: false,
+        },
+        {
+          ab: ['dfgh'],
+          keyLocation: true,
+        },
+        {
+          ab: ['yuio', 'sdfg', '1234'],
+          keyLocation: false,
+        },
+      ]);
+    });
+
+    test('does not break-down common chunks w/ context', () => {
+      const ab = Array(75)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      const content = [{ab}];
+      processor.context = 4;
+      const result = processor.splitCommonChunksWithKeyLocations(content);
+      assert.equal(result.length, 1);
+      assert.deepEqual(result[0].ab, content[0].ab);
+      assert.isFalse(result[0].keyLocation);
+    });
+
+    test('intraline normalization', () => {
+      // The content and highlights are in the format returned by the Gerrit
+      // REST API.
+      let content = [
+        '      <section class="summary">',
+        '        <gr-formatted-text content="' +
+          '[[_computeCurrentRevisionMessage(change)]]"></gr-formatted-text>',
+        '      </section>',
+      ];
+      let highlights = [
+        [31, 34],
+        [42, 26],
+      ];
+
+      let results = processor.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 31,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+          endIndex: 33,
+        },
+        {
+          contentIndex: 1,
+          endIndex: 101,
+          startIndex: 75,
+        },
+      ]);
+      const lines = processor.linesFromRows(
+        GrDiffLineType.BOTH,
+        content,
+        0,
+        highlights
+      );
+      assert.equal(lines.length, 3);
+      assert.isTrue(lines[0].hasIntralineInfo);
+      assert.equal(lines[0].highlights.length, 1);
+      assert.isTrue(lines[1].hasIntralineInfo);
+      assert.equal(lines[1].highlights.length, 2);
+      assert.isTrue(lines[2].hasIntralineInfo);
+      assert.equal(lines[2].highlights.length, 0);
+
+      content = [
+        '        this._path = value.path;',
+        '',
+        '        // When navigating away from the page, there is a ' +
+          'possibility that the',
+        '        // patch number is no longer a part of the URL ' +
+          '(say when navigating to',
+        '        // the top-level change info view) and therefore ' +
+          'undefined in `params`.',
+        '        if (!this._patchRange.patchNum) {',
+      ];
+      highlights = [
+        [14, 17],
+        [11, 70],
+        [12, 67],
+        [12, 67],
+        [14, 29],
+      ];
+      results = processor.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 14,
+          endIndex: 31,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 8,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 3,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 4,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 5,
+          startIndex: 12,
+          endIndex: 41,
+        },
+      ]);
+
+      content = ['🙈 a', '🙉 b', '🙊 c'];
+      highlights = [[2, 7]];
+      results = processor.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 2,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 1,
+        },
+      ]);
+    });
+
+    test('image diffs', async () => {
+      const content = Array(200).fill({ab: ['', '']});
+      options.isBinary = true;
+      processor = new GrDiffProcessorSimplified(options);
+      const groups = await processor.process(content);
+      assert.equal(groups.length, 2);
+
+      // Image diffs don't process content, just the 'FILE' line.
+      assert.equal(groups[0].lines.length, 1);
+    });
+
+    suite('processNext', () => {
+      let rows: string[];
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
+      });
+
+      test('FULL_CONTEXT', () => {
+        processor.context = FULL_CONTEXT;
+        const state: State = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = processor.processNext(state, chunks);
+
+        // Results in one, uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1);
+        assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+        assert.equal(result.groups[0].lines.length, rows.length);
+
+        // Line numbers are set correctly.
+        assert.equal(
+          result.groups[0].lines[0].beforeNumber,
+          state.lineNums.left + 1
+        );
+        assert.equal(
+          result.groups[0].lines[0].afterNumber,
+          state.lineNums.right + 1
+        );
+
+        assert.equal(
+          result.groups[0].lines[rows.length - 1].beforeNumber,
+          state.lineNums.left + rows.length
+        );
+        assert.equal(
+          result.groups[0].lines[rows.length - 1].afterNumber,
+          state.lineNums.right + rows.length
+        );
+      });
+
+      test('FULL_CONTEXT with skip chunks still get collapsed', () => {
+        processor.context = FULL_CONTEXT;
+        const lineNums = {left: 10, right: 100};
+        const state = {
+          lineNums,
+          chunkIndex: 1,
+        };
+        const skip = 10000;
+        const chunks = [{a: ['foo']}, {skip}, {ab: rows}, {a: ['bar']}];
+        const result = processor.processNext(state, chunks);
+        // Results in one, uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1);
+        assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
+
+        // Skip and ab group are hidden in the same context control
+        assert.equal(result.groups[0].contextGroups.length, 2);
+        const [skippedGroup, abGroup] = result.groups[0].contextGroups;
+
+        // Line numbers are set correctly.
+        assert.deepEqual(skippedGroup.lineRange, {
+          left: {
+            start_line: lineNums.left + 1,
+            end_line: lineNums.left + skip,
+          },
+          right: {
+            start_line: lineNums.right + 1,
+            end_line: lineNums.right + skip,
+          },
+        });
+
+        assert.deepEqual(abGroup.lineRange, {
+          left: {
+            start_line: lineNums.left + skip + 1,
+            end_line: lineNums.left + skip + rows.length,
+          },
+          right: {
+            start_line: lineNums.right + skip + 1,
+            end_line: lineNums.right + skip + rows.length,
+          },
+        });
+      });
+
+      test('with context', () => {
+        processor.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = processor.processNext(state, chunks);
+        const expectedCollapseSize = rows.length - 2 * processor.context;
+
+        assert.equal(result.groups.length, 3, 'Results in three groups');
+
+        // The first and last are uncollapsed context, whereas the middle has
+        // a single context-control line.
+        assert.equal(result.groups[0].lines.length, processor.context);
+        assert.equal(result.groups[2].lines.length, processor.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(
+          result.groups[1].contextGroups[0].lines.length,
+          expectedCollapseSize
+        );
+      });
+
+      test('first', () => {
+        processor.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+        const result = processor.processNext(state, chunks);
+        const expectedCollapseSize = rows.length - processor.context;
+
+        assert.equal(result.groups.length, 2, 'Results in two groups');
+
+        // Only the first group is collapsed.
+        assert.equal(result.groups[1].lines.length, processor.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(
+          result.groups[0].contextGroups[0].lines.length,
+          expectedCollapseSize
+        );
+      });
+
+      test('few-rows', () => {
+        // Only ten rows.
+        rows = rows.slice(0, 10);
+        processor.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+        const result = processor.processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      test('no single line collapse', () => {
+        rows = rows.slice(0, 7);
+        processor.context = 3;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = processor.processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      suite('with key location', () => {
+        let state: State;
+        let chunks: DiffContent[];
+
+        setup(() => {
+          state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 0,
+          };
+          processor.context = 10;
+          chunks = [{ab: rows}, {ab: ['foo'], keyLocation: true}, {ab: rows}];
+        });
+
+        test('context before', () => {
+          state.chunkIndex = 0;
+          const result = processor.processNext(state, chunks);
+
+          // The first chunk is split into two groups:
+          // 1) A context-control, hiding everything but the context before
+          //    the key location.
+          // 2) The context before the key location.
+          // The key location is not processed in this call to processNext
+          assert.equal(result.groups.length, 2);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(
+            result.groups[0].contextGroups[0].lines.length,
+            rows.length - processor.context
+          );
+          assert.equal(result.groups[1].lines.length, processor.context);
+        });
+
+        test('key location itself', () => {
+          state.chunkIndex = 1;
+          const result = processor.processNext(state, chunks);
+
+          // The second chunk results in a single group, that is just the
+          // line with the key location
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].lines.length, 1);
+          assert.equal(result.lineDelta.left, 1);
+          assert.equal(result.lineDelta.right, 1);
+        });
+
+        test('context after', () => {
+          state.chunkIndex = 2;
+          const result = processor.processNext(state, chunks);
+
+          // The last chunk is split into two groups:
+          // 1) The context after the key location.
+          // 1) A context-control, hiding everything but the context after the
+          //    key location.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].lines.length, processor.context);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(
+            result.groups[1].contextGroups[0].lines.length,
+            rows.length - processor.context
+          );
+        });
+      });
+    });
+
+    suite('gr-diff-processor helpers', () => {
+      let rows: string[];
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
+      });
+
+      test('linesFromRows', () => {
+        const startLineNum = 10;
+        let result = processor.linesFromRows(
+          GrDiffLineType.ADD,
+          rows,
+          startLineNum + 1
+        );
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLineType.ADD);
+        assert.notOk(result[0].hasIntralineInfo);
+        assert.equal(result[0].afterNumber, startLineNum + 1);
+        assert.notOk(result[0].beforeNumber);
+        assert.equal(
+          result[result.length - 1].afterNumber,
+          startLineNum + rows.length
+        );
+        assert.notOk(result[result.length - 1].beforeNumber);
+
+        result = processor.linesFromRows(
+          GrDiffLineType.REMOVE,
+          rows,
+          startLineNum + 1
+        );
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLineType.REMOVE);
+        assert.notOk(result[0].hasIntralineInfo);
+        assert.equal(result[0].beforeNumber, startLineNum + 1);
+        assert.notOk(result[0].afterNumber);
+        assert.equal(
+          result[result.length - 1].beforeNumber,
+          startLineNum + rows.length
+        );
+        assert.notOk(result[result.length - 1].afterNumber);
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index b59d7a8b3..4a1fc29 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -22,4 +22,5 @@
   ML_SUGGESTED_EDIT = 'UiFeature__ml_suggested_edit',
   ML_SUGGESTED_EDIT_V2 = 'UiFeature__ml_suggested_edit_v2',
   REVISION_PARENTS_DATA = 'UiFeature__revision_parents_data',
+  SIMPLIFIED_DIFF_PROCESSOR = 'UiFeature__simplified_diff_processor',
 }
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 3f02b2a..e12ee6c 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -41,7 +41,6 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeSubmissionId,
-  CommentInfo,
   CommentLinkInfo,
   CommentLinks,
   CommentSide,
@@ -146,7 +145,6 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeSubmissionId,
-  CommentInfo,
   CommentLinkInfo,
   CommentLinks,
   CommentRange,
@@ -809,6 +807,32 @@
   ERROR = 'ERROR',
 }
 
+/**
+ * The CommentInfo entity contains information about an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
+ */
+export interface CommentInfo {
+  id: UrlEncodedCommentId;
+  updated: Timestamp;
+  // TODO(TS): Make this required. Every comment must have patch_set set.
+  patch_set?: RevisionPatchSetNum;
+  path?: string;
+  side?: CommentSide;
+  parent?: number;
+  line?: number;
+  range?: CommentRange;
+  in_reply_to?: UrlEncodedCommentId;
+  message?: string;
+  author?: AccountInfo;
+  tag?: string;
+  unresolved?: boolean;
+  change_message_id?: string;
+  commit_id?: string;
+  context_lines?: ContextLine[];
+  source_content_type?: string;
+  fix_suggestions?: FixSuggestionInfo[];
+}
+
 export type DraftInfo = Omit<CommentInfo, 'id' | 'updated'> & {
   // Must be set for all drafts.
   // Drafts received from the backend will be modified immediately with
@@ -1203,6 +1227,7 @@
   message?: string;
   tag?: string;
   unresolved?: boolean;
+  fix_suggestions?: FixSuggestionInfo[];
 }
 
 /**
@@ -1431,7 +1456,6 @@
   robot_run_id: RobotRunId;
   url?: string;
   properties: {[propertyName: string]: string};
-  fix_suggestions: FixSuggestionInfo[];
 }
 export type PathToRobotCommentsInfoMap = {[path: string]: RobotCommentInfo[]};
 
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 7fad329..364f372 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -589,5 +589,8 @@
   if (comment.tag) {
     output.tag = comment.tag;
   }
+  if (comment.fix_suggestions) {
+    output.fix_suggestions = comment.fix_suggestions;
+  }
   return output;
 }
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 9f9b1b5..562b072 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -115,18 +115,18 @@
         sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
     )
 
-    SSHD_VERS = "2.10.0"
+    SSHD_VERS = "2.12.0"
 
     maven_jar(
         name = "sshd-osgi",
         artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS,
-        sha1 = "03677ac1da780b7bdb682da50b762d79ea0d940d",
+        sha1 = "32b8de1cbb722ba75bdf9898e0c41d42af00ce57",
     )
 
     maven_jar(
         name = "sshd-sftp",
         artifact = "org.apache.sshd:sshd-sftp:" + SSHD_VERS,
-        sha1 = "88707339ac0693d48df0ec1bafb84c78d792ed08",
+        sha1 = "0f96f00a07b186ea62838a6a4122e8f4cad44df6",
     )
 
     maven_jar(
@@ -144,7 +144,7 @@
     maven_jar(
         name = "sshd-mina",
         artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
-        sha1 = "b1f77377fbc517400e7665d0b2c83b58b41aa45d",
+        sha1 = "8b202f7d4c0d7b714fd0c93a1352af52aa031149",
     )
 
     maven_jar(