// 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. var PATCH_DESC_MAX_LENGTH = 500; var WARN_SHOW_ALL_THRESHOLD = 1000; var COMMIT_MESSAGE_PATH = '/COMMIT_MSG'; var FileStatus = { A: 'Added', C: 'Copied', D: 'Deleted', R: 'Renamed', W: 'Rewritten', }; Polymer({ is: 'gr-file-list', properties: { patchRange: { type: Object, observer: '_updateSelected', }, patchNum: String, changeNum: String, comments: Object, drafts: Object, revisions: Object, projectConfig: Object, selectedIndex: { type: Number, notify: true, }, keyEventTarget: { type: Object, value: function() { return document.body; }, }, change: Object, diffViewMode: { type: String, notify: true, }, _files: { type: Array, observer: '_filesChanged', value: function() { return []; }, }, _loggedIn: { type: Boolean, value: false, }, _reviewed: { type: Array, value: function() { return []; }, }, _diffAgainst: String, _diffPrefs: Object, _userPrefs: Object, _localPrefs: Object, _showInlineDiffs: Boolean, numFilesShown: { type: Number, notify: true, }, _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.*)', }, // Caps the number of files that can be shown and have the 'show diffs' / // 'hide diffs' buttons still be functional. _maxFilesForBulkActions: { type: Number, readOnly: true, value: 225, }, _expandedFilePaths: { type: Array, value: function() { return []; }, }, }, behaviors: [ Gerrit.BaseUrlBehavior, Gerrit.KeyboardShortcutBehavior, Gerrit.PatchSetBehavior, Gerrit.URLEncodingBehavior, ], observers: [ '_expandedPathsChanged(_expandedFilePaths.splices)', '_setReviewedFiles(_shownFiles, _files, _reviewed.*, _loggedIn)', ], keyBindings: { 'shift+left': '_handleShiftLeftKey', 'shift+right': '_handleShiftRightKey', 'i': '_handleIKey', 'shift+i': '_handleCapitalIKey', 'down j': '_handleDownKey', 'up k': '_handleUpKey', 'c': '_handleCKey', '[': '_handleLeftBracketKey', ']': '_handleRightBracketKey', 'o enter': '_handleEnterKey', 'n': '_handleNKey', 'p': '_handlePKey', 'shift+a': '_handleCapitalAKey', }, reload: function() { if (!this.changeNum || !this.patchRange.patchNum) { return Promise.resolve(); } this._collapseAllDiffs(); var promises = []; var _this = this; promises.push(this._getFiles().then(function(files) { _this._files = files; })); promises.push(this._getLoggedIn().then(function(loggedIn) { return _this._loggedIn = loggedIn; }).then(function(loggedIn) { if (!loggedIn) { return; } return _this._getReviewedFiles().then(function(reviewed) { _this._reviewed = reviewed; }); })); this._localPrefs = this.$.storage.getPreferences(); promises.push(this._getDiffPreferences().then(function(prefs) { this._diffPrefs = prefs; }.bind(this))); promises.push(this._getPreferences().then(function(prefs) { this._userPrefs = prefs; if (!this.diffViewMode) { this.set('diffViewMode', prefs.default_diff_view); } }.bind(this))); }, get diffs() { return Polymer.dom(this.root).querySelectorAll('gr-diff'); }, _calculatePatchChange: function(files) { var filesNoCommitMsg = files.filter(function(files) { return files.__path !== '/COMMIT_MSG'; }); return filesNoCommitMsg.reduce(function(acc, obj) { var inserted = obj.lines_inserted ? obj.lines_inserted : 0; var deleted = obj.lines_deleted ? obj.lines_deleted : 0; var total_size = (obj.size && obj.binary) ? obj.size : 0; var size_delta_inserted = obj.binary && obj.size_delta > 0 ? obj.size_delta : 0; var 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: function() { return this.$.restAPI.getDiffPreferences(); }, _getPreferences: function() { return this.$.restAPI.getPreferences(); }, _computePatchSets: function(revisionRecord) { var revisions = revisionRecord.base; var patchNums = []; for (var commit in revisions) { if (revisions.hasOwnProperty(commit)) { patchNums.push({ num: revisions[commit]._number, desc: revisions[commit].description, }); } } return patchNums.sort(function(a, b) { return a.num - b.num; }); }, _computePatchSetDisabled: function(patchNum, currentPatchNum) { return parseInt(patchNum, 10) >= parseInt(currentPatchNum, 10); }, _togglePathExpanded: function(path) { // Is the path in the list of expanded diffs? IF so remove it, otherwise // add it to the list. var pathIndex = this._expandedFilePaths.indexOf(path); if (pathIndex === -1) { this.push('_expandedFilePaths', path); } else { this.splice('_expandedFilePaths', pathIndex, 1); } }, _togglePathExpandedByIndex: function(index) { this._togglePathExpanded(this._files[index].__path); }, _handlePatchChange: function(e) { var patchRange = Object.assign({}, this.patchRange); patchRange.basePatchNum = Polymer.dom(e).rootTarget.value; page.show(this.encodeURL('/c/' + this.changeNum + '/' + this._patchRangeStr(patchRange), true)); }, _forEachDiff: function(fn) { var diffs = this.diffs; for (var i = 0; i < diffs.length; i++) { fn(diffs[i]); } }, _expandAllDiffs: function(e) { this._showInlineDiffs = true; // Find the list of paths that are in the file list, but not in the // expanded list. var newPaths = []; var path; for (var i = 0; i < this._shownFiles.length; i++) { path = this._shownFiles[i].__path; if (this._expandedFilePaths.indexOf(path) === -1) { newPaths.push(path); } } this.splice.apply(this, ['_expandedFilePaths', 0, 0].concat(newPaths)); }, _collapseAllDiffs: function(e) { this._showInlineDiffs = false; this._expandedFilePaths = []; this.$.diffCursor.handleDiffUpdate(); }, _computeCommentsString: function(comments, patchNum, path) { return this._computeCountString(comments, patchNum, path, 'comment'); }, _computeDraftsString: function(drafts, patchNum, path) { return this._computeCountString(drafts, patchNum, path, 'draft'); }, _computeDraftsStringMobile: function(drafts, patchNum, path) { var draftCount = this._computeCountString(drafts, patchNum, path); return draftCount ? draftCount + 'd' : ''; }, _computeCommentsStringMobile: function(comments, patchNum, path) { var commentCount = this._computeCountString(comments, patchNum, path); return commentCount ? commentCount + 'c' : ''; }, _getCommentsForPath: function(comments, patchNum, path) { return (comments[path] || []).filter(function(c) { return parseInt(c.patch_set, 10) === parseInt(patchNum, 10); }); }, _computeCountString: function(comments, patchNum, path, opt_noun) { if (!comments) { return ''; } var patchComments = this._getCommentsForPath(comments, patchNum, path); var num = patchComments.length; if (num === 0) { return ''; } if (!opt_noun) { return num; } var output = num + ' ' + opt_noun + (num > 1 ? 's' : ''); return output; }, /** * Computes a string counting the number of unresolved comment threads in a * given file and path. * * @param {Object} comments * @param {Object} drafts * @param {number} patchNum * @param {string} path * @return {string} */ _computeUnresolvedString: function(comments, drafts, patchNum, path) { comments = this._getCommentsForPath(comments, patchNum, path); drafts = this._getCommentsForPath(drafts, patchNum, path); comments = comments.concat(drafts); // Create an object where every comment ID is the key of an unresolved // comment. var idMap = comments.reduce(function(acc, comment) { if (comment.unresolved) { acc[comment.id] = true; } return acc; }, {}); // Set false for the comments that are marked as parents. comments.forEach(function(comment) { idMap[comment.in_reply_to] = false; }); // The unresolved comments are the comments that still have true. var unresolvedLeaves = Object.keys(idMap).filter(function(key) { return idMap[key]; }); return unresolvedLeaves.length === 0 ? '' : '(' + unresolvedLeaves.length + ' unresolved)'; }, _computeReviewed: function(file, _reviewed) { return _reviewed.indexOf(file.__path) !== -1; }, _handleReviewedChange: function(e) { this._reviewFile(Polymer.dom(e).rootTarget.getAttribute('data-path')); }, _reviewFile: function(path) { var index = this._reviewed.indexOf(path); var reviewed = index !== -1; if (reviewed) { this.splice('_reviewed', index, 1); } else { this.push('_reviewed', path); } this._saveReviewedState(path, !reviewed); }, _saveReviewedState: function(path, reviewed) { return this.$.restAPI.saveFileReviewed(this.changeNum, this.patchRange.patchNum, path, reviewed); }, _getLoggedIn: function() { return this.$.restAPI.getLoggedIn(); }, _getReviewedFiles: function() { return this.$.restAPI.getReviewedFiles(this.changeNum, this.patchRange.patchNum); }, _getFiles: function() { return this.$.restAPI.getChangeFilesAsSpeciallySortedArray( this.changeNum, this.patchRange).then(function(files) { // Append UI-specific properties. return files.map(function(file) { return file; }); }); }, /** * Handle all events from the file list dom-repeat so event handleers don't * have to get registered for potentially very long lists. */ _handleFileListTap: function(e) { // Handle checkbox mark as reviewed. if (e.target.classList.contains('reviewed')) { return this._handleReviewedChange(e); } // Check to see if the file should be expanded. var path = e.target.dataset.path || e.target.parentElement.dataset.path; // If the user prefers to expand inline diffs rather than opening the diff // view, intercept the click event. if (!path || e.detail.sourceEvent.metaKey || e.detail.sourceEvent.ctrlKey) { return; } if (e.target.dataset.expand || this._userPrefs && this._userPrefs.expand_inline_diffs) { e.preventDefault(); this._togglePathExpanded(path); } }, _handleShiftLeftKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } if (!this._showInlineDiffs) { return; } e.preventDefault(); this.$.diffCursor.moveLeft(); }, _handleShiftRightKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } if (!this._showInlineDiffs) { return; } e.preventDefault(); this.$.diffCursor.moveRight(); }, _handleIKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e) || this.$.fileCursor.index === -1) { return; } e.preventDefault(); this._togglePathExpandedByIndex(this.$.fileCursor.index); }, _handleCapitalIKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); this._toggleInlineDiffs(); }, _handleDownKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } e.preventDefault(); if (this._showInlineDiffs) { this.$.diffCursor.moveDown(); } else { this.$.fileCursor.next(); this.selectedIndex = this.$.fileCursor.index; } }, _handleUpKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } e.preventDefault(); if (this._showInlineDiffs) { this.$.diffCursor.moveUp(); } else { this.$.fileCursor.previous(); this.selectedIndex = this.$.fileCursor.index; } }, _handleCKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } var isRangeSelected = this.diffs.some(function(diff) { return diff.isRangeSelected(); }, this); if (this._showInlineDiffs && !isRangeSelected) { e.preventDefault(); this._addDraftAtTarget(); } }, _handleLeftBracketKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); this._openSelectedFile(this._files.length - 1); }, _handleRightBracketKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); this._openSelectedFile(0); }, _handleEnterKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } // Use native handling if an anchor is selected. @see Issue 5754 if (e.detail && e.detail.keyboardEvent && e.detail.keyboardEvent.target && e.detail.keyboardEvent.target.tagName === 'A') { return; } e.preventDefault(); if (this._showInlineDiffs) { this._openCursorFile(); } else { this._openSelectedFile(); } }, _handleNKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) { return; } if (!this._showInlineDiffs) { return; } e.preventDefault(); if (this.isModifierPressed(e, 'shiftKey')) { this.$.diffCursor.moveToNextCommentThread(); } else { this.$.diffCursor.moveToNextChunk(); } }, _handlePKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) { return; } if (!this._showInlineDiffs) { return; } e.preventDefault(); if (this.isModifierPressed(e, 'shiftKey')) { this.$.diffCursor.moveToPreviousCommentThread(); } else { this.$.diffCursor.moveToPreviousChunk(); } }, _handleCapitalAKey: function(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); this._forEachDiff(function(diff) { diff.toggleLeftDiff(); }); }, _toggleInlineDiffs: function() { if (this._showInlineDiffs) { this._collapseAllDiffs(); } else { this._expandAllDiffs(); } }, _openCursorFile: function() { var diff = this.$.diffCursor.getTargetDiffElement(); page.show(this._computeDiffURL(diff.changeNum, diff.patchRange, diff.path)); }, _openSelectedFile: function(opt_index) { if (opt_index != null) { this.$.fileCursor.setCursorAtIndex(opt_index); } page.show(this._computeDiffURL(this.changeNum, this.patchRange, this._files[this.$.fileCursor.index].__path)); }, _addDraftAtTarget: function() { var diff = this.$.diffCursor.getTargetDiffElement(); var target = this.$.diffCursor.getTargetLineElement(); if (diff && target) { diff.addDraftAtLine(target); } }, _shouldHideChangeTotals: function(_patchChange) { return _patchChange.inserted === 0 && _patchChange.deleted === 0; }, _shouldHideBinaryChangeTotals: function(_patchChange) { return _patchChange.size_delta_inserted === 0 && _patchChange.size_delta_deleted === 0; }, _computeFileStatus: function(status) { return status || 'M'; }, _computeDiffURL: function(changeNum, patchRange, path) { return this.encodeURL(this.getBaseUrl() + '/c/' + changeNum + '/' + this._patchRangeStr(patchRange) + '/' + path, true); }, _patchRangeStr: function(patchRange) { return patchRange.basePatchNum !== 'PARENT' ? patchRange.basePatchNum + '..' + patchRange.patchNum : patchRange.patchNum + ''; }, _computeFileDisplayName: function(path) { return path === COMMIT_MESSAGE_PATH ? 'Commit message' : path; }, _computeTruncatedFileDisplayName: function(path) { return path === COMMIT_MESSAGE_PATH ? 'Commit message' : util.truncatePath(path); }, _formatBytes: function(bytes) { if (bytes == 0) return '+/-0 B'; var bits = 1024; var decimals = 1; var sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; var exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits)); var prepend = bytes > 0 ? '+' : ''; return prepend + parseFloat((bytes / Math.pow(bits, exponent)) .toFixed(decimals)) + ' ' + sizes[exponent]; }, _formatPercentage: function(size, delta) { var oldSize = size - delta; if (oldSize === 0) { return ''; } var percentage = Math.round(Math.abs(delta * 100 / oldSize)); return '(' + (delta > 0 ? '+' : '-') + percentage + '%)'; }, _computeBinaryClass: function(delta) { if (delta === 0) { return; } return delta >= 0 ? 'added' : 'removed'; }, _computeClass: function(baseClass, path) { var classes = [baseClass]; if (path === COMMIT_MESSAGE_PATH) { classes.push('invisible'); } return classes.join(' '); }, _computeExpandInlineClass: function(userPrefs) { return userPrefs.expand_inline_diffs ? 'expandInline' : ''; }, _computePathClass: function(path, expandedFilesRecord) { return this._isFileExpanded(path, expandedFilesRecord) ? 'path expanded' : 'path'; }, _computeShowHideText: function(path, expandedFilesRecord) { return this._isFileExpanded(path, expandedFilesRecord) ? '▼' : '◀'; }, _computeFilesShown: function(numFilesShown, files) { return files.base.slice(0, numFilesShown); }, _setReviewedFiles: function(shownFiles, files, reviewedRecord, loggedIn) { if (!loggedIn) { return; } var reviewed = reviewedRecord.base; var fileReviewed; for (var i = 0; i < files.length; i++) { fileReviewed = this._computeReviewed(files[i], reviewed); this._files[i].isReviewed = fileReviewed; if (i < shownFiles.length) { this.set(['_shownFiles', i, 'isReviewed'], fileReviewed); } } }, _updateDiffCursor: function() { var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff'); // Overwrite the cursor's list of diffs: this.$.diffCursor.splice.apply(this.$.diffCursor, ['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements)); }, _filesChanged: function(files) { Polymer.dom.flush(); var files = Polymer.dom(this.root).querySelectorAll('.file-row'); this.$.fileCursor.stops = files; this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true); }, _incrementNumFilesShown: function() { this.numFilesShown += this.fileListIncrement; }, _computeFileListButtonHidden: function(numFilesShown, files) { return numFilesShown >= files.length; }, _computeIncrementText: function(numFilesShown, files) { if (!files) { return ''; } var text = Math.min(this.fileListIncrement, files.length - numFilesShown); return 'Show ' + text + ' more'; }, _computeShowAllText: function(files) { if (!files) { return ''; } return 'Show all ' + files.length + ' files'; }, _computeWarnShowAll: function(files) { return files.length > WARN_SHOW_ALL_THRESHOLD; }, _computeShowAllWarning: function(files) { if (!this._computeWarnShowAll(files)) { return ''; } return 'Warning: showing all ' + files.length + ' files may take several seconds.'; }, _showAllFiles: function() { this.numFilesShown = this._files.length; }, _updateSelected: function(patchRange) { this._diffAgainst = patchRange.basePatchNum; }, /** * _getDiffViewMode: Get the diff view (side-by-side or unified) based on * the current state. * * The expected behavior is to use the mode specified in the user's * preferences unless they have manually chosen the alternative view. * * Use side-by-side if there is no view mode or preferences. * * @return {String} */ _getDiffViewMode: function(diffViewMode, userPrefs) { if (diffViewMode) { return diffViewMode; } else if (userPrefs) { return this.diffViewMode = userPrefs.default_diff_view; } return 'SIDE_BY_SIDE'; }, _fileListActionsVisible: function(shownFilesRecord, maxFilesForBulkActions) { return shownFilesRecord.base.length <= maxFilesForBulkActions; }, _computePatchSetDescription: function(revisions, patchNum) { var rev = this.getRevisionByPatchNum(revisions, patchNum); return (rev && rev.description) ? rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : ''; }, _computeFileStatusLabel: function(status) { var statusCode = this._computeFileStatus(status); return FileStatus.hasOwnProperty(statusCode) ? FileStatus[statusCode] : 'Status Unknown'; }, _isFileExpanded: function(path, expandedFilesRecord) { return expandedFilesRecord.base.indexOf(path) !== -1; }, _onLineSelected: function(e, detail) { this.$.diffCursor.moveToLineNumber(detail.number, detail.side, detail.path); }, /** * 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 {splice} record The splice record in the expanded paths list. */ _expandedPathsChanged: function(record) { if (!record) { return; } // Find the paths introduced by the new index splices: var newPaths = record.indexSplices .map(function(splice) { return splice.object.slice(splice.index, splice.index + splice.addedCount); }) .reduce(function(acc, paths) { return acc.concat(paths); }, []); var timerName = 'Expand ' + newPaths.length + ' diffs'; this.$.reporting.time(timerName); // Required so that the newly created diff view is included in this.diffs. Polymer.dom.flush(); this._renderInOrder(newPaths, this.diffs, newPaths.length) .then(function() { this.$.reporting.timeEnd(timerName); this.$.diffCursor.handleDiffUpdate(); }.bind(this)); this._updateDiffCursor(); this.$.diffCursor.handleDiffUpdate(); }, /** * 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} paths * @param {!NodeList} diffElements * @param {Number} initialCount The total number of paths in the pass. This * is used to generate log messages. * @return {!Promise} */ _renderInOrder: function(paths, diffElements, initialCount) { if (!paths.length) { console.log('Finished expanding', initialCount, 'diff(s)'); return Promise.resolve(); } console.log('Expanding diff', 1 + initialCount - paths.length, 'of', initialCount, ':', paths[0]); var diffElem = this._findDiffByPath(paths[0], diffElements); var promises = [diffElem.reload()]; if (this._isLoggedIn) { promises.push(this._reviewFile(paths[0])); } return Promise.all(promises).then(function() { return this._renderInOrder(paths.slice(1), diffElements, initialCount); }.bind(this)); }, /** * In the given NodeList of diff elements, find the diff for the given path. * @param {!String} path * @param {!NodeList} diffElements * @return {!GrDiffElement} */ _findDiffByPath: function(path, diffElements) { for (var i = 0; i < diffElements.length; i++) { if (diffElements[i].path === path) { return diffElements[i]; } } }, }); })();