/** * @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 {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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 {asyncForeach} from '../../../utils/async-util.js'; import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.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 {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js'; import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js'; import {appContext} from '../../../services/app-context.js'; import {SpecialFilePath} from '../../../constants/constants.js'; import {descendedFromClass} from '../../../utils/dom-util.js'; import {getRevisionByPatchNum} from '../../../utils/patch-set-util.js'; import { addUnmodifiedFiles, computeDisplayPath, computeTruncatedPath, isMagicPath, specialFilePathCompare, } from '../../../utils/path-list-util.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', }; const FILE_ROW_CLASS = 'file-row'; /** * 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 KeyboardShortcutMixin( 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} */ _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} */ _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} */ _dynamicHeaderEndpoints: { type: Array, }, /** @type {Array} */ _dynamicContentEndpoints: { type: Array, }, /** @type {Array} */ _dynamicSummaryEndpoints: { type: Array, }, /** @type {Array} */ _dynamicPrependedHeaderEndpoints: { type: Array, }, /** @type {Array} */ _dynamicPrependedContentEndpoints: { type: Array, }, }; } static get observers() { return [ '_expandedFilesChanged(_expandedFiles.splices)', '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' + '_loading)', ]; } get keyBindings() { return { esc: '_handleEscKey', }; } keyboardShortcuts() { return { [Shortcut.LEFT_PANE]: '_handleLeftPane', [Shortcut.RIGHT_PANE]: '_handleRightPane', [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff', [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs', [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]: '_handleToggleHideAllCommentThreads', [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext', [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev', [Shortcut.NEXT_LINE]: '_handleCursorNext', [Shortcut.PREV_LINE]: '_handleCursorPrev', [Shortcut.NEW_COMMENT]: '_handleNewComment', [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile', [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile', [Shortcut.OPEN_FILE]: '_handleOpenFile', [Shortcut.NEXT_CHUNK]: '_handleNextChunk', [Shortcut.PREV_CHUNK]: '_handlePrevChunk', [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed', [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane', // Final two are actually handled by gr-comment-thread. [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null, [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(); getPluginLoader().awaitPluginsLoaded() .then(() => { this._dynamicHeaderEndpoints = getPluginEndpoints() .getDynamicEndpoints('change-view-file-list-header'); this._dynamicContentEndpoints = getPluginEndpoints() .getDynamicEndpoints('change-view-file-list-content'); this._dynamicPrependedHeaderEndpoints = getPluginEndpoints() .getDynamicEndpoints('change-view-file-list-header-prepend'); this._dynamicPrependedContentEndpoints = getPluginEndpoints() .getDynamicEndpoints('change-view-file-list-content-prepend'); this._dynamicSummaryEndpoints = getPluginEndpoints() .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 = 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 => !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].includes(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].includes(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].includes(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].includes(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); } /** * * @returns {!Array} */ _normalizeChangeFilesResponse(response) { if (!response) { return []; } const paths = Object.keys(response).sort(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; } /** * 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 */ _isClickEvent(e) { if (e.type === 'click') { return true; } const isSpaceOrEnter = (e.key === 'Enter' || e.key === ' '); return e.type === 'keydown' && isSpaceOrEnter; } _fileActionClick(e, fileAction) { 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); } } _reviewedClick(e) { this._fileActionClick(e, file => this._reviewFile(file.path)); } _expandedClick(e) { this._fileActionClick(e, file => this._toggleFileExpanded(file)); } /** * 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) { 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, 'pathLink')) { return; } // Disregard the event if the click target is in the edit controls. if (descendedFromClass(e.target, 'editFileControls')) { return; } e.preventDefault(); this.$.fileCursor.setCursor(fileRow.element); this._toggleFileExpanded(file); } _getFileRowFromEvent(e) { // Traverse upwards to find the row element if the target is not the row. let row = e.target; 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']), element: row, }; } /** * 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(); } _handleToggleHideAllCommentThreads(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } e.preventDefault(); this.toggleClass('hideComments'); } _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, ].includes(undefined)) { return; } // Await all promises resolving from reload. @See Issue 9057 if (loading || !changeComments) { return; } const commentedPaths = changeComments.getPaths(patchRange); const files = {...filesByPath}; 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].includes(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( this.root.querySelectorAll(`.${FILE_ROW_CLASS}`)); 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].includes(undefined)) { return ''; } const rev = 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'; } /** * 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. * * @param {object} val * @return {string} 'true' if val is true-like, otherwise false */ _booleanToString(val) { return val ? 'true' : 'false'; } _isFileExpanded(path, expandedFilesRecord) { return expandedFilesRecord.base.some(f => f.path === path); } _isFileExpandedStr(path, expandedFilesRecord) { return this._booleanToString( this._isFileExpanded(path, expandedFilesRecord)); } _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.reInitAndUpdateStops(); } _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} files * @param {!NodeList} 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; for (const file of files) { const path = file.path; const diffElem = this._findDiffByPath(path, diffElements); if (diffElem) { diffElem.prefetchDiff(); } } return (new Promise(resolve => { this.dispatchEvent(new CustomEvent('reload-drafts', { detail: {resolve}, composed: true, bubbles: true, })); })).then(() => asyncForeach(files, (file, cancel) => { const path = file.path; this._cancelForEachDiff = cancel; iter++; console.info('Expanding diff', iter, 'of', initialCount, ':', path); const diffElem = this._findDiffByPath(path, diffElements); if (!diffElem) { console.warn(`Did not find 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.info('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. * Using handleDiffUpdate 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 */ _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} 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; }); } /** * Wrapper for using in the element template and computed properties */ _computeDisplayPath(path) { return computeDisplayPath(path); } /** * Wrapper for using in the element template and computed properties */ _computeTruncatedPath(path) { return computeTruncatedPath(path); } } customElements.define(GrFileList.is, GrFileList);