blob: f9a31b4aad7d1363dd3b47c9f3a0716eeb0b0260 [file] [log] [blame]
/**
* @license
* Copyright 2015 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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';
import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
import '../gr-ranged-comment-hint/gr-ranged-comment-hint';
import {LineNumber} from './gr-diff-line';
import {
getLine,
getLineElByChild,
getLineNumber,
getRange,
getSide,
GrDiffThreadElement,
isLongCommentRange,
isThreadEl,
rangesEqual,
getResponsiveMode,
isResponsive,
getDiffLength,
} from './gr-diff-utils';
import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {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 {
createDefaultDiffPrefs,
DiffViewMode,
Side,
} from '../../../constants/constants';
import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
import {getContentEditableRange} from '../../../utils/safari-selection-util';
import {AbortStop} from '../../../api/core';
import {
CreateCommentEventDetail as CreateCommentEventDetailApi,
RenderPreferences,
GrDiff as GrDiffApi,
DisplayLine,
} from '../../../api/diff';
import {isSafari, toggleClass} from '../../../utils/dom-util';
import {assertIsDefined} from '../../../utils/common-util';
import {
debounceP,
DelayedPromise,
DELAYED_CANCELLATION,
} from '../../../utils/async-util';
import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
import {customElement, property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {when} from 'lit/directives/when.js';
import {grSyntaxTheme} from '../gr-syntax-themes/gr-syntax-theme';
import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
import {classMap} from 'lit/directives/class-map.js';
import {iconStyles} from '../../../styles/gr-icon-styles';
import {expandFileMode} from '../../../utils/file-util';
const NO_NEWLINE_LEFT = 'No newline at end of left file.';
const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
const LARGE_DIFF_THRESHOLD_LINES = 10000;
const FULL_CONTEXT = -1;
const COMMIT_MSG_PATH = '/COMMIT_MSG';
/**
* 72 is the unofficial length standard for git commit messages.
* Derived from the fact that git log/show appends 4 ws in the beginning of
* each line when displaying commit messages. To center the commit message
* in an 80 char terminal a 4 ws border is added to the rightmost side:
* 4 + 72 + 4
*/
const COMMIT_MSG_LINE_LENGTH = 72;
export interface CreateCommentEventDetail extends CreateCommentEventDetailApi {
path: string;
}
@customElement('gr-diff')
export class GrDiff extends LitElement implements GrDiffApi {
/**
* Fired when the user selects a line.
*
* @event line-selected
*/
/**
* Fired if being logged in is required.
*
* @event show-auth-required
*/
/**
* Fired when a comment is created
*
* @event create-comment
*/
/**
* Fired when rendering, including syntax highlighting, is done. Also fired
* when no rendering can be done because required preferences are not set.
*
* @event render
*/
/**
* Fired for interaction reporting when a diff context is expanded.
* Contains an event.detail with numLines about the number of lines that
* were expanded.
*
* @event diff-context-expanded
*/
@query('#diffTable')
diffTable?: HTMLTableElement;
@property({type: Boolean})
noAutoRender = false;
@property({type: String})
path?: string;
@property({type: Object})
prefs?: DiffPreferencesInfo;
@property({type: Object})
renderPrefs?: RenderPreferences;
@property({type: Boolean})
displayLine = false;
@property({type: Boolean})
isImageDiff?: boolean;
@property({type: Boolean, reflect: true})
override hidden = false;
@property({type: Boolean})
noRenderOnPrefsChange?: boolean;
// Private but used in tests.
@state()
commentRanges: CommentRangeLayer[] = [];
// explicitly highlight a range if it is not associated with any comment
@property({type: Object})
highlightRange?: CommentRange;
@property({type: Array})
coverageRanges: CoverageRange[] = [];
@property({type: Boolean})
lineWrapping = false;
@property({type: String})
viewMode = DiffViewMode.SIDE_BY_SIDE;
@property({type: Object})
lineOfInterest?: DisplayLine;
/**
* True when diff is changed, until the content is done rendering.
* Use getter/setter loading instead of this.
*/
private _loading = true;
get loading() {
return this._loading;
}
set loading(loading: boolean) {
if (this._loading === loading) return;
const oldLoading = this._loading;
this._loading = loading;
fire(this, 'loading-changed', {value: this._loading});
this.requestUpdate('loading', oldLoading);
}
@property({type: Boolean})
loggedIn = false;
@property({type: Object})
diff?: DiffInfo;
@state()
private diffTableClass = '';
@property({type: Object})
baseImage?: ImageInfo;
@property({type: Object})
revisionImage?: ImageInfo;
/**
* In order to allow multi-select in Safari browsers, a workaround is required
* to trigger 'beforeinput' events to get a list of static ranges. This is
* obtained by making the content of the diff table "contentEditable".
*/
@property({type: Boolean})
override isContentEditable = isSafari();
/**
* Whether the safety check for large diffs when whole-file is set has
* been bypassed. If the value is null, then the safety has not been
* bypassed. If the value is a number, then that number represents the
* context preference to use when rendering the bypassed diff.
*
* Private but used in tests.
*/
@state()
safetyBypass: number | null = null;
// Private but used in tests.
@state()
showWarning?: boolean;
@property({type: String})
errorMessage: string | null = null;
@property({type: Array})
blame: BlameInfo[] | null = null;
@property({type: Boolean})
showNewlineWarningLeft = false;
@property({type: Boolean})
showNewlineWarningRight = false;
@property({type: Boolean})
useNewImageDiffUi = false;
// Private but used in tests.
@state()
diffLength?: number;
/**
* Observes comment nodes added or removed at any point.
* Can be used to unregister upon detachment.
*/
private nodeObserver?: MutationObserver;
@property({type: Array})
layers?: DiffLayer[];
// Private but used in tests.
renderDiffTableTask?: DelayedPromise<void>;
private diffSelection = new GrDiffSelection();
// Private but used in tests.
highlights = new GrDiffHighlight();
// Private but used in tests.
diffBuilder = new GrDiffBuilderElement();
static override get styles() {
return [
iconStyles,
sharedStyles,
grSyntaxTheme,
grRangedCommentTheme,
css`
/**
This is used to hide all left side of the diff (e.g. diffs besides
comments in the change log). Since we want to remove the first 4
cells consistently in all rows except context buttons (.dividerRow).
*/
:host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
:host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
display: none;
}
:host(.disable-context-control-buttons) {
--context-control-display: none;
}
:host(.disable-context-control-buttons) .section {
border-right: none;
}
:host(.hide-line-length-indicator) .full-width td.content .contentText {
background-image: none;
}
:host {
font-family: var(--monospace-font-family, ''), 'Roboto Mono';
font-size: var(--font-size, var(--font-size-code, 12px));
/* usually 16px = 12px + 4px */
line-height: calc(
var(--font-size, var(--font-size-code, 12px)) +
var(--spacing-s, 4px)
);
}
.thread-group {
display: block;
max-width: var(--content-width, 80ch);
white-space: normal;
background-color: var(--diff-blank-background-color);
}
.diffContainer {
max-width: var(--diff-max-width, none);
display: flex;
font-family: var(--monospace-font-family);
}
table {
border-collapse: collapse;
table-layout: fixed;
}
td.lineNum {
/* Enforces background whenever lines wrap */
background-color: var(--diff-blank-background-color);
}
/**
Provides the option to add side borders (left and right) to the line
number column.
*/
td.lineNum,
td.blankLineNum,
td.moveControlsLineNumCol,
td.contextLineNum {
box-shadow: var(--line-number-box-shadow, unset);
}
/**
Context controls break up the table visually, so we set the right
border on individual sections to leave a gap for the divider.
Also taken into account for max-width calculations in SHRINK_ONLY mode
(check GrDiff.updatePreferenceStyles).
*/
.section {
border-right: 1px solid var(--border-color);
}
.section.contextControl {
/**
Divider inside this section must not have border; we set borders on
the padding rows below.
*/
border-right-width: 0;
}
/**
Padding rows behind context controls. The diff is styled to be cut
into two halves by the negative space of the divider on which the
context control buttons are anchored.
*/
.contextBackground {
border-right: 1px solid var(--border-color);
}
.contextBackground.above {
border-bottom: 1px solid var(--border-color);
}
.contextBackground.below {
border-top: 1px solid var(--border-color);
}
.lineNumButton {
display: block;
width: 100%;
height: 100%;
background-color: var(--diff-blank-background-color);
box-shadow: var(--line-number-box-shadow, unset);
}
td.lineNum {
vertical-align: top;
}
/**
The only way to focus this (clicking) will apply our own focus
styling, so this default styling is not needed and distracting.
*/
.lineNumButton:focus {
outline: none;
}
gr-image-viewer {
width: 100%;
height: 100%;
max-width: var(--image-viewer-max-width, 95vw);
max-height: var(--image-viewer-max-height, 90vh);
/**
Defined by paper-styles default-theme and used in various
components. background-color-secondary is a compromise between
fairly light in light theme (where we ideally would want
background-color-primary) yet slightly offset against the app
background in dark mode, where drop shadows e.g. around paper-card
are almost invisible.
*/
--primary-background-color: var(--background-color-secondary);
}
.image-diff .gr-diff {
text-align: center;
}
.image-diff img {
box-shadow: var(--elevation-level-1);
max-width: 50em;
}
.image-diff .right.lineNumButton {
border-left: 1px solid var(--border-color);
}
.image-diff label,
.binary-diff label {
font-family: var(--font-family);
font-style: italic;
}
.diff-row {
outline: none;
user-select: none;
}
.diff-row.target-row.target-side-left .lineNumButton.left,
.diff-row.target-row.target-side-right .lineNumButton.right,
.diff-row.target-row.unified .lineNumButton {
color: var(--primary-text-color);
}
/**
Preparing selected line cells with position relative so it allows a
positioned overlay with 'position: absolute'.
*/
.target-row td {
position: relative;
}
/**
Defines an overlay to the selected line for drawing an outline without
blocking user interaction (e.g. text selection).
*/
.target-row td::before {
border-width: 0;
border-style: solid;
border-color: var(--focused-line-outline-color);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
user-select: none;
content: ' ';
}
/**
the outline for the selected content cell should be the same in all
cases.
*/
.target-row.target-side-left td.left.content::before,
.target-row.target-side-right td.right.content::before,
.unified.target-row td.content::before {
border-width: 1px 1px 1px 0;
}
/**
the outline for the sign cell should be always be contiguous
top/bottom.
*/
.target-row.target-side-left td.left.sign::before,
.target-row.target-side-right td.right.sign::before {
border-width: 1px 0;
}
/**
For side-by-side we need to select the correct line number to
"visually close" the outline.
*/
.side-by-side.target-row.target-side-left td.left.lineNum::before,
.side-by-side.target-row.target-side-right td.right.lineNum::before {
border-width: 1px 0 1px 1px;
}
/**
For unified diff we always start the overlay from the left cell
*/
.unified.target-row td.left:not(.content)::before {
border-width: 1px 0 1px 1px;
}
/**
For unified diff we should continue the top/bottom border in right
line number column.
*/
.unified.target-row td.right:not(.content)::before {
border-width: 1px 0;
}
.content {
background-color: var(--diff-blank-background-color);
}
/**
Describes two states of semantic tokens: whenever a token has a
definition that can be navigated to (navigable) and whenever
the token is actually clickable to perform this navigation.
*/
.semantic-token.navigable {
text-decoration-style: dotted;
text-decoration-line: underline;
}
.semantic-token.navigable.clickable {
text-decoration-style: solid;
cursor: pointer;
}
/*
The file line, which has no contentText, add some margin before the
first comment. We cannot add padding the container because we only
want it if there is at least one comment thread, and the slotting
makes :empty not work as expected.
*/
.content.file slot:first-child::slotted(.comment-thread) {
display: block;
margin-top: var(--spacing-xs);
}
.contentText {
background-color: var(--view-background-color);
}
.blank {
background-color: var(--diff-blank-background-color);
}
.image-diff .content {
background-color: var(--diff-blank-background-color);
}
.responsive {
width: 100%;
}
.responsive .contentText {
white-space: break-spaces;
word-break: break-all;
}
.lineNumButton,
.content {
vertical-align: top;
white-space: pre;
}
.contextLineNum,
.lineNumButton {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
color: var(--deemphasized-text-color);
padding: 0 var(--spacing-m);
text-align: right;
}
.canComment .lineNumButton {
cursor: pointer;
}
.sign {
min-width: 1ch;
width: 1ch;
background-color: var(--view-background-color);
}
.sign.blank {
background-color: var(--diff-blank-background-color);
}
.content {
/*
Set min width since setting width on table cells still allows them
to shrink. Do not set max width because CJK
(Chinese-Japanese-Korean) glyphs have variable width
*/
min-width: var(--content-width, 80ch);
width: var(--content-width, 80ch);
}
.content.add .contentText .intraline,
/* If there are no intraline info, consider everything changed */
.content.add.no-intraline-info .contentText,
.sign.add.no-intraline-info,
.delta.total .content.add .contentText {
background-color: var(--dark-add-highlight-color);
}
.content.add .contentText,
.sign.add {
background-color: var(--light-add-highlight-color);
}
.content.remove .contentText .intraline,
/* If there are no intraline info, consider everything changed */
.content.remove.no-intraline-info .contentText,
.delta.total .content.remove .contentText,
.sign.remove.no-intraline-info {
background-color: var(--dark-remove-highlight-color);
}
.content.remove .contentText,
.sign.remove {
background-color: var(--light-remove-highlight-color);
}
.ignoredWhitespaceOnly .sign.no-intraline-info {
background-color: var(--view-background-color);
}
/* dueToRebase */
.dueToRebase .content.add .contentText .intraline,
.delta.total.dueToRebase .content.add .contentText {
background-color: var(--dark-rebased-add-highlight-color);
}
.dueToRebase .content.add .contentText {
background-color: var(--light-rebased-add-highlight-color);
}
.dueToRebase .content.remove .contentText .intraline,
.delta.total.dueToRebase .content.remove .contentText {
background-color: var(--dark-rebased-remove-highlight-color);
}
.dueToRebase .content.remove .contentText {
background-color: var(--light-rebased-remove-highlight-color);
}
/* dueToMove */
.dueToMove .sign.add,
.dueToMove .content.add .contentText,
.dueToMove .moveControls.movedIn .sign.right,
.dueToMove .moveControls.movedIn .moveHeader,
.delta.total.dueToMove .content.add .contentText {
background-color: var(--diff-moved-in-background);
}
.dueToMove.changed .sign.add,
.dueToMove.changed .content.add .contentText,
.dueToMove.changed .moveControls.movedIn .sign.right,
.dueToMove.changed .moveControls.movedIn .moveHeader,
.delta.total.dueToMove.changed .content.add .contentText {
background-color: var(--diff-moved-in-changed-background);
}
.dueToMove .sign.remove,
.dueToMove .content.remove .contentText,
.dueToMove .moveControls.movedOut .moveHeader,
.dueToMove .moveControls.movedOut .sign.left,
.delta.total.dueToMove .content.remove .contentText {
background-color: var(--diff-moved-out-background);
}
.delta.dueToMove .movedIn .moveHeader {
--gr-range-header-color: var(--diff-moved-in-label-color);
}
.delta.dueToMove.changed .movedIn .moveHeader {
--gr-range-header-color: var(--diff-moved-in-changed-label-color);
}
.delta.dueToMove .movedOut .moveHeader {
--gr-range-header-color: var(--diff-moved-out-label-color);
}
.moveHeader a {
color: inherit;
}
/* ignoredWhitespaceOnly */
.ignoredWhitespaceOnly .content.add .contentText .intraline,
.delta.total.ignoredWhitespaceOnly .content.add .contentText,
.ignoredWhitespaceOnly .content.add .contentText,
.ignoredWhitespaceOnly .content.remove .contentText .intraline,
.delta.total.ignoredWhitespaceOnly .content.remove .contentText,
.ignoredWhitespaceOnly .content.remove .contentText {
background-color: var(--view-background-color);
}
.content .contentText gr-diff-text:empty:after,
.content .contentText gr-legacy-text:empty:after,
.content .contentText:empty:after {
/* Newline, to ensure empty lines are one line-height tall. */
content: '\\A';
}
/* Context controls */
.contextControl {
display: var(--context-control-display, table-row-group);
background-color: transparent;
border: none;
--divider-height: var(--spacing-s);
--divider-border: 1px;
}
/* TODO: Is this still used? */
.contextControl gr-button gr-icon {
/* should match line-height of gr-button */
font-size: var(--line-height-mono, 18px);
}
.contextControl td:not(.lineNumButton) {
text-align: center;
}
/**
Padding rows behind context controls. Styled as a continuation of the
line gutters and code area.
*/
.contextBackground > .contextLineNum {
background-color: var(--diff-blank-background-color);
}
.contextBackground > td:not(.contextLineNum) {
background-color: var(--view-background-color);
}
.contextBackground {
/**
One line of background behind the context expanders which they can
render on top of, plus some padding.
*/
height: calc(var(--line-height-normal) + var(--spacing-s));
}
.dividerCell {
vertical-align: top;
}
.dividerRow.show-both .dividerCell {
height: var(--divider-height);
}
.dividerRow.show-above .dividerCell,
.dividerRow.show-above .dividerCell {
height: 0;
}
.br:after {
/* Line feed */
content: '\\A';
}
.tab {
display: inline-block;
}
.tab-indicator:before {
color: var(--diff-tab-indicator-color);
/* >> character */
content: '\\00BB';
position: absolute;
}
.special-char-indicator {
/* spacing so elements don't collide */
padding-right: var(--spacing-m);
}
.special-char-indicator:before {
color: var(--diff-tab-indicator-color);
content: '•';
position: absolute;
}
.special-char-warning {
/* spacing so elements don't collide */
padding-right: var(--spacing-m);
}
.special-char-warning:before {
color: var(--warning-foreground);
content: '!';
position: absolute;
}
/**
Is defined after other background-colors, such that this
rule wins in case of same specificity.
*/
.trailing-whitespace,
.content .trailing-whitespace,
.trailing-whitespace .intraline,
.content .trailing-whitespace .intraline {
border-radius: var(--border-radius, 4px);
background-color: var(--diff-trailing-whitespace-indicator);
}
#diffHeader {
background-color: var(--table-header-background-color);
border-bottom: 1px solid var(--border-color);
color: var(--link-color);
padding: var(--spacing-m) 0 var(--spacing-m) 48px;
}
#diffTable {
/* for gr-selection-action-box positioning */
position: relative;
}
#diffTable:focus {
outline: none;
}
#loadingError,
#sizeWarning {
display: block;
margin: var(--spacing-l) auto;
max-width: 60em;
text-align: center;
}
#loadingError {
color: var(--error-text-color);
}
#sizeWarning gr-button {
margin: var(--spacing-l);
}
.target-row td.blame {
background: var(--diff-selection-background-color);
}
td.lost div {
background-color: var(--info-background);
}
td.lost div.lost-message {
font-family: var(--font-family, 'Roboto');
font-size: var(--font-size-normal, 14px);
line-height: var(--line-height-normal);
padding: var(--spacing-s) 0;
}
td.lost div.lost-message gr-icon {
padding: 0 var(--spacing-s) 0 var(--spacing-m);
color: var(--blue-700);
}
col.sign,
td.sign {
display: none;
}
/* Sign column should only be shown in high-contrast mode. */
:host(.with-sign-col) col.sign {
display: table-column;
}
:host(.with-sign-col) td.sign {
display: table-cell;
}
col.blame {
display: none;
}
td.blame {
display: none;
padding: 0 var(--spacing-m);
white-space: pre;
}
:host(.showBlame) col.blame {
display: table-column;
}
:host(.showBlame) td.blame {
display: table-cell;
}
td.blame > span {
opacity: 0.6;
}
td.blame > span.startOfRange {
opacity: 1;
}
td.blame .blameDate {
font-family: var(--monospace-font-family);
color: var(--link-color);
text-decoration: none;
}
.responsive td.blame {
overflow: hidden;
width: 200px;
}
/** Support the line length indicator **/
.responsive td.content .contentText {
/**
Same strategy as in
https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
*/
background-image: linear-gradient(
var(--line-length-indicator-color),
var(--line-length-indicator-color)
);
background-size: 1px 100%;
background-position: var(--line-limit-marker) 0;
background-repeat: no-repeat;
}
.newlineWarning {
color: var(--deemphasized-text-color);
text-align: center;
}
.newlineWarning.hidden {
display: none;
}
.lineNum.COVERED .lineNumButton {
color: var(
--coverage-covered-line-num-color,
var(--deemphasized-text-color)
);
background-color: var(--coverage-covered, #e0f2f1);
}
.lineNum.NOT_COVERED .lineNumButton {
color: var(
--coverage-covered-line-num-color,
var(--deemphasized-text-color)
);
background-color: var(--coverage-not-covered, #ffd1a4);
}
.lineNum.PARTIALLY_COVERED .lineNumButton {
color: var(
--coverage-covered-line-num-color,
var(--deemphasized-text-color)
);
background: linear-gradient(
to right bottom,
var(--coverage-not-covered, #ffd1a4) 0%,
var(--coverage-not-covered, #ffd1a4) 50%,
var(--coverage-covered, #e0f2f1) 50%,
var(--coverage-covered, #e0f2f1) 100%
);
}
// TODO: Investigate whether this CSS is still necessary.
/** BEGIN: Select and copy for Polymer 2 */
/**
Below was copied and modified from the original css in
gr-diff-selection.html
*/
.content,
.contextControl,
.blame {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.selected-left:not(.selected-comment)
.side-by-side
.left
+ .content
.contentText,
.selected-right:not(.selected-comment)
.side-by-side
.right
+ .content
.contentText,
.selected-left:not(.selected-comment)
.unified
.left.lineNum
~ .content:not(.both)
.contentText,
.selected-right:not(.selected-comment)
.unified
.right.lineNum
~ .content
.contentText,
.selected-left.selected-comment .side-by-side .left + .content .message,
.selected-right.selected-comment
.side-by-side
.right
+ .content
.message
:not(.collapsedContent),
.selected-comment .unified .message :not(.collapsedContent),
.selected-blame .blame {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
/** Make comments and check results selectable when selected */
.selected-left.selected-comment
::slotted(.comment-thread[diff-side='left']),
.selected-right.selected-comment
::slotted(.comment-thread[diff-side='right']) {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
/** END: Select and copy for Polymer 2 */
.whitespace-change-only-message {
background-color: var(--diff-context-control-background-color);
border: 1px solid var(--diff-context-control-border-color);
text-align: center;
}
.token-highlight {
background-color: var(--token-highlighting-color, #fffd54);
}
gr-selection-action-box {
/**
* Needs z-index to appear above wrapped content, since it's inserted
* into DOM before it.
*/
z-index: 10;
}
gr-diff-section,
gr-context-controls-section,
gr-diff-row {
display: contents;
}
`,
];
}
constructor() {
super();
this.addEventListener('create-range-comment', (e: Event) =>
this.handleCreateRangeComment(e as CustomEvent)
);
this.addEventListener('render-content', () => this.handleRenderContent());
this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
this.dispatchSelectedLine(e.detail.lineNum, e.detail.side);
});
}
override connectedCallback() {
super.connectedCallback();
if (this.loggedIn) {
this.addSelectionListeners();
}
if (this.diff && this.diffTable) {
this.diffSelection.init(this.diff, this.diffTable);
}
if (this.diffTable && this.diffBuilder) {
this.highlights.init(this.diffTable, this.diffBuilder);
}
}
override disconnectedCallback() {
this.removeSelectionListeners();
this.renderDiffTableTask?.cancel();
this.diffSelection.cleanup();
this.highlights.cleanup();
this.diffBuilder.cleanup();
super.disconnectedCallback();
}
protected override willUpdate(changedProperties: PropertyValues<this>): void {
if (
changedProperties.has('path') ||
changedProperties.has('lineWrapping') ||
changedProperties.has('viewMode') ||
changedProperties.has('useNewImageDiffUi') ||
changedProperties.has('prefs')
) {
this.prefsChanged();
}
if (changedProperties.has('blame')) {
this.blameChanged();
}
if (changedProperties.has('renderPrefs')) {
this.renderPrefsChanged();
}
if (changedProperties.has('loggedIn')) {
if (this.loggedIn && this.isConnected) {
this.addSelectionListeners();
} else {
this.removeSelectionListeners();
}
}
if (changedProperties.has('coverageRanges')) {
this.diffBuilder.updateCoverageRanges(this.coverageRanges);
}
if (changedProperties.has('lineOfInterest')) {
this.lineOfInterestChanged();
}
}
protected override updated(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('diff')) {
// diffChanged relies on diffTable ahving been rendered.
this.diffChanged();
}
}
override render() {
return html`
${this.renderHeader()} ${this.renderContainer()}
${this.renderNewlineWarning()} ${this.renderLoadingError()}
${this.renderSizeWarning()}
`;
}
private renderHeader() {
const diffheaderItems = this.computeDiffHeaderItems();
if (diffheaderItems.length === 0) return nothing;
return html`
<div id="diffHeader">
${diffheaderItems.map(item => html`<div>${item}</div>`)}
</div>
`;
}
private renderContainer() {
const cssClasses = {
diffContainer: true,
unified: this.viewMode === DiffViewMode.UNIFIED,
sideBySide: this.viewMode === DiffViewMode.SIDE_BY_SIDE,
canComment: this.loggedIn,
displayLine: this.displayLine,
};
return html`
<div class=${classMap(cssClasses)} @click=${this.handleTap}>
<table
id="diffTable"
class=${this.diffTableClass}
?contenteditable=${this.isContentEditable}
></table>
${when(
this.showNoChangeMessage(),
() => html`
<div class="whitespace-change-only-message">
This file only contains whitespace changes. Modify the whitespace
setting to see the changes.
</div>
`
)}
</div>
`;
}
private renderNewlineWarning() {
const newlineWarning = this.computeNewlineWarning();
if (!newlineWarning) return nothing;
return html`<div class="newlineWarning">${newlineWarning}</div>`;
}
private renderLoadingError() {
if (!this.errorMessage) return nothing;
return html`<div id="loadingError">${this.errorMessage}</div>`;
}
private renderSizeWarning() {
if (!this.showWarning) return nothing;
// TODO: Update comment about 'Whole file' as it's not in settings.
return html`
<div id="sizeWarning">
<p>
Prevented render because "Whole file" is enabled and this diff is very
large (about ${this.diffLength} lines).
</p>
<gr-button @click=${this.collapseContext}>
Render with limited context
</gr-button>
<gr-button @click=${this.handleFullBypass}>
Render anyway (may be slow)
</gr-button>
</div>
`;
}
private addSelectionListeners() {
document.addEventListener('selectionchange', this.handleSelectionChange);
document.addEventListener('mouseup', this.handleMouseUp);
}
private removeSelectionListeners() {
document.removeEventListener('selectionchange', this.handleSelectionChange);
document.removeEventListener('mouseup', this.handleMouseUp);
}
getLineNumEls(side: Side): HTMLElement[] {
return this.diffBuilder.getLineNumEls(side);
}
// Private but used in tests.
showNoChangeMessage() {
return (
!this.loading &&
this.diff &&
!this.diff.binary &&
this.prefs &&
this.prefs.ignore_whitespace !== 'IGNORE_NONE' &&
this.diffLength === 0
);
}
private readonly handleSelectionChange = () => {
// Because of shadow DOM selections, we handle the selectionchange here,
// and pass the shadow DOM selection into gr-diff-highlight, where the
// corresponding range is determined and normalized.
const selection = this.getShadowOrDocumentSelection();
this.highlights.handleSelectionChange(selection, false);
};
private readonly handleMouseUp = () => {
// To handle double-click outside of text creating comments, we check on
// mouse-up if there's a selection that just covers a line change. We
// can't do that on selection change since the user may still be dragging.
const selection = this.getShadowOrDocumentSelection();
this.highlights.handleSelectionChange(selection, true);
};
/** Gets the current selection, preferring the shadow DOM selection. */
private getShadowOrDocumentSelection() {
// When using native shadow DOM, the selection returned by
// document.getSelection() cannot reference the actual DOM elements making
// up the diff in Safari because they are in the shadow DOM of the gr-diff
// element. This takes the shadow DOM selection if one exists.
return this.shadowRoot?.getSelection
? this.shadowRoot.getSelection()
: isSafari()
? getContentEditableRange()
: document.getSelection();
}
private updateRanges(
addedThreadEls: GrDiffThreadElement[],
removedThreadEls: GrDiffThreadElement[]
) {
function commentRangeFromThreadEl(
threadEl: GrDiffThreadElement
): CommentRangeLayer | undefined {
const side = getSide(threadEl);
if (!side) return undefined;
const range = getRange(threadEl);
if (!range) return undefined;
return {side, range, rootId: threadEl.rootId};
}
// TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
const addedCommentRanges = addedThreadEls
.map(commentRangeFromThreadEl)
.filter(range => !!range) as CommentRangeLayer[];
const removedCommentRanges = removedThreadEls
.map(commentRangeFromThreadEl)
.filter(range => !!range) as CommentRangeLayer[];
for (const removedCommentRange of removedCommentRanges) {
const i = this.commentRanges.findIndex(
cr =>
cr.side === removedCommentRange.side &&
rangesEqual(cr.range, removedCommentRange.range)
);
this.commentRanges.splice(i, 1);
}
if (addedCommentRanges?.length) {
this.commentRanges.push(...addedCommentRanges);
}
if (this.highlightRange) {
this.commentRanges.push({
side: Side.RIGHT,
range: this.highlightRange,
rootId: '',
});
}
this.diffBuilder.updateCommentRanges(this.commentRanges);
}
/**
* The key locations based on the comments and line of interests,
* where lines should not be collapsed.
*
*/
private computeKeyLocations() {
const keyLocations: KeyLocations = {left: {}, right: {}};
if (this.lineOfInterest) {
const side = this.lineOfInterest.side;
keyLocations[side][this.lineOfInterest.lineNum] = true;
}
const threadEls = [...this.childNodes].filter(isThreadEl);
for (const threadEl of threadEls) {
const side = getSide(threadEl);
if (!side) continue;
const lineNum = getLine(threadEl);
const commentRange = getRange(threadEl);
keyLocations[side][lineNum] = true;
// Add start_line as well if exists,
// the being and end of the range should not be collapsed.
if (commentRange?.start_line) {
keyLocations[side][commentRange.start_line] = true;
}
}
return keyLocations;
}
// Dispatch events that are handled by the gr-diff-highlight.
private redispatchHoverEvents(
hoverEl: HTMLElement,
threadEl: GrDiffThreadElement
) {
hoverEl.addEventListener('mouseenter', () => {
fireEvent(threadEl, 'comment-thread-mouseenter');
});
hoverEl.addEventListener('mouseleave', () => {
fireEvent(threadEl, 'comment-thread-mouseleave');
});
}
/** Cancel any remaining diff builder rendering work. */
cancel() {
this.diffBuilder.cleanup();
this.renderDiffTableTask?.cancel();
}
getCursorStops(): Array<HTMLElement | AbortStop> {
if (this.hidden && this.noAutoRender) return [];
// Get rendered stops.
const stops: Array<HTMLElement | AbortStop> =
this.diffBuilder.getLineNumberRows();
// If we are still loading this diff, abort after the rendered stops to
// avoid skipping over to e.g. the next file.
if (this.loading) {
stops.push(new AbortStop());
}
return stops;
}
isRangeSelected() {
return !!this.highlights.selectedRange;
}
toggleLeftDiff() {
toggleClass(this, 'no-left');
}
private blameChanged() {
this.diffBuilder.setBlame(this.blame);
if (this.blame) {
this.classList.add('showBlame');
} else {
this.classList.remove('showBlame');
}
}
// Private but used in tests.
handleTap(e: Event) {
const el = e.target as Element;
if (
el.getAttribute('data-value') !== 'LOST' &&
(el.classList.contains('lineNum') ||
el.classList.contains('lineNumButton'))
) {
this.addDraftAtLine(el);
} else if (
el.tagName === 'HL' ||
el.classList.contains('content') ||
el.classList.contains('contentText')
) {
const target = getLineElByChild(el);
if (target) {
this.selectLine(target);
}
}
}
// Private but used in tests.
selectLine(el: Element) {
const lineNumber = Number(el.getAttribute('data-value'));
const side = el.classList.contains('left') ? Side.LEFT : Side.RIGHT;
this.dispatchSelectedLine(lineNumber, side);
}
private dispatchSelectedLine(number: LineNumber, side: Side) {
this.dispatchEvent(
new CustomEvent('line-selected', {
detail: {
number,
side,
path: this.path,
},
composed: true,
bubbles: true,
})
);
}
addDraftAtLine(el: Element) {
this.selectLine(el);
const lineNum = getLineNumber(el);
if (lineNum === null) {
fireAlert(this, 'Invalid line number');
return;
}
this.createComment(el, lineNum);
}
createRangeComment() {
if (!this.isRangeSelected()) {
throw Error('Selection is needed for new range comment');
}
const selectedRange = this.highlights.selectedRange;
if (!selectedRange) throw Error('selected range not set');
const {side, range} = selectedRange;
this.createCommentForSelection(side, range);
}
createCommentForSelection(side: Side, range: CommentRange) {
const lineNum = range.end_line;
const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
if (lineEl) {
this.createComment(lineEl, lineNum, side, range);
}
}
private handleCreateRangeComment(e: CustomEvent) {
const range = e.detail.range;
const side = e.detail.side;
this.createCommentForSelection(side, range);
}
// Private but used in tests.
createComment(
lineEl: Element,
lineNum: LineNumber,
side?: Side,
range?: CommentRange
) {
const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
if (!contentEl) throw new Error('content el not found for line el');
side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
assertIsDefined(this.path, 'path');
this.dispatchEvent(
new CustomEvent<CreateCommentEventDetail>('create-comment', {
bubbles: true,
composed: true,
detail: {
path: this.path,
side,
lineNum,
range,
},
})
);
}
private getCommentSideByLineAndContent(
lineEl: Element,
contentEl: Element
): Side {
return lineEl.classList.contains(Side.LEFT) ||
contentEl.classList.contains('remove')
? Side.LEFT
: Side.RIGHT;
}
private lineOfInterestChanged() {
if (this.loading) return;
if (!this.lineOfInterest) return;
const lineNum = this.lineOfInterest.lineNum;
if (typeof lineNum !== 'number') return;
this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
}
private cleanup() {
this.cancel();
this.blame = null;
this.safetyBypass = null;
this.showWarning = false;
this.clearDiffContent();
}
private prefsChanged() {
if (!this.prefs) return;
this.blame = null;
this.updatePreferenceStyles();
if (this.diff && !this.noRenderOnPrefsChange) {
this.debounceRenderDiffTable();
}
}
private updatePreferenceStyles() {
assertIsDefined(this.prefs, 'prefs');
const lineLength =
this.path === COMMIT_MSG_PATH
? COMMIT_MSG_LINE_LENGTH
: this.prefs.line_length;
const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
const responsiveMode = getResponsiveMode(this.prefs, this.renderPrefs);
const responsive = isResponsive(responsiveMode);
this.diffTableClass = responsive ? 'responsive' : '';
const lineLimit = `${lineLength}ch`;
this.style.setProperty(
'--line-limit-marker',
responsiveMode === 'FULL_RESPONSIVE' ? lineLimit : '-1px'
);
this.style.setProperty('--content-width', responsive ? 'none' : lineLimit);
if (responsiveMode === 'SHRINK_ONLY') {
// Calculating ideal (initial) width for the whole table including
// width of each table column (content and line number columns) and
// border. We also add a 1px correction as some values are calculated
// in 'ch'.
// We might have 1 to 2 columns for content depending if side-by-side
// or unified mode
const contentWidth = `${sideBySide ? 2 : 1} * ${lineLimit}`;
// We always have 2 columns for line number
const lineNumberWidth = `2 * ${getLineNumberCellWidth(this.prefs)}px`;
// border-right in ".section" css definition (in gr-diff_html.ts)
const sectionRightBorder = '1px';
// each sign col has 1ch width.
const signColsWidth =
sideBySide && this.renderPrefs?.show_sign_col ? '2ch' : '0ch';
// As some of these calculations are done using 'ch' we end up having <1px
// difference between ideal and calculated size for each side leading to
// lines using the max columns (e.g. 80) to wrap (decided exclusively by
// the browser).This happens even in monospace fonts. Empirically adding
// 2px as correction to be sure wrapping won't happen in these cases so it
// doesn't block further experimentation with the SHRINK_MODE. This was
// previously set to 1px but due to to a more aggressive text wrapping
// (via word-break: break-all; - check .contextText) we need to be even
// more lenient in some cases. If we find another way to avoid this
// correction we will change it.
const dontWrapCorrection = '2px';
this.style.setProperty(
'--diff-max-width',
`calc(${contentWidth} + ${lineNumberWidth} + ${signColsWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`
);
} else {
this.style.setProperty('--diff-max-width', 'none');
}
if (this.prefs.font_size) {
this.style.setProperty('--font-size', `${this.prefs.font_size}px`);
}
}
private renderPrefsChanged() {
if (!this.renderPrefs) return;
if (this.renderPrefs.hide_left_side) {
this.classList.add('no-left');
}
if (this.renderPrefs.disable_context_control_buttons) {
this.classList.add('disable-context-control-buttons');
}
if (this.renderPrefs.hide_line_length_indicator) {
this.classList.add('hide-line-length-indicator');
}
if (this.renderPrefs.show_sign_col) {
this.classList.add('with-sign-col');
}
if (this.prefs) {
this.updatePreferenceStyles();
}
this.diffBuilder.updateRenderPrefs(this.renderPrefs);
}
private diffChanged() {
this.loading = true;
this.cleanup();
if (this.diff) {
this.diffLength = this.getDiffLength(this.diff);
this.debounceRenderDiffTable();
assertIsDefined(this.diffTable, 'diffTable');
this.diffSelection.init(this.diff, this.diffTable);
this.highlights.init(this.diffTable, this.diffBuilder);
}
}
// Implemented so the test can stub it.
getDiffLength(diff?: DiffInfo) {
return getDiffLength(diff);
}
/**
* When called multiple times from the same task, will call
* _renderDiffTable only once, in the next task (scheduled via `setTimeout`).
*
* This should be used instead of calling _renderDiffTable directly to
* render the diff in response to an input change, because there may be
* multiple inputs changing in the same microtask, but we only want to
* render once.
*/
private debounceRenderDiffTable() {
// at this point gr-diff might be considered as rendered from the outside
// (client), although it was not actually rendered. Clients need to know
// when it is safe to perform operations like cursor moves, for example,
// and if changing an input actually requires a reload of the diff table.
// Since `fireEvent` is synchronous it allows clients to be aware when an
// async render is needed and that they can wait for a further `render`
// event to actually take further action.
fireEvent(this, 'render-required');
this.renderDiffTableTask = debounceP(
this.renderDiffTableTask,
async () => await this.renderDiffTable()
);
this.renderDiffTableTask.catch((e: unknown) => {
if (e === DELAYED_CANCELLATION) return;
throw e;
});
}
// Private but used in tests.
async renderDiffTable() {
this.unobserveNodes();
if (!this.prefs) {
fireEvent(this, 'render');
return;
}
if (
this.prefs.context === -1 &&
this.diffLength &&
this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
this.safetyBypass === null
) {
this.showWarning = true;
fireEvent(this, 'render');
return;
}
this.showWarning = false;
const keyLocations = this.computeKeyLocations();
// TODO: Setting tons of public properties like this is obviously a code
// smell. We are planning to introduce 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);
}
private handleRenderContent() {
this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
element.remove()
);
this.loading = false;
this.observeNodes();
// We are just converting 'render-content' into 'render' here. Maybe we
// should retire the 'render' event in favor of 'render-content'?
fireEvent(this, 'render');
}
private observeNodes() {
// First stop observing old nodes.
this.unobserveNodes();
// Then introduce a Mutation observer that watches for children being added
// to gr-diff. If those children are `isThreadEl`, namely then they are
// processed.
this.nodeObserver = new MutationObserver(mutations => {
const addedThreadEls = extractAddedNodes(mutations).filter(isThreadEl);
const removedThreadEls =
extractRemovedNodes(mutations).filter(isThreadEl);
this.processNodes(addedThreadEls, removedThreadEls);
});
this.nodeObserver.observe(this, {childList: true});
// Make sure to process existing gr-comment-threads that already exist.
this.processNodes([...this.childNodes].filter(isThreadEl), []);
}
private processNodes(
addedThreadEls: GrDiffThreadElement[],
removedThreadEls: GrDiffThreadElement[]
) {
this.updateRanges(addedThreadEls, removedThreadEls);
addedThreadEls.forEach(threadEl =>
this.redispatchHoverEvents(threadEl, threadEl)
);
// Removed nodes do not need to be handled because all this code does is
// adding a slot for the added thread elements, and the extra slots do
// not hurt. It's probably a bigger performance cost to remove them than
// to keep them around. Medium term we can even consider to add one slot
// for each line from the start.
for (const threadEl of addedThreadEls) {
const lineNum = getLine(threadEl);
const commentSide = getSide(threadEl);
const range = getRange(threadEl);
if (!commentSide) continue;
const lineEl = this.diffBuilder.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
if (!lineEl) {
console.error(
'thread attached to line ',
commentSide,
lineNum,
' which does not exist.'
);
continue;
}
const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
if (!contentEl) continue;
if (lineNum === 'LOST') {
this.insertPortedCommentsWithoutRangeMessage(contentEl);
}
const slotAtt = threadEl.getAttribute('slot');
if (range && isLongCommentRange(range) && slotAtt) {
const longRangeCommentHint = document.createElement(
'gr-ranged-comment-hint'
);
longRangeCommentHint.range = range;
longRangeCommentHint.setAttribute('threadElRootId', threadEl.rootId);
longRangeCommentHint.setAttribute('slot', slotAtt);
this.insertBefore(longRangeCommentHint, threadEl);
this.redispatchHoverEvents(longRangeCommentHint, threadEl);
}
}
for (const threadEl of removedThreadEls) {
this.querySelector(
`gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
)?.remove();
}
}
private unobserveNodes() {
if (this.nodeObserver) {
this.nodeObserver.disconnect();
this.nodeObserver = undefined;
}
// You only stop observing for comment thread elements when the diff is
// completely rendered from scratch. And then comment thread elements
// will be (re-)added *after* rendering is done. That is also when we
// re-start observing. So it is appropriate to thoroughly clean up
// everything that the observer is managing.
this.commentRanges = [];
}
private insertPortedCommentsWithoutRangeMessage(lostCell: Element) {
const existingMessage = lostCell.querySelector('div.lost-message');
if (existingMessage) return;
const div = document.createElement('div');
div.className = 'lost-message';
const icon = document.createElement('gr-icon');
icon.setAttribute('icon', 'info');
div.appendChild(icon);
const span = document.createElement('span');
span.innerText = 'Original comment position not found in this patchset';
div.appendChild(span);
lostCell.insertBefore(div, lostCell.firstChild);
}
/**
* Get the preferences object including the safety bypass context (if any).
*/
private getBypassPrefs() {
assertIsDefined(this.prefs, 'prefs');
if (this.safetyBypass !== null) {
return {...this.prefs, context: this.safetyBypass};
}
return this.prefs;
}
clearDiffContent() {
this.unobserveNodes();
if (!this.diffTable) return;
while (this.diffTable.hasChildNodes()) {
this.diffTable.removeChild(this.diffTable.lastChild!);
}
}
// Private but used in tests.
computeDiffHeaderItems() {
return (this.diff?.diff_header ?? [])
.filter(
item =>
!(
item.startsWith('diff --git ') ||
item.startsWith('index ') ||
item.startsWith('+++ ') ||
item.startsWith('--- ') ||
item === 'Binary files differ'
)
)
.map(expandFileMode);
}
private handleFullBypass() {
this.safetyBypass = FULL_CONTEXT;
this.debounceRenderDiffTable();
}
private collapseContext() {
// Uses the default context amount if the preference is for the entire file.
this.safetyBypass =
this.prefs?.context && this.prefs.context >= 0
? null
: createDefaultDiffPrefs().context;
this.debounceRenderDiffTable();
}
toggleAllContext() {
if (!this.prefs) {
return;
}
if (this.getBypassPrefs().context < 0) {
this.collapseContext();
} else {
this.handleFullBypass();
}
}
private computeNewlineWarning(): string | undefined {
const messages = [];
if (this.showNewlineWarningLeft) {
messages.push(NO_NEWLINE_LEFT);
}
if (this.showNewlineWarningRight) {
messages.push(NO_NEWLINE_RIGHT);
}
if (!messages.length) {
return undefined;
}
return messages.join(' \u2014 '); // \u2014 - '—'
}
}
function extractAddedNodes(mutations: MutationRecord[]) {
return mutations.flatMap(mutation => [...mutation.addedNodes]);
}
function extractRemovedNodes(mutations: MutationRecord[]) {
return mutations.flatMap(mutation => [...mutation.removedNodes]);
}
declare global {
interface HTMLElementTagNameMap {
'gr-diff': GrDiff;
}
interface HTMLElementEventMap {
'loading-changed': ValueChangedEvent<boolean>;
}
}