Files
gerrit/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
Ben Rohlfs 3cf22a7d6d Fix new change being paired with old comments
This could manifest in old comments being shown in the reply dialog.
But it could also mean that the Change Log was computed based on the
new change with an outdated ChangeComments object.

Change-Id: I6bd9dc8d562d1c3d55d3a8fcf5e0a2ae12497c81
2020-06-02 13:47:52 +02:00

1528 lines
45 KiB
JavaScript

/**
* @license
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '../../../styles/shared-styles.js';
import '../../diff/gr-diff-cursor/gr-diff-cursor.js';
import '../../diff/gr-diff-host/gr-diff-host.js';
import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
import '../../edit/gr-edit-file-controls/gr-edit-file-controls.js';
import '../../shared/gr-button/gr-button.js';
import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
import '../../shared/gr-icons/gr-icons.js';
import '../../shared/gr-linked-text/gr-linked-text.js';
import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
import '../../shared/gr-select/gr-select.js';
import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-file-list_html.js';
import {AsyncForeachBehavior} from '../../../behaviors/async-foreach-behavior/async-foreach-behavior.js';
import {DomUtilBehavior} from '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
import {GrFileListConstants} from '../gr-file-list-constants.js';
import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {appContext} from '../../../services/app-context.js';
import {SpecialFilePath} from '../../../constants/constants.js';
// Maximum length for patch set descriptions.
const PATCH_DESC_MAX_LENGTH = 500;
const WARN_SHOW_ALL_THRESHOLD = 1000;
const LOADING_DEBOUNCE_INTERVAL = 100;
const SIZE_BAR_MAX_WIDTH = 61;
const SIZE_BAR_GAP_WIDTH = 1;
const SIZE_BAR_MIN_WIDTH = 1.5;
const RENDER_TIMING_LABEL = 'FileListRenderTime';
const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
const FileStatus = {
A: 'Added',
C: 'Copied',
D: 'Deleted',
M: 'Modified',
R: 'Renamed',
W: 'Rewritten',
U: 'Unchanged',
};
/**
* 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
*/
/**
* @extends PolymerElement
*/
class GrFileList extends mixinBehaviors( [
AsyncForeachBehavior,
DomUtilBehavior,
KeyboardShortcutBehavior,
PatchSetBehavior,
PathListBehavior,
], GestureEventListeners(
LegacyElementMixin(
PolymerElement))) {
static get template() { return htmlTemplate; }
static get is() { return 'gr-file-list'; }
/**
* Fired when a draft refresh should get triggered
*
* @event reload-drafts
*/
static get properties() {
return {
/** @type {?} */
patchRange: Object,
patchNum: String,
changeNum: String,
/** @type {?} */
changeComments: Object,
drafts: Object,
revisions: Array,
projectConfig: Object,
selectedIndex: {
type: Number,
notify: true,
},
keyEventTarget: {
type: Object,
value() { return document.body; },
},
/** @type {?} */
change: Object,
diffViewMode: {
type: String,
notify: true,
observer: '_updateDiffPreferences',
},
editMode: {
type: Boolean,
observer: '_editModeChanged',
},
filesExpanded: {
type: String,
value: GrFileListConstants.FilesExpandedState.NONE,
notify: true,
},
_filesByPath: Object,
/** @type {!Array<FileInfo>} */
_files: {
type: Array,
observer: '_filesChanged',
value() { return []; },
},
_loggedIn: {
type: Boolean,
value: false,
},
_reviewed: {
type: Array,
value() { return []; },
},
diffPrefs: {
type: Object,
notify: true,
observer: '_updateDiffPreferences',
},
/** @type {?} */
_userPrefs: Object,
_showInlineDiffs: Boolean,
numFilesShown: {
type: Number,
notify: true,
},
/** @type {?} */
_patchChange: {
type: Object,
computed: '_calculatePatchChange(_files)',
},
fileListIncrement: Number,
_hideChangeTotals: {
type: Boolean,
computed: '_shouldHideChangeTotals(_patchChange)',
},
_hideBinaryChangeTotals: {
type: Boolean,
computed: '_shouldHideBinaryChangeTotals(_patchChange)',
},
_shownFiles: {
type: Array,
computed: '_computeFilesShown(numFilesShown, _files)',
},
/**
* The amount of files added to the shown files list the last time it was
* updated. This is used for reporting the average render time.
*/
_reportinShownFilesIncrement: Number,
/** @type {!Array<Gerrit.FileRange>} */
_expandedFiles: {
type: Array,
value() { return []; },
},
_displayLine: Boolean,
_loading: {
type: Boolean,
observer: '_loadingChanged',
},
/** @type {Gerrit.LayoutStats|undefined} */
_sizeBarLayout: {
type: Object,
computed: '_computeSizeBarLayout(_shownFiles.*)',
},
_showSizeBars: {
type: Boolean,
value: true,
computed: '_computeShowSizeBars(_userPrefs)',
},
/** @type {Function} */
_cancelForEachDiff: Function,
_showDynamicColumns: {
type: Boolean,
computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
'_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
},
_showPrependedDynamicColumns: {
type: Boolean,
computed: '_computeShowPrependedDynamicColumns(' +
'_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
},
/** @type {Array<string>} */
_dynamicHeaderEndpoints: {
type: Array,
},
/** @type {Array<string>} */
_dynamicContentEndpoints: {
type: Array,
},
/** @type {Array<string>} */
_dynamicSummaryEndpoints: {
type: Array,
},
/** @type {Array<string>} */
_dynamicPrependedHeaderEndpoints: {
type: Array,
},
/** @type {Array<string>} */
_dynamicPrependedContentEndpoints: {
type: Array,
},
};
}
static get observers() {
return [
'_expandedFilesChanged(_expandedFiles.splices)',
'_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
'_loading)',
];
}
get keyBindings() {
return {
esc: '_handleEscKey',
};
}
keyboardShortcuts() {
return {
[this.Shortcut.LEFT_PANE]: '_handleLeftPane',
[this.Shortcut.RIGHT_PANE]: '_handleRightPane',
[this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
[this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
[this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
[this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
[this.Shortcut.NEXT_LINE]: '_handleCursorNext',
[this.Shortcut.PREV_LINE]: '_handleCursorPrev',
[this.Shortcut.NEW_COMMENT]: '_handleNewComment',
[this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
[this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
[this.Shortcut.OPEN_FILE]: '_handleOpenFile',
[this.Shortcut.NEXT_CHUNK]: '_handleNextChunk',
[this.Shortcut.PREV_CHUNK]: '_handlePrevChunk',
[this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
[this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
// Final two are actually handled by gr-comment-thread.
[this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
[this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
};
}
constructor() {
super();
this.reporting = appContext.reportingService;
}
/** @override */
created() {
super.created();
this.addEventListener('keydown',
e => this._scopedKeydownHandler(e));
}
/** @override */
attached() {
super.attached();
pluginLoader.awaitPluginsLoaded().then(() => {
this._dynamicHeaderEndpoints = pluginEndpoints
.getDynamicEndpoints('change-view-file-list-header');
this._dynamicContentEndpoints = pluginEndpoints
.getDynamicEndpoints('change-view-file-list-content');
this._dynamicPrependedHeaderEndpoints = pluginEndpoints
.getDynamicEndpoints('change-view-file-list-header-prepend');
this._dynamicPrependedContentEndpoints = pluginEndpoints
.getDynamicEndpoints('change-view-file-list-content-prepend');
this._dynamicSummaryEndpoints = pluginEndpoints
.getDynamicEndpoints('change-view-file-list-summary');
if (this._dynamicHeaderEndpoints.length !==
this._dynamicContentEndpoints.length) {
console.warn(
'Different number of dynamic file-list header and content.');
}
if (this._dynamicPrependedHeaderEndpoints.length !==
this._dynamicPrependedContentEndpoints.length) {
console.warn(
'Different number of dynamic file-list header and content.');
}
if (this._dynamicHeaderEndpoints.length !==
this._dynamicSummaryEndpoints.length) {
console.warn(
'Different number of dynamic file-list headers and summary.');
}
});
}
/** @override */
detached() {
super.detached();
this._cancelDiffs();
}
/**
* Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
* events must be scoped to a component level (e.g. `enter`) in order to not
* override native browser functionality.
*
* Context: Issue 7277
*/
_scopedKeydownHandler(e) {
if (e.keyCode === 13) {
// Enter.
this._handleOpenFile(e);
}
}
reload() {
if (!this.changeNum || !this.patchRange.patchNum) {
return Promise.resolve();
}
this._loading = true;
this.collapseAllDiffs();
const promises = [];
promises.push(this._getFiles().then(filesByPath => {
this._filesByPath = filesByPath;
}));
promises.push(this._getLoggedIn()
.then(loggedIn => this._loggedIn = loggedIn)
.then(loggedIn => {
if (!loggedIn) { return; }
return this._getReviewedFiles().then(reviewed => {
this._reviewed = reviewed;
});
}));
promises.push(this._getDiffPreferences().then(prefs => {
this.diffPrefs = prefs;
}));
promises.push(this._getPreferences().then(prefs => {
this._userPrefs = prefs;
}));
return Promise.all(promises).then(() => {
this._loading = false;
this._detectChromiteButler();
this.reporting.fileListDisplayed();
});
}
_detectChromiteButler() {
const hasButler = !!document.getElementById('butler-suggested-owners');
if (hasButler) {
this.reporting.reportExtension('butler');
}
}
get diffs() {
const diffs = dom(this.root).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');
}
openDiffPrefs() {
this.$.diffPreferencesDialog.open();
}
_calculatePatchChange(files) {
const magicFilesExcluded = files.filter(files =>
!this.isMagicPath(files.__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 > 0 ? obj.size_delta : 0;
const size_delta_deleted =
obj.binary && 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,
};
}, {inserted: 0, deleted: 0, size_delta_inserted: 0,
size_delta_deleted: 0, total_size: 0});
}
_getDiffPreferences() {
return this.$.restAPI.getDiffPreferences();
}
_getPreferences() {
return this.$.restAPI.getPreferences();
}
_toggleFileExpanded(file) {
// Is the path in the list of expanded diffs? IF so remove it, otherwise
// add it to the list.
const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
if (pathIndex === -1) {
this.push('_expandedFiles', file);
} else {
this.splice('_expandedFiles', pathIndex, 1);
}
}
_toggleFileExpandedByIndex(index) {
this._toggleFileExpanded(this._computeFileRange(this._files[index]));
}
_updateDiffPreferences() {
if (!this.diffs.length) { return; }
// Re-render all expanded diffs sequentially.
this.reporting.time(EXPAND_ALL_TIMING_LABEL);
this._renderInOrder(this._expandedFiles, this.diffs,
this._expandedFiles.length);
}
_forEachDiff(fn) {
const diffs = this.diffs;
for (let i = 0; i < diffs.length; i++) {
fn(diffs[i]);
}
}
expandAllDiffs() {
this._showInlineDiffs = true;
// Find the list of paths that are in the file list, but not in the
// expanded list.
const newFiles = [];
let path;
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._computeFileRange(this._shownFiles[i]));
}
}
this.splice(...['_expandedFiles', 0, 0].concat(newFiles));
}
collapseAllDiffs() {
this._showInlineDiffs = false;
this._expandedFiles = [];
this.filesExpanded = this._computeExpandedFiles(
this._expandedFiles.length, this._files.length);
this.$.diffCursor.handleDiffUpdate();
}
/**
* Computes a string with the number of comments and unresolved comments.
*
* @param {!Object} changeComments
* @param {!Object} patchRange
* @param {string} path
* @return {string}
*/
_computeCommentsString(changeComments, patchRange, path) {
if ([changeComments, patchRange, path].some(arg => arg === undefined)) {
return '';
}
const unresolvedCount =
changeComments.computeUnresolvedNum({
patchNum: patchRange.basePatchNum,
path,
}) +
changeComments.computeUnresolvedNum({
patchNum: patchRange.patchNum,
path,
});
const commentCount =
changeComments.computeCommentCount({
patchNum: patchRange.basePatchNum,
path,
}) +
changeComments.computeCommentCount({
patchNum: patchRange.patchNum,
path,
});
const commentString = GrCountStringFormatter.computePluralString(
commentCount, 'comment');
const unresolvedString = GrCountStringFormatter.computeString(
unresolvedCount, 'unresolved');
return commentString +
// Add a space if both comments and unresolved
(commentString && unresolvedString ? ' ' : '') +
// Add parentheses around unresolved if it exists.
(unresolvedString ? `(${unresolvedString})` : '');
}
/**
* Computes a string with the number of drafts.
*
* @param {!Object} changeComments
* @param {!Object} patchRange
* @param {string} path
* @return {string}
*/
_computeDraftsString(changeComments, patchRange, path) {
if ([changeComments, patchRange, path].some(arg => arg === undefined)) {
return '';
}
const draftCount =
changeComments.computeDraftCount({
patchNum: patchRange.basePatchNum,
path,
}) +
changeComments.computeDraftCount({
patchNum: patchRange.patchNum,
path,
});
return GrCountStringFormatter.computePluralString(draftCount, 'draft');
}
/**
* Computes a shortened string with the number of drafts.
*
* @param {!Object} changeComments
* @param {!Object} patchRange
* @param {string} path
* @return {string}
*/
_computeDraftsStringMobile(changeComments, patchRange, path) {
if ([changeComments, patchRange, path].some(arg => arg === undefined)) {
return '';
}
const draftCount =
changeComments.computeDraftCount({
patchNum: patchRange.basePatchNum,
path,
}) +
changeComments.computeDraftCount({
patchNum: patchRange.patchNum,
path,
});
return GrCountStringFormatter.computeShortString(draftCount, 'd');
}
/**
* Computes a shortened string with the number of comments.
*
* @param {!Object} changeComments
* @param {!Object} patchRange
* @param {string} path
* @return {string}
*/
_computeCommentsStringMobile(changeComments, patchRange, path) {
if ([changeComments, patchRange, path].some(arg => arg === undefined)) {
return '';
}
const commentCount =
changeComments.computeCommentCount({
patchNum: patchRange.basePatchNum,
path,
}) +
changeComments.computeCommentCount({
patchNum: patchRange.patchNum,
path,
});
return GrCountStringFormatter.computeShortString(commentCount, 'c');
}
/**
* @param {string} path
* @param {boolean=} opt_reviewed
*/
_reviewFile(path, opt_reviewed) {
if (this.editMode) { return; }
const index = this._files.findIndex(file => file.__path === path);
const reviewed = opt_reviewed || !this._files[index].isReviewed;
this.set(['_files', index, 'isReviewed'], reviewed);
if (index < this._shownFiles.length) {
this.notifyPath(`_shownFiles.${index}.isReviewed`);
}
this._saveReviewedState(path, reviewed);
}
_saveReviewedState(path, reviewed) {
return this.$.restAPI.saveFileReviewed(this.changeNum,
this.patchRange.patchNum, path, reviewed);
}
_getLoggedIn() {
return this.$.restAPI.getLoggedIn();
}
_getReviewedFiles() {
if (this.editMode) { return Promise.resolve([]); }
return this.$.restAPI.getReviewedFiles(this.changeNum,
this.patchRange.patchNum);
}
_getFiles() {
return this.$.restAPI.getChangeOrEditFiles(
this.changeNum, this.patchRange);
}
/**
* The closure compiler doesn't realize this.specialFilePathCompare is
* valid.
*
* @returns {!Array<FileInfo>}
*/
_normalizeChangeFilesResponse(response) {
if (!response) { return []; }
const paths = Object.keys(response).sort(this.specialFilePathCompare);
const files = [];
for (let i = 0; i < paths.length; i++) {
const info = response[paths[i]];
info.__path = paths[i];
info.lines_inserted = info.lines_inserted || 0;
info.lines_deleted = info.lines_deleted || 0;
info.size_delta = info.size_delta || 0;
files.push(info);
}
return files;
}
/**
* Handle all events from the file list dom-repeat so event handleers don't
* have to get registered for potentially very long lists.
*/
_handleFileListClick(e) {
// Traverse upwards to find the row element if the target is not the row.
let row = e.target;
while (!row.classList.contains('row') && row.parentElement) {
row = row.parentElement;
}
// No action needed for item without a valid file
if (!row.dataset.file) {
return;
}
const file = JSON.parse(row.dataset.file);
const path = file.path;
// Handle checkbox mark as reviewed.
if (e.target.classList.contains('markReviewed')) {
e.preventDefault();
return this._reviewFile(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 || this.descendedFromClass(e.target, 'pathLink')) { return; }
// Disregard the event if the click target is in the edit controls.
if (this.descendedFromClass(e.target, 'editFileControls')) { return; }
e.preventDefault();
this._toggleFileExpanded(file);
}
/**
* Generates file range from file info object.
*
* @param {FileInfo} file
* @returns {Gerrit.FileRange}
*/
_computeFileRange(file) {
const fileData = {
path: file.__path,
};
if (file.old_path) {
fileData.basePath = file.old_path;
}
return fileData;
}
_handleLeftPane(e) {
if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
return;
}
e.preventDefault();
this.$.diffCursor.moveLeft();
}
_handleRightPane(e) {
if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
return;
}
e.preventDefault();
this.$.diffCursor.moveRight();
}
_handleToggleInlineDiff(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e) ||
this.$.fileCursor.index === -1) { return; }
e.preventDefault();
this._toggleFileExpandedByIndex(this.$.fileCursor.index);
}
_handleToggleAllInlineDiffs(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
e.preventDefault();
this._toggleInlineDiffs();
}
_handleCursorNext(e) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
if (this._showInlineDiffs) {
e.preventDefault();
this.$.diffCursor.moveDown();
this._displayLine = true;
} else {
// Down key
if (this.getKeyboardEvent(e).keyCode === 40) { return; }
e.preventDefault();
this.$.fileCursor.next();
this.selectedIndex = this.$.fileCursor.index;
}
}
_handleCursorPrev(e) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
if (this._showInlineDiffs) {
e.preventDefault();
this.$.diffCursor.moveUp();
this._displayLine = true;
} else {
// Up key
if (this.getKeyboardEvent(e).keyCode === 38) { return; }
e.preventDefault();
this.$.fileCursor.previous();
this.selectedIndex = this.$.fileCursor.index;
}
}
_handleNewComment(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.$.diffCursor.createCommentInPlace();
}
_handleOpenLastFile(e) {
// Check for meta key to avoid overriding native chrome shortcut.
if (this.shouldSuppressKeyboardShortcut(e) ||
this.getKeyboardEvent(e).metaKey) { return; }
e.preventDefault();
this._openSelectedFile(this._files.length - 1);
}
_handleOpenFirstFile(e) {
// Check for meta key to avoid overriding native chrome shortcut.
if (this.shouldSuppressKeyboardShortcut(e) ||
this.getKeyboardEvent(e).metaKey) { return; }
e.preventDefault();
this._openSelectedFile(0);
}
_handleOpenFile(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
if (this._showInlineDiffs) {
this._openCursorFile();
return;
}
this._openSelectedFile();
}
_handleNextChunk(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
(this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
this._noDiffsExpanded()) {
return;
}
e.preventDefault();
if (this.isModifierPressed(e, 'shiftKey')) {
this.$.diffCursor.moveToNextCommentThread();
} else {
this.$.diffCursor.moveToNextChunk();
}
}
_handlePrevChunk(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
(this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
this._noDiffsExpanded()) {
return;
}
e.preventDefault();
if (this.isModifierPressed(e, 'shiftKey')) {
this.$.diffCursor.moveToPreviousCommentThread();
} else {
this.$.diffCursor.moveToPreviousChunk();
}
}
_handleToggleFileReviewed(e) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
e.preventDefault();
if (!this._files[this.$.fileCursor.index]) { return; }
this._reviewFile(this._files[this.$.fileCursor.index].__path);
}
_handleToggleLeftPane(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
e.preventDefault();
this._forEachDiff(diff => {
diff.toggleLeftDiff();
});
}
_toggleInlineDiffs() {
if (this._showInlineDiffs) {
this.collapseAllDiffs();
} else {
this.expandAllDiffs();
}
}
_openCursorFile() {
const diff = this.$.diffCursor.getTargetDiffElement();
GerritNav.navigateToDiff(this.change, diff.path,
diff.patchRange.patchNum, this.patchRange.basePatchNum);
}
/**
* @param {number=} opt_index
*/
_openSelectedFile(opt_index) {
if (opt_index != null) {
this.$.fileCursor.setCursorAtIndex(opt_index);
}
if (!this._files[this.$.fileCursor.index]) { return; }
GerritNav.navigateToDiff(this.change,
this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
this.patchRange.basePatchNum);
}
_addDraftAtTarget() {
const diff = this.$.diffCursor.getTargetDiffElement();
const target = this.$.diffCursor.getTargetLineElement();
if (diff && target) {
diff.addDraftAtLine(target);
}
}
_shouldHideChangeTotals(_patchChange) {
return _patchChange.inserted === 0 && _patchChange.deleted === 0;
}
_shouldHideBinaryChangeTotals(_patchChange) {
return _patchChange.size_delta_inserted === 0 &&
_patchChange.size_delta_deleted === 0;
}
_computeFileStatus(status) {
return status || 'M';
}
_computeDiffURL(change, patchRange, path, editMode) {
// Polymer 2: check for undefined
if ([change, patchRange, path, editMode]
.some(arg => arg === undefined)) {
return;
}
if (editMode && path !== SpecialFilePath.MERGE_LIST) {
return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum,
patchRange.basePatchNum);
}
return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
patchRange.basePatchNum);
}
_formatBytes(bytes) {
if (bytes == 0) 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 ? '+' : '';
return prepend + parseFloat((bytes / Math.pow(bits, exponent))
.toFixed(decimals)) + ' ' + sizes[exponent];
}
_formatPercentage(size, delta) {
const oldSize = size - delta;
if (oldSize === 0) { return ''; }
const percentage = Math.round(Math.abs(delta * 100 / oldSize));
return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
}
_computeBinaryClass(delta) {
if (delta === 0) { return; }
return delta >= 0 ? 'added' : 'removed';
}
/**
* @param {string} baseClass
* @param {string} path
*/
_computeClass(baseClass, path) {
const classes = [];
if (baseClass) {
classes.push(baseClass);
}
if (path === SpecialFilePath.COMMIT_MESSAGE ||
path === SpecialFilePath.MERGE_LIST) {
classes.push('invisible');
}
return classes.join(' ');
}
_computeStatusClass(file) {
const classStr = this._computeClass('status', file.__path);
return `${classStr} ${this._computeFileStatus(file.status)}`;
}
_computePathClass(path, expandedFilesRecord) {
return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
}
_computeShowHideIcon(path, expandedFilesRecord) {
return this._isFileExpanded(path, expandedFilesRecord) ?
'gr-icons:expand-less' : 'gr-icons:expand-more';
}
_computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
// Polymer 2: check for undefined
if ([
filesByPath,
changeComments,
patchRange,
reviewed,
loading,
].some(arg => arg === undefined)) {
return;
}
// Await all promises resolving from reload. @See Issue 9057
if (loading || !changeComments) { return; }
const commentedPaths = changeComments.getPaths(patchRange);
const files = Object.assign({}, filesByPath);
this.addUnmodifiedFiles(files, commentedPaths);
const reviewedSet = new Set(reviewed || []);
for (const filePath in files) {
if (!files.hasOwnProperty(filePath)) { continue; }
files[filePath].isReviewed = reviewedSet.has(filePath);
}
this._files = this._normalizeChangeFilesResponse(files);
}
_computeFilesShown(numFilesShown, files) {
// Polymer 2: check for undefined
if ([numFilesShown, files].some(arg => arg === undefined)) {
return undefined;
}
const previousNumFilesShown = this._shownFiles ?
this._shownFiles.length : 0;
const filesShown = files.slice(0, numFilesShown);
this.dispatchEvent(new CustomEvent('files-shown-changed', {
detail: {length: filesShown.length},
composed: true, bubbles: true,
}));
// Start the timer for the rendering work hwere because this is where the
// _shownFiles property is being set, and _shownFiles is used in the
// dom-repeat binding.
this.reporting.time(RENDER_TIMING_LABEL);
// How many more files are being shown (if it's an increase).
this._reportinShownFilesIncrement =
Math.max(0, filesShown.length - previousNumFilesShown);
return filesShown;
}
_updateDiffCursor() {
// Overwrite the cursor's list of diffs:
this.$.diffCursor.splice(
...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
}
_filesChanged() {
if (this._files && this._files.length > 0) {
flush();
this.$.fileCursor.stops = Array.from(
dom(this.root).querySelectorAll('.file-row'));
this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
}
}
_incrementNumFilesShown() {
this.numFilesShown += this.fileListIncrement;
}
_computeFileListControlClass(numFilesShown, files) {
return numFilesShown >= files.length ? 'invisible' : '';
}
_computeIncrementText(numFilesShown, files) {
if (!files) { return ''; }
const text =
Math.min(this.fileListIncrement, files.length - numFilesShown);
return 'Show ' + text + ' more';
}
_computeShowAllText(files) {
if (!files) { return ''; }
return 'Show all ' + files.length + ' files';
}
_computeWarnShowAll(files) {
return files.length > WARN_SHOW_ALL_THRESHOLD;
}
_computeShowAllWarning(files) {
if (!this._computeWarnShowAll(files)) { return ''; }
return 'Warning: showing all ' + files.length +
' files may take several seconds.';
}
_showAllFiles() {
this.numFilesShown = this._files.length;
}
_computePatchSetDescription(revisions, patchNum) {
// Polymer 2: check for undefined
if ([revisions, patchNum].some(arg => arg === undefined)) {
return '';
}
const rev = this.getRevisionByPatchNum(revisions, patchNum);
return (rev && rev.description) ?
rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
}
/**
* Get a descriptive label for use in the status indicator's tooltip and
* ARIA label.
*
* @param {string} status
* @return {string}
*/
_computeFileStatusLabel(status) {
const statusCode = this._computeFileStatus(status);
return FileStatus.hasOwnProperty(statusCode) ?
FileStatus[statusCode] : 'Status Unknown';
}
_isFileExpanded(path, expandedFilesRecord) {
return expandedFilesRecord.base.some(f => f.path === path);
}
_computeExpandedFiles(expandedCount, totalCount) {
if (expandedCount === 0) {
return GrFileListConstants.FilesExpandedState.NONE;
} else if (expandedCount === totalCount) {
return GrFileListConstants.FilesExpandedState.ALL;
}
return GrFileListConstants.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 {!Array} record The splice record in the expanded paths list.
*/
_expandedFilesChanged(record) {
// 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);
if (!record) { return; } // Happens after "Collapse all" clicked.
this.filesExpanded = this._computeExpandedFiles(
this._expandedFiles.length, this._files.length);
// Find the paths introduced by the new index splices:
const newFiles = record.indexSplices
.map(splice => splice.object.slice(
splice.index, splice.index + splice.addedCount))
.reduce((acc, paths) => acc.concat(paths), []);
// Required so that the newly created diff view is included in this.diffs.
flush();
this.reporting.time(EXPAND_ALL_TIMING_LABEL);
if (newFiles.length) {
this._renderInOrder(newFiles, this.diffs, newFiles.length);
}
this._updateDiffCursor();
this.$.diffCursor.handleDiffUpdate();
}
_clearCollapsedDiffs(collapsedDiffs) {
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.
*
* @param {!Array<Gerrit.FileRange>} files
* @param {!NodeList<!Object>} diffElements (GrDiffHostElement)
* @param {number} initialCount The total number of paths in the pass. This
* is used to generate log messages.
* @return {!Promise}
*/
_renderInOrder(files, diffElements, initialCount) {
let iter = 0;
return (new Promise(resolve => {
this.dispatchEvent(new CustomEvent('reload-drafts', {
detail: {resolve},
composed: true, bubbles: true,
}));
})).then(() => this.asyncForeach(files, (file, cancel) => {
const path = file.path;
this._cancelForEachDiff = cancel;
iter++;
console.log('Expanding diff', iter, 'of', initialCount, ':',
path);
const diffElem = this._findDiffByPath(path, diffElements);
if (!diffElem) {
console.warn(`Did not find <gr-diff-host> element for ${path}`);
return Promise.resolve();
}
diffElem.comments = this.changeComments.getCommentsBySideForFile(
file, this.patchRange, this.projectConfig);
const promises = [diffElem.reload()];
if (this._loggedIn && !this.diffPrefs.manual_review) {
promises.push(this._reviewFile(path, true));
}
return Promise.all(promises);
}).then(() => {
this._cancelForEachDiff = null;
this._nextRenderParams = null;
console.log('Finished expanding', initialCount, 'diff(s)');
this.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
/* 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.
*/
this.$.diffCursor.reInitAndUpdateStops();
}));
}
/** Cancel the rendering work of every diff in the list */
_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.
*
* @param {string} path
* @param {!NodeList<!Object>} diffElements (GrDiffElement)
* @return {!Object|undefined} (GrDiffElement)
*/
_findDiffByPath(path, diffElements) {
for (let i = 0; i < diffElements.length; i++) {
if (diffElements[i].path === path) {
return diffElements[i];
}
}
}
/**
* Reset the comments of a modified thread
*
* @param {string} rootId
* @param {string} path
*/
reloadCommentsForThreadWithRootId(rootId, path) {
// Don't bother continuing if we already know that the path that contains
// the updated comment thread is not expanded.
if (!this._expandedFiles.some(f => f.path === path)) { return; }
const diff = this.diffs.find(d => d.path === path);
const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
if (!threadEl) { return; }
const newComments = this.changeComments.getCommentsForThread(rootId);
// If newComments is null, it means that a single draft was
// removed from a thread in the thread view, and the thread should
// no longer exist. Remove the existing thread element in the diff
// view.
if (!newComments) {
threadEl.fireRemoveSelf();
return;
}
// Comments are not returned with the commentSide attribute from
// the api, but it's necessary to be stored on the diff's
// comments due to use in the _handleCommentUpdate function.
// The comment thread already has a side associated with it, so
// set the comment's side to match.
threadEl.comments = newComments.map(c => Object.assign(
c, {__commentSide: threadEl.commentSide}
));
flush();
}
_handleEscKey(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this._displayLine = false;
}
/**
* Update the loading class for the file list rows. The update is inside a
* debouncer so that the file list doesn't flash gray when the API requests
* are reasonably fast.
*
* @param {boolean} loading
*/
_loadingChanged(loading) {
this.debounce('loading-change', () => {
// Only show set the loading if there have been files loaded to show. In
// this way, the gray loading style is not shown on initial loads.
this.classList.toggle('loading', loading && !!this._files.length);
}, LOADING_DEBOUNCE_INTERVAL);
}
_editModeChanged(editMode) {
this.classList.toggle('editMode', editMode);
}
_computeReviewedClass(isReviewed) {
return isReviewed ? 'isReviewed' : '';
}
_computeReviewedText(isReviewed) {
return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
}
/**
* Given a file path, return whether that path should have visible size bars
* and be included in the size bars calculation.
*
* @param {string} path
* @return {boolean}
*/
_showBarsForPath(path) {
return path !== SpecialFilePath.COMMIT_MESSAGE &&
path !== SpecialFilePath.MERGE_LIST;
}
/**
* Compute size bar layout values from the file list.
*
* @return {Gerrit.LayoutStats|undefined}
*
*/
_computeSizeBarLayout(shownFilesRecord) {
if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
const stats = {
maxInserted: 0,
maxDeleted: 0,
maxAdditionWidth: 0,
maxDeletionWidth: 0,
deletionOffset: 0,
};
shownFilesRecord.base
.filter(f => this._showBarsForPath(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.
*
* @param {Object} file
* @param {Gerrit.LayoutStats} stats
* @return {number}
*/
_computeBarAdditionWidth(file, stats) {
if (stats.maxInserted === 0 ||
!file.lines_inserted ||
!this._showBarsForPath(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.
*
* @param {Object} file
* @param {Gerrit.LayoutStats} stats
* @return {number}
*/
_computeBarAdditionX(file, stats) {
return stats.maxAdditionWidth -
this._computeBarAdditionWidth(file, stats);
}
/**
* Get the width of the deletion bar for a file.
*
* @param {Object} file
* @param {Gerrit.LayoutStats} stats
* @return {number}
*/
_computeBarDeletionWidth(file, stats) {
if (stats.maxDeleted === 0 ||
!file.lines_deleted ||
!this._showBarsForPath(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.
*
* @param {Gerrit.LayoutStats} stats
*
* @return {number}
*/
_computeBarDeletionX(stats) {
return stats.deletionOffset;
}
_computeShowSizeBars(userPrefs) {
return !!userPrefs.size_bar_in_change_table;
}
_computeSizeBarsClass(showSizeBars, path) {
let hideClass = '';
if (!showSizeBars) {
hideClass = 'hide';
} else if (!this._showBarsForPath(path)) {
hideClass = 'invisible';
}
return `sizeBars desktop ${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.
*/
_computeShowDynamicColumns(
headerEndpoints, contentEndpoints, summaryEndpoints) {
return headerEndpoints && contentEndpoints && summaryEndpoints &&
headerEndpoints.length &&
headerEndpoints.length === contentEndpoints.length &&
headerEndpoints.length === summaryEndpoints.length;
}
/**
* Shows registered dynamic prepended columns iff the 'header', 'content'
* endpoints are registered the exact same number of times.
*/
_computeShowPrependedDynamicColumns(
headerEndpoints, contentEndpoints) {
return headerEndpoints && contentEndpoints &&
headerEndpoints.length &&
headerEndpoints.length === contentEndpoints.length;
}
/**
* Returns true if none of the inline diffs have been expanded.
*
* @return {boolean}
*/
_noDiffsExpanded() {
return this.filesExpanded === GrFileListConstants.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 {number} index The index of the row being rendered.
* @return {string} an empty string.
*/
_reportRenderedRow(index) {
if (index === this._shownFiles.length - 1) {
this.async(() => {
this.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
}, 1);
}
return '';
}
_reviewedTitle(reviewed) {
if (reviewed) {
return 'Mark as not reviewed (shortcut: r)';
}
return 'Mark as reviewed (shortcut: r)';
}
_handleReloadingDiffPreference() {
this._getDiffPreferences().then(prefs => {
this.diffPrefs = prefs;
});
}
}
customElements.define(GrFileList.is, GrFileList);