Merge "Unite gr-diff with gr-diff-builder-element"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 0abcaf5..e0d5f1d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -630,16 +630,11 @@
element.blame = [];
await element.updateComplete;
assertIsDefined(element.diffElement);
- const setBlameSpy = sinon.spy(
- element.diffElement.diffBuilder,
- 'setBlame'
- );
const isBlameLoadedStub = sinon.stub();
element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
element.clearBlame();
await element.updateComplete;
assert.isNull(element.blame);
- assert.isTrue(setBlameSpy.calledWithExactly(null));
assert.isTrue(isBlameLoadedStub.calledOnce);
assert.isFalse(isBlameLoadedStub.args[0][0].detail.value);
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
deleted file mode 100644
index 1452e0f..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ /dev/null
@@ -1,574 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../gr-diff-processor/gr-diff-processor';
-import '../../../elements/shared/gr-hovercard/gr-hovercard';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {
- GrDiffBuilder,
- DiffContextExpandedEventDetail,
- isImageDiffBuilder,
- isBinaryDiffBuilder,
-} from './gr-diff-builder';
-import {GrDiffBuilderImage} from './gr-diff-builder-image';
-import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {BlameInfo, ImageInfo} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {CoverageRange, DiffLayer} from '../../../types/types';
-import {
- GrDiffProcessor,
- GroupConsumer,
- KeyLocations,
-} from '../gr-diff-processor/gr-diff-processor';
-import {
- CommentRangeLayer,
- GrRangedCommentLayer,
-} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
-import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
-import {DiffViewMode, LineNumber, RenderPreferences} from '../../../api/diff';
-import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
-import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {
- GrDiffGroup,
- GrDiffGroupType,
- hideInContextControl,
-} from '../gr-diff/gr-diff-group';
-import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fire} from '../../../utils/event-util';
-import {assertIsDefined} from '../../../utils/common-util';
-
-const TRAILING_WHITESPACE_PATTERN = /\s+$/;
-const COMMIT_MSG_PATH = '/COMMIT_MSG';
-const COMMIT_MSG_LINE_LENGTH = 72;
-
-declare global {
- interface HTMLElementEventMap {
- /**
- * Fired when the diff begins rendering - both for full renders and for
- * partial rerenders.
- */
- 'render-start': CustomEvent<{}>;
- /**
- * Fired when the diff finishes rendering text content - both for full
- * renders and for partial rerenders.
- */
- 'render-content': CustomEvent<{}>;
- }
-}
-
-export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
- return prefs.font_size * 4;
-}
-
-function annotateSymbols(
- contentEl: HTMLElement,
- line: GrDiffLine,
- separator: string | RegExp,
- className: string
-) {
- const split = line.text.split(separator);
- if (!split || split.length < 2) {
- return;
- }
- for (let i = 0, pos = 0; i < split.length - 1; i++) {
- // Skip forward by the length of the content
- pos += split[i].length;
-
- GrAnnotation.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
-
- pos++;
- }
-}
-
-// TODO: Rename the class and the file and remove "element". This is not an
-// element anymore.
-export class GrDiffBuilderElement implements GroupConsumer {
- diff?: DiffInfo;
-
- diffElement?: HTMLTableElement;
-
- viewMode?: string;
-
- isImageDiff?: boolean;
-
- baseImage: ImageInfo | null = null;
-
- revisionImage: ImageInfo | null = null;
-
- path?: string;
-
- prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
-
- renderPrefs?: RenderPreferences;
-
- useNewImageDiffUi = false;
-
- /**
- * Layers passed in from the outside.
- *
- * See `layersInternal` for where these layers will end up together with the
- * internal layers.
- */
- layers: DiffLayer[] = [];
-
- // visible for testing
- builder?: GrDiffBuilder;
-
- /**
- * All layers, both from the outside and the default ones. See `layers` for
- * the property that can be set from the outside.
- */
- // visible for testing
- layersInternal: DiffLayer[] = [];
-
- // visible for testing
- showTabs?: boolean;
-
- // visible for testing
- showTrailingWhitespace?: boolean;
-
- private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
-
- private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
-
- private rangeLayer?: GrRangedCommentLayer;
-
- // visible for testing
- processor?: GrDiffProcessor;
-
- /**
- * Groups are mostly just passed on to the diff builder (this.builder). But
- * we also keep track of them here for being able to fire a `render-content`
- * event when .element of each group has rendered.
- *
- * TODO: Refactor DiffBuilderElement and DiffBuilders with a cleaner
- * separation of responsibilities.
- */
- private groups: GrDiffGroup[] = [];
-
- updateCommentRanges(ranges: CommentRangeLayer[]) {
- this.rangeLayer?.updateRanges(ranges);
- }
-
- updateCoverageRanges(rs: CoverageRange[]) {
- this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
- this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
- }
-
- render(keyLocations: KeyLocations): Promise<void> {
- assertIsDefined(this.diff, 'diff');
- assertIsDefined(this.diffElement, 'diff table');
-
- // Setting up annotation layers must happen after plugins are
- // installed, and |render| satisfies the requirement, however,
- // |attached| doesn't because in the diff view page, the element is
- // attached before plugins are installed.
- this.setupAnnotationLayers();
-
- this.showTabs = this.prefs.show_tabs;
- this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
-
- this.cleanup();
- this.builder = this.getDiffBuilder();
- this.init();
-
- // TODO: Just pass along the diff model here instead of setting many
- // individual properties.
- this.processor = new GrDiffProcessor();
- this.processor.consumer = this;
- this.processor.context = this.prefs.context;
- this.processor.keyLocations = keyLocations;
- if (this.renderPrefs?.num_lines_rendered_at_once) {
- this.processor.asyncThreshold =
- this.renderPrefs.num_lines_rendered_at_once;
- }
-
- this.clearDiffContent();
- this.builder.addColumns(
- this.diffElement,
- getLineNumberCellWidth(this.prefs)
- );
-
- const isBinary = !!(this.isImageDiff || this.diff.binary);
-
- fire(this.diffElement, 'render-start', {});
- return (
- this.processor
- .process(this.diff.content, isBinary)
- .then(async () => {
- if (isImageDiffBuilder(this.builder)) {
- this.builder.renderImageDiff();
- } else if (isBinaryDiffBuilder(this.builder)) {
- this.builder.renderBinaryDiff();
- }
- await this.untilGroupsRendered();
- fire(this.diffElement, 'render-content', {});
- })
- // Mocha testing does not like uncaught rejections, so we catch
- // the cancels which are expected and should not throw errors in
- // tests.
- .catch(e => {
- if (!e.isCanceled) return Promise.reject(e);
- return;
- })
- );
- }
-
- // visible for testing
- async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
- return Promise.all(groups.map(g => g.waitUntilRendered()));
- }
-
- private onDiffContextExpanded = (
- e: CustomEvent<DiffContextExpandedEventDetail>
- ) => {
- // Don't stop propagation. The host may listen for reporting or
- // resizing.
- this.replaceGroup(e.detail.contextGroup, e.detail.groups);
- };
-
- // visible for testing
- setupAnnotationLayers() {
- this.rangeLayer = new GrRangedCommentLayer();
-
- const layers: DiffLayer[] = [
- this.createTrailingWhitespaceLayer(),
- this.createIntralineLayer(),
- this.createTabIndicatorLayer(),
- this.createSpecialCharacterIndicatorLayer(),
- this.rangeLayer,
- this.coverageLayerLeft,
- this.coverageLayerRight,
- ];
-
- if (this.layers) {
- layers.push(...this.layers);
- }
- this.layersInternal = layers;
- }
-
- getContentTdByLine(lineNumber: LineNumber, side?: Side) {
- if (!this.builder) return undefined;
- return this.builder.getContentTdByLine(lineNumber, side);
- }
-
- getContentTdByLineEl(lineEl?: Element): Element | undefined {
- if (!lineEl) return undefined;
- const line = getLineNumber(lineEl);
- if (!line) return undefined;
- const side = getSideByLineEl(lineEl);
- return this.getContentTdByLine(line, side);
- }
-
- getLineElByNumber(lineNumber: LineNumber, side?: Side) {
- if (!this.builder) return undefined;
- return this.builder.getLineElByNumber(lineNumber, side);
- }
-
- getLineNumberRows() {
- if (!this.builder) return [];
- return this.builder.getLineNumberRows();
- }
-
- getLineNumEls(side: Side) {
- if (!this.builder) return [];
- return this.builder.getLineNumEls(side);
- }
-
- /**
- * When the line is hidden behind a context expander, expand it.
- *
- * @param lineNum A line number to expand. Using number here because other
- * special case line numbers are never hidden, so it does not make sense
- * to expand them.
- * @param side The side the line number refer to.
- */
- unhideLine(lineNum: number, side: Side) {
- if (!this.builder) return;
- const group = this.builder.findGroup(side, lineNum);
- // Cannot unhide a line that is not part of the diff.
- if (!group) return;
- // If it's already visible, great!
- if (group.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
- const lineRange = group.lineRange[side];
- const lineOffset = lineNum - lineRange.start_line;
- const newGroups = [];
- const groups = hideInContextControl(
- group.contextGroups,
- 0,
- lineOffset - 1 - this.prefs.context
- );
- // If there is a context group, it will be the first group because we
- // start hiding from 0 offset
- if (groups[0].type === GrDiffGroupType.CONTEXT_CONTROL) {
- newGroups.push(groups.shift()!);
- }
- newGroups.push(
- ...hideInContextControl(
- groups,
- lineOffset + 1 + this.prefs.context,
- // Both ends inclusive, so difference is the offset of the last line.
- // But we need to pass the first line not to hide, which is the element
- // after.
- lineRange.end_line - lineRange.start_line + 1
- )
- );
- this.replaceGroup(group, newGroups);
- }
-
- /**
- * Replace the group of a context control section by rendering the provided
- * groups instead. This happens in response to expanding a context control
- * group.
- *
- * @param contextGroup The context control group to replace
- * @param newGroups The groups that are replacing the context control group
- */
- private replaceGroup(
- contextGroup: GrDiffGroup,
- newGroups: readonly GrDiffGroup[]
- ) {
- if (!this.builder) return;
- fire(this.diffElement, 'render-start', {});
- this.builder.replaceGroup(contextGroup, newGroups);
- this.groups = this.groups.filter(g => g !== contextGroup);
- this.groups.push(...newGroups);
- this.untilGroupsRendered(newGroups).then(() => {
- fire(this.diffElement, 'render-content', {});
- });
- }
-
- /**
- * This is meant to be called when the gr-diff component re-connects, or when
- * the diff is (re-)rendered.
- *
- * Make sure that this method is symmetric with cleanup(), which is called
- * when gr-diff disconnects.
- */
- init() {
- this.cleanup();
- this.diffElement?.addEventListener(
- 'diff-context-expanded-internal-new',
- this.onDiffContextExpanded
- );
- this.builder?.init();
- }
-
- /**
- * This is meant to be called when the gr-diff component disconnects, or when
- * the diff is (re-)rendered.
- *
- * Make sure that this method is symmetric with init(), which is called when
- * gr-diff re-connects.
- */
- cleanup() {
- this.processor?.cancel();
- this.builder?.cleanup();
- this.diffElement?.removeEventListener(
- 'diff-context-expanded-internal-new',
- this.onDiffContextExpanded
- );
- }
-
- // visible for testing
- handlePreferenceError(pref: string): never {
- const message =
- `The value of the '${pref}' user preference is ` +
- 'invalid. Fix in diff preferences';
- assertIsDefined(this.diffElement, 'diff table');
- fireAlert(this.diffElement, message);
- throw Error(`Invalid preference value: ${pref}`);
- }
-
- // visible for testing
- getDiffBuilder(): GrDiffBuilder {
- assertIsDefined(this.diff, 'diff');
- assertIsDefined(this.diffElement, 'diff table');
- if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
- this.handlePreferenceError('tab size');
- }
-
- if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
- this.handlePreferenceError('diff width');
- }
-
- const localPrefs = {...this.prefs};
- if (this.path === COMMIT_MSG_PATH) {
- // override line_length for commit msg the same way as
- // in gr-diff
- localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
- }
-
- let builder = null;
- if (this.isImageDiff) {
- builder = new GrDiffBuilderImage(
- this.diff,
- localPrefs,
- this.diffElement,
- this.baseImage,
- this.revisionImage,
- this.renderPrefs,
- this.useNewImageDiffUi
- );
- } else if (this.diff.binary) {
- return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
- } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
- this.renderPrefs = {
- ...this.renderPrefs,
- view_mode: DiffViewMode.SIDE_BY_SIDE,
- };
- builder = new GrDiffBuilder(
- this.diff,
- localPrefs,
- this.diffElement,
- this.layersInternal,
- this.renderPrefs
- );
- } else if (this.viewMode === DiffViewMode.UNIFIED) {
- this.renderPrefs = {
- ...this.renderPrefs,
- view_mode: DiffViewMode.UNIFIED,
- };
- builder = new GrDiffBuilder(
- this.diff,
- localPrefs,
- this.diffElement,
- this.layersInternal,
- this.renderPrefs
- );
- }
- if (!builder) {
- throw Error(`Unsupported diff view mode: ${this.viewMode}`);
- }
- return builder;
- }
-
- private clearDiffContent() {
- assertIsDefined(this.diffElement, 'diff table');
- this.diffElement.innerHTML = '';
- }
-
- /**
- * Called when the processor starts converting the diff information from the
- * server into chunks.
- */
- clearGroups() {
- if (!this.builder) return;
- this.groups = [];
- this.builder.clearGroups();
- }
-
- /**
- * Called when the processor is done converting a chunk of the diff.
- */
- addGroup(group: GrDiffGroup) {
- if (!this.builder) return;
- this.builder.addGroups([group]);
- this.groups.push(group);
- }
-
- // visible for testing
- createIntralineLayer(): DiffLayer {
- return {
- // Take a DIV.contentText element and a line object with intraline
- // differences to highlight and apply them to the element as
- // annotations.
- annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
- const HL_CLASS = 'gr-diff intraline';
- for (const highlight of line.highlights) {
- // The start and end indices could be the same if a highlight is
- // meant to start at the end of a line and continue onto the
- // next one. Ignore it.
- if (highlight.startIndex === highlight.endIndex) {
- continue;
- }
-
- // If endIndex isn't present, continue to the end of the line.
- const endIndex =
- highlight.endIndex === undefined
- ? GrAnnotation.getStringLength(line.text)
- : highlight.endIndex;
-
- GrAnnotation.annotateElement(
- contentEl,
- highlight.startIndex,
- endIndex - highlight.startIndex,
- HL_CLASS
- );
- }
- },
- };
- }
-
- // visible for testing
- createTabIndicatorLayer(): DiffLayer {
- const show = () => this.showTabs;
- return {
- annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
- // If visible tabs are disabled, do nothing.
- if (!show()) {
- return;
- }
-
- // Find and annotate the locations of tabs.
- annotateSymbols(contentEl, line, '\t', 'tab-indicator');
- },
- };
- }
-
- private createSpecialCharacterIndicatorLayer(): DiffLayer {
- return {
- annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
- // Find and annotate the locations of soft hyphen (\u00AD)
- annotateSymbols(contentEl, line, '\u00AD', 'special-char-indicator');
- // Find and annotate Stateful Unicode directional controls
- annotateSymbols(
- contentEl,
- line,
- /[\u202A-\u202E\u2066-\u2069]/,
- 'special-char-warning'
- );
- },
- };
- }
-
- // visible for testing
- createTrailingWhitespaceLayer(): DiffLayer {
- const show = () => this.showTrailingWhitespace;
-
- return {
- annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
- if (!show()) {
- return;
- }
-
- const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
- if (match) {
- // Normalize string positions in case there is unicode before or
- // within the match.
- const index = GrAnnotation.getStringLength(
- line.text.substr(0, match.index)
- );
- const length = GrAnnotation.getStringLength(match[0]);
- GrAnnotation.annotateElement(
- contentEl,
- index,
- length,
- 'gr-diff trailing-whitespace'
- );
- }
- },
- };
- }
-
- setBlame(blame: BlameInfo[] | null) {
- if (!this.builder) return;
- this.builder.setBlame(blame ?? []);
- }
-
- updateRenderPrefs(renderPrefs: RenderPreferences) {
- this.builder?.updateRenderPrefs(renderPrefs);
- }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
deleted file mode 100644
index f6f0cb3..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ /dev/null
@@ -1,628 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import {
- createConfig,
- createEmptyDiff,
-} from '../../../test/test-data-generators';
-import './gr-diff-builder-element';
-import {stubBaseUrl, waitUntil} from '../../../test/test-utils';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {
- DiffContent,
- DiffLayer,
- DiffPreferencesInfo,
- DiffViewMode,
- GrDiffLineType,
- Side,
-} from '../../../api/diff';
-import {stubRestApi} from '../../../test/test-utils';
-import {waitForEventOnce} from '../../../utils/event-util';
-import {GrDiffBuilderElement} from './gr-diff-builder-element';
-import {createDefaultDiffPrefs} from '../../../constants/constants';
-import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
-import {fixture, html, assert} from '@open-wc/testing';
-import {GrDiffRow} from './gr-diff-row';
-import {GrDiffBuilder} from './gr-diff-builder';
-import {querySelectorAll} from '../../../utils/dom-util';
-
-const DEFAULT_PREFS = createDefaultDiffPrefs();
-
-suite('gr-diff-builder tests', () => {
- let element: GrDiffBuilderElement;
- let builder: GrDiffBuilder;
- let diffTable: HTMLTableElement;
-
- const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
- builder = new GrDiffBuilder(
- createEmptyDiff(),
- {...createDefaultDiffPrefs(), ...prefs},
- diffTable
- );
- };
-
- const line = (text: string) => {
- const line = new GrDiffLine(GrDiffLineType.BOTH);
- line.text = text;
- return line;
- };
-
- setup(async () => {
- diffTable = await fixture(html`<table id="diffTable"></table>`);
- element = new GrDiffBuilderElement();
- element.diffElement = diffTable;
- stubRestApi('getLoggedIn').returns(Promise.resolve(false));
- stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
- stubBaseUrl('/r');
- setBuilderPrefs({});
- });
-
- [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
- test(`line_length used for regular files under ${mode}`, () => {
- element.path = '/a.txt';
- element.viewMode = mode;
- element.diff = createEmptyDiff();
- element.prefs = {
- ...createDefaultDiffPrefs(),
- tab_size: 4,
- line_length: 50,
- };
- builder = element.getDiffBuilder();
- assert.equal(builder.prefs.line_length, 50);
- });
-
- test(`line_length ignored for commit msg under ${mode}`, () => {
- element.path = '/COMMIT_MSG';
- element.viewMode = mode;
- element.diff = createEmptyDiff();
- element.prefs = {
- ...createDefaultDiffPrefs(),
- tab_size: 4,
- line_length: 50,
- };
- builder = element.getDiffBuilder();
- assert.equal(builder.prefs.line_length, 72);
- });
- });
-
- test('_handlePreferenceError throws with invalid preference', () => {
- element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
- assert.throws(() => element.getDiffBuilder());
- });
-
- test('_handlePreferenceError triggers alert and javascript error', () => {
- const errorStub = sinon.stub();
- diffTable.addEventListener('show-alert', errorStub);
- assert.throws(() => element.handlePreferenceError('tab size'));
- assert.equal(
- errorStub.lastCall.args[0].detail.message,
- "The value of the 'tab size' user preference is invalid. " +
- 'Fix in diff preferences'
- );
- });
-
- suite('intraline differences', () => {
- let el: HTMLElement;
- let str: string;
- let annotateElementSpy: sinon.SinonSpy;
- let layer: DiffLayer;
- const lineNumberEl = document.createElement('td');
-
- function slice(str: string, start: number, end?: number) {
- return Array.from(str).slice(start, end).join('');
- }
-
- setup(async () => {
- el = await fixture(html`
- <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
- `);
- str = el.textContent ?? '';
- annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
- layer = element.createIntralineLayer();
- });
-
- test('annotate no highlights', () => {
- layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
-
- // The content is unchanged.
- assert.isFalse(annotateElementSpy.called);
- assert.equal(el.childNodes.length, 1);
- assert.instanceOf(el.childNodes[0], Text);
- assert.equal(str, el.childNodes[0].textContent);
- });
-
- test('annotate with highlights', () => {
- const l = line(str);
- l.highlights = [
- {contentIndex: 0, startIndex: 6, endIndex: 12},
- {contentIndex: 0, startIndex: 18, endIndex: 22},
- ];
- const str0 = slice(str, 0, 6);
- const str1 = slice(str, 6, 12);
- const str2 = slice(str, 12, 18);
- const str3 = slice(str, 18, 22);
- const str4 = slice(str, 22);
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.isTrue(annotateElementSpy.called);
- assert.equal(el.childNodes.length, 5);
-
- assert.instanceOf(el.childNodes[0], Text);
- assert.equal(el.childNodes[0].textContent, str0);
-
- assert.notInstanceOf(el.childNodes[1], Text);
- assert.equal(el.childNodes[1].textContent, str1);
-
- assert.instanceOf(el.childNodes[2], Text);
- assert.equal(el.childNodes[2].textContent, str2);
-
- assert.notInstanceOf(el.childNodes[3], Text);
- assert.equal(el.childNodes[3].textContent, str3);
-
- assert.instanceOf(el.childNodes[4], Text);
- assert.equal(el.childNodes[4].textContent, str4);
- });
-
- test('annotate without endIndex', () => {
- const l = line(str);
- l.highlights = [{contentIndex: 0, startIndex: 28}];
-
- const str0 = slice(str, 0, 28);
- const str1 = slice(str, 28);
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.isTrue(annotateElementSpy.called);
- assert.equal(el.childNodes.length, 2);
-
- assert.instanceOf(el.childNodes[0], Text);
- assert.equal(el.childNodes[0].textContent, str0);
-
- assert.notInstanceOf(el.childNodes[1], Text);
- assert.equal(el.childNodes[1].textContent, str1);
- });
-
- test('annotate ignores empty highlights', () => {
- const l = line(str);
- l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.isFalse(annotateElementSpy.called);
- assert.equal(el.childNodes.length, 1);
- });
-
- test('annotate handles unicode', () => {
- // Put some unicode into the string:
- str = str.replace(/\s/g, '💢');
- el.textContent = str;
- const l = line(str);
- l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
-
- const str0 = slice(str, 0, 6);
- const str1 = slice(str, 6, 12);
- const str2 = slice(str, 12);
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.isTrue(annotateElementSpy.called);
- assert.equal(el.childNodes.length, 3);
-
- assert.instanceOf(el.childNodes[0], Text);
- assert.equal(el.childNodes[0].textContent, str0);
-
- assert.notInstanceOf(el.childNodes[1], Text);
- assert.equal(el.childNodes[1].textContent, str1);
-
- assert.instanceOf(el.childNodes[2], Text);
- assert.equal(el.childNodes[2].textContent, str2);
- });
-
- test('annotate handles unicode w/o endIndex', () => {
- // Put some unicode into the string:
- str = str.replace(/\s/g, '💢');
- el.textContent = str;
-
- const l = line(str);
- l.highlights = [{contentIndex: 0, startIndex: 6}];
-
- const str0 = slice(str, 0, 6);
- const str1 = slice(str, 6);
- const numHighlightedChars = GrAnnotation.getStringLength(str1);
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.isTrue(annotateElementSpy.calledWith(el, 6, numHighlightedChars));
- assert.equal(el.childNodes.length, 2);
-
- assert.instanceOf(el.childNodes[0], Text);
- assert.equal(el.childNodes[0].textContent, str0);
-
- assert.notInstanceOf(el.childNodes[1], Text);
- assert.equal(el.childNodes[1].textContent, str1);
- });
- });
-
- suite('tab indicators', () => {
- let layer: DiffLayer;
- const lineNumberEl = document.createElement('td');
-
- setup(() => {
- element.showTabs = true;
- layer = element.createTabIndicatorLayer();
- });
-
- test('does nothing with empty line', () => {
- const l = line('');
- const el = document.createElement('div');
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.isFalse(annotateElementStub.called);
- });
-
- test('does nothing with no tabs', () => {
- const str = 'lorem ipsum no tabs';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.isFalse(annotateElementStub.called);
- });
-
- test('annotates tab at beginning', () => {
- const str = '\tlorem upsum';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.equal(annotateElementStub.callCount, 1);
- const args = annotateElementStub.getCalls()[0].args;
- assert.equal(args[0], el);
- assert.equal(args[1], 0, 'offset of tab indicator');
- assert.equal(args[2], 1, 'length of tab indicator');
- assert.include(args[3], 'tab-indicator');
- });
-
- test('does not annotate when disabled', () => {
- element.showTabs = false;
-
- const str = '\tlorem upsum';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.isFalse(annotateElementStub.called);
- });
-
- test('annotates multiple in beginning', () => {
- const str = '\t\tlorem upsum';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.equal(annotateElementStub.callCount, 2);
-
- let args = annotateElementStub.getCalls()[0].args;
- assert.equal(args[0], el);
- assert.equal(args[1], 0, 'offset of tab indicator');
- assert.equal(args[2], 1, 'length of tab indicator');
- assert.include(args[3], 'tab-indicator');
-
- args = annotateElementStub.getCalls()[1].args;
- assert.equal(args[0], el);
- assert.equal(args[1], 1, 'offset of tab indicator');
- assert.equal(args[2], 1, 'length of tab indicator');
- assert.include(args[3], 'tab-indicator');
- });
-
- test('annotates intermediate tabs', () => {
- const str = 'lorem\tupsum';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
- assert.equal(annotateElementStub.callCount, 1);
- const args = annotateElementStub.getCalls()[0].args;
- assert.equal(args[0], el);
- assert.equal(args[1], 5, 'offset of tab indicator');
- assert.equal(args[2], 1, 'length of tab indicator');
- assert.include(args[3], 'tab-indicator');
- });
- });
-
- suite('layers', () => {
- let initialLayersCount = 0;
- let withLayerCount = 0;
- setup(() => {
- const layers: DiffLayer[] = [];
- element.layers = layers;
- element.showTrailingWhitespace = true;
- element.setupAnnotationLayers();
- initialLayersCount = element.layersInternal.length;
- });
-
- test('no layers', () => {
- element.setupAnnotationLayers();
- assert.equal(element.layersInternal.length, initialLayersCount);
- });
-
- suite('with layers', () => {
- const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
- setup(() => {
- element.layers = layers;
- element.showTrailingWhitespace = true;
- element.setupAnnotationLayers();
- withLayerCount = element.layersInternal.length;
- });
- test('with layers', () => {
- element.setupAnnotationLayers();
- assert.equal(element.layersInternal.length, withLayerCount);
- assert.equal(initialLayersCount + layers.length, withLayerCount);
- });
- });
- });
-
- suite('trailing whitespace', () => {
- let layer: DiffLayer;
- const lineNumberEl = document.createElement('td');
-
- setup(() => {
- element.showTrailingWhitespace = true;
- layer = element.createTrailingWhitespaceLayer();
- });
-
- test('does nothing with empty line', () => {
- const l = line('');
- const el = document.createElement('div');
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
- assert.isFalse(annotateElementStub.called);
- });
-
- test('does nothing with no trailing whitespace', () => {
- const str = 'lorem ipsum blah blah';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
- assert.isFalse(annotateElementStub.called);
- });
-
- test('annotates trailing spaces', () => {
- const str = 'lorem ipsum ';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
- assert.isTrue(annotateElementStub.called);
- assert.equal(annotateElementStub.lastCall.args[1], 11);
- assert.equal(annotateElementStub.lastCall.args[2], 3);
- });
-
- test('annotates trailing tabs', () => {
- const str = 'lorem ipsum\t\t\t';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
- assert.isTrue(annotateElementStub.called);
- assert.equal(annotateElementStub.lastCall.args[1], 11);
- assert.equal(annotateElementStub.lastCall.args[2], 3);
- });
-
- test('annotates mixed trailing whitespace', () => {
- const str = 'lorem ipsum\t \t';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
- assert.isTrue(annotateElementStub.called);
- assert.equal(annotateElementStub.lastCall.args[1], 11);
- assert.equal(annotateElementStub.lastCall.args[2], 3);
- });
-
- test('unicode preceding trailing whitespace', () => {
- const str = '💢\t';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
- assert.isTrue(annotateElementStub.called);
- assert.equal(annotateElementStub.lastCall.args[1], 1);
- assert.equal(annotateElementStub.lastCall.args[2], 1);
- });
-
- test('does not annotate when disabled', () => {
- element.showTrailingWhitespace = false;
- const str = 'lorem upsum\t \t ';
- const l = line(str);
- const el = document.createElement('div');
- el.textContent = str;
- const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
- layer.annotate(el, lineNumberEl, l, Side.LEFT);
- assert.isFalse(annotateElementStub.called);
- });
- });
-
- suite('rendering text, images and binary files', () => {
- let keyLocations: KeyLocations;
- let content: DiffContent[] = [];
-
- setup(() => {
- element.viewMode = 'SIDE_BY_SIDE';
- keyLocations = {left: {}, right: {}};
- element.prefs = {
- ...DEFAULT_PREFS,
- context: -1,
- syntax_highlighting: true,
- };
- content = [
- {
- a: ['all work and no play make andybons a dull boy'],
- b: ['elgoog elgoog elgoog'],
- },
- {
- ab: [
- 'Non eram nescius, Brute, cum, quae summis ingeniis ',
- 'exquisitaque doctrina philosophi Graeco sermone tractavissent',
- ],
- },
- ];
- });
-
- test('text', async () => {
- element.diff = {...createEmptyDiff(), content};
- element.render(keyLocations);
- await waitForEventOnce(diffTable, 'render-content');
- assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
- });
-
- test('image', async () => {
- element.diff = {...createEmptyDiff(), content, binary: true};
- element.isImageDiff = true;
- element.render(keyLocations);
- await waitForEventOnce(diffTable, 'render-content');
- assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
- });
-
- test('binary', async () => {
- element.diff = {...createEmptyDiff(), content, binary: true};
- element.render(keyLocations);
- await waitForEventOnce(diffTable, 'render-content');
- assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 3);
- });
- });
-
- suite('context hiding and expanding', () => {
- let dispatchStub: sinon.SinonStub;
-
- setup(async () => {
- dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
- element.diff = {
- ...createEmptyDiff(),
- content: [
- {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
- {a: ['before'], b: ['after']},
- {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
- ],
- };
- element.viewMode = DiffViewMode.SIDE_BY_SIDE;
-
- const keyLocations: KeyLocations = {left: {}, right: {}};
- element.prefs = {
- ...DEFAULT_PREFS,
- context: 1,
- };
- element.render(keyLocations);
- // Make sure all listeners are installed.
- await element.untilGroupsRendered();
- });
-
- test('hides lines behind two context controls', () => {
- const contextControls = diffTable.querySelectorAll('gr-context-controls');
- assert.equal(contextControls.length, 2);
-
- const diffRows = diffTable.querySelectorAll('.diff-row');
- // The first two are LOST and FILE line
- assert.equal(diffRows.length, 2 + 1 + 1 + 1);
- assert.include(diffRows[2].textContent, 'unchanged 10');
- assert.include(diffRows[3].textContent, 'before');
- assert.include(diffRows[3].textContent, 'after');
- assert.include(diffRows[4].textContent, 'unchanged 11');
- });
-
- test('clicking +x common lines expands those lines', async () => {
- const contextControls = diffTable.querySelectorAll('gr-context-controls');
- const topExpandCommonButton =
- contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
- '.showContext'
- )[0];
- assert.isOk(topExpandCommonButton);
- assert.include(topExpandCommonButton!.textContent, '+9 common lines');
- let diffRows = diffTable.querySelectorAll('.diff-row');
- // 5 lines:
- // FILE, LOST, the changed line plus one line of context in each direction
- assert.equal(diffRows.length, 5);
-
- topExpandCommonButton!.click();
-
- await waitUntil(() => {
- diffRows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
- return diffRows.length === 14;
- });
- // 14 lines: The 5 above plus the 9 unchanged lines that were expanded
- assert.equal(diffRows.length, 14);
- assert.include(diffRows[2].textContent, 'unchanged 1');
- assert.include(diffRows[3].textContent, 'unchanged 2');
- assert.include(diffRows[4].textContent, 'unchanged 3');
- assert.include(diffRows[5].textContent, 'unchanged 4');
- assert.include(diffRows[6].textContent, 'unchanged 5');
- assert.include(diffRows[7].textContent, 'unchanged 6');
- assert.include(diffRows[8].textContent, 'unchanged 7');
- assert.include(diffRows[9].textContent, 'unchanged 8');
- assert.include(diffRows[10].textContent, 'unchanged 9');
- assert.include(diffRows[11].textContent, 'unchanged 10');
- assert.include(diffRows[12].textContent, 'before');
- assert.include(diffRows[12].textContent, 'after');
- assert.include(diffRows[13].textContent, 'unchanged 11');
- });
-
- test('unhideLine shows the line with context', async () => {
- dispatchStub.reset();
- element.unhideLine(4, Side.LEFT);
-
- await waitUntil(() => {
- const rows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
- return rows.length === 2 + 5 + 1 + 1 + 1;
- });
-
- const diffRows = diffTable.querySelectorAll('.diff-row');
- // The first two are LOST and FILE line
- // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
- // Because context expanders do not hide <3 lines, lines 1-2 will also
- // be shown.
- // Lines 6-9 continue to be hidden
- assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
- assert.include(diffRows[2].textContent, 'unchanged 1');
- assert.include(diffRows[3].textContent, 'unchanged 2');
- assert.include(diffRows[4].textContent, 'unchanged 3');
- assert.include(diffRows[5].textContent, 'unchanged 4');
- assert.include(diffRows[6].textContent, 'unchanged 5');
- assert.include(diffRows[7].textContent, 'unchanged 10');
- assert.include(diffRows[8].textContent, 'before');
- assert.include(diffRows[8].textContent, 'after');
- assert.include(diffRows[9].textContent, 'unchanged 11');
-
- await element.untilGroupsRendered();
- const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
- assert.include(firedEventTypes, 'render-content');
- });
- });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index edd68dc..d87d4d8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -6,7 +6,6 @@
import '../../../styles/shared-styles';
import '../../../elements/shared/gr-button/gr-button';
import '../../../elements/shared/gr-icon/gr-icon';
-import '../gr-diff-builder/gr-diff-builder-element';
import '../gr-diff-highlight/gr-diff-highlight';
import '../gr-diff-selection/gr-diff-selection';
import '../gr-syntax-themes/gr-syntax-theme';
@@ -25,6 +24,7 @@
getResponsiveMode,
isResponsive,
isNewDiff,
+ getSideByLineEl,
} from '../gr-diff/gr-diff-utils';
import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -32,18 +32,20 @@
CreateRangeCommentEventDetail,
GrDiffHighlight,
} from '../gr-diff-highlight/gr-diff-highlight';
-import {
- GrDiffBuilderElement,
- getLineNumberCellWidth,
-} from '../gr-diff-builder/gr-diff-builder-element';
import {CoverageRange, DiffLayer} from '../../../types/types';
-import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {
+ CommentRangeLayer,
+ GrRangedCommentLayer,
+} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
import {
createDefaultDiffPrefs,
DiffViewMode,
Side,
} from '../../../constants/constants';
-import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {
+ GrDiffProcessor,
+ KeyLocations,
+} from '../gr-diff-processor/gr-diff-processor';
import {fire, fireAlert} from '../../../utils/event-util';
import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
import {getContentEditableRange} from '../../../utils/safari-selection-util';
@@ -76,6 +78,24 @@
import {provide} from '../../../models/dependency';
import {grDiffStyles} from './gr-diff-styles';
import {getDiffLength} from '../../../utils/diff-util';
+import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
+import {
+ GrDiffBuilder,
+ isImageDiffBuilder,
+ isBinaryDiffBuilder,
+ DiffContextExpandedEventDetail,
+} from '../gr-diff-builder/gr-diff-builder';
+import {GrDiffBuilderBinary} from '../gr-diff-builder/gr-diff-builder-binary';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {
+ GrDiffGroup,
+ GrDiffGroupType,
+ hideInContextControl,
+} from './gr-diff-group';
+import {GrDiffLine} from './gr-diff-line';
+
+const TRAILING_WHITESPACE_PATTERN = /\s+$/;
const NO_NEWLINE_LEFT = 'No newline at end of left file.';
const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -264,11 +284,40 @@
// Private but used in tests.
highlights = new GrDiffHighlight();
- // Private but used in tests.
- diffBuilder = new GrDiffBuilderElement();
-
private diffModel = new DiffModel(undefined);
+ // visible for testing
+ builder?: GrDiffBuilder;
+
+ /**
+ * All layers, both from the outside and the default ones. See `layers` for
+ * the property that can be set from the outside.
+ */
+ // visible for testing
+ layersInternal: DiffLayer[] = [];
+
+ // visible for testing
+ showTabs?: boolean;
+
+ // visible for testing
+ showTrailingWhitespace?: boolean;
+
+ private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
+
+ private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
+
+ private rangeLayer?: GrRangedCommentLayer;
+
+ // visible for testing
+ processor?: GrDiffProcessor;
+
+ /**
+ * Groups are mostly just passed on to the diff builder (this.builder). But
+ * we also keep track of them here for being able to fire a `render-content`
+ * event when .element of each group has rendered.
+ */
+ private groups: GrDiffGroup[] = [];
+
static override get styles() {
return [
iconStyles,
@@ -301,10 +350,10 @@
if (this.diff && this.diffTable) {
this.diffSelection.init(this.diff, this.diffTable);
}
- if (this.diffTable && this.diffBuilder) {
- this.highlights.init(this.diffTable, this.diffBuilder);
+ if (this.diffTable) {
+ this.highlights.init(this.diffTable, this);
}
- this.diffBuilder.init();
+ this.diffBuilderInit();
}
override disconnectedCallback() {
@@ -312,7 +361,7 @@
this.renderDiffTableTask?.cancel();
this.diffSelection.cleanup();
this.highlights.cleanup();
- this.diffBuilder.cleanup();
+ this.diffBuilderCleanup();
super.disconnectedCallback();
}
@@ -340,7 +389,7 @@
}
}
if (changedProperties.has('coverageRanges')) {
- this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+ this.updateCoverageRanges(this.coverageRanges);
}
if (changedProperties.has('lineOfInterest')) {
this.lineOfInterestChanged();
@@ -439,10 +488,6 @@
document.removeEventListener('mouseup', this.handleMouseUp);
}
- getLineNumEls(side: Side): HTMLElement[] {
- return this.diffBuilder.getLineNumEls(side);
- }
-
// Private but used in tests.
showNoChangeMessage() {
return (
@@ -526,7 +571,7 @@
});
}
- this.diffBuilder.updateCommentRanges(this.commentRanges);
+ this.updateCommentRanges(this.commentRanges);
}
/**
@@ -534,7 +579,8 @@
* where lines should not be collapsed.
*
*/
- private computeKeyLocations() {
+ // visible for testing
+ computeKeyLocations() {
const keyLocations: KeyLocations = {left: {}, right: {}};
if (this.lineOfInterest) {
const side = this.lineOfInterest.side;
@@ -572,7 +618,7 @@
/** Cancel any remaining diff builder rendering work. */
cancel() {
- this.diffBuilder.cleanup();
+ this.diffBuilderCleanup();
this.renderDiffTableTask?.cancel();
}
@@ -580,8 +626,7 @@
if (this.hidden && this.noAutoRender) return [];
// Get rendered stops.
- const stops: Array<HTMLElement | AbortStop> =
- this.diffBuilder.getLineNumberRows();
+ const stops: Array<HTMLElement | AbortStop> = this.getLineNumberRows();
// If we are still loading this diff, abort after the rendered stops to
// avoid skipping over to e.g. the next file.
@@ -600,7 +645,7 @@
}
private blameChanged() {
- this.diffBuilder.setBlame(this.blame);
+ this.setBlame(this.blame);
if (this.blame) {
this.classList.add('showBlame');
} else {
@@ -669,7 +714,7 @@
createCommentForSelection(side: Side, range: CommentRange) {
const lineNum = range.end_line;
- const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
+ const lineEl = this.getLineElByNumber(lineNum, side);
if (lineEl) {
this.createComment(lineEl, lineNum, side, range);
}
@@ -690,7 +735,7 @@
side?: Side,
range?: CommentRange
) {
- const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+ const contentEl = this.getContentTdByLineEl(lineEl);
if (!contentEl) throw new Error('content el not found for line el');
side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
fire(this, 'create-comment', {
@@ -715,7 +760,7 @@
if (!this.lineOfInterest) return;
const lineNum = this.lineOfInterest.lineNum;
if (typeof lineNum !== 'number') return;
- this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+ this.unhideLine(lineNum, this.lineOfInterest.side);
}
private cleanup() {
@@ -815,7 +860,7 @@
if (this.prefs) {
this.updatePreferenceStyles();
}
- this.diffBuilder.updateRenderPrefs(this.renderPrefs);
+ this.updateRenderPrefs(this.renderPrefs);
}
private diffChanged() {
@@ -826,7 +871,7 @@
this.debounceRenderDiffTable();
assertIsDefined(this.diffTable, 'diffTable');
this.diffSelection.init(this.diff, this.diffTable);
- this.highlights.init(this.diffTable, this.diffBuilder);
+ this.highlights.init(this.diffTable, this);
}
}
@@ -871,7 +916,7 @@
return;
}
if (
- this.prefs.context === -1 &&
+ this.getBypassPrefs().context === -1 &&
this.diffLength &&
this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
this.safetyBypass === null
@@ -883,8 +928,6 @@
this.showWarning = false;
- const keyLocations = this.computeKeyLocations();
-
this.diffModel.setState({
diff: this.diff,
path: this.path,
@@ -892,25 +935,9 @@
diffPrefs: this.prefs,
});
- // TODO: Setting tons of public properties like this is obviously a code
- // smell. We are introducing a diff model for managing all this
- // data. Then diff builder will only need access to that model.
- this.diffBuilder.prefs = this.getBypassPrefs();
- this.diffBuilder.renderPrefs = this.renderPrefs;
- this.diffBuilder.diff = this.diff;
- this.diffBuilder.path = this.path;
- this.diffBuilder.viewMode = this.viewMode;
- this.diffBuilder.layers = this.layers ?? [];
- this.diffBuilder.isImageDiff = this.isImageDiff;
- this.diffBuilder.baseImage = this.baseImage ?? null;
- this.diffBuilder.revisionImage = this.revisionImage ?? null;
- this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
- this.diffBuilder.diffElement = this.diffTable;
- // `this.commentRanges` are probably empty here, because they will only be
- // populated by the node observer, which starts observing *after* rendering.
- this.diffBuilder.updateCommentRanges(this.commentRanges);
- this.diffBuilder.updateCoverageRanges(this.coverageRanges);
- await this.diffBuilder.render(keyLocations);
+ this.updateCommentRanges(this.commentRanges);
+ this.updateCoverageRanges(this.coverageRanges);
+ await this.legacyRender();
}
private handleRenderContent() {
@@ -959,7 +986,7 @@
const commentSide = getSide(threadEl);
const range = getRange(threadEl);
if (!commentSide) continue;
- const lineEl = this.diffBuilder.getLineElByNumber(lineNum, commentSide);
+ const lineEl = this.getLineElByNumber(lineNum, commentSide);
// When the line the comment refers to does not exist, log an error
// but don't crash. This can happen e.g. if the API does not fully
// validate e.g. (robot) comments
@@ -972,7 +999,7 @@
);
continue;
}
- const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+ const contentEl = this.getContentTdByLineEl(lineEl);
if (!contentEl) continue;
if (lineNum === LOST) {
this.insertPortedCommentsWithoutRangeMessage(contentEl);
@@ -1029,7 +1056,8 @@
/**
* Get the preferences object including the safety bypass context (if any).
*/
- private getBypassPrefs() {
+ // visible for testing
+ getBypassPrefs() {
assertIsDefined(this.prefs, 'prefs');
if (this.safetyBypass !== null) {
return {...this.prefs, context: this.safetyBypass};
@@ -1040,9 +1068,7 @@
clearDiffContent() {
this.unobserveNodes();
if (!this.diffTable) return;
- while (this.diffTable.hasChildNodes()) {
- this.diffTable.removeChild(this.diffTable.lastChild!);
- }
+ this.diffTable.innerHTML = '';
}
// Private but used in tests.
@@ -1099,6 +1125,425 @@
}
return messages.join(' \u2014 '); // \u2014 - '—'
}
+
+ private updateCommentRanges(ranges: CommentRangeLayer[]) {
+ this.rangeLayer?.updateRanges(ranges);
+ }
+
+ private updateCoverageRanges(rs: CoverageRange[]) {
+ this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+ this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
+ }
+
+ legacyRender(): Promise<void> {
+ assertIsDefined(this.diff, 'diff');
+ assertIsDefined(this.diffTable, 'diff table');
+ assertIsDefined(this.prefs, 'prefs');
+
+ // Setting up annotation layers must happen after plugins are
+ // installed, and |render| satisfies the requirement, however,
+ // |attached| doesn't because in the diff view page, the element is
+ // attached before plugins are installed.
+ this.setupAnnotationLayers();
+
+ this.showTabs = this.prefs.show_tabs;
+ this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
+
+ this.diffBuilderCleanup();
+ this.builder = this.getDiffBuilder();
+ this.diffBuilderInit();
+
+ // TODO: Just pass along the diff model here instead of setting many
+ // individual properties.
+ this.processor = new GrDiffProcessor();
+ this.processor.consumer = this;
+ this.processor.context = this.getBypassPrefs().context;
+ this.processor.keyLocations = this.computeKeyLocations();
+ if (this.renderPrefs?.num_lines_rendered_at_once) {
+ this.processor.asyncThreshold =
+ this.renderPrefs.num_lines_rendered_at_once;
+ }
+
+ this.diffTable.innerHTML = '';
+ this.builder.addColumns(this.diffTable, getLineNumberCellWidth(this.prefs));
+
+ const isBinary = !!(this.isImageDiff || this.diff.binary);
+
+ fire(this.diffTable, 'render-start', {});
+ return (
+ this.processor
+ .process(this.diff.content, isBinary)
+ .then(async () => {
+ if (isImageDiffBuilder(this.builder)) {
+ this.builder.renderImageDiff();
+ } else if (isBinaryDiffBuilder(this.builder)) {
+ this.builder.renderBinaryDiff();
+ }
+ await this.untilGroupsRendered();
+ fire(this.diffTable, 'render-content', {});
+ })
+ // Mocha testing does not like uncaught rejections, so we catch
+ // the cancels which are expected and should not throw errors in
+ // tests.
+ .catch(e => {
+ if (!e.isCanceled) return Promise.reject(e);
+ return;
+ })
+ );
+ }
+
+ // visible for testing
+ async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
+ return Promise.all(groups.map(g => g.waitUntilRendered()));
+ }
+
+ private onDiffContextExpanded = (
+ e: CustomEvent<DiffContextExpandedEventDetail>
+ ) => {
+ // Don't stop propagation. The host may listen for reporting or
+ // resizing.
+ this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+ };
+
+ // visible for testing
+ setupAnnotationLayers() {
+ this.rangeLayer = new GrRangedCommentLayer();
+
+ const layers: DiffLayer[] = [
+ this.createTrailingWhitespaceLayer(),
+ this.createIntralineLayer(),
+ this.createTabIndicatorLayer(),
+ this.createSpecialCharacterIndicatorLayer(),
+ this.rangeLayer,
+ this.coverageLayerLeft,
+ this.coverageLayerRight,
+ ];
+
+ if (this.layers) {
+ layers.push(...this.layers);
+ }
+ this.layersInternal = layers;
+ }
+
+ getContentTdByLine(lineNumber: LineNumber, side?: Side) {
+ if (!this.builder) return undefined;
+ return this.builder.getContentTdByLine(lineNumber, side);
+ }
+
+ getContentTdByLineEl(lineEl?: Element): Element | undefined {
+ if (!lineEl) return undefined;
+ const line = getLineNumber(lineEl);
+ if (!line) return undefined;
+ const side = getSideByLineEl(lineEl);
+ return this.getContentTdByLine(line, side);
+ }
+
+ getLineElByNumber(lineNumber: LineNumber, side?: Side) {
+ if (!this.builder) return undefined;
+ return this.builder.getLineElByNumber(lineNumber, side);
+ }
+
+ getLineNumberRows() {
+ if (!this.builder) return [];
+ return this.builder.getLineNumberRows();
+ }
+
+ getLineNumEls(side: Side) {
+ if (!this.builder) return [];
+ return this.builder.getLineNumEls(side);
+ }
+
+ /**
+ * When the line is hidden behind a context expander, expand it.
+ *
+ * @param lineNum A line number to expand. Using number here because other
+ * special case line numbers are never hidden, so it does not make sense
+ * to expand them.
+ * @param side The side the line number refer to.
+ */
+ unhideLine(lineNum: number, side: Side) {
+ assertIsDefined(this.prefs, 'prefs');
+ if (!this.builder) return;
+ const group = this.builder.findGroup(side, lineNum);
+ // Cannot unhide a line that is not part of the diff.
+ if (!group) return;
+ // If it's already visible, great!
+ if (group.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+ const lineRange = group.lineRange[side];
+ const lineOffset = lineNum - lineRange.start_line;
+ const newGroups = [];
+ const groups = hideInContextControl(
+ group.contextGroups,
+ 0,
+ lineOffset - 1 - this.prefs.context
+ );
+ // If there is a context group, it will be the first group because we
+ // start hiding from 0 offset
+ if (groups[0].type === GrDiffGroupType.CONTEXT_CONTROL) {
+ newGroups.push(groups.shift()!);
+ }
+ newGroups.push(
+ ...hideInContextControl(
+ groups,
+ lineOffset + 1 + this.prefs.context,
+ // Both ends inclusive, so difference is the offset of the last line.
+ // But we need to pass the first line not to hide, which is the element
+ // after.
+ lineRange.end_line - lineRange.start_line + 1
+ )
+ );
+ this.replaceGroup(group, newGroups);
+ }
+
+ /**
+ * Replace the group of a context control section by rendering the provided
+ * groups instead. This happens in response to expanding a context control
+ * group.
+ *
+ * @param contextGroup The context control group to replace
+ * @param newGroups The groups that are replacing the context control group
+ */
+ private replaceGroup(
+ contextGroup: GrDiffGroup,
+ newGroups: readonly GrDiffGroup[]
+ ) {
+ if (!this.builder) return;
+ fire(this.diffTable, 'render-start', {});
+ this.builder.replaceGroup(contextGroup, newGroups);
+ this.groups = this.groups.filter(g => g !== contextGroup);
+ this.groups.push(...newGroups);
+ this.untilGroupsRendered(newGroups).then(() => {
+ fire(this.diffTable, 'render-content', {});
+ });
+ }
+
+ /**
+ * This is meant to be called when the gr-diff component re-connects, or when
+ * the diff is (re-)rendered.
+ *
+ * Make sure that this method is symmetric with cleanup(), which is called
+ * when gr-diff disconnects.
+ */
+ private diffBuilderInit() {
+ this.cleanup();
+ this.diffTable?.addEventListener(
+ 'diff-context-expanded-internal-new',
+ this.onDiffContextExpanded
+ );
+ this.builder?.init();
+ }
+
+ /**
+ * This is meant to be called when the gr-diff component disconnects, or when
+ * the diff is (re-)rendered.
+ *
+ * Make sure that this method is symmetric with init(), which is called when
+ * gr-diff re-connects.
+ */
+ private diffBuilderCleanup() {
+ this.processor?.cancel();
+ this.builder?.cleanup();
+ this.diffTable?.removeEventListener(
+ 'diff-context-expanded-internal-new',
+ this.onDiffContextExpanded
+ );
+ }
+
+ // visible for testing
+ handlePreferenceError(pref: string): never {
+ const message =
+ `The value of the '${pref}' user preference is ` +
+ 'invalid. Fix in diff preferences';
+ assertIsDefined(this.diffTable, 'diff table');
+ fireAlert(this.diffTable, message);
+ throw Error(`Invalid preference value: ${pref}`);
+ }
+
+ // visible for testing
+ getDiffBuilder(): GrDiffBuilder {
+ assertIsDefined(this.diff, 'diff');
+ assertIsDefined(this.diffTable, 'diff table');
+ assertIsDefined(this.prefs, 'prefs');
+ if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
+ this.handlePreferenceError('tab size');
+ }
+
+ if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
+ this.handlePreferenceError('diff width');
+ }
+
+ const localPrefs = {...this.prefs};
+ if (this.path === COMMIT_MSG_PATH) {
+ // override line_length for commit msg the same way as
+ // in gr-diff
+ localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
+ }
+
+ let builder = null;
+ if (this.isImageDiff) {
+ builder = new GrDiffBuilderImage(
+ this.diff,
+ localPrefs,
+ this.diffTable,
+ this.baseImage ?? null,
+ this.revisionImage ?? null,
+ this.renderPrefs,
+ this.useNewImageDiffUi
+ );
+ } else if (this.diff.binary) {
+ return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffTable);
+ } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
+ this.renderPrefs = {
+ ...this.renderPrefs,
+ view_mode: DiffViewMode.SIDE_BY_SIDE,
+ };
+ builder = new GrDiffBuilder(
+ this.diff,
+ localPrefs,
+ this.diffTable,
+ this.layersInternal,
+ this.renderPrefs
+ );
+ } else if (this.viewMode === DiffViewMode.UNIFIED) {
+ this.renderPrefs = {
+ ...this.renderPrefs,
+ view_mode: DiffViewMode.UNIFIED,
+ };
+ builder = new GrDiffBuilder(
+ this.diff,
+ localPrefs,
+ this.diffTable,
+ this.layersInternal,
+ this.renderPrefs
+ );
+ }
+ if (!builder) {
+ throw Error(`Unsupported diff view mode: ${this.viewMode}`);
+ }
+ return builder;
+ }
+
+ /**
+ * Called when the processor starts converting the diff information from the
+ * server into chunks.
+ */
+ clearGroups() {
+ if (!this.builder) return;
+ this.groups = [];
+ this.builder.clearGroups();
+ }
+
+ /**
+ * Called when the processor is done converting a chunk of the diff.
+ */
+ addGroup(group: GrDiffGroup) {
+ if (!this.builder) return;
+ this.builder.addGroups([group]);
+ this.groups.push(group);
+ }
+
+ // visible for testing
+ createIntralineLayer(): DiffLayer {
+ return {
+ // Take a DIV.contentText element and a line object with intraline
+ // differences to highlight and apply them to the element as
+ // annotations.
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ const HL_CLASS = 'gr-diff intraline';
+ for (const highlight of line.highlights) {
+ // The start and end indices could be the same if a highlight is
+ // meant to start at the end of a line and continue onto the
+ // next one. Ignore it.
+ if (highlight.startIndex === highlight.endIndex) {
+ continue;
+ }
+
+ // If endIndex isn't present, continue to the end of the line.
+ const endIndex =
+ highlight.endIndex === undefined
+ ? GrAnnotation.getStringLength(line.text)
+ : highlight.endIndex;
+
+ GrAnnotation.annotateElement(
+ contentEl,
+ highlight.startIndex,
+ endIndex - highlight.startIndex,
+ HL_CLASS
+ );
+ }
+ },
+ };
+ }
+
+ // visible for testing
+ createTabIndicatorLayer(): DiffLayer {
+ const show = () => this.showTabs;
+ return {
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ // If visible tabs are disabled, do nothing.
+ if (!show()) {
+ return;
+ }
+
+ // Find and annotate the locations of tabs.
+ annotateSymbols(contentEl, line, '\t', 'tab-indicator');
+ },
+ };
+ }
+
+ private createSpecialCharacterIndicatorLayer(): DiffLayer {
+ return {
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ // Find and annotate the locations of soft hyphen (\u00AD)
+ annotateSymbols(contentEl, line, '\u00AD', 'special-char-indicator');
+ // Find and annotate Stateful Unicode directional controls
+ annotateSymbols(
+ contentEl,
+ line,
+ /[\u202A-\u202E\u2066-\u2069]/,
+ 'special-char-warning'
+ );
+ },
+ };
+ }
+
+ // visible for testing
+ createTrailingWhitespaceLayer(): DiffLayer {
+ const show = () => this.showTrailingWhitespace;
+
+ return {
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ if (!show()) {
+ return;
+ }
+
+ const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+ if (match) {
+ // Normalize string positions in case there is unicode before or
+ // within the match.
+ const index = GrAnnotation.getStringLength(
+ line.text.substr(0, match.index)
+ );
+ const length = GrAnnotation.getStringLength(match[0]);
+ GrAnnotation.annotateElement(
+ contentEl,
+ index,
+ length,
+ 'gr-diff trailing-whitespace'
+ );
+ }
+ },
+ };
+ }
+
+ setBlame(blame: BlameInfo[] | null) {
+ if (!this.builder) return;
+ this.builder.setBlame(blame ?? []);
+ }
+
+ updateRenderPrefs(renderPrefs: RenderPreferences) {
+ this.builder?.updateRenderPrefs(renderPrefs);
+ }
}
function extractAddedNodes(mutations: MutationRecord[]) {
@@ -1109,6 +1554,30 @@
return mutations.flatMap(mutation => [...mutation.removedNodes]);
}
+function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
+ return prefs.font_size * 4;
+}
+
+function annotateSymbols(
+ contentEl: HTMLElement,
+ line: GrDiffLine,
+ separator: string | RegExp,
+ className: string
+) {
+ const split = line.text.split(separator);
+ if (!split || split.length < 2) {
+ return;
+ }
+ for (let i = 0, pos = 0; i < split.length - 1; i++) {
+ // Skip forward by the length of the content
+ pos += split[i].length;
+
+ GrAnnotation.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
+
+ pos++;
+ }
+}
+
// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
if (isNewDiff()) {
customElements.define('gr-diff', GrDiff);
@@ -1124,5 +1593,15 @@
'comment-thread-mouseleave': CustomEvent<{}>;
'loading-changed': ValueChangedEvent<boolean>;
'render-required': CustomEvent<{}>;
+ /**
+ * Fired when the diff begins rendering - both for full renders and for
+ * partial rerenders.
+ */
+ 'render-start': CustomEvent<{}>;
+ /**
+ * Fired when the diff finishes rendering text content - both for full
+ * renders and for partial rerenders.
+ */
+ 'render-content': CustomEvent<{}>;
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index f0826ad..d542633 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -4,15 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
-import {createDiff} from '../../../test/test-data-generators';
+import {
+ createConfig,
+ createDiff,
+ createEmptyDiff,
+} from '../../../test/test-data-generators';
import './gr-diff';
-import {getComputedStyleValue} from '../../../utils/dom-util';
+import {getComputedStyleValue, querySelectorAll} from '../../../utils/dom-util';
import '@polymer/paper-button/paper-button';
import {
DiffContent,
DiffInfo,
+ DiffLayer,
DiffPreferencesInfo,
DiffViewMode,
+ GrDiffLineType,
IgnoreWhitespaceType,
Side,
} from '../../../api/diff';
@@ -22,6 +28,8 @@
query,
queryAll,
queryAndAssert,
+ stubBaseUrl,
+ stubRestApi,
waitEventLoop,
waitQueryAndAssert,
waitUntil,
@@ -33,6 +41,13 @@
import {GrRangedCommentHint} from '../gr-ranged-comment-hint/gr-ranged-comment-hint';
import {assertIsDefined} from '../../../utils/common-util';
import {fixture, html, assert} from '@open-wc/testing';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder';
+import {GrDiffRow} from '../gr-diff-builder/gr-diff-row';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine} from './gr-diff-line';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
suite('gr-diff a11y test', () => {
test('audit', async () => {
@@ -3021,12 +3036,6 @@
});
});
- test('cancel', () => {
- const cleanupStub = sinon.stub(element.diffBuilder, 'cleanup');
- element.cancel();
- assert.isTrue(cleanupStub.calledOnce);
- });
-
test('line limit with line_wrapping', async () => {
element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
await element.updateComplete;
@@ -3796,10 +3805,9 @@
let renderStub: sinon.SinonStub;
setup(async () => {
- renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+ renderStub = sinon.stub(element, 'legacyRender').callsFake(() => {
assertIsDefined(element.diffTable);
- const diffTable = element.diffTable;
- diffTable.dispatchEvent(
+ element.diffTable.dispatchEvent(
new CustomEvent('render', {bubbles: true, composed: true})
);
return Promise.resolve();
@@ -3847,7 +3855,7 @@
assert.equal(element.prefs.context, 3);
assert.equal(element.safetyBypass, -1);
- assert.equal(element.diffBuilder.prefs.context, -1);
+ assert.equal(element.getBypassPrefs().context, -1);
});
test('toggles collapse context from bypass', async () => {
@@ -3860,7 +3868,7 @@
assert.equal(element.prefs.context, 3);
assert.isNull(element.safetyBypass);
- assert.equal(element.diffBuilder.prefs.context, 3);
+ assert.equal(element.getBypassPrefs().context, 3);
});
test('toggles collapse context from pref using default', async () => {
@@ -3872,14 +3880,14 @@
assert.equal(element.prefs.context, -1);
assert.equal(element.safetyBypass, 10);
- assert.equal(element.diffBuilder.prefs.context, 10);
+ assert.equal(element.getBypassPrefs().context, 10);
});
});
suite('blame', () => {
test('unsetting', async () => {
element.blame = [];
- const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
+ const setBlameSpy = sinon.spy(element, 'setBlame');
element.classList.add('showBlame');
element.blame = null;
await element.updateComplete;
@@ -3957,20 +3965,15 @@
});
suite('key locations', () => {
- let renderStub: sinon.SinonStub;
-
setup(async () => {
element.prefs = {...MINIMAL_PREFS};
element.diff = createDiff();
- renderStub = sinon.stub(element.diffBuilder, 'render');
await element.updateComplete;
});
test('lineOfInterest is a key location', () => {
element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
- element.renderDiffTable();
- assert.isTrue(renderStub.called);
- assert.deepEqual(renderStub.lastCall.args[0], {
+ assert.deepEqual(element.computeKeyLocations(), {
left: {789: true},
right: {},
});
@@ -3984,9 +3987,7 @@
element.appendChild(threadEl);
await element.updateComplete;
- element.renderDiffTable();
- assert.isTrue(renderStub.called);
- assert.deepEqual(renderStub.lastCall.args[0], {
+ assert.deepEqual(element.computeKeyLocations(), {
left: {},
right: {3: true},
});
@@ -3999,9 +4000,7 @@
element.appendChild(threadEl);
await element.updateComplete;
- element.renderDiffTable();
- assert.isTrue(renderStub.called);
- assert.deepEqual(renderStub.lastCall.args[0], {
+ assert.deepEqual(element.computeKeyLocations(), {
left: {FILE: true},
right: {},
});
@@ -4057,8 +4056,7 @@
];
function diffTableHasContent() {
assertIsDefined(element.diffTable);
- const diffTable = element.diffTable;
- return diffTable.innerText.includes(content[0].a?.[0] ?? '');
+ return element.diffTable.innerText.includes(content[0].a?.[0] ?? '');
}
await setupSampleDiff({content});
await waitUntil(diffTableHasContent);
@@ -4066,8 +4064,7 @@
await element.updateComplete;
// immediately cleaned up
assertIsDefined(element.diffTable);
- const diffTable = element.diffTable;
- assert.equal(diffTable.innerHTML, '');
+ assert.equal(element.diffTable.innerHTML, '');
element.renderDiffTable();
await element.updateComplete;
// rendered again
@@ -4182,3 +4179,598 @@
assert.equal(element.getDiffLength(diff), 52);
});
});
+
+suite('former gr-diff-builder tests', () => {
+ let element: GrDiff;
+ let builder: GrDiffBuilder;
+ let diffTable: HTMLTableElement;
+
+ const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
+ builder = new GrDiffBuilder(
+ createEmptyDiff(),
+ {...createDefaultDiffPrefs(), ...prefs},
+ diffTable
+ );
+ };
+
+ const line = (text: string) => {
+ const line = new GrDiffLine(GrDiffLineType.BOTH);
+ line.text = text;
+ return line;
+ };
+
+ setup(async () => {
+ element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+ element.diff = createEmptyDiff();
+ await element.updateComplete;
+ stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+ stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+ stubBaseUrl('/r');
+ setBuilderPrefs({});
+ });
+
+ [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+ test(`line_length used for regular files under ${mode}`, () => {
+ element.path = '/a.txt';
+ element.viewMode = mode;
+ element.diff = createEmptyDiff();
+ element.prefs = {
+ ...createDefaultDiffPrefs(),
+ tab_size: 4,
+ line_length: 50,
+ };
+ builder = element.getDiffBuilder();
+ assert.equal(builder.prefs.line_length, 50);
+ });
+
+ test(`line_length ignored for commit msg under ${mode}`, () => {
+ element.path = '/COMMIT_MSG';
+ element.viewMode = mode;
+ element.diff = createEmptyDiff();
+ element.prefs = {
+ ...createDefaultDiffPrefs(),
+ tab_size: 4,
+ line_length: 50,
+ };
+ builder = element.getDiffBuilder();
+ assert.equal(builder.prefs.line_length, 72);
+ });
+ });
+
+ test('_handlePreferenceError throws with invalid preference', () => {
+ element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
+ assert.throws(() => element.getDiffBuilder());
+ });
+
+ test('_handlePreferenceError triggers alert and javascript error', () => {
+ const errorStub = sinon.stub();
+ element.diffTable!.addEventListener('show-alert', errorStub);
+ assert.throws(() => element.handlePreferenceError('tab size'));
+ assert.equal(
+ errorStub.lastCall.args[0].detail.message,
+ "The value of the 'tab size' user preference is invalid. " +
+ 'Fix in diff preferences'
+ );
+ });
+
+ suite('intraline differences', () => {
+ let el: HTMLElement;
+ let str: string;
+ let annotateElementSpy: sinon.SinonSpy;
+ let layer: DiffLayer;
+ const lineNumberEl = document.createElement('td');
+
+ function slice(str: string, start: number, end?: number) {
+ return Array.from(str).slice(start, end).join('');
+ }
+
+ setup(async () => {
+ el = await fixture(html`
+ <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+ `);
+ str = el.textContent ?? '';
+ annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+ layer = element.createIntralineLayer();
+ });
+
+ test('annotate no highlights', () => {
+ layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
+
+ // The content is unchanged.
+ assert.isFalse(annotateElementSpy.called);
+ assert.equal(el.childNodes.length, 1);
+ assert.instanceOf(el.childNodes[0], Text);
+ assert.equal(str, el.childNodes[0].textContent);
+ });
+
+ test('annotate with highlights', () => {
+ const l = line(str);
+ l.highlights = [
+ {contentIndex: 0, startIndex: 6, endIndex: 12},
+ {contentIndex: 0, startIndex: 18, endIndex: 22},
+ ];
+ const str0 = slice(str, 0, 6);
+ const str1 = slice(str, 6, 12);
+ const str2 = slice(str, 12, 18);
+ const str3 = slice(str, 18, 22);
+ const str4 = slice(str, 22);
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isTrue(annotateElementSpy.called);
+ assert.equal(el.childNodes.length, 5);
+
+ assert.instanceOf(el.childNodes[0], Text);
+ assert.equal(el.childNodes[0].textContent, str0);
+
+ assert.notInstanceOf(el.childNodes[1], Text);
+ assert.equal(el.childNodes[1].textContent, str1);
+
+ assert.instanceOf(el.childNodes[2], Text);
+ assert.equal(el.childNodes[2].textContent, str2);
+
+ assert.notInstanceOf(el.childNodes[3], Text);
+ assert.equal(el.childNodes[3].textContent, str3);
+
+ assert.instanceOf(el.childNodes[4], Text);
+ assert.equal(el.childNodes[4].textContent, str4);
+ });
+
+ test('annotate without endIndex', () => {
+ const l = line(str);
+ l.highlights = [{contentIndex: 0, startIndex: 28}];
+
+ const str0 = slice(str, 0, 28);
+ const str1 = slice(str, 28);
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isTrue(annotateElementSpy.called);
+ assert.equal(el.childNodes.length, 2);
+
+ assert.instanceOf(el.childNodes[0], Text);
+ assert.equal(el.childNodes[0].textContent, str0);
+
+ assert.notInstanceOf(el.childNodes[1], Text);
+ assert.equal(el.childNodes[1].textContent, str1);
+ });
+
+ test('annotate ignores empty highlights', () => {
+ const l = line(str);
+ l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isFalse(annotateElementSpy.called);
+ assert.equal(el.childNodes.length, 1);
+ });
+
+ test('annotate handles unicode', () => {
+ // Put some unicode into the string:
+ str = str.replace(/\s/g, '💢');
+ el.textContent = str;
+ const l = line(str);
+ l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
+
+ const str0 = slice(str, 0, 6);
+ const str1 = slice(str, 6, 12);
+ const str2 = slice(str, 12);
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isTrue(annotateElementSpy.called);
+ assert.equal(el.childNodes.length, 3);
+
+ assert.instanceOf(el.childNodes[0], Text);
+ assert.equal(el.childNodes[0].textContent, str0);
+
+ assert.notInstanceOf(el.childNodes[1], Text);
+ assert.equal(el.childNodes[1].textContent, str1);
+
+ assert.instanceOf(el.childNodes[2], Text);
+ assert.equal(el.childNodes[2].textContent, str2);
+ });
+
+ test('annotate handles unicode w/o endIndex', () => {
+ // Put some unicode into the string:
+ str = str.replace(/\s/g, '💢');
+ el.textContent = str;
+
+ const l = line(str);
+ l.highlights = [{contentIndex: 0, startIndex: 6}];
+
+ const str0 = slice(str, 0, 6);
+ const str1 = slice(str, 6);
+ const numHighlightedChars = GrAnnotation.getStringLength(str1);
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isTrue(annotateElementSpy.calledWith(el, 6, numHighlightedChars));
+ assert.equal(el.childNodes.length, 2);
+
+ assert.instanceOf(el.childNodes[0], Text);
+ assert.equal(el.childNodes[0].textContent, str0);
+
+ assert.notInstanceOf(el.childNodes[1], Text);
+ assert.equal(el.childNodes[1].textContent, str1);
+ });
+ });
+
+ suite('tab indicators', () => {
+ let layer: DiffLayer;
+ const lineNumberEl = document.createElement('td');
+
+ setup(() => {
+ element.showTabs = true;
+ layer = element.createTabIndicatorLayer();
+ });
+
+ test('does nothing with empty line', () => {
+ const l = line('');
+ const el = document.createElement('div');
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isFalse(annotateElementStub.called);
+ });
+
+ test('does nothing with no tabs', () => {
+ const str = 'lorem ipsum no tabs';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isFalse(annotateElementStub.called);
+ });
+
+ test('annotates tab at beginning', () => {
+ const str = '\tlorem upsum';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.equal(annotateElementStub.callCount, 1);
+ const args = annotateElementStub.getCalls()[0].args;
+ assert.equal(args[0], el);
+ assert.equal(args[1], 0, 'offset of tab indicator');
+ assert.equal(args[2], 1, 'length of tab indicator');
+ assert.include(args[3], 'tab-indicator');
+ });
+
+ test('does not annotate when disabled', () => {
+ element.showTabs = false;
+
+ const str = '\tlorem upsum';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isFalse(annotateElementStub.called);
+ });
+
+ test('annotates multiple in beginning', () => {
+ const str = '\t\tlorem upsum';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.equal(annotateElementStub.callCount, 2);
+
+ let args = annotateElementStub.getCalls()[0].args;
+ assert.equal(args[0], el);
+ assert.equal(args[1], 0, 'offset of tab indicator');
+ assert.equal(args[2], 1, 'length of tab indicator');
+ assert.include(args[3], 'tab-indicator');
+
+ args = annotateElementStub.getCalls()[1].args;
+ assert.equal(args[0], el);
+ assert.equal(args[1], 1, 'offset of tab indicator');
+ assert.equal(args[2], 1, 'length of tab indicator');
+ assert.include(args[3], 'tab-indicator');
+ });
+
+ test('annotates intermediate tabs', () => {
+ const str = 'lorem\tupsum';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.equal(annotateElementStub.callCount, 1);
+ const args = annotateElementStub.getCalls()[0].args;
+ assert.equal(args[0], el);
+ assert.equal(args[1], 5, 'offset of tab indicator');
+ assert.equal(args[2], 1, 'length of tab indicator');
+ assert.include(args[3], 'tab-indicator');
+ });
+ });
+
+ suite('layers', () => {
+ let initialLayersCount = 0;
+ let withLayerCount = 0;
+ setup(() => {
+ const layers: DiffLayer[] = [];
+ element.layers = layers;
+ element.showTrailingWhitespace = true;
+ element.setupAnnotationLayers();
+ initialLayersCount = element.layersInternal.length;
+ });
+
+ test('no layers', () => {
+ element.setupAnnotationLayers();
+ assert.equal(element.layersInternal.length, initialLayersCount);
+ });
+
+ suite('with layers', () => {
+ const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
+ setup(() => {
+ element.layers = layers;
+ element.showTrailingWhitespace = true;
+ element.setupAnnotationLayers();
+ withLayerCount = element.layersInternal.length;
+ });
+ test('with layers', () => {
+ element.setupAnnotationLayers();
+ assert.equal(element.layersInternal.length, withLayerCount);
+ assert.equal(initialLayersCount + layers.length, withLayerCount);
+ });
+ });
+ });
+
+ suite('trailing whitespace', () => {
+ let layer: DiffLayer;
+ const lineNumberEl = document.createElement('td');
+
+ setup(() => {
+ element.showTrailingWhitespace = true;
+ layer = element.createTrailingWhitespaceLayer();
+ });
+
+ test('does nothing with empty line', () => {
+ const l = line('');
+ const el = document.createElement('div');
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isFalse(annotateElementStub.called);
+ });
+
+ test('does nothing with no trailing whitespace', () => {
+ const str = 'lorem ipsum blah blah';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isFalse(annotateElementStub.called);
+ });
+
+ test('annotates trailing spaces', () => {
+ const str = 'lorem ipsum ';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isTrue(annotateElementStub.called);
+ assert.equal(annotateElementStub.lastCall.args[1], 11);
+ assert.equal(annotateElementStub.lastCall.args[2], 3);
+ });
+
+ test('annotates trailing tabs', () => {
+ const str = 'lorem ipsum\t\t\t';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isTrue(annotateElementStub.called);
+ assert.equal(annotateElementStub.lastCall.args[1], 11);
+ assert.equal(annotateElementStub.lastCall.args[2], 3);
+ });
+
+ test('annotates mixed trailing whitespace', () => {
+ const str = 'lorem ipsum\t \t';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isTrue(annotateElementStub.called);
+ assert.equal(annotateElementStub.lastCall.args[1], 11);
+ assert.equal(annotateElementStub.lastCall.args[2], 3);
+ });
+
+ test('unicode preceding trailing whitespace', () => {
+ const str = '💢\t';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isTrue(annotateElementStub.called);
+ assert.equal(annotateElementStub.lastCall.args[1], 1);
+ assert.equal(annotateElementStub.lastCall.args[2], 1);
+ });
+
+ test('does not annotate when disabled', () => {
+ element.showTrailingWhitespace = false;
+ const str = 'lorem upsum\t \t ';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isFalse(annotateElementStub.called);
+ });
+ });
+
+ suite('rendering text, images and binary files', () => {
+ let content: DiffContent[] = [];
+
+ setup(() => {
+ element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+ element.prefs = {
+ ...DEFAULT_PREFS,
+ context: -1,
+ syntax_highlighting: true,
+ };
+ content = [
+ {
+ a: ['all work and no play make andybons a dull boy'],
+ b: ['elgoog elgoog elgoog'],
+ },
+ {
+ ab: [
+ 'Non eram nescius, Brute, cum, quae summis ingeniis ',
+ 'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+ ],
+ },
+ ];
+ });
+
+ test('text', async () => {
+ element.diff = {...createEmptyDiff(), content};
+ await waitForEventOnce(element.diffTable!, 'render-content');
+ assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 4);
+ });
+
+ test('image', async () => {
+ element.diff = {...createEmptyDiff(), content, binary: true};
+ element.isImageDiff = true;
+ await waitForEventOnce(element.diffTable!, 'render-content');
+ assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 4);
+ });
+
+ test('binary', async () => {
+ element.diff = {...createEmptyDiff(), content, binary: true};
+ await waitForEventOnce(element.diffTable!, 'render-content');
+ assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 3);
+ });
+ });
+
+ suite('context hiding and expanding', () => {
+ let dispatchStub: sinon.SinonStub;
+
+ setup(async () => {
+ dispatchStub = sinon.stub(element.diffTable!, 'dispatchEvent');
+ element.diff = {
+ ...createEmptyDiff(),
+ content: [
+ {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
+ {a: ['before'], b: ['after']},
+ {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
+ ],
+ };
+ element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+ element.prefs = {
+ ...DEFAULT_PREFS,
+ context: 1,
+ };
+ await element.updateComplete;
+ element.legacyRender();
+ // Make sure all listeners are installed.
+ await element.untilGroupsRendered();
+ });
+
+ test('hides lines behind two context controls', () => {
+ const contextControls = element.diffTable!.querySelectorAll(
+ 'gr-context-controls'
+ );
+ assert.equal(contextControls.length, 2);
+
+ const diffRows = element.diffTable!.querySelectorAll('.diff-row');
+ // The first two are LOST and FILE line
+ assert.equal(diffRows.length, 2 + 1 + 1 + 1);
+ assert.include(diffRows[2].textContent, 'unchanged 10');
+ assert.include(diffRows[3].textContent, 'before');
+ assert.include(diffRows[3].textContent, 'after');
+ assert.include(diffRows[4].textContent, 'unchanged 11');
+ });
+
+ test('clicking +x common lines expands those lines', async () => {
+ const contextControls = element.diffTable!.querySelectorAll(
+ 'gr-context-controls'
+ );
+ const topExpandCommonButton =
+ contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+ '.showContext'
+ )[0];
+ assert.isOk(topExpandCommonButton);
+ assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+ let diffRows = element.diffTable!.querySelectorAll('.diff-row');
+ // 5 lines:
+ // FILE, LOST, the changed line plus one line of context in each direction
+ assert.equal(diffRows.length, 5);
+
+ topExpandCommonButton!.click();
+
+ await waitUntil(() => {
+ diffRows = element.diffTable!.querySelectorAll<GrDiffRow>('.diff-row');
+ return diffRows.length === 14;
+ });
+ // 14 lines: The 5 above plus the 9 unchanged lines that were expanded
+ assert.equal(diffRows.length, 14);
+ assert.include(diffRows[2].textContent, 'unchanged 1');
+ assert.include(diffRows[3].textContent, 'unchanged 2');
+ assert.include(diffRows[4].textContent, 'unchanged 3');
+ assert.include(diffRows[5].textContent, 'unchanged 4');
+ assert.include(diffRows[6].textContent, 'unchanged 5');
+ assert.include(diffRows[7].textContent, 'unchanged 6');
+ assert.include(diffRows[8].textContent, 'unchanged 7');
+ assert.include(diffRows[9].textContent, 'unchanged 8');
+ assert.include(diffRows[10].textContent, 'unchanged 9');
+ assert.include(diffRows[11].textContent, 'unchanged 10');
+ assert.include(diffRows[12].textContent, 'before');
+ assert.include(diffRows[12].textContent, 'after');
+ assert.include(diffRows[13].textContent, 'unchanged 11');
+ });
+
+ test('unhideLine shows the line with context', async () => {
+ dispatchStub.reset();
+ element.unhideLine(4, Side.LEFT);
+
+ await waitUntil(() => {
+ const rows =
+ element.diffTable!.querySelectorAll<GrDiffRow>('.diff-row');
+ return rows.length === 2 + 5 + 1 + 1 + 1;
+ });
+
+ const diffRows = element.diffTable!.querySelectorAll('.diff-row');
+ // The first two are LOST and FILE line
+ // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
+ // Because context expanders do not hide <3 lines, lines 1-2 will also
+ // be shown.
+ // Lines 6-9 continue to be hidden
+ assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
+ assert.include(diffRows[2].textContent, 'unchanged 1');
+ assert.include(diffRows[3].textContent, 'unchanged 2');
+ assert.include(diffRows[4].textContent, 'unchanged 3');
+ assert.include(diffRows[5].textContent, 'unchanged 4');
+ assert.include(diffRows[6].textContent, 'unchanged 5');
+ assert.include(diffRows[7].textContent, 'unchanged 10');
+ assert.include(diffRows[8].textContent, 'before');
+ assert.include(diffRows[8].textContent, 'after');
+ assert.include(diffRows[9].textContent, 'unchanged 11');
+
+ await element.untilGroupsRendered();
+ const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+ assert.include(firedEventTypes, 'render-content');
+ });
+ });
+});