| /** |
| * @license |
| * Copyright (C) 2018 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 '../../shared/gr-rest-api-interface/gr-rest-api-interface.js'; |
| import '../../shared/gr-comment-thread/gr-comment-thread.js'; |
| import '../../shared/gr-js-api-interface/gr-js-api-interface.js'; |
| import '../gr-diff/gr-diff.js'; |
| import '../gr-syntax-layer/gr-syntax-layer.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-diff-host_html.js'; |
| import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder.js'; |
| import {parseDate} from '../../../utils/date-util.js'; |
| import {GerritNav} from '../../core/gr-navigation/gr-navigation.js'; |
| import {DiffSide, rangesEqual} from '../gr-diff/gr-diff-utils.js'; |
| import {appContext} from '../../../services/app-context.js'; |
| import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util.js'; |
| |
| const MSG_EMPTY_BLAME = 'No blame information for this diff.'; |
| |
| const EVENT_AGAINST_PARENT = 'diff-against-parent'; |
| const EVENT_ZERO_REBASE = 'rebase-percent-zero'; |
| const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero'; |
| |
| const DiffViewMode = { |
| SIDE_BY_SIDE: 'SIDE_BY_SIDE', |
| UNIFIED: 'UNIFIED_DIFF', |
| }; |
| |
| /** @enum {string} */ |
| const TimingLabel = { |
| TOTAL: 'Diff Total Render', |
| CONTENT: 'Diff Content Render', |
| SYNTAX: 'Diff Syntax Render', |
| }; |
| |
| // Disable syntax highlighting if the overall diff is too large. |
| const SYNTAX_MAX_DIFF_LENGTH = 20000; |
| |
| // If any line of the diff is more than the character limit, then disable |
| // syntax highlighting for the entire file. |
| const SYNTAX_MAX_LINE_LENGTH = 500; |
| |
| // 120 lines is good enough threshold for full-sized window viewport |
| const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120; |
| |
| const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE'; |
| |
| /** |
| * @param {Object} diff |
| * @return {boolean} |
| */ |
| function isImageDiff(diff) { |
| if (!diff) { return false; } |
| |
| const isA = diff.meta_a && |
| diff.meta_a.content_type.startsWith('image/'); |
| const isB = diff.meta_b && |
| diff.meta_b.content_type.startsWith('image/'); |
| |
| return !!(diff.binary && (isA || isB)); |
| } |
| |
| /** |
| * Wrapper around gr-diff. |
| * |
| * Webcomponent fetching diffs and related data from restAPI and passing them |
| * to the presentational gr-diff for rendering. |
| * |
| * @extends PolymerElement |
| */ |
| class GrDiffHost extends GestureEventListeners( |
| LegacyElementMixin( |
| PolymerElement)) { |
| static get template() { return htmlTemplate; } |
| |
| static get is() { return 'gr-diff-host'; } |
| /** |
| * Fired when the user selects a line. |
| * |
| * @event line-selected |
| */ |
| |
| /** |
| * Fired if being logged in is required. |
| * |
| * @event show-auth-required |
| */ |
| |
| /** |
| * Fired when a comment is saved or discarded |
| * |
| * @event diff-comments-modified |
| */ |
| |
| static get properties() { |
| return { |
| changeNum: String, |
| noAutoRender: { |
| type: Boolean, |
| value: false, |
| }, |
| /** @type {?} */ |
| patchRange: Object, |
| /** @type {!Gerrit.FileRange} */ |
| file: Object, |
| // TODO: deprecate path since that info is included in file |
| path: String, |
| prefs: { |
| type: Object, |
| }, |
| projectName: String, |
| displayLine: { |
| type: Boolean, |
| value: false, |
| }, |
| isImageDiff: { |
| type: Boolean, |
| computed: '_computeIsImageDiff(diff)', |
| notify: true, |
| }, |
| commitRange: Object, |
| // The return type is FilesWebLinks from gr-patch-range-select. |
| filesWeblinks: { |
| type: Object, |
| value() { |
| return {}; |
| }, |
| notify: true, |
| }, |
| hidden: { |
| type: Boolean, |
| reflectToAttribute: true, |
| }, |
| noRenderOnPrefsChange: { |
| type: Boolean, |
| value: false, |
| }, |
| comments: { |
| type: Object, |
| observer: '_commentsChanged', |
| }, |
| lineWrapping: { |
| type: Boolean, |
| value: false, |
| }, |
| viewMode: { |
| type: String, |
| value: DiffViewMode.SIDE_BY_SIDE, |
| }, |
| |
| /** |
| * Special line number which should not be collapsed into a shared region. |
| * |
| * @type {{ |
| * number: number, |
| * leftSide: {boolean} |
| * }|null} |
| */ |
| lineOfInterest: Object, |
| |
| /** |
| * If the diff fails to load, show the failure message in the diff rather |
| * than bubbling the error up to the whole page. This is useful for when |
| * loading inline diffs because one diff failing need not mark the whole |
| * page with a failure. |
| */ |
| showLoadFailure: Boolean, |
| |
| isBlameLoaded: { |
| type: Boolean, |
| notify: true, |
| computed: '_computeIsBlameLoaded(_blame)', |
| }, |
| |
| _loggedIn: { |
| type: Boolean, |
| value: false, |
| }, |
| |
| _loading: { |
| type: Boolean, |
| value: false, |
| }, |
| |
| /** @type {?string} */ |
| _errorMessage: { |
| type: String, |
| value: null, |
| }, |
| |
| /** @type {?Object} */ |
| _baseImage: Object, |
| /** @type {?Object} */ |
| _revisionImage: Object, |
| /** |
| * This is a DiffInfo object. |
| */ |
| diff: { |
| type: Object, |
| notify: true, |
| }, |
| |
| _fetchDiffPromise: { |
| type: Object, |
| value: null, |
| }, |
| |
| /** @type {?Object} */ |
| _blame: { |
| type: Object, |
| value: null, |
| }, |
| |
| /** |
| * @type {!Array<!Gerrit.CoverageRange>} |
| */ |
| _coverageRanges: { |
| type: Array, |
| value: () => [], |
| }, |
| |
| _loadedWhitespaceLevel: String, |
| |
| _parentIndex: { |
| type: Number, |
| computed: '_computeParentIndex(patchRange.*)', |
| }, |
| |
| _syntaxHighlightingEnabled: { |
| type: Boolean, |
| computed: |
| '_isSyntaxHighlightingEnabled(prefs.*, diff)', |
| }, |
| |
| _layers: { |
| type: Array, |
| value: [], |
| }, |
| }; |
| } |
| |
| static get observers() { |
| return [ |
| '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' + |
| ' noRenderOnPrefsChange)', |
| '_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)', |
| ]; |
| } |
| |
| constructor() { |
| super(); |
| this.reporting = appContext.reportingService; |
| } |
| |
| /** @override */ |
| created() { |
| super.created(); |
| this.addEventListener( |
| // These are named inconsistently for a reason: |
| // The create-comment event is fired to indicate that we should |
| // create a comment. |
| // The comment-* events are just notifying that the comments did already |
| // change in some way, and that we should update any models we may want |
| // to keep in sync. |
| 'create-comment', |
| e => this._handleCreateComment(e)); |
| this.addEventListener('comment-discard', |
| e => this._handleCommentDiscard(e)); |
| this.addEventListener('comment-update', |
| e => this._handleCommentUpdate(e)); |
| this.addEventListener('comment-save', |
| e => this._handleCommentSave(e)); |
| this.addEventListener('render-start', |
| () => this._handleRenderStart()); |
| this.addEventListener('render-content', |
| () => this._handleRenderContent()); |
| this.addEventListener('normalize-range', |
| event => this._handleNormalizeRange(event)); |
| this.addEventListener('diff-context-expanded', |
| event => this._handleDiffContextExpanded(event)); |
| } |
| |
| /** @override */ |
| ready() { |
| super.ready(); |
| if (this._canReload()) { |
| this.reload(); |
| } |
| } |
| |
| /** @override */ |
| attached() { |
| super.attached(); |
| this._getLoggedIn().then(loggedIn => { |
| this._loggedIn = loggedIn; |
| }); |
| } |
| |
| /** @override */ |
| detached() { |
| super.detached(); |
| this.clear(); |
| } |
| |
| /** |
| * @param {boolean=} shouldReportMetric indicate a new Diff Page. This is a |
| * signal to report metrics event that started on location change. |
| * @return {!Promise} |
| **/ |
| reload(shouldReportMetric) { |
| this.clear(); |
| this._loading = true; |
| this._errorMessage = null; |
| const whitespaceLevel = this._getIgnoreWhitespace(); |
| |
| const layers = [this.$.syntaxLayer]; |
| // Get layers from plugins (if any). |
| for (const pluginLayer of this.$.jsAPI.getDiffLayers( |
| this.path, this.changeNum, this.patchNum)) { |
| layers.push(pluginLayer); |
| } |
| this._layers = layers; |
| |
| if (shouldReportMetric) { |
| // We listen on render viewport only on DiffPage (on paramsChanged) |
| this._listenToViewportRender(); |
| } |
| |
| this._coverageRanges = []; |
| this._getCoverageData(); |
| const diffRequest = this._getDiff() |
| .then(diff => { |
| this._loadedWhitespaceLevel = whitespaceLevel; |
| this._reportDiff(diff); |
| return diff; |
| }) |
| .catch(e => { |
| this._handleGetDiffError(e); |
| return null; |
| }); |
| |
| const assetRequest = diffRequest.then(diff => { |
| // If the diff is null, then it's failed to load. |
| if (!diff) { return null; } |
| |
| return this._loadDiffAssets(diff); |
| }); |
| |
| // Not waiting for coverage ranges intentionally as |
| // plugin loading should not block the content rendering |
| return Promise.all([diffRequest, assetRequest]) |
| .then(results => { |
| const diff = results[0]; |
| if (!diff) { |
| return Promise.resolve(); |
| } |
| this.filesWeblinks = this._getFilesWeblinks(diff); |
| return new Promise(resolve => { |
| const callback = event => { |
| const needsSyntaxHighlighting = event.detail && |
| event.detail.contentRendered; |
| if (needsSyntaxHighlighting) { |
| this.reporting.time(TimingLabel.SYNTAX); |
| this.$.syntaxLayer.process().finally(() => { |
| this.reporting.timeEnd(TimingLabel.SYNTAX); |
| this.reporting.timeEnd(TimingLabel.TOTAL); |
| resolve(); |
| }); |
| } else { |
| this.reporting.timeEnd(TimingLabel.TOTAL); |
| resolve(); |
| } |
| this.removeEventListener('render', callback); |
| if (shouldReportMetric) { |
| // We report diffViewContentDisplayed only on reload caused |
| // by params changed - expected only on Diff Page. |
| this.reporting.diffViewContentDisplayed(); |
| } |
| }; |
| this.addEventListener('render', callback); |
| this.diff = diff; |
| }); |
| }) |
| .catch(err => { |
| console.warn('Error encountered loading diff:', err); |
| }) |
| .then(() => { this._loading = false; }); |
| } |
| |
| clear() { |
| this.$.jsAPI.disposeDiffLayers(this.path); |
| this._layers = []; |
| } |
| |
| _getCoverageData() { |
| const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this; |
| this.$.jsAPI.getCoverageAnnotationApi(). |
| then(coverageAnnotationApi => { |
| if (!coverageAnnotationApi) return; |
| const provider = coverageAnnotationApi.getCoverageProvider(); |
| return provider(changeNum, path, basePatchNum, patchNum) |
| .then(coverageRanges => { |
| if (!coverageRanges || |
| changeNum !== this.changeNum || |
| path !== this.path || |
| basePatchNum !== this.patchRange.basePatchNum || |
| patchNum !== this.patchRange.patchNum) { |
| return; |
| } |
| |
| const existingCoverageRanges = this._coverageRanges; |
| this._coverageRanges = coverageRanges; |
| |
| // Notify with existing coverage ranges |
| // in case there is some existing coverage data that needs to be removed |
| existingCoverageRanges.forEach(range => { |
| coverageAnnotationApi.notify( |
| path, |
| range.code_range.start_line, |
| range.code_range.end_line, |
| range.side); |
| }); |
| |
| // Notify with new coverage data |
| coverageRanges.forEach(range => { |
| coverageAnnotationApi.notify( |
| path, |
| range.code_range.start_line, |
| range.code_range.end_line, |
| range.side); |
| }); |
| }); |
| }) |
| .catch(err => { |
| console.warn('Loading coverage ranges failed: ', err); |
| }); |
| } |
| |
| _getFilesWeblinks(diff) { |
| // The return type is FilesWebLinks from gr-patch-range-select. |
| if (!this.commitRange) { |
| return {}; |
| } |
| return { |
| meta_a: GerritNav.getFileWebLinks( |
| this.projectName, this.commitRange.baseCommit, this.path, |
| {weblinks: diff && diff.meta_a && diff.meta_a.web_links}), |
| meta_b: GerritNav.getFileWebLinks( |
| this.projectName, this.commitRange.commit, this.path, |
| {weblinks: diff && diff.meta_b && diff.meta_b.web_links}), |
| }; |
| } |
| |
| /** Cancel any remaining diff builder rendering work. */ |
| cancel() { |
| this.$.diff.cancel(); |
| this.$.syntaxLayer.cancel(); |
| } |
| |
| /** @return {!Array<!HTMLElement>} */ |
| getCursorStops() { |
| return this.$.diff.getCursorStops(); |
| } |
| |
| /** @return {boolean} */ |
| isRangeSelected() { |
| return this.$.diff.isRangeSelected(); |
| } |
| |
| createRangeComment() { |
| return this.$.diff.createRangeComment(); |
| } |
| |
| toggleLeftDiff() { |
| this.$.diff.toggleLeftDiff(); |
| } |
| |
| /** |
| * Load and display blame information for the base of the diff. |
| * |
| * @return {Promise} A promise that resolves when blame finishes rendering. |
| */ |
| loadBlame() { |
| return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum, |
| this.path, true) |
| .then(blame => { |
| if (!blame.length) { |
| this.dispatchEvent(new CustomEvent('show-alert', { |
| detail: {message: MSG_EMPTY_BLAME}, |
| composed: true, bubbles: true, |
| })); |
| return Promise.reject(MSG_EMPTY_BLAME); |
| } |
| |
| this._blame = blame; |
| }); |
| } |
| |
| /** Unload blame information for the diff. */ |
| clearBlame() { |
| this._blame = null; |
| } |
| |
| /** |
| * The thread elements in this diff, in no particular order. |
| * |
| * @return {!Array<!HTMLElement>} |
| */ |
| getThreadEls() { |
| return Array.from( |
| this.$.diff.querySelectorAll('.comment-thread')); |
| } |
| |
| /** @param {HTMLElement} el */ |
| addDraftAtLine(el) { |
| this.$.diff.addDraftAtLine(el); |
| } |
| |
| clearDiffContent() { |
| this.$.diff.clearDiffContent(); |
| } |
| |
| expandAllContext() { |
| this.$.diff.expandAllContext(); |
| } |
| |
| /** @return {!Promise} */ |
| _getLoggedIn() { |
| return this.$.restAPI.getLoggedIn(); |
| } |
| |
| /** @return {boolean}} */ |
| _canReload() { |
| return !!this.changeNum && !!this.patchRange && !!this.path && |
| !this.noAutoRender; |
| } |
| |
| // TODO(milutin): Use rest-api with fetchCacheURL instead of this. |
| prefetchDiff() { |
| if (!!this.changeNum && !!this.patchRange && !!this.path |
| && this._fetchDiffPromise === null) { |
| this._fetchDiffPromise = this._getDiff(); |
| } |
| } |
| |
| /** @return {!Promise<!Object>} */ |
| _getDiff() { |
| if (this._fetchDiffPromise !== null) { |
| const fetchDiffPromise = this._fetchDiffPromise; |
| this._fetchDiffPromise = null; |
| return fetchDiffPromise; |
| } |
| // Wrap the diff request in a new promise so that the error handler |
| // rejects the promise, allowing the error to be handled in the .catch. |
| return new Promise((resolve, reject) => { |
| this.$.restAPI.getDiff( |
| this.changeNum, |
| this.patchRange.basePatchNum, |
| this.patchRange.patchNum, |
| this.path, |
| this._getIgnoreWhitespace(), |
| reject) |
| .then(resolve); |
| }); |
| } |
| |
| _handleGetDiffError(response) { |
| // Loading the diff may respond with 409 if the file is too large. In this |
| // case, use a toast error.. |
| if (response.status === 409) { |
| this.dispatchEvent(new CustomEvent('server-error', { |
| detail: {response}, |
| composed: true, bubbles: true, |
| })); |
| return; |
| } |
| |
| if (this.showLoadFailure) { |
| this._errorMessage = [ |
| 'Encountered error when loading the diff:', |
| response.status, |
| response.statusText, |
| ].join(' '); |
| return; |
| } |
| |
| this.dispatchEvent(new CustomEvent('page-error', { |
| detail: {response}, |
| composed: true, bubbles: true, |
| })); |
| } |
| |
| /** |
| * Report info about the diff response. |
| */ |
| _reportDiff(diff) { |
| if (!diff || !diff.content) { |
| return; |
| } |
| |
| // Count the delta lines stemming from normal deltas, and from |
| // due_to_rebase deltas. |
| let nonRebaseDelta = 0; |
| let rebaseDelta = 0; |
| diff.content.forEach(chunk => { |
| if (chunk.ab) { return; } |
| const deltaSize = Math.max( |
| chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0); |
| if (chunk.due_to_rebase) { |
| rebaseDelta += deltaSize; |
| } else { |
| nonRebaseDelta += deltaSize; |
| } |
| }); |
| |
| // Find the percent of the delta from due_to_rebase chunks rounded to two |
| // digits. Diffs with no delta are considered 0%. |
| const totalDelta = rebaseDelta + nonRebaseDelta; |
| const percentRebaseDelta = !totalDelta ? 0 : |
| Math.round(100 * rebaseDelta / totalDelta); |
| |
| // Report the due_to_rebase percentage in the "diff" category when |
| // applicable. |
| if (this.patchRange.basePatchNum === 'PARENT') { |
| this.reporting.reportInteraction(EVENT_AGAINST_PARENT); |
| } else if (percentRebaseDelta === 0) { |
| this.reporting.reportInteraction(EVENT_ZERO_REBASE); |
| } else { |
| this.reporting.reportInteraction(EVENT_NONZERO_REBASE, |
| {percentRebaseDelta}); |
| } |
| } |
| |
| /** |
| * @param {Object} diff |
| * @return {!Promise} |
| */ |
| _loadDiffAssets(diff) { |
| if (isImageDiff(diff)) { |
| return this._getImages(diff).then(images => { |
| this._baseImage = images.baseImage; |
| this._revisionImage = images.revisionImage; |
| }); |
| } else { |
| this._baseImage = null; |
| this._revisionImage = null; |
| return Promise.resolve(); |
| } |
| } |
| |
| /** |
| * @param {Object} diff |
| * @return {boolean} |
| */ |
| _computeIsImageDiff(diff) { |
| return isImageDiff(diff); |
| } |
| |
| _commentsChanged(newComments) { |
| const allComments = []; |
| for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) { |
| // This is needed by the threading. |
| for (const comment of newComments[side]) { |
| comment.__commentSide = side; |
| } |
| allComments.push(...newComments[side]); |
| } |
| // Currently, the only way this is ever changed here is when the initial |
| // comments are loaded, so it's okay performance wise to clear the threads |
| // and recreate them. If this changes in future, we might want to reuse |
| // some DOM nodes here. |
| this._clearThreads(); |
| const threads = this._createThreads(allComments); |
| for (const thread of threads) { |
| const threadEl = this._createThreadElement(thread); |
| this._attachThreadElement(threadEl); |
| } |
| } |
| |
| _sortComments(comments) { |
| return comments.slice(0).sort((a, b) => { |
| if (b.__draft && !a.__draft ) { return -1; } |
| if (a.__draft && !b.__draft ) { return 1; } |
| return parseDate(a.updated) - parseDate(b.updated); |
| }); |
| } |
| |
| /** |
| * @param {!Array<!Object>} comments |
| * @return {!Array<!Object>} Threads for the given comments. |
| */ |
| _createThreads(comments) { |
| const sortedComments = this._sortComments(comments); |
| const threads = []; |
| for (const comment of sortedComments) { |
| // If the comment is in reply to another comment, find that comment's |
| // thread and append to it. |
| if (comment.in_reply_to) { |
| const thread = threads.find(thread => |
| thread.comments.some(c => c.id === comment.in_reply_to)); |
| if (thread) { |
| thread.comments.push(comment); |
| continue; |
| } |
| } |
| |
| // Otherwise, this comment starts its own thread. |
| const newThread = { |
| start_datetime: comment.updated, |
| comments: [comment], |
| commentSide: comment.__commentSide, |
| patchNum: comment.patch_set, |
| rootId: comment.id || comment.__draftID, |
| lineNum: comment.line, |
| isOnParent: comment.side === 'PARENT', |
| }; |
| if (comment.range) { |
| newThread.range = {...comment.range}; |
| } |
| threads.push(newThread); |
| } |
| return threads; |
| } |
| |
| /** |
| * @param {Object} blame |
| * @return {boolean} |
| */ |
| _computeIsBlameLoaded(blame) { |
| return !!blame; |
| } |
| |
| /** |
| * @param {Object} diff |
| * @return {!Promise} |
| */ |
| _getImages(diff) { |
| return this.$.restAPI.getImagesForDiff(this.changeNum, diff, |
| this.patchRange); |
| } |
| |
| /** @param {CustomEvent} e */ |
| _handleCreateComment(e) { |
| const {lineNum, side, patchNum, isOnParent, range} = e.detail; |
| const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range, |
| isOnParent); |
| threadEl.addOrEditDraft(lineNum, range); |
| |
| this.reporting.recordDraftInteraction(); |
| } |
| |
| /** |
| * Gets or creates a comment thread at a given location. |
| * May provide a range, to get/create a range comment. |
| * |
| * @param {string} patchNum |
| * @param {?number} lineNum |
| * @param {string} commentSide |
| * @param {Gerrit.Range|undefined} range |
| * @param {boolean} isOnParent |
| * @return {!Object} |
| */ |
| _getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) { |
| let threadEl = this._getThreadEl(lineNum, commentSide, range); |
| if (!threadEl) { |
| threadEl = this._createThreadElement({ |
| comments: [], |
| commentSide, |
| patchNum, |
| lineNum, |
| range, |
| isOnParent, |
| }); |
| this._attachThreadElement(threadEl); |
| } |
| return threadEl; |
| } |
| |
| _attachThreadElement(threadEl) { |
| this.$.diff.appendChild(threadEl); |
| } |
| |
| _clearThreads() { |
| for (const threadEl of this.getThreadEls()) { |
| const parent = threadEl.parentNode; |
| parent.removeChild(threadEl); |
| } |
| } |
| |
| _createThreadElement(thread) { |
| const threadEl = document.createElement('gr-comment-thread'); |
| threadEl.className = 'comment-thread'; |
| threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`); |
| threadEl.comments = thread.comments; |
| threadEl.commentSide = thread.commentSide; |
| threadEl.isOnParent = !!thread.isOnParent; |
| threadEl.parentIndex = this._parentIndex; |
| // Use path before renmaing when comment added on the left when comparing |
| // two patch sets (not against base) |
| if (this.file && this.file.basePath |
| && thread.commentSide === GrDiffBuilder.Side.LEFT |
| && !thread.isOnParent) { |
| threadEl.path = this.file.basePath; |
| } else { |
| threadEl.path = this.path; |
| } |
| threadEl.changeNum = this.changeNum; |
| threadEl.patchNum = thread.patchNum; |
| threadEl.showPatchset = false; |
| threadEl.lineNum = thread.lineNum; |
| const rootIdChangedListener = changeEvent => { |
| thread.rootId = changeEvent.detail.value; |
| }; |
| threadEl.addEventListener('root-id-changed', rootIdChangedListener); |
| threadEl.projectName = this.projectName; |
| threadEl.range = thread.range; |
| const threadDiscardListener = e => { |
| const threadEl = /** @type {!Node} */ (e.currentTarget); |
| |
| const parent = threadEl.parentNode; |
| parent.removeChild(threadEl); |
| |
| threadEl.removeEventListener('root-id-changed', rootIdChangedListener); |
| threadEl.removeEventListener('thread-discard', threadDiscardListener); |
| }; |
| threadEl.addEventListener('thread-discard', threadDiscardListener); |
| return threadEl; |
| } |
| |
| /** |
| * Gets a comment thread element at a given location. |
| * May provide a range, to get a range comment. |
| * |
| * @param {?number} lineNum |
| * @param {string} commentSide |
| * @param {!Gerrit.Range=} range |
| * @return {?Node} |
| */ |
| _getThreadEl(lineNum, commentSide, range = undefined) { |
| let line; |
| if (commentSide === GrDiffBuilder.Side.LEFT) { |
| line = {beforeNumber: lineNum}; |
| } else if (commentSide === GrDiffBuilder.Side.RIGHT) { |
| line = {afterNumber: lineNum}; |
| } else { |
| throw new Error(`Unknown side: ${commentSide}`); |
| } |
| function matchesRange(threadEl) { |
| const threadRange = /** @type {!Gerrit.Range} */( |
| JSON.parse(threadEl.getAttribute('range'))); |
| return rangesEqual(threadRange, range); |
| } |
| |
| const filteredThreadEls = this._filterThreadElsForLocation( |
| this.getThreadEls(), line, commentSide).filter(matchesRange); |
| return filteredThreadEls.length ? filteredThreadEls[0] : null; |
| } |
| |
| /** |
| * @param {!Array<!HTMLElement>} threadEls |
| * @param {!{beforeNumber: (number|string|undefined|null), |
| * afterNumber: (number|string|undefined|null)}} |
| * lineInfo |
| * @param {!DiffSide=} side The side (LEFT, RIGHT) for |
| * which to return the threads. |
| * @return {!Array<!HTMLElement>} The thread elements matching the given |
| * location. |
| */ |
| _filterThreadElsForLocation(threadEls, lineInfo, side) { |
| function matchesLeftLine(threadEl) { |
| return threadEl.getAttribute('comment-side') == |
| DiffSide.LEFT && |
| threadEl.getAttribute('line-num') == lineInfo.beforeNumber; |
| } |
| function matchesRightLine(threadEl) { |
| return threadEl.getAttribute('comment-side') == |
| DiffSide.RIGHT && |
| threadEl.getAttribute('line-num') == lineInfo.afterNumber; |
| } |
| function matchesFileComment(threadEl) { |
| return threadEl.getAttribute('comment-side') == side && |
| // line/range comments have 1-based line set, if line is falsy it's |
| // a file comment |
| !threadEl.getAttribute('line-num'); |
| } |
| |
| // Select the appropriate matchers for the desired side and line |
| // If side is BOTH, we want both the left and right matcher. |
| const matchers = []; |
| if (side !== DiffSide.RIGHT) { |
| matchers.push(matchesLeftLine); |
| } |
| if (side !== DiffSide.LEFT) { |
| matchers.push(matchesRightLine); |
| } |
| if (lineInfo.afterNumber === 'FILE' || |
| lineInfo.beforeNumber === 'FILE') { |
| matchers.push(matchesFileComment); |
| } |
| return threadEls.filter(threadEl => |
| matchers.some(matcher => matcher(threadEl))); |
| } |
| |
| _getIgnoreWhitespace() { |
| if (!this.prefs || !this.prefs.ignore_whitespace) { |
| return WHITESPACE_IGNORE_NONE; |
| } |
| return this.prefs.ignore_whitespace; |
| } |
| |
| _whitespaceChanged( |
| preferredWhitespaceLevel, loadedWhitespaceLevel, |
| noRenderOnPrefsChange) { |
| // Polymer 2: check for undefined |
| if ([ |
| preferredWhitespaceLevel, |
| loadedWhitespaceLevel, |
| noRenderOnPrefsChange, |
| ].includes(undefined)) { |
| return; |
| } |
| |
| this._fetchDiffPromise = null; |
| if (preferredWhitespaceLevel !== loadedWhitespaceLevel && |
| !noRenderOnPrefsChange) { |
| this.reload(); |
| } |
| } |
| |
| _syntaxHighlightingChanged(noRenderOnPrefsChange, prefsChangeRecord) { |
| // Polymer 2: check for undefined |
| if ([ |
| noRenderOnPrefsChange, |
| prefsChangeRecord, |
| ].includes(undefined)) { |
| return; |
| } |
| |
| if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') { |
| return; |
| } |
| |
| if (!noRenderOnPrefsChange) { |
| this.reload(); |
| } |
| } |
| |
| /** |
| * @param {Object} patchRangeRecord |
| * @return {number|null} |
| */ |
| _computeParentIndex(patchRangeRecord) { |
| if (!patchRangeRecord.base) return null; |
| return isMergeParent(patchRangeRecord.base.basePatchNum) ? |
| getParentIndex(patchRangeRecord.base.basePatchNum) : null; |
| } |
| |
| _handleCommentSave(e) { |
| const comment = e.detail.comment; |
| const side = e.detail.comment.__commentSide; |
| const idx = this._findDraftIndex(comment, side); |
| this.set(['comments', side, idx], comment); |
| this._handleCommentSaveOrDiscard(); |
| } |
| |
| _handleCommentDiscard(e) { |
| const comment = e.detail.comment; |
| this._removeComment(comment); |
| this._handleCommentSaveOrDiscard(); |
| } |
| |
| /** |
| * Closure annotation for Polymer.prototype.push is off. Submitted PR: |
| * https://github.com/Polymer/polymer/pull/4776 |
| * but for not suppressing annotations. |
| * |
| * @suppress {checkTypes} |
| */ |
| _handleCommentUpdate(e) { |
| const comment = e.detail.comment; |
| const side = e.detail.comment.__commentSide; |
| let idx = this._findCommentIndex(comment, side); |
| if (idx === -1) { |
| idx = this._findDraftIndex(comment, side); |
| } |
| if (idx !== -1) { // Update draft or comment. |
| this.set(['comments', side, idx], comment); |
| } else { // Create new draft. |
| this.push(['comments', side], comment); |
| } |
| } |
| |
| _handleCommentSaveOrDiscard() { |
| this.dispatchEvent(new CustomEvent( |
| 'diff-comments-modified', {bubbles: true, composed: true})); |
| } |
| |
| _removeComment(comment) { |
| const side = comment.__commentSide; |
| this._removeCommentFromSide(comment, side); |
| } |
| |
| _removeCommentFromSide(comment, side) { |
| let idx = this._findCommentIndex(comment, side); |
| if (idx === -1) { |
| idx = this._findDraftIndex(comment, side); |
| } |
| if (idx !== -1) { |
| this.splice('comments.' + side, idx, 1); |
| } |
| } |
| |
| /** @return {number} */ |
| _findCommentIndex(comment, side) { |
| if (!comment.id || !this.comments[side]) { |
| return -1; |
| } |
| return this.comments[side].findIndex(item => item.id === comment.id); |
| } |
| |
| /** @return {number} */ |
| _findDraftIndex(comment, side) { |
| if (!comment.__draftID || !this.comments[side]) { |
| return -1; |
| } |
| return this.comments[side].findIndex( |
| item => item.__draftID === comment.__draftID); |
| } |
| |
| _isSyntaxHighlightingEnabled(preferenceChangeRecord, diff) { |
| if (!preferenceChangeRecord || |
| !preferenceChangeRecord.base || |
| !preferenceChangeRecord.base.syntax_highlighting || |
| !diff) { |
| return false; |
| } |
| return !this._anyLineTooLong(diff) && |
| this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH; |
| } |
| |
| /** |
| * @return {boolean} whether any of the lines in diff are longer |
| * than SYNTAX_MAX_LINE_LENGTH. |
| */ |
| _anyLineTooLong(diff) { |
| if (!diff) return false; |
| return diff.content.some(section => { |
| const lines = section.ab ? |
| section.ab : |
| (section.a || []).concat(section.b || []); |
| return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH); |
| }); |
| } |
| |
| _listenToViewportRender() { |
| const renderUpdateListener = start => { |
| if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) { |
| this.reporting.diffViewDisplayed(); |
| this.$.syntaxLayer.removeListener(renderUpdateListener); |
| } |
| }; |
| |
| this.$.syntaxLayer.addListener(renderUpdateListener); |
| } |
| |
| _handleRenderStart() { |
| this.reporting.time(TimingLabel.TOTAL); |
| this.reporting.time(TimingLabel.CONTENT); |
| } |
| |
| _handleRenderContent() { |
| this.reporting.timeEnd(TimingLabel.CONTENT); |
| } |
| |
| _handleNormalizeRange(event) { |
| this.reporting.reportInteraction('normalize-range', |
| { |
| side: event.detail.side, |
| lineNum: event.detail.lineNum, |
| }); |
| } |
| |
| _handleDiffContextExpanded(event) { |
| this.reporting.reportInteraction( |
| 'diff-context-expanded', {numLines: event.detail.numLines} |
| ); |
| } |
| |
| /** |
| * Find the last chunk for the given side. |
| * |
| * @param {!Object} diff |
| * @param {boolean} leftSide true if checking the base of the diff, |
| * false if testing the revision. |
| * @return {Object|null} returns the chunk object or null if there was |
| * no chunk for that side. |
| */ |
| _lastChunkForSide(diff, leftSide) { |
| if (!diff.content.length) { return null; } |
| |
| let chunkIndex = diff.content.length; |
| let chunk; |
| |
| // Walk backwards until we find a chunk for the given side. |
| do { |
| chunkIndex--; |
| chunk = diff.content[chunkIndex]; |
| } while ( |
| // We haven't reached the beginning. |
| chunkIndex >= 0 && |
| |
| // The chunk doesn't have both sides. |
| !chunk.ab && |
| |
| // The chunk doesn't have the given side. |
| ((leftSide && (!chunk.a || !chunk.a.length)) || |
| (!leftSide && (!chunk.b || !chunk.b.length)))); |
| |
| // If we reached the beginning of the diff and failed to find a chunk |
| // with the given side, return null. |
| if (chunkIndex === -1) { return null; } |
| |
| return chunk; |
| } |
| |
| /** |
| * Check whether the specified side of the diff has a trailing newline. |
| * |
| * @param {!Object} diff |
| * @param {boolean} leftSide true if checking the base of the diff, |
| * false if testing the revision. |
| * @return {boolean|null} Return true if the side has a trailing newline. |
| * Return false if it doesn't. Return null if not applicable (for |
| * example, if the diff has no content on the specified side). |
| */ |
| _hasTrailingNewlines(diff, leftSide) { |
| const chunk = this._lastChunkForSide(diff, leftSide); |
| if (!chunk) { return null; } |
| let lines; |
| if (chunk.ab) { |
| lines = chunk.ab; |
| } else { |
| lines = leftSide ? chunk.a : chunk.b; |
| } |
| return lines[lines.length - 1] === ''; |
| } |
| |
| _showNewlineWarningLeft(diff) { |
| return this._hasTrailingNewlines(diff, true) === false; |
| } |
| |
| _showNewlineWarningRight(diff) { |
| return this._hasTrailingNewlines(diff, false) === false; |
| } |
| } |
| |
| customElements.define(GrDiffHost.is, GrDiffHost); |