| /** |
| * @license |
| * Copyright (C) 2016 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. |
| */ |
| (function() { |
| 'use strict'; |
| |
| // 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', |
| }; |
| |
| /** |
| * @appliesMixin Gerrit.AsyncForeachMixin |
| * @appliesMixin Gerrit.DomUtilMixin |
| * @appliesMixin Gerrit.FireMixin |
| * @appliesMixin Gerrit.KeyboardShortcutMixin |
| * @appliesMixin Gerrit.PatchSetMixin |
| * @appliesMixin Gerrit.PathListMixin |
| * @extends Polymer.Element |
| */ |
| class GrFileList extends Polymer.mixinBehaviors( [ |
| Gerrit.AsyncForeachBehavior, |
| Gerrit.DomUtilBehavior, |
| Gerrit.FireBehavior, |
| Gerrit.KeyboardShortcutBehavior, |
| Gerrit.PatchSetBehavior, |
| Gerrit.PathListBehavior, |
| ], Polymer.GestureEventListeners( |
| Polymer.LegacyElementMixin( |
| Polymer.Element))) { |
| 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, |
| _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, |
| |
| _expandedFilePaths: { |
| 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)', |
| }, |
| /** @type {Array<string>} */ |
| _dynamicHeaderEndpoints: { |
| type: Array, |
| }, |
| /** @type {Array<string>} */ |
| _dynamicContentEndpoints: { |
| type: Array, |
| }, |
| /** @type {Array<string>} */ |
| _dynamicSummaryEndpoints: { |
| type: Array, |
| }, |
| }; |
| } |
| |
| static get observers() { |
| return [ |
| '_expandedPathsChanged(_expandedFilePaths.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, |
| }; |
| } |
| |
| /** @override */ |
| created() { |
| super.created(); |
| this.addEventListener('keydown', |
| e => this._scopedKeydownHandler(e)); |
| } |
| |
| /** @override */ |
| attached() { |
| super.attached(); |
| Gerrit.awaitPluginsLoaded().then(() => { |
| this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints( |
| 'change-view-file-list-header'); |
| this._dynamicContentEndpoints = Gerrit._endpoints.getDynamicEndpoints( |
| 'change-view-file-list-content'); |
| this._dynamicSummaryEndpoints = Gerrit._endpoints.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._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() { |
| return Array.from( |
| Polymer.dom(this.root).querySelectorAll('gr-diff-host')); |
| } |
| |
| 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(); |
| } |
| |
| _togglePathExpanded(path) { |
| // Is the path in the list of expanded diffs? IF so remove it, otherwise |
| // add it to the list. |
| const pathIndex = this._expandedFilePaths.indexOf(path); |
| if (pathIndex === -1) { |
| this.push('_expandedFilePaths', path); |
| } else { |
| this.splice('_expandedFilePaths', pathIndex, 1); |
| } |
| } |
| |
| _togglePathExpandedByIndex(index) { |
| this._togglePathExpanded(this._files[index].__path); |
| } |
| |
| _updateDiffPreferences() { |
| if (!this.diffs.length) { return; } |
| // Re-render all expanded diffs sequentially. |
| this.$.reporting.time(EXPAND_ALL_TIMING_LABEL); |
| this._renderInOrder(this._expandedFilePaths, this.diffs, |
| this._expandedFilePaths.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 newPaths = []; |
| let path; |
| for (let i = 0; i < this._shownFiles.length; i++) { |
| path = this._shownFiles[i].__path; |
| if (!this._expandedFilePaths.includes(path)) { |
| newPaths.push(path); |
| } |
| } |
| |
| this.splice(...['_expandedFilePaths', 0, 0].concat(newPaths)); |
| } |
| |
| collapseAllDiffs() { |
| this._showInlineDiffs = false; |
| this._expandedFilePaths = []; |
| this.filesExpanded = this._computeExpandedFiles( |
| this._expandedFilePaths.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) { |
| const unresolvedCount = |
| changeComments.computeUnresolvedNum(patchRange.basePatchNum, path) + |
| changeComments.computeUnresolvedNum(patchRange.patchNum, path); |
| const commentCount = |
| changeComments.computeCommentCount(patchRange.basePatchNum, path) + |
| changeComments.computeCommentCount(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) { |
| const draftCount = |
| changeComments.computeDraftCount(patchRange.basePatchNum, path) + |
| changeComments.computeDraftCount(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) { |
| const draftCount = |
| changeComments.computeDraftCount(patchRange.basePatchNum, path) + |
| changeComments.computeDraftCount(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) { |
| const commentCount = |
| changeComments.computeCommentCount(patchRange.basePatchNum, path) + |
| changeComments.computeCommentCount(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. |
| * |
| * @suppress {checkTypes} |
| */ |
| _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; |
| 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; |
| } |
| |
| const path = row.dataset.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._togglePathExpanded(path); |
| } |
| |
| _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._togglePathExpandedByIndex(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(); |
| Gerrit.Nav.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; } |
| Gerrit.Nav.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, patchNum, basePatchNum, path, editMode) { |
| // Polymer 2: check for undefined |
| if ([change, patchNum, basePatchNum, path, editMode] |
| .some(arg => arg === undefined)) { |
| return; |
| } |
| // TODO(kaspern): Fix editing for commit messages and merge lists. |
| if (editMode && path !== this.COMMIT_MESSAGE_PATH && |
| path !== this.MERGE_LIST_PATH) { |
| return Gerrit.Nav.getEditUrlForDiff(change, path, patchNum, |
| basePatchNum); |
| } |
| return Gerrit.Nav.getUrlForDiff(change, path, patchNum, 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 === this.COMMIT_MESSAGE_PATH || path === this.MERGE_LIST_PATH) { |
| classes.push('invisible'); |
| } |
| return classes.join(' '); |
| } |
| |
| _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); |
| Object.keys(commentedPaths).forEach(commentedPath => { |
| if (files.hasOwnProperty(commentedPath)) { return; } |
| files[commentedPath] = {status: 'U'}; |
| }); |
| 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.fire('files-shown-changed', {length: filesShown.length}); |
| |
| // 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) { |
| Polymer.dom.flush(); |
| const files = Array.from( |
| Polymer.dom(this.root).querySelectorAll('.file-row')); |
| this.$.fileCursor.stops = files; |
| 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.includes(path); |
| } |
| |
| _onLineSelected(e, detail) { |
| this.$.diffCursor.moveToLineNumber(detail.number, detail.side, |
| detail.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. |
| */ |
| _expandedPathsChanged(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._expandedFilePaths.indexOf(diff.path) === -1); |
| this._clearCollapsedDiffs(collapsedDiffs); |
| |
| if (!record) { return; } // Happens after "Collapse all" clicked. |
| |
| this.filesExpanded = this._computeExpandedFiles( |
| this._expandedFilePaths.length, this._files.length); |
| |
| // Find the paths introduced by the new index splices: |
| const newPaths = 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. |
| Polymer.dom.flush(); |
| |
| this.$.reporting.time(EXPAND_ALL_TIMING_LABEL); |
| |
| if (newPaths.length) { |
| this._renderInOrder(newPaths, this.diffs, newPaths.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 |
| * continung. |
| * |
| * @param {!Array<string>} paths |
| * @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(paths, diffElements, initialCount) { |
| let iter = 0; |
| |
| return (new Promise(resolve => { |
| this.fire('reload-drafts', {resolve}); |
| })).then(() => this.asyncForeach(paths, (path, cancel) => { |
| this._cancelForEachDiff = cancel; |
| |
| iter++; |
| console.log('Expanding diff', iter, 'of', initialCount, ':', |
| path); |
| const diffElem = this._findDiffByPath(path, diffElements); |
| diffElem.comments = this.changeComments.getCommentsBySideForPath( |
| path, 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); |
| this.$.diffCursor.handleDiffUpdate(); |
| })); |
| } |
| |
| /** 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._expandedFilePaths.includes(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} |
| )); |
| Polymer.dom.flush(); |
| return; |
| } |
| |
| _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 !== this.COMMIT_MESSAGE_PATH && path !== this.MERGE_LIST_PATH; |
| } |
| |
| /** |
| * 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 regiestered 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 === contentEndpoints.length && |
| headerEndpoints.length === summaryEndpoints.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); |
| })(); |