blob: d4defcbd34fcc9a151d1944f358a6c4cf1fac4c5 [file] [log] [blame]
/**
* @license
* Copyright 2015 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../styles/gr-a11y-styles';
import '../../../styles/shared-styles';
import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
import '../../diff/gr-diff-host/gr-diff-host';
import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-cursor-manager/gr-cursor-manager';
import '../../shared/gr-icon/gr-icon';
import '../../shared/gr-select/gr-select';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
import '../../shared/gr-file-status/gr-file-status';
import {assertIsDefined} from '../../../utils/common-util';
import {asyncForeach} from '../../../utils/async-util';
import {FilesExpandedState} from '../gr-file-list-constants';
import {diffFilePaths, pluralize} from '../../../utils/string-util';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {getAppContext} from '../../../services/app-context';
import {
DiffViewMode,
FileInfoStatus,
ScrollMode,
SpecialFilePath,
} from '../../../constants/constants';
import {descendedFromClass, Key, toggleClass} from '../../../utils/dom-util';
import {
computeDisplayPath,
computeTruncatedPath,
isMagicPath,
} from '../../../utils/path-list-util';
import {customElement, property, query, state} from 'lit/decorators.js';
import {
BasePatchSetNum,
EDIT,
FileInfo,
NumericChangeId,
PARENT,
PatchRange,
} from '../../../types/common';
import {DiffPreferencesInfo} from '../../../types/diff';
import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
import {Interaction, Timing} from '../../../constants/reporting';
import {RevisionInfo} from '../../shared/revision-info/revision-info';
import {select} from '../../../utils/observable-util';
import {resolve} from '../../../models/dependency';
import {browserModelToken} from '../../../models/browser/browser-model';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {changeModelToken} from '../../../models/change/change-model';
import {filesModelToken} from '../../../models/change/files-model';
import {ShortcutController} from '../../lit/shortcut-controller';
import {
css,
html,
LitElement,
nothing,
PropertyValues,
TemplateResult,
} from 'lit';
import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
import {fire} from '../../../utils/event-util';
import {a11yStyles} from '../../../styles/gr-a11y-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {ValueChangedEvent} from '../../../types/events';
import {subscribe} from '../../lit/subscription-controller';
import {when} from 'lit/directives/when.js';
import {classMap} from 'lit/directives/class-map.js';
import {incrementalRepeat} from '../../lit/incremental-repeat';
import {ifDefined} from 'lit/directives/if-defined.js';
import {HtmlPatched} from '../../../utils/lit-util';
import {
createDiffUrl,
createEditUrl,
createChangeUrl,
} from '../../../models/views/change';
import {userModelToken} from '../../../models/user/user-model';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {FileMode, fileModeToString} from '../../../utils/file-util';
export const DEFAULT_NUM_FILES_SHOWN = 200;
const WARN_SHOW_ALL_THRESHOLD = 1000;
const SIZE_BAR_MAX_WIDTH = 61;
const SIZE_BAR_GAP_WIDTH = 1;
const SIZE_BAR_MIN_WIDTH = 1.5;
const FILE_ROW_CLASS = 'file-row';
export interface NormalizedFileInfo extends FileInfo {
__path: string;
}
interface PatchChange {
inserted: number;
deleted: number;
size_delta_inserted: number;
size_delta_deleted: number;
total_size: number;
}
function createDefaultPatchChange(): PatchChange {
// Use function instead of const to prevent unexpected changes in the default
// values.
return {
inserted: 0,
deleted: 0,
size_delta_inserted: 0,
size_delta_deleted: 0,
total_size: 0,
};
}
interface SizeBarLayout {
maxInserted: number;
maxDeleted: number;
maxAdditionWidth: number;
maxDeletionWidth: number;
deletionOffset: number;
}
function createDefaultSizeBarLayout(): SizeBarLayout {
// Use function instead of const to prevent unexpected changes in the default
// values.
return {
maxInserted: 0,
maxDeleted: 0,
maxAdditionWidth: 0,
maxDeletionWidth: 0,
deletionOffset: 0,
};
}
interface FileRow {
file: PatchSetFile;
element: HTMLElement;
}
/**
* Type for FileInfo
*
* This should match with the type returned from `files` API plus
* additional info like `__path`.
*
* @typedef {Object} FileInfo
* @property {string} __path
* @property {?string} old_path
* @property {number} size
* @property {number} size_delta - fallback to 0 if not present in api
* @property {number} lines_deleted - fallback to 0 if not present in api
* @property {number} lines_inserted - fallback to 0 if not present in api
*/
declare global {
interface HTMLElementEventMap {
'files-shown-changed': CustomEvent<{length: number}>;
'files-expanded-changed': ValueChangedEvent<FilesExpandedState>;
'diff-prefs-changed': ValueChangedEvent<DiffPreferencesInfo>;
}
interface HTMLElementTagNameMap {
'gr-file-list': GrFileList;
}
}
@customElement('gr-file-list')
export class GrFileList extends LitElement {
/**
* @event files-expanded-changed
* @event files-shown-changed
* @event diff-prefs-changed
*/
@query('#diffPreferencesDialog')
diffPreferencesDialog?: GrDiffPreferencesDialog;
@property({type: Object})
patchRange?: PatchRange;
@property({type: String})
patchNum?: string;
@property({type: Number})
changeNum?: NumericChangeId;
@property({type: Object})
changeComments?: ChangeComments;
@state() selectedIndex = 0;
@property({type: Object})
change?: ParsedChangeInfo;
@state()
diffViewMode?: DiffViewMode;
@property({type: Boolean})
editMode?: boolean;
private _filesExpanded = FilesExpandedState.NONE;
get filesExpanded() {
return this._filesExpanded;
}
set filesExpanded(filesExpanded: FilesExpandedState) {
if (this._filesExpanded === filesExpanded) return;
const oldFilesExpanded = this._filesExpanded;
this._filesExpanded = filesExpanded;
fire(this, 'files-expanded-changed', {value: this._filesExpanded});
this.requestUpdate('filesExpanded', oldFilesExpanded);
}
// Private but used in tests.
@state()
files: NormalizedFileInfo[] = [];
// Private but used in tests.
@state() filesLeftBase: NormalizedFileInfo[] = [];
@state() private filesRightBase: NormalizedFileInfo[] = [];
// Private but used in tests.
@state()
loggedIn = false;
/**
* List of paths of files that are marked as reviewed. Direct model
* subscription.
*/
@state()
reviewed: string[] = [];
@state()
diffPrefs?: DiffPreferencesInfo;
@state() numFilesShown = DEFAULT_NUM_FILES_SHOWN;
@state()
fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
// Private but used in tests.
shownFiles: NormalizedFileInfo[] = [];
@state()
private reportinShownFilesIncrement = 0;
// Private but used in tests.
@state()
expandedFiles: PatchSetFile[] = [];
// Private but used in tests.
@state()
displayLine?: boolean;
// Private but used in tests.
@state()
showSizeBars = true;
// For merge commits vs Auto Merge, an extra file row is shown detailing the
// files that were merged without conflict. These files are also passed to any
// plugins.
@state()
private cleanlyMergedPaths: string[] = [];
// Private but used in tests.
@state()
cleanlyMergedOldPaths: string[] = [];
private cancelForEachDiff?: () => void;
@state()
private dynamicHeaderEndpoints?: string[];
@state()
private dynamicContentEndpoints?: string[];
@state()
private dynamicSummaryEndpoints?: string[];
@state()
private dynamicPrependedHeaderEndpoints?: string[];
@state()
private dynamicPrependedContentEndpoints?: string[];
private readonly reporting = getAppContext().reportingService;
private readonly restApiService = getAppContext().restApiService;
private readonly getPluginLoader = resolve(this, pluginLoaderToken);
private readonly getUserModel = resolve(this, userModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getFilesModel = resolve(this, filesModelToken);
private readonly getCommentsModel = resolve(this, commentsModelToken);
private readonly getBrowserModel = resolve(this, browserModelToken);
private readonly patched = new HtmlPatched(key => {
this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
component: this.tagName,
key: key.substring(0, 300),
});
});
shortcutsController = new ShortcutController(this);
private readonly getNavigation = resolve(this, navigationToken);
// private but used in test
fileCursor = new GrCursorManager();
// private but used in test
diffCursor?: GrDiffCursor;
static override get styles() {
return [
a11yStyles,
sharedStyles,
css`
:host {
display: block;
}
.row {
align-items: center;
border-top: 1px solid var(--border-color);
display: flex;
min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
padding: var(--spacing-xs) var(--spacing-l);
}
/* The class defines a content visible only to screen readers */
.noCommentsScreenReaderText {
opacity: 0;
max-width: 1px;
overflow: hidden;
display: none;
vertical-align: top;
}
div[role='gridcell']
> div.comments
> span:empty
+ span:empty
+ span.noCommentsScreenReaderText {
/* inline-block instead of block, such that it can control width */
display: inline-block;
}
:host(.editMode) .hideOnEdit {
display: none;
}
.showOnEdit {
display: none;
}
:host(.editMode) .showOnEdit {
display: initial;
}
.invisible {
visibility: hidden;
}
.header-row {
background-color: var(--background-color-secondary);
}
.controlRow {
align-items: center;
display: flex;
height: 2.25em;
justify-content: center;
}
.controlRow.invisible,
.show-hide.invisible {
display: none;
}
.reviewed {
align-items: center;
display: inline-flex;
}
.reviewed {
display: inline-block;
text-align: left;
width: 1.5em;
}
.file-row {
cursor: pointer;
}
.file-row.expanded {
border-bottom: 1px solid var(--border-color);
position: -webkit-sticky;
position: sticky;
top: 0;
/* Has to visible above the diff view, and by default has a lower
z-index. setting to 1 places it directly above. */
z-index: 1;
}
.file-row:hover {
background-color: var(--hover-background-color);
}
.file-row.selected {
background-color: var(--selection-background-color);
}
.file-row.expanded,
.file-row.expanded:hover {
background-color: var(--expanded-background-color);
}
.status {
margin-right: var(--spacing-m);
display: flex;
width: 20px;
justify-content: flex-end;
}
.status.extended {
width: 56px;
}
.status > * {
display: block;
}
.header-row .status .content {
width: 20px;
text-align: center;
}
.path {
cursor: pointer;
flex: 1;
/* Wrap it into multiple lines if too long. */
white-space: normal;
word-break: break-word;
}
.oldPath {
color: var(--deemphasized-text-color);
}
.header-stats {
text-align: center;
min-width: 7.5em;
}
.stats {
text-align: right;
min-width: 7.5em;
}
.comments {
padding-left: var(--spacing-l);
min-width: 7.5em;
white-space: nowrap;
}
.row:not(.header-row) .stats,
.total-stats {
font-family: var(--monospace-font-family);
font-size: var(--font-size-mono);
line-height: var(--line-height-mono);
display: flex;
}
.sizeBars {
margin-left: var(--spacing-m);
min-width: 7em;
text-align: center;
}
.sizeBars.hide {
display: none;
}
.added,
.removed {
display: inline-block;
min-width: 3.5em;
}
.added {
color: var(--positive-green-text-color);
}
.removed {
color: var(--negative-red-text-color);
text-align: left;
min-width: 4em;
padding-left: var(--spacing-s);
}
.drafts {
color: var(--error-foreground);
font-weight: var(--font-weight-bold);
}
.show-hide-icon:focus {
outline: none;
}
.show-hide {
margin-left: var(--spacing-s);
width: 1.9em;
}
.fileListButton {
margin: var(--spacing-m);
}
.totalChanges {
justify-content: flex-end;
text-align: right;
}
.warning {
color: var(--deemphasized-text-color);
}
input.show-hide {
display: none;
}
label.show-hide {
cursor: pointer;
display: block;
min-width: 2em;
}
gr-diff {
display: block;
overflow-x: auto;
}
.matchingFilePath {
color: var(--deemphasized-text-color);
}
.newFilePath {
color: var(--primary-text-color);
}
.fileName {
color: var(--link-color);
}
.truncatedFileName {
display: none;
}
.mobile {
display: none;
}
.reviewed {
margin-left: var(--spacing-xxl);
width: 15em;
}
.reviewedSwitch {
color: var(--link-color);
opacity: 0;
justify-content: flex-end;
width: 100%;
}
.reviewedSwitch:hover {
cursor: pointer;
opacity: 100;
}
.showParentButton {
line-height: var(--line-height-normal);
margin-bottom: calc(var(--spacing-s) * -1);
margin-left: var(--spacing-m);
margin-top: calc(var(--spacing-s) * -1);
}
.row:focus {
outline: none;
}
.row:hover .reviewedSwitch,
.row:focus-within .reviewedSwitch,
.row.expanded .reviewedSwitch {
opacity: 100;
}
.reviewedLabel {
color: var(--deemphasized-text-color);
margin-right: var(--spacing-l);
opacity: 0;
}
.reviewedLabel.isReviewed {
display: initial;
opacity: 100;
}
.editFileControls {
width: 7em;
}
.markReviewed:focus {
outline: none;
}
.markReviewed,
.pathLink {
display: inline-block;
margin: -2px 0;
padding: var(--spacing-s) 0;
text-decoration: none;
}
.pathLink:hover span.fullFileName,
.pathLink:hover span.truncatedFileName {
text-decoration: underline;
}
/** copy on file path **/
.pathLink gr-copy-clipboard,
.oldPath gr-copy-clipboard {
display: inline-block;
visibility: hidden;
vertical-align: bottom;
--gr-button-padding: 0px;
}
.row:focus-within gr-copy-clipboard,
.row:hover gr-copy-clipboard {
visibility: visible;
}
.file-status-arrow {
font-size: 16px;
position: relative;
top: 2px;
display: block;
}
.file-mode-warning {
font-size: 16px;
position: relative;
top: 2px;
color: var(--warning-foreground);
}
.file-mode-content {
display: inline-block;
color: var(--deemphasized-text-color);
}
@media screen and (max-width: 1200px) {
gr-endpoint-decorator.extra-col {
display: none;
}
}
@media screen and (max-width: 1000px) {
.reviewed {
display: none;
}
}
@media screen and (max-width: 800px) {
.desktop {
display: none;
}
.mobile {
display: block;
}
.row.selected {
background-color: var(--view-background-color);
}
.stats {
display: none;
}
.reviewed,
.status {
justify-content: flex-start;
}
.comments {
min-width: initial;
}
.expanded .fullFileName,
.truncatedFileName {
display: inline;
}
.expanded .truncatedFileName,
.fullFileName {
display: none;
}
}
:host(.hideComments) {
--gr-comment-thread-display: none;
}
`,
];
}
constructor() {
super();
this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
this.fileCursor.cursorTargetClass = 'selected';
this.fileCursor.focusOnMove = true;
this.shortcutsController.addAbstract(Shortcut.LEFT_PANE, _ =>
this.handleLeftPane()
);
this.shortcutsController.addAbstract(Shortcut.RIGHT_PANE, _ =>
this.handleRightPane()
);
this.shortcutsController.addAbstract(Shortcut.TOGGLE_INLINE_DIFF, _ =>
this.handleToggleInlineDiff()
);
this.shortcutsController.addAbstract(Shortcut.TOGGLE_ALL_INLINE_DIFFS, _ =>
this.toggleInlineDiffs()
);
this.shortcutsController.addAbstract(
Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
_ => toggleClass(this, 'hideComments')
);
this.shortcutsController.addAbstract(
Shortcut.CURSOR_NEXT_FILE,
e => this.handleCursorNext(e),
{preventDefault: false}
);
this.shortcutsController.addAbstract(
Shortcut.CURSOR_PREV_FILE,
e => this.handleCursorPrev(e),
{preventDefault: false}
);
// This is already been taken care of by CURSOR_NEXT_FILE above. The two
// shortcuts share the same bindings. It depends on whether all files
// are expanded whether the cursor moves to the next file or line.
this.shortcutsController.addAbstract(Shortcut.NEXT_LINE, _ => {}, {
preventDefault: false,
}); // docOnly
// This is already been taken care of by CURSOR_PREV_FILE above. The two
// shortcuts share the same bindings. It depends on whether all files
// are expanded whether the cursor moves to the previous file or line.
this.shortcutsController.addAbstract(Shortcut.PREV_LINE, _ => {}, {
preventDefault: false,
}); // docOnly
this.shortcutsController.addAbstract(Shortcut.NEW_COMMENT, _ =>
this.handleNewComment()
);
this.shortcutsController.addAbstract(Shortcut.OPEN_LAST_FILE, _ =>
this.openSelectedFile(this.files.length - 1)
);
this.shortcutsController.addAbstract(Shortcut.OPEN_FIRST_FILE, _ =>
this.openSelectedFile(0)
);
this.shortcutsController.addAbstract(Shortcut.OPEN_FILE, _ =>
this.handleOpenFile()
);
this.shortcutsController.addAbstract(Shortcut.NEXT_CHUNK, _ =>
this.handleNextChunk()
);
this.shortcutsController.addAbstract(Shortcut.PREV_CHUNK, _ =>
this.handlePrevChunk()
);
this.shortcutsController.addAbstract(Shortcut.NEXT_COMMENT_THREAD, _ =>
this.handleNextComment()
);
this.shortcutsController.addAbstract(Shortcut.PREV_COMMENT_THREAD, _ =>
this.handlePrevComment()
);
this.shortcutsController.addAbstract(Shortcut.TOGGLE_FILE_REVIEWED, _ =>
this.handleToggleFileReviewed()
);
this.shortcutsController.addAbstract(Shortcut.TOGGLE_LEFT_PANE, _ =>
this.handleToggleLeftPane()
);
this.shortcutsController.addGlobal({key: Key.ESC}, _ =>
this.handleEscKey()
);
this.shortcutsController.addAbstract(
Shortcut.EXPAND_ALL_COMMENT_THREADS,
_ => {}
); // docOnly
this.shortcutsController.addAbstract(
Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
_ => {}
); // docOnly
this.shortcutsController.addLocal(
{key: Key.ENTER},
_ => this.handleOpenFile(),
{
shouldSuppress: true,
}
);
subscribe(
this,
() => this.getCommentsModel().changeComments$,
changeComments => {
this.changeComments = changeComments;
}
);
subscribe(
this,
() => this.getFilesModel().filesIncludingUnmodified$,
files => {
this.files = [...files];
}
);
subscribe(
this,
() => this.getFilesModel().filesLeftBase$,
files => {
this.filesLeftBase = [...files];
}
);
subscribe(
this,
() => this.getFilesModel().filesRightBase$,
files => {
this.filesRightBase = [...files];
}
);
subscribe(
this,
() => this.getBrowserModel().diffViewMode$,
diffView => {
this.diffViewMode = diffView;
}
);
subscribe(
this,
() => this.getUserModel().diffPreferences$,
diffPreferences => {
this.diffPrefs = diffPreferences;
}
);
subscribe(
this,
() =>
select(
this.getUserModel().preferences$,
prefs => !!prefs?.size_bar_in_change_table
),
sizeBarInChangeTable => {
this.showSizeBars = sizeBarInChangeTable;
}
);
subscribe(
this,
() => this.getUserModel().loggedIn$,
loggedIn => {
this.loggedIn = loggedIn;
}
);
subscribe(
this,
() => this.getChangeModel().reviewedFiles$,
reviewedFiles => {
this.reviewed = reviewedFiles ?? [];
}
);
}
override willUpdate(changedProperties: PropertyValues): void {
if (
changedProperties.has('diffPrefs') ||
changedProperties.has('diffViewMode')
) {
this.updateDiffPreferences();
}
if (changedProperties.has('files')) {
this.filesChanged();
}
if (
changedProperties.has('files') ||
changedProperties.has('numFilesShown')
) {
this.shownFiles = this.computeFilesShown();
}
if (changedProperties.has('expandedFiles')) {
this.expandedFilesChanged(changedProperties.get('expandedFiles'));
}
}
override connectedCallback() {
super.connectedCallback();
this.getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
this.dynamicHeaderEndpoints =
this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
'change-view-file-list-header'
);
this.dynamicContentEndpoints =
this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
'change-view-file-list-content'
);
this.dynamicPrependedHeaderEndpoints =
this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
'change-view-file-list-header-prepend'
);
this.dynamicPrependedContentEndpoints =
this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
'change-view-file-list-content-prepend'
);
this.dynamicSummaryEndpoints =
this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
'change-view-file-list-summary'
);
if (
this.dynamicHeaderEndpoints.length !==
this.dynamicContentEndpoints.length
) {
this.reporting.error(
'Plugin change-view-file-list',
new Error('dynamic header/content mismatch')
);
}
if (
this.dynamicPrependedHeaderEndpoints.length !==
this.dynamicPrependedContentEndpoints.length
) {
this.reporting.error(
'Plugin change-view-file-list',
new Error('dynamic prepend header/content mismatch')
);
}
if (
this.dynamicHeaderEndpoints.length !==
this.dynamicSummaryEndpoints.length
) {
this.reporting.error(
'Plugin change-view-file-list',
new Error('dynamic header/summary mismatch')
);
}
});
this.diffCursor = new GrDiffCursor();
this.diffCursor.replaceDiffs(this.diffs);
}
override disconnectedCallback() {
this.diffCursor?.dispose();
this.fileCursor.unsetCursor();
this.cancelDiffs();
super.disconnectedCallback();
}
protected override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await Promise.all(this.diffs.map(d => d.updateComplete));
return result;
}
override render() {
this.classList.toggle('editMode', this.editMode);
const patchChange = this.calculatePatchChange();
return html`
<h3 class="assistive-tech-only">File list</h3>
${this.renderContainer()} ${this.renderChangeTotals(patchChange)}
${this.renderBinaryTotals(patchChange)} ${this.renderControlRow()}
<gr-diff-preferences-dialog
id="diffPreferencesDialog"
@reload-diff-preference=${this.handleReloadingDiffPreference}
>
</gr-diff-preferences-dialog>
`;
}
private renderContainer() {
return html`
<div
id="container"
@click=${(e: MouseEvent) => this.handleFileListClick(e)}
role="grid"
aria-label="Files list"
>
${this.renderHeaderRow()} ${this.renderShownFiles()}
${when(this.computeShowNumCleanlyMerged(), () =>
this.renderCleanlyMerged()
)}
</div>
`;
}
private renderHeaderRow() {
const showPrependedDynamicColumns =
this.computeShowPrependedDynamicColumns();
const showDynamicColumns = this.computeShowDynamicColumns();
return html` <div class="header-row row" role="row">
<!-- endpoint: change-view-file-list-header-prepend -->
${when(showPrependedDynamicColumns, () =>
this.renderPrependedHeaderEndpoints()
)}
${this.renderFileStatus()}
<div class="path" role="columnheader">File</div>
<div class="comments desktop" role="columnheader">Comments</div>
<div class="comments mobile" role="columnheader" title="Comments">C</div>
${when(
this.showSizeBars,
() => html`<div class="sizeBars desktop" role="columnheader">Size</div>`
)}
<div class="header-stats" role="columnheader">Delta</div>
<!-- endpoint: change-view-file-list-header -->
${when(showDynamicColumns, () => this.renderDynamicHeaderEndpoints())}
<!-- Empty div here exists to keep spacing in sync with file rows. -->
<div
class="reviewed hideOnEdit"
?hidden=${!this.loggedIn}
aria-hidden="true"
></div>
<div class="editFileControls showOnEdit" aria-hidden="true"></div>
<div class="show-hide" aria-hidden="true"></div>
</div>`;
}
private renderPrependedHeaderEndpoints() {
return this.dynamicPrependedHeaderEndpoints?.map(
headerEndpoint => html`
<gr-endpoint-decorator
class="prepended-col"
.name=${headerEndpoint}
role="columnheader"
>
<gr-endpoint-param name="change" .value=${this.change}>
</gr-endpoint-param>
<gr-endpoint-param name="patchRange" .value=${this.patchRange}>
</gr-endpoint-param>
<gr-endpoint-param name="files" .value=${this.files}>
</gr-endpoint-param>
</gr-endpoint-decorator>
`
);
}
private renderDynamicHeaderEndpoints() {
return this.dynamicHeaderEndpoints?.map(
headerEndpoint => html`
<gr-endpoint-decorator
class="extra-col"
.name=${headerEndpoint}
role="columnheader"
></gr-endpoint-decorator>
`
);
}
// for DIFF_AUTOCLOSE logging purposes only
private shownFilesOld: NormalizedFileInfo[] = this.shownFiles;
private renderShownFiles() {
const showDynamicColumns = this.computeShowDynamicColumns();
const showPrependedDynamicColumns =
this.computeShowPrependedDynamicColumns();
const sizeBarLayout = this.computeSizeBarLayout();
// for DIFF_AUTOCLOSE logging purposes only
if (
this.shownFilesOld.length > 0 &&
this.shownFiles !== this.shownFilesOld
) {
this.reporting.reportInteraction(
Interaction.DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED
);
}
this.shownFilesOld = this.shownFiles;
return incrementalRepeat({
values: this.shownFiles,
mapFn: (f, i) =>
this.renderFileRow(
f as NormalizedFileInfo,
i,
sizeBarLayout,
showDynamicColumns,
showPrependedDynamicColumns
),
initialCount: this.fileListIncrement,
targetFrameRate: 1,
});
}
private renderFileRow(
file: NormalizedFileInfo,
index: number,
sizeBarLayout: SizeBarLayout,
showDynamicColumns: boolean,
showPrependedDynamicColumns: boolean
) {
this.reportRenderedRow(index);
const previousFileName = this.shownFiles[index - 1]?.__path;
const patchSetFile = this.computePatchSetFile(file);
return html` <div class="stickyArea">
<div
class=${`file-row row ${this.computePathClass(file.__path)}`}
data-file=${JSON.stringify(patchSetFile)}
tabindex="-1"
role="row"
aria-label=${file.__path}
>
<!-- endpoint: change-view-file-list-content-prepend -->
${when(showPrependedDynamicColumns, () =>
this.renderPrependedContentEndpointsForFile(file)
)}
${this.renderFileStatus(file)}
${this.renderFilePath(file, previousFileName)}
${this.renderFileComments(file)}
${this.renderSizeBar(file, sizeBarLayout)} ${this.renderFileStats(file)}
${when(showDynamicColumns, () =>
this.renderDynamicContentEndpointsForFile(file)
)}
<!-- endpoint: change-view-file-list-content -->
${this.renderReviewed(file)} ${this.renderFileControls(file)}
${this.renderShowHide(file)}
</div>
${when(
this.isFileExpanded(file.__path),
() => this.patched.html`
<gr-diff-host
?noAutoRender=${true}
?showLoadFailure=${true}
.displayLine=${this.displayLine}
.changeNum=${this.changeNum}
.change=${this.change}
.patchRange=${this.patchRange}
.file=${patchSetFile}
.path=${file.__path}
.projectName=${this.change?.project}
?noRenderOnPrefsChange=${true}
></gr-diff-host>
`
)}
</div>`;
}
private renderPrependedContentEndpointsForFile(file: NormalizedFileInfo) {
return this.dynamicPrependedContentEndpoints?.map(
contentEndpoint => html`
<gr-endpoint-decorator
class="prepended-col"
.name=${contentEndpoint}
role="gridcell"
>
<gr-endpoint-param name="change" .value=${this.change}>
</gr-endpoint-param>
<gr-endpoint-param name="changeNum" .value=${this.changeNum}>
</gr-endpoint-param>
<gr-endpoint-param name="patchRange" .value=${this.patchRange}>
</gr-endpoint-param>
<gr-endpoint-param name="path" .value=${file.__path}>
</gr-endpoint-param>
<gr-endpoint-param name="oldPath" .value=${this.getOldPath(file)}>
</gr-endpoint-param>
</gr-endpoint-decorator>
`
);
}
private renderFileStatus(file?: NormalizedFileInfo) {
const hasExtendedStatus = this.filesLeftBase.length > 0;
const leftStatus = this.renderFileStatusLeft(file?.__path);
const rightStatus = this.renderFileStatusRight(file);
return html`<div
class=${classMap({status: true, extended: hasExtendedStatus})}
role="gridcell"
>
${leftStatus}${rightStatus}
</div>`;
}
private renderDivWithTooltip(
content: TemplateResult | string,
tooltip: string,
cssClass = 'content'
) {
return html`
<gr-tooltip-content title=${tooltip} has-tooltip>
<div class=${cssClass}>${content}</div>
</gr-tooltip-content>
`;
}
private renderFileStatusRight(file?: NormalizedFileInfo) {
const hasExtendedStatus = this.filesLeftBase.length > 0;
// no file means "header row"
if (!file) {
const psNum = this.patchRange?.patchNum;
return hasExtendedStatus
? this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)
: nothing;
}
if (isMagicPath(file.__path)) return nothing;
const fileWasAlreadyChanged = this.filesLeftBase.some(
info => info.__path === file?.__path
);
const fileIsReverted =
fileWasAlreadyChanged &&
!this.filesRightBase.some(info => info.__path === file?.__path);
const newlyChanged = hasExtendedStatus && !fileWasAlreadyChanged;
const status = fileIsReverted
? FileInfoStatus.REVERTED
: file?.status ?? FileInfoStatus.MODIFIED;
const left = `patchset ${this.patchRange?.basePatchNum}`;
const right = `patchset ${this.patchRange?.patchNum}`;
const postfix = ` between ${left} and ${right}`;
return html`<gr-file-status
.status=${status}
.labelPostfix=${postfix}
?newlyChanged=${newlyChanged}
></gr-file-status>`;
}
private renderFileStatusLeft(path?: string) {
if (this.filesLeftBase.length === 0) return nothing;
const arrow = html`
<gr-icon
icon="arrow_right_alt"
class="file-status-arrow"
aria-label="then"
></gr-icon>
`;
// no path means "header row"
const psNum = this.patchRange?.basePatchNum;
if (!path) {
return html`
${this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)} ${arrow}
`;
}
if (isMagicPath(path)) return nothing;
const file = this.filesLeftBase.find(info => info.__path === path);
if (!file) return nothing;
const status = file.status ?? FileInfoStatus.MODIFIED;
const left = 'base';
const right = `patchset ${this.patchRange?.basePatchNum}`;
const postfix = ` between ${left} and ${right}`;
return html`
<gr-file-status
.status=${status}
.labelPostfix=${postfix}
></gr-file-status>
${arrow}
`;
}
private renderFilePath(file: NormalizedFileInfo, previousFilePath?: string) {
return html`
<span class="path" role="gridcell">
<a class="pathLink" href=${ifDefined(this.computeDiffURL(file.__path))}>
<span title=${computeDisplayPath(file.__path)} class="fullFileName">
${this.renderStyledPath(file.__path, previousFilePath)}
</span>
<span
title=${computeDisplayPath(file.__path)}
class="truncatedFileName"
>
${computeTruncatedPath(file.__path)}
</span>
${this.renderFileMode(file)}
<gr-copy-clipboard
?hideInput=${true}
.text=${file.__path}
></gr-copy-clipboard>
</a>
${when(
file.old_path,
() => html`
<div class="oldPath" title=${ifDefined(file.old_path)}>
${file.old_path}
<gr-copy-clipboard
?hideInput=${true}
.text=${file.old_path}
></gr-copy-clipboard>
</div>
`
)}
</span>
`;
}
private renderFileMode(file: NormalizedFileInfo) {
const {old_mode, new_mode} = file;
// For added, modified or deleted regular files we do not want to render
// anything. Only if a file changed from something else to regular, then let
// the user know.
if (new_mode === undefined) return nothing;
let newModeStr = fileModeToString(new_mode, false);
if (new_mode === FileMode.REGULAR_FILE) {
if (old_mode === undefined) return nothing;
if (old_mode === FileMode.REGULAR_FILE) return nothing;
newModeStr = `non-${fileModeToString(old_mode, false)}`;
}
const changed = old_mode !== undefined && old_mode !== new_mode;
const icon = changed
? html`<gr-icon icon="warning" class="file-mode-warning"></gr-icon> `
: '';
const action = changed
? `changed from ${fileModeToString(old_mode)} to`
: 'is';
return this.renderDivWithTooltip(
html`${icon}(${newModeStr})`,
`file mode ${action} ${fileModeToString(new_mode)}`,
'file-mode-content'
);
}
private renderStyledPath(filePath: string, previousFilePath?: string) {
const {matchingFolders, newFolders, fileName} = diffFilePaths(
filePath,
previousFilePath
);
return [
matchingFolders.length > 0
? html`<span class="matchingFilePath">${matchingFolders}</span>`
: nothing,
newFolders.length > 0
? html`<span class="newFilePath">${newFolders}</span>`
: nothing,
html`<span class="fileName">${fileName}</span>`,
];
}
private renderFileComments(file: NormalizedFileInfo) {
return html` <div role="gridcell">
<div class="comments desktop">
<span class="drafts">${this.computeDraftsString(file)}</span>
<span>${this.computeCommentsString(file)}</span>
<span class="noCommentsScreenReaderText">
<!-- Screen readers read the following content only if 2 other
spans in the parent div is empty. The content is not visible on
the page.
Without this span, screen readers don't navigate correctly inside
table, because empty div doesn't rendered. For example, VoiceOver
jumps back to the whole table.
We can use &nbsp instead, but it sounds worse.
-->
No comments
</span>
</div>
<div class="comments mobile">
<span class="drafts">${this.computeDraftsStringMobile(file)}</span>
<span>${this.computeCommentsStringMobile(file)}</span>
<span class="noCommentsScreenReaderText">
<!-- The same as for desktop comments -->
No comments
</span>
</div>
</div>`;
}
private renderSizeBar(
file: NormalizedFileInfo,
sizeBarLayout: SizeBarLayout
) {
return html` <div class="desktop" role="gridcell">
<!-- The content must be in a separate div. It guarantees, that
gridcell always visible for screen readers.
For example, without a nested div screen readers pronounce the
"Commit message" row content with incorrect column headers.
-->
<div class=${this.computeSizeBarsClass(file.__path)} aria-hidden="true">
<svg width="61" height="8">
<rect
x=${this.computeBarAdditionX(file, sizeBarLayout)}
y="0"
height="8"
fill="var(--positive-green-text-color)"
width=${this.computeBarAdditionWidth(file, sizeBarLayout)}
></rect>
<rect
x=${this.computeBarDeletionX(sizeBarLayout)}
y="0"
height="8"
fill="var(--negative-red-text-color)"
width=${this.computeBarDeletionWidth(file, sizeBarLayout)}
></rect>
</svg>
</div>
</div>`;
}
private renderFileStats(file: NormalizedFileInfo) {
return html` <div class="stats" role="gridcell">
<!-- The content must be in a separate div. It guarantees, that
gridcell always visible for screen readers.
For example, without a nested div screen readers pronounce the
"Commit message" row content with incorrect column headers.
-->
<div class=${this.computeClass('', file.__path)}>
<span
class="added"
tabindex="0"
aria-label=${`${file.lines_inserted} added`}
?hidden=${file.binary}
>
+${file.lines_inserted}
</span>
<span
class="removed"
tabindex="0"
aria-label=${`${file.lines_deleted} removed`}
?hidden=${file.binary}
>
-${file.lines_deleted}
</span>
<span
class=${ifDefined(this.computeBinaryClass(file.size_delta))}
?hidden=${!file.binary}
>
${this.formatBytes(file.size_delta)}
${this.formatPercentage(file.size, file.size_delta)}
</span>
</div>
</div>`;
}
private renderDynamicContentEndpointsForFile(file: NormalizedFileInfo) {
return this.dynamicContentEndpoints?.map(
contentEndpoint => html` <div
class=${this.computeClass('', file.__path)}
role="gridcell"
>
<gr-endpoint-decorator class="extra-col" .name=${contentEndpoint}>
<gr-endpoint-param name="change" .value=${this.change}>
</gr-endpoint-param>
<gr-endpoint-param name="changeNum" .value=${this.changeNum}>
</gr-endpoint-param>
<gr-endpoint-param name="patchRange" .value=${this.patchRange}>
</gr-endpoint-param>
<gr-endpoint-param name="path" .value=${file.__path}>
</gr-endpoint-param>
</gr-endpoint-decorator>
</div>`
);
}
private renderReviewed(file: NormalizedFileInfo) {
if (!this.loggedIn) return nothing;
const isReviewed = this.reviewed.includes(file.__path);
const reviewedTitle = `Mark as ${
isReviewed ? 'not ' : ''
}reviewed (shortcut: r)`;
const reviewedText = isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
return html` <div class="reviewed hideOnEdit" role="gridcell">
<span
class=${`reviewedLabel ${isReviewed ? 'isReviewed' : ''}`}
aria-hidden=${this.booleanToString(!isReviewed)}
>Reviewed</span
>
<!-- Do not use input type="checkbox" with hidden input and
visible label here. Screen readers don't read/interract
correctly with such input.
-->
<span
class="reviewedSwitch"
role="switch"
tabindex="0"
@click=${(e: MouseEvent) => this.reviewedClick(e)}
@keydown=${(e: KeyboardEvent) => this.reviewedClick(e)}
aria-label="Reviewed"
aria-checked=${this.booleanToString(isReviewed)}
>
<!-- Trick with tabindex to avoid outline on mouse focus, but
preserve focus outline for keyboard navigation -->
<span tabindex="-1" class="markReviewed" title=${reviewedTitle}
>${reviewedText}</span
>
</span>
</div>`;
}
private renderFileControls(file: NormalizedFileInfo) {
return html` <div
class="editFileControls showOnEdit"
role="gridcell"
aria-hidden=${this.booleanToString(!this.editMode)}
>
${when(
this.editMode,
() => html`
<gr-edit-file-controls
class=${this.computeClass('', file.__path)}
.filePath=${file.__path}
></gr-edit-file-controls>
`
)}
</div>`;
}
private renderShowHide(file: NormalizedFileInfo) {
const expanded = this.isFileExpanded(file.__path);
return html` <div class="show-hide" role="gridcell">
<!-- Do not use input type="checkbox" with hidden input and
visible label here. Screen readers don't read/interract
correctly with such input.
-->
<span
class="show-hide"
data-path=${file.__path}
data-expand="true"
role="switch"
tabindex="0"
aria-checked=${this.isFileExpandedStr(file.__path)}
aria-label=${expanded ? 'collapse' : 'expand'}
aria-description=${expanded
? 'Collapse diff of this file'
: 'Expand diff of this file'}
@click=${this.expandedClick}
@keydown=${this.expandedClick}
>
<!-- Trick with tabindex to avoid outline on mouse focus, but
preserve focus outline for keyboard navigation -->
<gr-icon
class="show-hide-icon"
tabindex="-1"
id="icon"
icon=${expanded ? 'expand_less' : 'expand_more'}
></gr-icon>
</span>
</div>`;
}
private renderCleanlyMerged() {
const showPrependedDynamicColumns =
this.computeShowPrependedDynamicColumns();
return html` <div class="row">
<!-- endpoint: change-view-file-list-content-prepend -->
${when(showPrependedDynamicColumns, () =>
this.renderPrependedContentEndpoints()
)}
<div role="gridcell">
<div>
<span class="cleanlyMergedText">
${this.computeCleanlyMergedText()}
</span>
<gr-button
link
class="showParentButton"
@click=${this.handleShowParent1}
>
Show Parent 1
</gr-button>
</div>
</div>
</div>`;
}
private renderPrependedContentEndpoints() {
return this.dynamicPrependedContentEndpoints?.map(
contentEndpoint => html`
<gr-endpoint-decorator
class="prepended-col"
.name=${contentEndpoint}
role="gridcell"
>
<gr-endpoint-param name="change" .value=${this.change}>
</gr-endpoint-param>
<gr-endpoint-param name="changeNum" .value=${this.changeNum}>
</gr-endpoint-param>
<gr-endpoint-param name="patchRange" .value=${this.patchRange}>
</gr-endpoint-param>
<gr-endpoint-param
name="cleanlyMergedPaths"
.value=${this.cleanlyMergedPaths}
>
</gr-endpoint-param>
<gr-endpoint-param
name="cleanlyMergedOldPaths"
.value=${this.cleanlyMergedOldPaths}
>
</gr-endpoint-param>
</gr-endpoint-decorator>
`
);
}
private renderChangeTotals(patchChange: PatchChange) {
const showDynamicColumns = this.computeShowDynamicColumns();
if (this.shouldHideChangeTotals(patchChange)) return nothing;
return html`
<div class="row totalChanges">
<div class="total-stats">
<div>
<span
class="added"
tabindex="0"
aria-label="Total ${patchChange.inserted} lines added"
>
+${patchChange.inserted}
</span>
<span
class="removed"
tabindex="0"
aria-label="Total ${patchChange.deleted} lines removed"
>
-${patchChange.deleted}
</span>
</div>
</div>
${when(showDynamicColumns, () =>
this.dynamicSummaryEndpoints?.map(
summaryEndpoint => html`
<gr-endpoint-decorator class="extra-col" name=${summaryEndpoint}>
<gr-endpoint-param name="change" .value=${this.change}>
</gr-endpoint-param>
<gr-endpoint-param name="patchRange" .value=${this.patchRange}>
</gr-endpoint-param>
</gr-endpoint-decorator>
`
)
)}
<!-- Empty div here exists to keep spacing in sync with file rows. -->
<div class="reviewed hideOnEdit" ?hidden=${!this.loggedIn}></div>
<div class="editFileControls showOnEdit"></div>
<div class="show-hide"></div>
</div>
`;
}
private renderBinaryTotals(patchChange: PatchChange) {
if (this.shouldHideBinaryChangeTotals(patchChange)) return nothing;
const deltaInserted = this.formatBytes(patchChange.size_delta_inserted);
const deltaDeleted = this.formatBytes(patchChange.size_delta_deleted);
return html`
<div class="row totalChanges">
<div class="total-stats">
<span
class="added"
aria-label="Total bytes inserted: ${deltaInserted}"
>
${deltaInserted}
${this.formatPercentage(
patchChange.total_size,
patchChange.size_delta_inserted
)}
</span>
<span
class="removed"
aria-label="Total bytes removed: ${deltaDeleted}"
>
${deltaDeleted}
${this.formatPercentage(
patchChange.total_size,
patchChange.size_delta_deleted
)}
</span>
</div>
</div>
`;
}
private renderControlRow() {
return html`<div
class=${`row controlRow ${this.computeFileListControlClass()}`}
>
<gr-button
class="fileListButton"
id="incrementButton"
link=""
@click=${this.incrementNumFilesShown}
>
${this.computeIncrementText()}
</gr-button>
<gr-tooltip-content
?has-tooltip=${this.computeWarnShowAll()}
?show-icon=${this.computeWarnShowAll()}
.title=${this.computeShowAllWarning()}
>
<gr-button
class="fileListButton"
id="showAllButton"
link=""
@click=${this.showAllFiles}
>
${this.computeShowAllText()}
</gr-button>
</gr-tooltip-content>
</div>`;
}
protected override firstUpdated(): void {
this.detectChromiteButler();
this.reporting.fileListDisplayed();
}
protected override updated(): void {
// for DIFF_AUTOCLOSE logging purposes only
const ids = this.diffs.map(d => d.uid);
if (ids.length > 0) {
this.reporting.reportInteraction(
Interaction.DIFF_AUTOCLOSE_FILE_LIST_UPDATED,
{l: ids.length, ids: ids.slice(0, 10)}
);
}
}
// TODO: Move into files-model.
// visible for testing
async updateCleanlyMergedPaths() {
// When viewing Auto Merge base vs a patchset, add an additional row that
// knows how many files were cleanly merged. This requires an additional RPC
// for the diffs between target parent and the patch set. The cleanly merged
// files are all the files in the target RPC that weren't in the Auto Merge
// RPC.
if (
this.change &&
this.changeNum &&
this.patchRange?.patchNum &&
new RevisionInfo(this.change).isMergeCommit(this.patchRange.patchNum) &&
this.patchRange.basePatchNum === PARENT &&
this.patchRange.patchNum !== EDIT
) {
const allFilesByPath = await this.restApiService.getChangeOrEditFiles(
this.changeNum,
{
basePatchNum: -1 as BasePatchSetNum, // -1 is first (target) parent
patchNum: this.patchRange.patchNum,
}
);
if (!allFilesByPath) return;
const conflictingPaths = this.files.map(f => f.__path);
this.cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
path => !conflictingPaths.includes(path)
);
this.cleanlyMergedOldPaths = this.cleanlyMergedPaths
.map(path => allFilesByPath[path].old_path)
.filter((oldPath): oldPath is string => !!oldPath);
} else {
this.cleanlyMergedPaths = [];
this.cleanlyMergedOldPaths = [];
}
}
private detectChromiteButler() {
const hasButler = !!document.getElementById('butler-suggested-owners');
if (hasButler) {
this.reporting.reportExtension('butler');
}
}
get diffs(): GrDiffHost[] {
const diffs = this.shadowRoot!.querySelectorAll('gr-diff-host');
// It is possible that a bogus diff element is hanging around invisibly
// from earlier with a different patch set choice and associated with a
// different entry in the files array. So filter on visible items only.
return Array.from(diffs).filter(
el => !!el && !!el.style && el.style.display !== 'none'
);
}
resetFileState() {
this.numFilesShown = DEFAULT_NUM_FILES_SHOWN;
this.selectedIndex = 0;
this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
}
openDiffPrefs() {
this.diffPreferencesDialog?.open();
}
// Private but used in tests.
calculatePatchChange(): PatchChange {
const magicFilesExcluded = this.files.filter(
file => !isMagicPath(file.__path)
);
return magicFilesExcluded.reduce((acc, obj) => {
const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
const total_size = obj.size && obj.binary ? obj.size : 0;
const size_delta_inserted =
obj.binary && obj.size_delta && obj.size_delta > 0 ? obj.size_delta : 0;
const size_delta_deleted =
obj.binary && obj.size_delta && obj.size_delta < 0 ? obj.size_delta : 0;
return {
inserted: acc.inserted + inserted,
deleted: acc.deleted + deleted,
size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
total_size: acc.total_size + total_size,
};
}, createDefaultPatchChange());
}
// private but used in test
toggleFileExpanded(file: PatchSetFile) {
// Is the path in the list of expanded diffs? If so, remove it, otherwise
// add it to the list.
const indexInExpanded = this.expandedFiles.findIndex(
f => f.path === file.path
);
if (indexInExpanded === -1) {
this.expandedFiles = this.expandedFiles.concat([file]);
} else {
this.expandedFiles = this.expandedFiles.filter(
(_val, idx) => idx !== indexInExpanded
);
}
const indexInAll = this.files.findIndex(f => f.__path === file.path);
this.shadowRoot!.querySelectorAll(`.${FILE_ROW_CLASS}`)[
indexInAll
].scrollIntoView({block: 'nearest'});
}
private toggleFileExpandedByIndex(index: number) {
this.toggleFileExpanded(this.computePatchSetFile(this.files[index]));
}
// Private but used in tests.
updateDiffPreferences() {
if (!this.diffs.length) {
return;
}
this.reporting.reportInteraction(
Interaction.DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS
);
// Re-render all expanded diffs sequentially.
this.renderInOrder(this.expandedFiles, this.diffs);
}
private forEachDiff(fn: (host: GrDiffHost) => void) {
const diffs = this.diffs;
for (let i = 0; i < diffs.length; i++) {
fn(diffs[i]);
}
}
expandAllDiffs() {
// Find the list of paths that are in the file list, but not in the
// expanded list.
const newFiles: PatchSetFile[] = [];
let path: string;
for (let i = 0; i < this.shownFiles.length; i++) {
path = this.shownFiles[i].__path;
if (!this.expandedFiles.some(f => f.path === path)) {
newFiles.push(this.computePatchSetFile(this.shownFiles[i]));
}
}
this.expandedFiles = newFiles.concat(this.expandedFiles);
}
collapseAllDiffs() {
this.expandedFiles = [];
}
/**
* Computes a string with the number of comments and unresolved comments.
*/
computeCommentsString(file?: NormalizedFileInfo) {
if (
this.changeComments === undefined ||
this.patchRange === undefined ||
file?.__path === undefined
) {
return '';
}
return this.changeComments.computeCommentsString(
this.patchRange,
file.__path,
file
);
}
/**
* Computes a string with the number of drafts.
*/
computeDraftsString(file?: NormalizedFileInfo) {
if (this.changeComments === undefined) return '';
const draftCount = this.changeComments.computeDraftCountForFile(
this.patchRange,
file
);
if (draftCount === 0) return '';
return pluralize(Number(draftCount), 'draft');
}
/**
* Computes a shortened string with the number of drafts.
* Private but used in tests.
*/
computeDraftsStringMobile(file?: NormalizedFileInfo) {
if (this.changeComments === undefined) return '';
const draftCount = this.changeComments.computeDraftCountForFile(
this.patchRange,
file
);
return draftCount === 0 ? '' : `${draftCount}d`;
}
/**
* Computes a shortened string with the number of comments.
*/
computeCommentsStringMobile(file?: NormalizedFileInfo) {
if (
this.changeComments === undefined ||
this.patchRange === undefined ||
file === undefined
) {
return '';
}
const commentThreadCount =
this.changeComments.computeCommentThreadCount({
patchNum: this.patchRange.basePatchNum,
path: file.__path,
}) +
this.changeComments.computeCommentThreadCount({
patchNum: this.patchRange.patchNum,
path: file.__path,
});
return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
}
// Private but used in tests.
reviewFile(path: string, reviewed?: boolean) {
if (this.editMode) return Promise.resolve();
reviewed = reviewed ?? !this.reviewed.includes(path);
return this._saveReviewedState(path, reviewed);
}
_saveReviewedState(path: string, reviewed: boolean) {
assertIsDefined(this.changeNum, 'changeNum');
assertIsDefined(this.patchRange, 'patchRange');
return this.getChangeModel().setReviewedFilesStatus(
this.changeNum,
this.patchRange.patchNum,
path,
reviewed
);
}
/**
* Returns true if the event e is a click on an element.
*
* The click is: mouse click or pressing Enter or Space key
* P.S> Screen readers sends click event as well
*/
private isClickEvent(e: MouseEvent | KeyboardEvent) {
if (e.type === 'click') {
return true;
}
const ke = e as KeyboardEvent;
const isSpaceOrEnter = ke.key === 'Enter' || ke.key === ' ';
return ke.type === 'keydown' && isSpaceOrEnter;
}
private fileActionClick(
e: MouseEvent | KeyboardEvent,
fileAction: (file: PatchSetFile) => void
) {
if (this.isClickEvent(e)) {
const fileRow = this.getFileRowFromEvent(e);
if (!fileRow) {
return;
}
// Prevent default actions (e.g. scrolling for space key)
e.preventDefault();
// Prevent handleFileListClick handler call
e.stopPropagation();
this.fileCursor.setCursor(fileRow.element);
fileAction(fileRow.file);
}
}
// Private but used in tests.
reviewedClick(e: MouseEvent | KeyboardEvent) {
this.fileActionClick(e, file => this.reviewFile(file.path));
}
private expandedClick(e: MouseEvent | KeyboardEvent) {
this.fileActionClick(e, file => this.toggleFileExpanded(file));
}
/**
* Handle all events from the file list dom-repeat so event handlers don't
* have to get registered for potentially very long lists.
* Private but used in tests.
*/
handleFileListClick(e: MouseEvent) {
if (!e.target) {
return;
}
const fileRow = this.getFileRowFromEvent(e);
if (!fileRow) {
return;
}
const file = fileRow.file;
const path = file.path;
// If a path cannot be interpreted from the click target (meaning it's not
// somewhere in the row, e.g. diff content) or if the user clicked the
// link, defer to the native behavior.
if (!path || descendedFromClass(e.target as Element, 'pathLink')) {
return;
}
// Disregard the event if the click target is in the edit controls.
if (descendedFromClass(e.target as Element, 'editFileControls')) {
return;
}
e.preventDefault();
this.fileCursor.setCursor(fileRow.element);
this.toggleFileExpanded(file);
}
private getFileRowFromEvent(e: Event): FileRow | null {
// Traverse upwards to find the row element if the target is not the row.
let row = e.target as HTMLElement;
while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
row = row.parentElement;
}
// No action needed for item without a valid file
if (!row.dataset['file']) {
return null;
}
return {
file: JSON.parse(row.dataset['file']) as PatchSetFile,
element: row,
};
}
/**
* Generates file range from file info object.
*/
private computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
const fileData: PatchSetFile = {
path: file.__path,
};
if (file.old_path) {
fileData.basePath = file.old_path;
}
return fileData;
}
private handleLeftPane() {
if (this.noDiffsExpanded()) return;
this.diffCursor?.moveLeft();
}
private handleRightPane() {
if (this.noDiffsExpanded()) return;
this.diffCursor?.moveRight();
}
private handleToggleInlineDiff() {
if (this.fileCursor.index === -1) return;
this.toggleFileExpandedByIndex(this.fileCursor.index);
}
// Private but used in tests.
handleCursorNext(e: KeyboardEvent) {
// We want to allow users to use arrow keys for standard browser scrolling
// when files are not expanded. That is also why we use the `preventDefault`
// option when registering the shortcut.
if (this.filesExpanded !== FilesExpandedState.ALL && e.key === Key.DOWN) {
return;
}
e.preventDefault();
e.stopPropagation();
if (this.filesExpanded === FilesExpandedState.ALL) {
this.diffCursor?.moveDown();
this.displayLine = true;
} else {
this.fileCursor.next({circular: true});
this.selectedIndex = this.fileCursor.index;
}
}
// Private but used in tests.
handleCursorPrev(e: KeyboardEvent) {
// We want to allow users to use arrow keys for standard browser scrolling
// when files are not expanded. That is also why we use the `preventDefault`
// option when registering the shortcut.
if (this.filesExpanded !== FilesExpandedState.ALL && e.key === Key.UP) {
return;
}
e.preventDefault();
e.stopPropagation();
if (this.filesExpanded === FilesExpandedState.ALL) {
this.diffCursor?.moveUp();
this.displayLine = true;
} else {
this.fileCursor.previous({circular: true});
this.selectedIndex = this.fileCursor.index;
}
}
private handleNewComment() {
this.classList.remove('hideComments');
this.diffCursor?.createCommentInPlace();
}
// Private but used in tests.
handleOpenFile() {
if (this.filesExpanded === FilesExpandedState.ALL) {
this.openCursorFile();
return;
}
this.openSelectedFile();
}
private handleNextChunk() {
if (this.noDiffsExpanded()) return;
this.diffCursor?.moveToNextChunk();
}
private handleNextComment() {
if (this.noDiffsExpanded()) return;
this.diffCursor?.moveToNextCommentThread();
}
private handlePrevChunk() {
if (this.noDiffsExpanded()) return;
this.diffCursor?.moveToPreviousChunk();
}
private handlePrevComment() {
if (this.noDiffsExpanded()) return;
this.diffCursor?.moveToPreviousCommentThread();
}
private handleToggleFileReviewed() {
if (!this.files[this.fileCursor.index]) {
return;
}
this.reviewFile(this.files[this.fileCursor.index].__path);
}
private handleToggleLeftPane() {
this.forEachDiff(diff => {
diff.toggleLeftDiff();
});
}
private toggleInlineDiffs() {
if (this.filesExpanded === FilesExpandedState.ALL) {
this.collapseAllDiffs();
} else {
this.expandAllDiffs();
}
}
// Private but used in tests.
openCursorFile() {
const diff = this.diffCursor?.getTargetDiffElement();
if (!this.change || !diff || !this.patchRange || !diff.path) {
throw new Error('change, diff and patchRange must be all set and valid');
}
this.getNavigation().setUrl(
createDiffUrl({
change: this.change,
patchNum: this.patchRange.patchNum,
basePatchNum: this.patchRange.basePatchNum,
diffView: {path: diff.path},
})
);
}
// Private but used in tests.
openSelectedFile(index?: number) {
if (index !== undefined) {
this.fileCursor.setCursorAtIndex(index);
}
if (!this.files[this.fileCursor.index]) {
return;
}
if (!this.change || !this.patchRange) {
throw new Error('change and patchRange must be set');
}
this.getNavigation().setUrl(
createDiffUrl({
change: this.change,
patchNum: this.patchRange.patchNum,
basePatchNum: this.patchRange.basePatchNum,
diffView: {path: this.files[this.fileCursor.index].__path},
})
);
}
// Private but used in tests.
shouldHideChangeTotals(patchChange: PatchChange): boolean {
return patchChange.inserted === 0 && patchChange.deleted === 0;
}
// Private but used in tests.
shouldHideBinaryChangeTotals(patchChange: PatchChange) {
return (
patchChange.size_delta_inserted === 0 &&
patchChange.size_delta_deleted === 0
);
}
// Private but used in tests
computeDiffURL(path?: string) {
if (
this.change === undefined ||
this.patchRange?.patchNum === undefined ||
path === undefined ||
this.editMode === undefined
) {
return;
}
if (this.editMode && path !== SpecialFilePath.MERGE_LIST) {
return createEditUrl({
changeNum: this.change._number,
repo: this.change.project,
patchNum: this.patchRange.patchNum,
editView: {path},
});
}
return createDiffUrl({
changeNum: this.change._number,
repo: this.change.project,
patchNum: this.patchRange.patchNum,
basePatchNum: this.patchRange.basePatchNum,
diffView: {path},
});
}
// Private but used in tests.
formatBytes(bytes?: number) {
if (!bytes) return '+/-0 B';
const bits = 1024;
const decimals = 1;
const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
const prepend = bytes > 0 ? '+' : '';
const value = parseFloat(
(bytes / Math.pow(bits, exponent)).toFixed(decimals)
);
return `${prepend}${value} ${sizes[exponent]}`;
}
// Private but used in tests.
formatPercentage(size?: number, delta?: number) {
if (size === undefined || delta === undefined) {
return '';
}
const oldSize = size - delta;
if (oldSize === 0) {
return '';
}
const percentage = Math.round(Math.abs((delta * 100) / oldSize));
return `(${delta > 0 ? '+' : '-'}${percentage}%)`;
}
private computeBinaryClass(delta?: number) {
if (!delta) {
return;
}
return delta > 0 ? 'added' : 'removed';
}
private computeClass(baseClass?: string, path?: string) {
const classes = [];
if (baseClass) classes.push(baseClass);
if (isMagicPath(path)) classes.push('invisible');
return classes.join(' ');
}
private computePathClass(path: string | undefined) {
return this.isFileExpanded(path) ? 'expanded' : '';
}
private computeShowNumCleanlyMerged(): boolean {
return this.cleanlyMergedPaths.length > 0;
}
private computeCleanlyMergedText(): string {
const fileCount = pluralize(this.cleanlyMergedPaths.length, 'file');
return `${fileCount} merged cleanly in Parent 1`;
}
private handleShowParent1(): void {
if (!this.change || !this.patchRange) return;
this.getNavigation().setUrl(
createChangeUrl({
change: this.change,
patchNum: this.patchRange.patchNum,
basePatchNum: -1 as BasePatchSetNum, // Parent 1
})
);
}
private computeFilesShown(): NormalizedFileInfo[] {
const previousNumFilesShown = this.shownFiles ? this.shownFiles.length : 0;
const filesShown = this.files.slice(0, this.numFilesShown);
this.dispatchEvent(
new CustomEvent('files-shown-changed', {
detail: {length: filesShown.length},
composed: true,
bubbles: true,
})
);
// Start the timer for the rendering work here because this is where the
// shownFiles property is being set, and shownFiles is used in the
// dom-repeat binding.
this.reporting.time(Timing.FILE_RENDER);
// How many more files are being shown (if it's an increase).
this.reportinShownFilesIncrement = Math.max(
0,
filesShown.length - previousNumFilesShown
);
return filesShown;
}
// Private but used in tests.
updateDiffCursor() {
// Overwrite the cursor's list of diffs:
this.diffCursor?.replaceDiffs(this.diffs);
}
async filesChanged() {
if (this.expandedFiles.length > 0) this.expandedFiles = [];
await this.updateCleanlyMergedPaths();
if (!this.files || this.files.length === 0) return;
await this.updateComplete;
this.fileCursor.stops = Array.from(
this.shadowRoot?.querySelectorAll(`.${FILE_ROW_CLASS}`) ?? []
);
this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
}
private incrementNumFilesShown() {
this.numFilesShown += this.fileListIncrement;
}
private computeFileListControlClass() {
return this.numFilesShown >= this.files.length ? 'invisible' : '';
}
private computeIncrementText() {
const text = Math.min(
this.fileListIncrement,
this.files.length - this.numFilesShown
);
return `Show ${text} more`;
}
private computeShowAllText() {
return `Show all ${this.files.length} files`;
}
private computeWarnShowAll() {
return this.files.length > WARN_SHOW_ALL_THRESHOLD;
}
private computeShowAllWarning() {
if (!this.computeWarnShowAll()) {
return '';
}
return `Warning: showing all ${this.files.length} files may take several seconds.`;
}
private showAllFiles() {
this.numFilesShown = this.files.length;
}
/**
* Converts any boolean-like variable to the string 'true' or 'false'
*
* This method is useful when you bind aria-checked attribute to a boolean
* value. The aria-checked attribute is string attribute. Binding directly
* to boolean variable causes problem on gerrit-CI.
*
* @return 'true' if val is true-like, otherwise false
*/
private booleanToString(val?: unknown) {
return val ? 'true' : 'false';
}
private isFileExpanded(path: string | undefined) {
return this.expandedFiles.some(f => f.path === path);
}
private isFileExpandedStr(path: string | undefined) {
return this.booleanToString(this.isFileExpanded(path));
}
private computeExpandedFiles(): FilesExpandedState {
if (this.expandedFiles.length === 0) {
return FilesExpandedState.NONE;
} else if (this.expandedFiles.length === this.files.length) {
return FilesExpandedState.ALL;
}
return FilesExpandedState.SOME;
}
/**
* Handle splices to the list of expanded file paths. If there are any new
* entries in the expanded list, then render each diff corresponding in
* order by waiting for the previous diff to finish before starting the next
* one.
*
* @param newFiles The new files that have been added.
* Private but used in tests.
*/
async expandedFilesChanged(oldFiles: Array<PatchSetFile>) {
// Clear content for any diffs that are not open so if they get re-opened
// the stale content does not flash before it is cleared and reloaded.
const collapsedDiffs = this.diffs.filter(
diff => this.expandedFiles.findIndex(f => f.path === diff.path) === -1
);
this.clearCollapsedDiffs(collapsedDiffs);
this.filesExpanded = this.computeExpandedFiles();
const newFiles = this.expandedFiles.filter(
file => (oldFiles ?? []).findIndex(f => f.path === file.path) === -1
);
// Required so that the newly created diff view is included in this.diffs.
await this.updateComplete;
if (newFiles.length) {
await this.renderInOrder(newFiles, this.diffs);
}
this.updateDiffCursor();
this.diffCursor?.reInitAndUpdateStops();
}
// private but used in test
clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
for (const diff of collapsedDiffs) {
diff.cancel();
diff.clearDiffContent();
}
}
/**
* Given an array of paths and a NodeList of diff elements, render the diff
* for each path in order, awaiting the previous render to complete before
* continuing.
*
* private but used in test
*
* @param initialCount The total number of paths in the pass.
*/
async renderInOrder(files: PatchSetFile[], diffElements: GrDiffHost[]) {
this.reporting.time(Timing.FILE_EXPAND_ALL);
for (const file of files) {
const path = file.path;
const diffElem = this.findDiffByPath(path, diffElements);
if (!diffElem) {
this.reporting.error(
'GrFileList',
new Error(`Did not find <gr-diff-host> element for ${path}`)
);
return;
}
diffElem.prefetchDiff();
}
await asyncForeach(files, async (file, cancel) => {
const path = file.path;
this.cancelForEachDiff = cancel;
const diffElem = this.findDiffByPath(path, diffElements);
if (!diffElem) {
this.reporting.error(
'GrFileList',
new Error(`Did not find <gr-diff-host> element for ${path}`)
);
return;
}
if (!this.diffPrefs) {
throw new Error('diffPrefs must be set');
}
// When one file is expanded individually then automatically mark as
// reviewed, if the user's diff prefs request it. Doing this for
// "Expand All" would not be what the user wants, because there is no
// control over which diffs were actually seen. And for lots of diffs
// that would even be a problem for write QPS quota.
if (
this.loggedIn &&
!this.diffPrefs.manual_review &&
files.length === 1
) {
await this.reviewFile(path, true);
}
await diffElem.reload();
});
this.cancelForEachDiff = undefined;
this.reporting.timeEnd(Timing.FILE_EXPAND_ALL, {
count: files.length,
height: this.clientHeight,
});
/*
* Block diff cursor from auto scrolling after files are done rendering.
* This prevents the bug where the screen jumps to the first diff chunk
* after files are done being rendered after the user has already begun
* scrolling.
* This also however results in the fact that the cursor does not auto
* focus on the first diff chunk on a small screen. This is however, a use
* case we are willing to not support for now.
* Using reInit resulted in diffCursor.row being set which
* prevented the issue of scrolling to top when we expand the second
* file individually.
*/
this.diffCursor?.reInitAndUpdateStops();
}
/** Cancel the rendering work of every diff in the list */
private cancelDiffs() {
if (this.cancelForEachDiff) {
this.cancelForEachDiff();
}
this.forEachDiff(d => d.cancel());
}
/**
* In the given NodeList of diff elements, find the diff for the given path.
*/
private findDiffByPath(path: string, diffElements: GrDiffHost[]) {
for (let i = 0; i < diffElements.length; i++) {
if (diffElements[i].path === path) {
return diffElements[i];
}
}
return undefined;
}
// Private but used in tests.
handleEscKey() {
this.displayLine = false;
}
/**
* Compute size bar layout values from the file list.
* Private but used in tests.
*/
computeSizeBarLayout() {
const stats: SizeBarLayout = createDefaultSizeBarLayout();
this.shownFiles
.filter(f => !isMagicPath(f.__path))
.forEach(f => {
if (f.lines_inserted) {
stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
}
if (f.lines_deleted) {
stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
}
});
const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
if (!isNaN(ratio)) {
stats.maxAdditionWidth =
(SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
stats.maxDeletionWidth =
SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
}
return stats;
}
/**
* Get the width of the addition bar for a file.
* Private but used in tests.
*/
computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
if (
!file ||
!stats ||
stats.maxInserted === 0 ||
!file.lines_inserted ||
!!isMagicPath(file.__path)
) {
return 0;
}
const width =
(stats.maxAdditionWidth * file.lines_inserted) / stats.maxInserted;
return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
}
/**
* Get the x-offset of the addition bar for a file.
* Private but used in tests.
*/
computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
if (!file || !stats) return;
return stats.maxAdditionWidth - this.computeBarAdditionWidth(file, stats);
}
/**
* Get the width of the deletion bar for a file.
* Private but used in tests.
*/
computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
if (
!file ||
!stats ||
stats.maxDeleted === 0 ||
!file.lines_deleted ||
!!isMagicPath(file.__path)
) {
return 0;
}
const width =
(stats.maxDeletionWidth * file.lines_deleted) / stats.maxDeleted;
return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
}
/**
* Get the x-offset of the deletion bar for a file.
*/
private computeBarDeletionX(stats: SizeBarLayout) {
return stats.deletionOffset;
}
// Private but used in tests.
computeSizeBarsClass(path?: string) {
let hideClass = '';
if (!this.showSizeBars) {
hideClass = 'hide';
} else if (isMagicPath(path)) {
hideClass = 'invisible';
}
return `sizeBars ${hideClass}`;
}
/**
* Shows registered dynamic columns iff the 'header', 'content' and
* 'summary' endpoints are registered the exact same number of times.
* Ideally, there should be a better way to enforce the expectation of the
* dependencies between dynamic endpoints.
*/
private computeShowDynamicColumns() {
return !!(
this.dynamicHeaderEndpoints &&
this.dynamicContentEndpoints &&
this.dynamicSummaryEndpoints &&
this.dynamicHeaderEndpoints.length &&
this.dynamicHeaderEndpoints.length ===
this.dynamicContentEndpoints.length &&
this.dynamicHeaderEndpoints.length === this.dynamicSummaryEndpoints.length
);
}
/**
* Shows registered dynamic prepended columns iff the 'header', 'content'
* endpoints are registered the exact same number of times.
*/
private computeShowPrependedDynamicColumns() {
return !!(
this.dynamicPrependedHeaderEndpoints &&
this.dynamicPrependedContentEndpoints &&
this.dynamicPrependedHeaderEndpoints.length &&
this.dynamicPrependedHeaderEndpoints.length ===
this.dynamicPrependedContentEndpoints.length
);
}
/**
* Returns true if none of the inline diffs have been expanded.
* Private but used in tests.
*/
noDiffsExpanded() {
return this.filesExpanded === FilesExpandedState.NONE;
}
/**
* Method to call via binding when each file list row is rendered. This
* allows approximate detection of when the dom-repeat has completed
* rendering.
*
* @param index The index of the row being rendered.
* Private but used in tests.
*/
reportRenderedRow(index: number) {
if (index === this.shownFiles.length - 1) {
setTimeout(() => {
this.reporting.timeEnd(Timing.FILE_RENDER, {
count: this.reportinShownFilesIncrement,
});
}, 1);
}
}
private handleReloadingDiffPreference() {
this.getUserModel().getDiffPreferences();
}
private getOldPath(file: NormalizedFileInfo) {
// The gr-endpoint-decorator is waiting until all gr-endpoint-param
// values are updated.
// The old_path property is undefined for added files, and the
// gr-endpoint-param value bound to file.old_path is never updates.
// As a results, the gr-endpoint-decorator doesn't work for added files.
// As a workaround, this method returns null instead of undefined.
return file.old_path ?? null;
}
}