| /** |
| * @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'; |
| import '../../shared/gr-comment-thread/gr-comment-thread'; |
| import '../../shared/gr-js-api-interface/gr-js-api-interface'; |
| import '../gr-diff/gr-diff'; |
| import '../gr-syntax-layer/gr-syntax-layer'; |
| import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners'; |
| import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {htmlTemplate} from './gr-diff-host_html'; |
| import {GerritNav} from '../../core/gr-navigation/gr-navigation'; |
| import {rangesEqual} from '../gr-diff/gr-diff-utils'; |
| import {appContext} from '../../../services/app-context'; |
| import { |
| getParentIndex, |
| isMergeParent, |
| isNumber, |
| } from '../../../utils/patch-set-util'; |
| import { |
| Comment, |
| isDraft, |
| PatchSetFile, |
| sortComments, |
| UIComment, |
| } from '../../../utils/comment-util'; |
| import {TwoSidesComments} from '../gr-comment-api/gr-comment-api'; |
| import {customElement, observe, property} from '@polymer/decorators'; |
| import { |
| CommitRange, |
| CoverageRange, |
| DiffLayer, |
| DiffLayerListener, |
| } from '../../../types/types'; |
| import { |
| Base64ImageFile, |
| BlameInfo, |
| CommentRange, |
| DiffInfo, |
| DiffPreferencesInfo, |
| NumericChangeId, |
| PatchRange, |
| PatchSetNum, |
| RepoName, |
| } from '../../../types/common'; |
| import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api'; |
| import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types'; |
| import {GrDiff, LineOfInterest} from '../gr-diff/gr-diff'; |
| import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer'; |
| import { |
| DiffViewMode, |
| IgnoreWhitespaceType, |
| Side, |
| } from '../../../constants/constants'; |
| import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces'; |
| import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select'; |
| import {LineNumber} from '../gr-diff/gr-diff-line'; |
| import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread'; |
| |
| 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 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; |
| |
| function isImageDiff(diff?: DiffInfo) { |
| 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)); |
| } |
| |
| interface LineInfo { |
| beforeNumber?: LineNumber; |
| afterNumber?: LineNumber; |
| } |
| |
| // TODO(TS): Consolidate this with the CommentThread interface of comment-api. |
| // What is being used here is just a local object for collecting all the data |
| // that is needed to create a GrCommentThread component, see |
| // _createThreadElement(). |
| interface CommentThread { |
| comments: UIComment[]; |
| // In the context of a diff each thread must have a side! |
| commentSide: Side; |
| patchNum?: PatchSetNum; |
| lineNum?: LineNumber; |
| isOnParent?: boolean; |
| range?: CommentRange; |
| } |
| |
| export interface GrDiffHost { |
| $: { |
| restAPI: RestApiService & Element; |
| jsAPI: JsApiService & Element; |
| syntaxLayer: GrSyntaxLayer & Element; |
| diff: GrDiff; |
| }; |
| } |
| |
| /** |
| * Wrapper around gr-diff. |
| * |
| * Webcomponent fetching diffs and related data from restAPI and passing them |
| * to the presentational gr-diff for rendering. <gr-diff-host> is a Gerrit |
| * specific component, while <gr-diff> is a re-usable component. |
| */ |
| @customElement('gr-diff-host') |
| export class GrDiffHost extends GestureEventListeners( |
| LegacyElementMixin(PolymerElement) |
| ) { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| /** |
| * 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 |
| */ |
| |
| @property({type: Number}) |
| changeNum?: NumericChangeId; |
| |
| @property({type: Boolean}) |
| noAutoRender = false; |
| |
| @property({type: Object}) |
| patchRange?: PatchRange; |
| |
| @property({type: Object}) |
| file?: PatchSetFile; |
| |
| @property({type: String}) |
| path?: string; |
| |
| @property({type: Object}) |
| prefs?: DiffPreferencesInfo; |
| |
| @property({type: String}) |
| projectName?: RepoName; |
| |
| @property({type: Boolean}) |
| displayLine = false; |
| |
| @property({ |
| type: Boolean, |
| computed: '_computeIsImageDiff(diff)', |
| notify: true, |
| }) |
| isImageDiff?: boolean; |
| |
| @property({type: Object}) |
| commitRange?: CommitRange; |
| |
| @property({type: Object, notify: true}) |
| filesWeblinks: FilesWebLinks | {} = {}; |
| |
| @property({type: Boolean, reflectToAttribute: true}) |
| hidden = false; |
| |
| @property({type: Boolean}) |
| noRenderOnPrefsChange = false; |
| |
| @property({type: Object, observer: '_commentsChanged'}) |
| comments?: TwoSidesComments; |
| |
| @property({type: Boolean}) |
| lineWrapping = false; |
| |
| @property({type: String}) |
| viewMode = DiffViewMode.SIDE_BY_SIDE; |
| |
| @property({type: Object}) |
| lineOfInterest?: LineOfInterest; |
| |
| @property({type: Boolean}) |
| showLoadFailure?: boolean; |
| |
| @property({ |
| type: Boolean, |
| notify: true, |
| computed: '_computeIsBlameLoaded(_blame)', |
| }) |
| isBlameLoaded?: boolean; |
| |
| @property({type: Boolean}) |
| _loggedIn = false; |
| |
| @property({type: String}) |
| _errorMessage: string | null = null; |
| |
| @property({type: Object}) |
| _baseImage: Base64ImageFile | null = null; |
| |
| @property({type: Object}) |
| _revisionImage: Base64ImageFile | null = null; |
| |
| @property({type: Object, notify: true}) |
| diff?: DiffInfo; |
| |
| @property({type: Object}) |
| _fetchDiffPromise: Promise<DiffInfo> | null = null; |
| |
| @property({type: Object}) |
| _blame: BlameInfo[] | null = null; |
| |
| @property({type: Array}) |
| _coverageRanges: CoverageRange[] = []; |
| |
| @property({type: String}) |
| _loadedWhitespaceLevel?: IgnoreWhitespaceType; |
| |
| @property({type: Number, computed: '_computeParentIndex(patchRange.*)'}) |
| _parentIndex: number | null = null; |
| |
| @property({ |
| type: Boolean, |
| computed: '_isSyntaxHighlightingEnabled(prefs.*, diff)', |
| }) |
| _syntaxHighlightingEnabled?: boolean; |
| |
| @property({type: Array}) |
| _layers: DiffLayer[] = []; |
| |
| private readonly 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 shouldReportMetric indicate a new Diff Page. This is a |
| * signal to report metrics event that started on location change. |
| * @return |
| */ |
| async reload(shouldReportMetric?: boolean) { |
| this.clear(); |
| if (!this.path) throw new Error('Missing required "path" property.'); |
| if (!this.changeNum) throw new Error('Missing required "changeNum" prop.'); |
| this.diff = undefined; |
| this._errorMessage = null; |
| const whitespaceLevel = this._getIgnoreWhitespace(); |
| |
| this._layers = this._getLayers(this.path, this.changeNum); |
| |
| if (shouldReportMetric) { |
| // We listen on render viewport only on DiffPage (on paramsChanged) |
| this._listenToViewportRender(); |
| } |
| |
| this._coverageRanges = []; |
| this._getCoverageData(); |
| |
| try { |
| const diff = await this._getDiff(); |
| this._loadedWhitespaceLevel = whitespaceLevel; |
| this._reportDiff(diff); |
| |
| await this._loadDiffAssets(diff); |
| |
| // Not waiting for coverage ranges intentionally as |
| // plugin loading should not block the content rendering |
| |
| this.filesWeblinks = this._getFilesWeblinks(diff); |
| this.diff = diff; |
| const event = await this._onRenderOnce(); |
| if (shouldReportMetric) { |
| // We report diffViewContentDisplayed only on reload caused |
| // by params changed - expected only on Diff Page. |
| this.reporting.diffViewContentDisplayed(); |
| } |
| const needsSyntaxHighlighting = !!event.detail?.contentRendered; |
| if (needsSyntaxHighlighting) { |
| this.reporting.time(TimingLabel.SYNTAX); |
| try { |
| await this.$.syntaxLayer.process(); |
| } finally { |
| this.reporting.timeEnd(TimingLabel.SYNTAX); |
| } |
| } |
| } catch (e) { |
| if (e instanceof Response) { |
| this._handleGetDiffError(e); |
| } else { |
| console.warn('Error encountered loading diff:', e); |
| } |
| } finally { |
| this.reporting.timeEnd(TimingLabel.TOTAL); |
| } |
| } |
| |
| private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] { |
| // Get layers from plugins (if any). |
| return [this.$.syntaxLayer, ...this.$.jsAPI.getDiffLayers(path, changeNum)]; |
| } |
| |
| private _onRenderOnce(): Promise<CustomEvent> { |
| return new Promise<CustomEvent>(resolve => { |
| const callback = (event: CustomEvent) => { |
| this.removeEventListener('render', callback); |
| resolve(event); |
| }; |
| this.addEventListener('render', callback); |
| }); |
| } |
| |
| clear() { |
| if (this.path) this.$.jsAPI.disposeDiffLayers(this.path); |
| this._layers = []; |
| } |
| |
| _getCoverageData() { |
| if (!this.changeNum) throw new Error('Missing required "changeNum" prop.'); |
| if (!this.path) throw new Error('Missing required "path" prop.'); |
| if (!this.patchRange) throw new Error('Missing required "patchRange".'); |
| const changeNum = this.changeNum; |
| const path = this.path; |
| // Coverage providers do not provide data for EDIT and PARENT patch sets. |
| |
| const toNumberOnly = (patchNum: PatchSetNum) => |
| isNumber(patchNum) ? patchNum : undefined; |
| |
| const basePatchNum = toNumberOnly(this.patchRange.basePatchNum); |
| const patchNum = toNumberOnly(this.patchRange.patchNum); |
| this.$.jsAPI |
| .getCoverageAnnotationApi() |
| .then(coverageAnnotationApi => { |
| if (!coverageAnnotationApi) return; |
| const provider = coverageAnnotationApi.getCoverageProvider(); |
| if (!provider) return; |
| return provider(changeNum, path, basePatchNum, patchNum).then( |
| coverageRanges => { |
| if (!this.patchRange) throw new Error('Missing "patchRange".'); |
| if ( |
| !coverageRanges || |
| changeNum !== this.changeNum || |
| path !== this.path || |
| basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) || |
| patchNum !== toNumberOnly(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: DiffInfo) { |
| if (!this.projectName || !this.commitRange || !this.path) 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(); |
| } |
| |
| getCursorStops() { |
| return this.$.diff.getCursorStops(); |
| } |
| |
| 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. |
| */ |
| loadBlame(): Promise<BlameInfo[]> { |
| if (!this.changeNum) throw new Error('Missing required "changeNum" prop.'); |
| if (!this.patchRange) throw new Error('Missing required "patchRange".'); |
| if (!this.path) throw new Error('Missing required "path" property.'); |
| return this.$.restAPI |
| .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true) |
| .then(blame => { |
| if (!blame || !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; |
| return blame; |
| }); |
| } |
| |
| clearBlame() { |
| this._blame = null; |
| } |
| |
| getThreadEls(): GrCommentThread[] { |
| return Array.from(this.$.diff.querySelectorAll('.comment-thread')); |
| } |
| |
| addDraftAtLine(el: Element) { |
| this.$.diff.addDraftAtLine(el); |
| } |
| |
| clearDiffContent() { |
| this.$.diff.clearDiffContent(); |
| } |
| |
| expandAllContext() { |
| this.$.diff.expandAllContext(); |
| } |
| |
| _getLoggedIn() { |
| return this.$.restAPI.getLoggedIn(); |
| } |
| |
| _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(); |
| } |
| } |
| |
| _getDiff(): Promise<DiffInfo> { |
| 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) => { |
| if (!this.changeNum) throw new Error('Missing required "changeNum".'); |
| if (!this.patchRange) throw new Error('Missing required "patchRange".'); |
| if (!this.path) throw new Error('Missing required "path" property.'); |
| this.$.restAPI |
| .getDiff( |
| this.changeNum, |
| this.patchRange.basePatchNum, |
| this.patchRange.patchNum, |
| this.path, |
| this._getIgnoreWhitespace(), |
| reject |
| ) |
| .then(resolve); |
| }); |
| } |
| |
| _handleGetDiffError(response: 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?: DiffInfo) { |
| 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) throw new Error('Missing required "patchRange".'); |
| 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, |
| }); |
| } |
| } |
| |
| _loadDiffAssets(diff?: DiffInfo) { |
| if (isImageDiff(diff)) { |
| // diff! is justified, because isImageDiff() returns false otherwise |
| return this._getImages(diff!).then(images => { |
| this._baseImage = images.baseImage; |
| this._revisionImage = images.revisionImage; |
| }); |
| } else { |
| this._baseImage = null; |
| this._revisionImage = null; |
| return Promise.resolve(); |
| } |
| } |
| |
| _computeIsImageDiff(diff?: DiffInfo) { |
| return isImageDiff(diff); |
| } |
| |
| _commentsChanged(newComments: TwoSidesComments) { |
| const allComments = []; |
| for (const side of [Side.LEFT, 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); |
| } |
| } |
| |
| _createThreads(comments: UIComment[]): CommentThread[] { |
| const sortedComments = 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. |
| if (!comment.__commentSide) throw new Error('Missing "__commentSide".'); |
| const newThread: CommentThread = { |
| comments: [comment], |
| commentSide: comment.__commentSide, |
| patchNum: comment.patch_set, |
| lineNum: comment.line, |
| isOnParent: comment.side === 'PARENT', |
| }; |
| if (comment.range) { |
| newThread.range = {...comment.range}; |
| } |
| threads.push(newThread); |
| } |
| return threads; |
| } |
| |
| _computeIsBlameLoaded(blame: BlameInfo[] | null) { |
| return !!blame; |
| } |
| |
| _getImages(diff: DiffInfo) { |
| if (!this.changeNum) throw new Error('Missing required "changeNum" prop.'); |
| if (!this.patchRange) throw new Error('Missing required "patchRange".'); |
| return this.$.restAPI.getImagesForDiff( |
| this.changeNum, |
| diff, |
| this.patchRange |
| ); |
| } |
| |
| _handleCreateComment(e: CustomEvent) { |
| 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. |
| */ |
| _getOrCreateThread( |
| patchNum: PatchSetNum, |
| lineNum: LineNumber | undefined, |
| commentSide: Side, |
| range?: CommentRange, |
| isOnParent?: boolean |
| ): GrCommentThread { |
| 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: Element) { |
| this.$.diff.appendChild(threadEl); |
| } |
| |
| _clearThreads() { |
| for (const threadEl of this.getThreadEls()) { |
| const parent = threadEl.parentNode; |
| if (parent) parent.removeChild(threadEl); |
| } |
| } |
| |
| _createThreadElement(thread: CommentThread) { |
| 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 === 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; |
| // GrCommentThread does not understand 'FILE', but requires undefined. |
| threadEl.lineNum = thread.lineNum !== 'FILE' ? thread.lineNum : undefined; |
| threadEl.projectName = this.projectName; |
| threadEl.range = thread.range; |
| const threadDiscardListener = (e: Event) => { |
| const threadEl = e.currentTarget as Element; |
| const parent = threadEl.parentNode; |
| if (parent) parent.removeChild(threadEl); |
| 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. |
| */ |
| _getThreadEl( |
| lineNum: LineNumber | undefined, |
| commentSide: Side, |
| range?: CommentRange |
| ): GrCommentThread | null { |
| let line: LineInfo; |
| if (commentSide === Side.LEFT) { |
| line = {beforeNumber: lineNum}; |
| } else if (commentSide === Side.RIGHT) { |
| line = {afterNumber: lineNum}; |
| } else { |
| throw new Error(`Unknown side: ${commentSide}`); |
| } |
| function matchesRange(threadEl: GrCommentThread) { |
| const rangeAtt = threadEl.getAttribute('range'); |
| const threadRange = rangeAtt |
| ? (JSON.parse(rangeAtt) as CommentRange) |
| : undefined; |
| return rangesEqual(threadRange, range); |
| } |
| |
| const filteredThreadEls = this._filterThreadElsForLocation( |
| this.getThreadEls(), |
| line, |
| commentSide |
| ).filter(matchesRange); |
| return filteredThreadEls.length ? filteredThreadEls[0] : null; |
| } |
| |
| _filterThreadElsForLocation( |
| threadEls: GrCommentThread[], |
| lineInfo: LineInfo, |
| side: Side |
| ) { |
| function matchesLeftLine(threadEl: GrCommentThread) { |
| return ( |
| threadEl.getAttribute('comment-side') === Side.LEFT && |
| threadEl.getAttribute('line-num') === String(lineInfo.beforeNumber) |
| ); |
| } |
| function matchesRightLine(threadEl: GrCommentThread) { |
| return ( |
| threadEl.getAttribute('comment-side') === Side.RIGHT && |
| threadEl.getAttribute('line-num') === String(lineInfo.afterNumber) |
| ); |
| } |
| function matchesFileComment(threadEl: GrCommentThread) { |
| 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: ((thread: GrCommentThread) => boolean)[] = []; |
| if (side !== Side.RIGHT) { |
| matchers.push(matchesLeftLine); |
| } |
| if (side !== Side.LEFT) { |
| matchers.push(matchesRightLine); |
| } |
| if (lineInfo.afterNumber === 'FILE' || lineInfo.beforeNumber === 'FILE') { |
| matchers.push(matchesFileComment); |
| } |
| return threadEls.filter(threadEl => |
| matchers.some(matcher => matcher(threadEl)) |
| ); |
| } |
| |
| _getIgnoreWhitespace(): IgnoreWhitespaceType { |
| if (!this.prefs || !this.prefs.ignore_whitespace) { |
| return IgnoreWhitespaceType.IGNORE_NONE; |
| } |
| return this.prefs.ignore_whitespace; |
| } |
| |
| @observe( |
| 'prefs.ignore_whitespace', |
| '_loadedWhitespaceLevel', |
| 'noRenderOnPrefsChange' |
| ) |
| _whitespaceChanged( |
| preferredWhitespaceLevel?: IgnoreWhitespaceType, |
| loadedWhitespaceLevel?: IgnoreWhitespaceType, |
| noRenderOnPrefsChange?: boolean |
| ) { |
| if (preferredWhitespaceLevel === undefined) return; |
| if (loadedWhitespaceLevel === undefined) return; |
| if (noRenderOnPrefsChange === undefined) return; |
| |
| this._fetchDiffPromise = null; |
| if ( |
| preferredWhitespaceLevel !== loadedWhitespaceLevel && |
| !noRenderOnPrefsChange |
| ) { |
| this.reload(); |
| } |
| } |
| |
| @observe('noRenderOnPrefsChange', 'prefs.*') |
| _syntaxHighlightingChanged( |
| noRenderOnPrefsChange?: boolean, |
| prefsChangeRecord?: PolymerDeepPropertyChange< |
| DiffPreferencesInfo, |
| DiffPreferencesInfo |
| > |
| ) { |
| if (noRenderOnPrefsChange === undefined) return; |
| if (prefsChangeRecord === undefined) return; |
| if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') return; |
| |
| if (!noRenderOnPrefsChange) this.reload(); |
| } |
| |
| _computeParentIndex( |
| patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange> |
| ) { |
| if (!patchRangeRecord.base) return null; |
| return isMergeParent(patchRangeRecord.base.basePatchNum) |
| ? getParentIndex(patchRangeRecord.base.basePatchNum) |
| : null; |
| } |
| |
| _handleCommentSave(e: CustomEvent) { |
| 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: CustomEvent) { |
| const comment = e.detail.comment; |
| this._removeComment(comment); |
| this._handleCommentSaveOrDiscard(); |
| } |
| |
| _handleCommentUpdate(e: CustomEvent) { |
| 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: UIComment) { |
| const side = comment.__commentSide; |
| if (!side) throw new Error('Missing required "side" in comment.'); |
| this._removeCommentFromSide(comment, side); |
| } |
| |
| _removeCommentFromSide(comment: Comment, side: Side) { |
| let idx = this._findCommentIndex(comment, side); |
| if (idx === -1) { |
| idx = this._findDraftIndex(comment, side); |
| } |
| if (idx !== -1) { |
| this.splice('comments.' + side, idx, 1); |
| } |
| } |
| |
| _findCommentIndex(comment: Comment, side: Side) { |
| if (!comment.id || !this.comments || !this.comments[side]) { |
| return -1; |
| } |
| return this.comments[side].findIndex(item => item.id === comment.id); |
| } |
| |
| _findDraftIndex(comment: Comment, side: Side) { |
| if ( |
| !isDraft(comment) || |
| !comment.__draftID || |
| !this.comments || |
| !this.comments[side] |
| ) { |
| return -1; |
| } |
| return this.comments[side].findIndex( |
| item => isDraft(item) && item.__draftID === comment.__draftID |
| ); |
| } |
| |
| _isSyntaxHighlightingEnabled( |
| preferenceChangeRecord?: PolymerDeepPropertyChange< |
| DiffPreferencesInfo, |
| DiffPreferencesInfo |
| >, |
| diff?: DiffInfo |
| ) { |
| if ( |
| !preferenceChangeRecord || |
| !preferenceChangeRecord.base || |
| !preferenceChangeRecord.base.syntax_highlighting || |
| !diff |
| ) { |
| return false; |
| } |
| return ( |
| !this._anyLineTooLong(diff) && |
| this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH |
| ); |
| } |
| |
| /** |
| * @return whether any of the lines in diff are longer |
| * than SYNTAX_MAX_LINE_LENGTH. |
| */ |
| _anyLineTooLong(diff?: DiffInfo) { |
| 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: DiffLayerListener = 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: CustomEvent) { |
| this.reporting.reportInteraction('normalize-range', { |
| side: event.detail.side, |
| lineNum: event.detail.lineNum, |
| }); |
| } |
| |
| _handleDiffContextExpanded(event: CustomEvent) { |
| this.reporting.reportInteraction('diff-context-expanded', { |
| numLines: event.detail.numLines, |
| }); |
| } |
| |
| /** |
| * Find the last chunk for the given side. |
| * |
| * @param leftSide true if checking the base of the diff, |
| * false if testing the revision. |
| * @return returns the chunk object or null if there was |
| * no chunk for that side. |
| */ |
| _lastChunkForSide(diff: DiffInfo | undefined, leftSide: boolean) { |
| 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 leftSide true if checking the base of the diff, |
| * false if testing the revision. |
| * @return 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: DiffInfo | undefined, leftSide: boolean) { |
| 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; |
| } |
| if (!lines) return null; |
| return lines[lines.length - 1] === ''; |
| } |
| |
| _showNewlineWarningLeft(diff?: DiffInfo) { |
| return this._hasTrailingNewlines(diff, true) === false; |
| } |
| |
| _showNewlineWarningRight(diff?: DiffInfo) { |
| return this._hasTrailingNewlines(diff, false) === false; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-diff-host': GrDiffHost; |
| } |
| } |
| |
| // TODO(TS): Be more specific than CustomEvent, which has detail:any. |
| declare global { |
| interface HTMLElementEventMap { |
| render: CustomEvent; |
| 'normalize-range': CustomEvent; |
| 'diff-context-expanded': CustomEvent; |
| 'create-comment': CustomEvent; |
| 'comment-discard': CustomEvent; |
| 'comment-update': CustomEvent; |
| 'comment-save': CustomEvent; |
| 'root-id-changed': CustomEvent; |
| } |
| } |