| /** |
| * @license |
| * Copyright 2018 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../../shared/gr-comment-thread/gr-comment-thread'; |
| import '../../checks/gr-diff-check-result'; |
| import '../../../embed/diff/gr-diff/gr-diff'; |
| import { |
| GerritNav, |
| GeneratedWebLink, |
| } from '../../core/gr-navigation/gr-navigation'; |
| import { |
| anyLineTooLong, |
| getDiffLength, |
| getLine, |
| getSide, |
| SYNTAX_MAX_LINE_LENGTH, |
| } from '../../../embed/diff/gr-diff/gr-diff-utils'; |
| import {getAppContext} from '../../../services/app-context'; |
| import { |
| getParentIndex, |
| isAParent, |
| isMergeParent, |
| isNumber, |
| } from '../../../utils/patch-set-util'; |
| import { |
| CommentThread, |
| equalLocation, |
| isInBaseOfPatchRange, |
| isInRevisionOfPatchRange, |
| } from '../../../utils/comment-util'; |
| import { |
| CommitRange, |
| CoverageRange, |
| DiffLayer, |
| PatchSetFile, |
| } from '../../../types/types'; |
| import { |
| Base64ImageFile, |
| BlameInfo, |
| ChangeInfo, |
| EDIT, |
| NumericChangeId, |
| PARENT, |
| PatchRange, |
| PatchSetNum, |
| RepoName, |
| RevisionPatchSetNum, |
| UrlEncodedCommentId, |
| } from '../../../types/common'; |
| import { |
| DiffInfo, |
| DiffPreferencesInfo, |
| IgnoreWhitespaceType, |
| } from '../../../types/diff'; |
| import { |
| CreateCommentEventDetail, |
| GrDiff, |
| } from '../../../embed/diff/gr-diff/gr-diff'; |
| import {DiffViewMode, Side, CommentSide} from '../../../constants/constants'; |
| import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select'; |
| import {LineNumber, FILE} from '../../../embed/diff/gr-diff/gr-diff-line'; |
| import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread'; |
| import {KnownExperimentId} from '../../../services/flags/flags'; |
| import { |
| firePageError, |
| fireAlert, |
| fireServerError, |
| fireEvent, |
| waitForEventOnce, |
| fire, |
| } from '../../../utils/event-util'; |
| import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader'; |
| import {assertIsDefined} from '../../../utils/common-util'; |
| import {DiffContextExpandedEventDetail} from '../../../embed/diff/gr-diff-builder/gr-diff-builder'; |
| import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer'; |
| import {Timing, Interaction} from '../../../constants/reporting'; |
| import {ChangeComments} from '../gr-comment-api/gr-comment-api'; |
| import {Subscription} from 'rxjs'; |
| import {DisplayLine, RenderPreferences} from '../../../api/diff'; |
| import {resolve} from '../../../models/dependency'; |
| import {browserModelToken} from '../../../models/browser/browser-model'; |
| import {commentsModelToken} from '../../../models/comments/comments-model'; |
| import {checksModelToken, RunResult} from '../../../models/checks/checks-model'; |
| import {GrDiffCheckResult} from '../../checks/gr-diff-check-result'; |
| import {distinctUntilChanged, map} from 'rxjs/operators'; |
| import {deepEqual} from '../../../utils/deep-util'; |
| import {Category} from '../../../api/checks'; |
| import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker'; |
| import {CODE_MAX_LINES} from '../../../services/highlight/highlight-service'; |
| import {html, LitElement, PropertyValues} from 'lit'; |
| import {customElement, property, query, state} from 'lit/decorators'; |
| import {ValueChangedEvent} from '../../../types/events'; |
| import {debounceP, DelayedPromise} from '../../../utils/async-util'; |
| import {subscribe} from '../../lit/subscription-controller'; |
| |
| const 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'; |
| |
| 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)); |
| } |
| |
| // visible for testing |
| export interface LineInfo { |
| beforeNumber?: LineNumber; |
| afterNumber?: LineNumber; |
| } |
| |
| declare global { |
| interface HTMLElementEventMap { |
| /* prettier-ignore */ |
| 'render': CustomEvent; |
| 'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>; |
| 'create-comment': CustomEvent<CreateCommentEventDetail>; |
| 'is-blame-loaded-changed': ValueChangedEvent<boolean>; |
| 'diff-changed': ValueChangedEvent<DiffInfo | undefined>; |
| 'edit-weblinks-changed': ValueChangedEvent<GeneratedWebLink[] | undefined>; |
| 'files-weblinks-changed': ValueChangedEvent<FilesWebLinks | undefined>; |
| 'is-image-diff-changed': ValueChangedEvent<boolean>; |
| // Fired when the user selects a line (See gr-diff). |
| 'line-selected': CustomEvent; |
| // Fired if being logged in is required. |
| 'show-auth-required': void; |
| } |
| } |
| |
| /** |
| * 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 LitElement { |
| @query('#diff') |
| diffElement?: GrDiff; |
| |
| @property({type: Number}) |
| changeNum?: NumericChangeId; |
| |
| @property({type: Object}) |
| change?: ChangeInfo; |
| |
| @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; |
| |
| @state() |
| private _isImageDiff = false; |
| |
| get isImageDiff() { |
| return this._isImageDiff; |
| } |
| |
| set isImageDiff(isImageDiff: boolean) { |
| if (this._isImageDiff === isImageDiff) return; |
| this._isImageDiff = isImageDiff; |
| fire(this, 'is-image-diff-changed', {value: isImageDiff}); |
| } |
| |
| @property({type: Object}) |
| commitRange?: CommitRange; |
| |
| @state() |
| private _editWeblinks?: GeneratedWebLink[]; |
| |
| get editWeblinks() { |
| return this._editWeblinks; |
| } |
| |
| set editWeblinks(editWeblinks: GeneratedWebLink[] | undefined) { |
| if (this._editWeblinks === editWeblinks) return; |
| this._editWeblinks = editWeblinks; |
| fire(this, 'edit-weblinks-changed', {value: editWeblinks}); |
| } |
| |
| @state() |
| private _filesWeblinks?: FilesWebLinks; |
| |
| get filesWeblinks() { |
| return this._filesWeblinks; |
| } |
| |
| set filesWeblinks(filesWeblinks: FilesWebLinks | undefined) { |
| if (this._filesWeblinks === filesWeblinks) return; |
| this._filesWeblinks = filesWeblinks; |
| fire(this, 'files-weblinks-changed', {value: filesWeblinks}); |
| } |
| |
| @property({type: Boolean, reflect: true}) |
| override hidden = false; |
| |
| @property({type: Boolean}) |
| noRenderOnPrefsChange = false; |
| |
| // Private but used in tests. |
| @state() |
| threads: CommentThread[] = []; |
| |
| @property({type: Boolean}) |
| lineWrapping = false; |
| |
| @property({type: Object}) |
| lineOfInterest?: DisplayLine; |
| |
| @property({type: String}) |
| viewMode = DiffViewMode.SIDE_BY_SIDE; |
| |
| @property({type: Boolean}) |
| showLoadFailure?: boolean; |
| |
| @state() |
| private loggedIn = false; |
| |
| // Private but used in tests. |
| @state() |
| errorMessage: string | null = null; |
| |
| @state() |
| private baseImage?: Base64ImageFile; |
| |
| @state() |
| private revisionImage?: Base64ImageFile; |
| |
| // Do not use, use diff instead through the getters and setters. |
| // This is not a regular @state because we need to also send the |
| // 'diff-changed' event when it is changed. And if we rely on @state |
| // then the name to look for in willUpdate/update/updated is '_diff'. |
| private _diff?: DiffInfo; |
| |
| get diff() { |
| return this._diff; |
| } |
| |
| set diff(diff: DiffInfo | undefined) { |
| if (this._diff === diff) return; |
| const oldDiff = this._diff; |
| this._diff = diff; |
| this.isImageDiff = isImageDiff(this._diff); |
| fire(this, 'diff-changed', {value: this._diff}); |
| this.requestUpdate('diff', oldDiff); |
| } |
| |
| @state() |
| private changeComments?: ChangeComments; |
| |
| @state() |
| private fetchDiffPromise: Promise<DiffInfo> | null = null; |
| |
| // Do not use, use blame instead through the getters and setters. |
| // This is not a regular @state because we need to also send tgetImagesForDiffhe |
| // 'is-blame-loading-changed' event when it is changed. And if we |
| // rely on @state then the name to look for in willUpdate/update/updated |
| // is '_blame'. |
| private _blame: BlameInfo[] | null = null; |
| |
| @state() |
| get blame() { |
| return this._blame; |
| } |
| |
| set blame(blame: BlameInfo[] | null) { |
| if (this._blame === blame) return; |
| const oldBlame = this._blame; |
| this._blame = blame; |
| fire(this, 'is-blame-loaded-changed', {value: !!this._blame}); |
| this.requestUpdate('blame', oldBlame); |
| } |
| |
| @state() |
| private coverageRanges: CoverageRange[] = []; |
| |
| @state() |
| private loadedWhitespaceLevel?: IgnoreWhitespaceType; |
| |
| @state() |
| private layers: DiffLayer[] = []; |
| |
| @state() |
| private renderPrefs: RenderPreferences = { |
| num_lines_rendered_at_once: 128, |
| }; |
| |
| // Debounces across multiple reload calls and ensures that waiters can |
| // wait on it whenever a reload is requested. If more than one reload is |
| // requested within a given time-frame, the first one is canceled but will |
| // still be resolved when the second one is resolved. (and inductively, any |
| // further ones that were requested within a animation-frame). |
| private reloadPromise?: DelayedPromise<void>; |
| |
| private readonly getBrowserModel = resolve(this, browserModelToken); |
| |
| private readonly getCommentsModel = resolve(this, commentsModelToken); |
| |
| private readonly getChecksModel = resolve(this, checksModelToken); |
| |
| // visible for testing |
| readonly reporting = getAppContext().reportingService; |
| |
| private readonly flags = getAppContext().flagsService; |
| |
| private readonly restApiService = getAppContext().restApiService; |
| |
| // visible for testing |
| readonly userModel = getAppContext().userModel; |
| |
| // visible for testing |
| readonly jsAPI = getAppContext().jsApiService; |
| |
| // visible for testing |
| readonly syntaxLayer: GrSyntaxLayerWorker; |
| |
| private checksSubscription?: Subscription; |
| |
| constructor() { |
| super(); |
| this.syntaxLayer = new GrSyntaxLayerWorker(); |
| this.renderPrefs = { |
| ...this.renderPrefs, |
| use_lit_components: this.flags.isEnabled( |
| KnownExperimentId.DIFF_RENDERING_LIT |
| ), |
| }; |
| 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.handleCreateThread(e) |
| ); |
| this.addEventListener('diff-context-expanded', event => |
| this.handleDiffContextExpanded(event) |
| ); |
| subscribe( |
| this, |
| () => this.getBrowserModel().diffViewMode$, |
| diffView => (this.viewMode = diffView) |
| ); |
| subscribe( |
| this, |
| () => this.userModel.loggedIn$, |
| loggedIn => (this.loggedIn = loggedIn) |
| ); |
| subscribe( |
| this, |
| () => this.getCommentsModel().changeComments$, |
| changeComments => { |
| this.changeComments = changeComments; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.userModel.diffPreferences$, |
| diffPreferences => { |
| this.prefs = diffPreferences; |
| } |
| ); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| this.subscribeToChecks(); |
| } |
| |
| override disconnectedCallback() { |
| if (this.reloadPromise) { |
| this.reloadPromise.cancel(); |
| this.reloadPromise = undefined; |
| } |
| if (this.checksSubscription) { |
| this.checksSubscription.unsubscribe(); |
| this.checksSubscription = undefined; |
| } |
| this.clear(); |
| super.disconnectedCallback(); |
| } |
| |
| protected override willUpdate(changedProperties: PropertyValues) { |
| // Important to call as this will call render, see LitElement. |
| super.willUpdate(changedProperties); |
| if (changedProperties.has('diff')) { |
| this.isImageDiff = isImageDiff(this.diff); |
| } |
| if ( |
| changedProperties.has('changeComments') || |
| changedProperties.has('patchRange') || |
| changedProperties.has('file') |
| ) { |
| this.threads = this.computeFileThreads( |
| this.changeComments, |
| this.patchRange, |
| this.file |
| ); |
| } |
| if ( |
| changedProperties.has('noRenderOnPrefsChange') || |
| changedProperties.has('prefs') || |
| changedProperties.has('path') || |
| changedProperties.has('changeNum') |
| ) { |
| this.syntaxHighlightingChanged( |
| this.noRenderOnPrefsChange, |
| changedProperties.get('prefs'), |
| this.prefs, |
| this.path, |
| this.changeNum |
| ); |
| } |
| if ( |
| changedProperties.has('prefs') || |
| changedProperties.has('loadedWhitespaceLevel') || |
| changedProperties.has('noRenderOnPrefsChange') || |
| changedProperties.has('path') || |
| changedProperties.has('changeNum') |
| ) { |
| this.whitespaceChanged( |
| this.prefs?.ignore_whitespace, |
| this.loadedWhitespaceLevel, |
| this.noRenderOnPrefsChange, |
| this.path, |
| this.changeNum |
| ); |
| } |
| } |
| |
| protected override updated(changedProperties: PropertyValues) { |
| super.updated(changedProperties); |
| // This needs to happen in updated() because it has to happen post-render as |
| // this method calls getThreadEls which inspects the DOM. |
| if (changedProperties.has('threads')) { |
| this.threadsChanged(this.threads); |
| } |
| } |
| |
| async waitForReloadToRender(): Promise<void> { |
| await this.updateComplete; |
| if (this.reloadPromise) { |
| try { |
| // If we are reloading, wait for the reload to finish and then ensure |
| // that any changes are captured in another update. |
| await this.reloadPromise; |
| } catch (e: unknown) { |
| // TODO: Consider moving this logic to a helper method on |
| if (e instanceof Error) { |
| this.reporting.error(e); |
| } else { |
| this.reporting.error(new Error('reloadPromise error'), undefined, e); |
| } |
| } |
| await this.updateComplete; |
| } |
| } |
| |
| override render() { |
| const showNewlineWarningLeft = |
| this.hasTrailingNewlines(this.diff, true) === false; |
| const showNewlineWarningRight = |
| this.hasTrailingNewlines(this.diff, false) === false; |
| const useNewImageDiffUi = this.flags.isEnabled( |
| KnownExperimentId.NEW_IMAGE_DIFF_UI |
| ); |
| |
| return html` <gr-diff |
| id="diff" |
| ?hidden=${this.hidden} |
| .noAutoRender=${this.noAutoRender} |
| .path=${this.path} |
| .prefs=${this.prefs} |
| .displayLine=${this.displayLine} |
| .isImageDiff=${this.isImageDiff} |
| .noRenderOnPrefsChange=${this.noRenderOnPrefsChange} |
| .renderPrefs=${this.renderPrefs} |
| .lineWrapping=${this.lineWrapping} |
| .viewMode=${this.viewMode} |
| .lineOfInterest=${this.lineOfInterest} |
| .loggedIn=${this.loggedIn} |
| .errorMessage=${this.errorMessage} |
| .baseImage=${this.baseImage} |
| .revisionImage=${this.revisionImage} |
| .coverageRanges=${this.coverageRanges} |
| .blame=${this.blame} |
| .layers=${this.layers} |
| .diff=${this.diff} |
| .showNewlineWarningLeft=${showNewlineWarningLeft} |
| .showNewlineWarningRight=${showNewlineWarningRight} |
| .useNewImageDiffUi=${useNewImageDiffUi} |
| ></gr-diff>`; |
| } |
| |
| async initLayers() { |
| const preferencesPromise = this.restApiService.getPreferences(); |
| await getPluginLoader().awaitPluginsLoaded(); |
| const prefs = await preferencesPromise; |
| const enableTokenHighlight = !prefs?.disable_token_highlighting; |
| |
| assertIsDefined(this.path, 'path'); |
| this.layers = this.getLayers(this.path, enableTokenHighlight); |
| this.coverageRanges = []; |
| // We kick off fetching the data here, but we don't return the promise, |
| // so awaiting initLayers() will not wait for coverage data to be |
| // completely loaded. |
| this.getCoverageData(); |
| } |
| |
| /** |
| * @param shouldReportMetric indicate a new Diff Page. This is a |
| * signal to report metrics event that started on location change. |
| */ |
| reload(shouldReportMetric?: boolean): Promise<void> { |
| this.reloadPromise = debounceP( |
| this.reloadPromise, |
| async () => { |
| try { |
| await this.reloadInternal(shouldReportMetric); |
| return; |
| } catch (e: unknown) { |
| if (e instanceof Error) { |
| this.reporting.error(e); |
| } else { |
| this.reporting.error( |
| new Error('reloadInternal error'), |
| undefined, |
| e |
| ); |
| } |
| } finally { |
| this.reloadPromise = undefined; |
| } |
| }, |
| 0 |
| ); |
| return this.reloadPromise; |
| } |
| |
| // for DIFF_AUTOCLOSE logging purposes only |
| private reloadOngoing = false; |
| |
| async reloadInternal(shouldReportMetric?: boolean) { |
| this.reporting.time(Timing.DIFF_TOTAL); |
| this.reporting.time(Timing.DIFF_LOAD); |
| this.clear(); |
| assertIsDefined(this.path, 'path'); |
| assertIsDefined(this.changeNum, 'changeNum'); |
| this.diff = undefined; |
| this.errorMessage = null; |
| const whitespaceLevel = this.getIgnoreWhitespace(); |
| if (this.reloadOngoing) { |
| this.reporting.reportInteraction(Interaction.DIFF_AUTOCLOSE_DIFF_ONGOING); |
| } |
| this.reloadOngoing = true; |
| |
| try { |
| // We are carefully orchestrating operations that have to wait for another |
| // and operations that can be run in parallel. Plugins may provide layers, |
| // so we have to wait on plugins being loaded before we can initialize |
| // layers and proceed to rendering. OTOH we want to fetch diffs and diff |
| // assets in parallel. |
| const layerPromise = this.initLayers(); |
| const diff = await this.getDiff(); |
| if (diff === undefined) { |
| this.reporting.reportInteraction( |
| Interaction.DIFF_AUTOCLOSE_DIFF_UNDEFINED |
| ); |
| } |
| this.loadedWhitespaceLevel = whitespaceLevel; |
| this.reportDiff(diff); |
| |
| await this.loadDiffAssets(diff); |
| // Only now we are awaiting layers (and plugin loading), which was kicked |
| // off above. |
| await layerPromise; |
| |
| // Not waiting for coverage ranges intentionally as |
| // plugin loading should not block the content rendering |
| |
| this.editWeblinks = this.getEditWeblinks(diff); |
| this.filesWeblinks = this.getFilesWeblinks(diff); |
| this.diff = diff; |
| this.reporting.timeEnd(Timing.DIFF_LOAD, this.timingDetails()); |
| |
| this.reporting.time(Timing.DIFF_CONTENT); |
| this.syntaxLayer.setEnabled(this.isSyntaxHighlightingEnabled()); |
| const syntaxLayerPromise = this.syntaxLayer.process(diff); |
| await waitForEventOnce(this, 'render'); |
| this.subscribeToChecks(); |
| this.reporting.timeEnd(Timing.DIFF_CONTENT, this.timingDetails()); |
| |
| if (shouldReportMetric) { |
| // We report diffViewContentDisplayed only on reload caused |
| // by params changed - expected only on Diff Page. |
| this.reporting.diffViewContentDisplayed(); |
| } |
| |
| this.reporting.time(Timing.DIFF_SYNTAX); |
| await syntaxLayerPromise; |
| this.reporting.timeEnd(Timing.DIFF_SYNTAX, this.timingDetails()); |
| } catch (e: unknown) { |
| if (e instanceof Response) { |
| this.handleGetDiffError(e); |
| } else if (e instanceof Error) { |
| this.reporting.error(e); |
| } else { |
| this.reporting.error(new Error('reload error'), undefined, e); |
| } |
| } finally { |
| this.reporting.timeEnd(Timing.DIFF_TOTAL, this.timingDetails()); |
| this.reloadOngoing = false; |
| } |
| } |
| |
| /** |
| * Produces an event detail object for reporting. |
| */ |
| private timingDetails() { |
| if (!this.diff) return {}; |
| const metaLines = |
| (this.diff.meta_a?.lines ?? 0) + (this.diff.meta_b?.lines ?? 0); |
| |
| let contentLines = 0; |
| let contentChanged = 0; |
| let contentUnchanged = 0; |
| for (const chunk of this.diff.content) { |
| const ab = chunk.ab?.length ?? 0; |
| const a = chunk.a?.length ?? 0; |
| const b = chunk.b?.length ?? 0; |
| contentLines += ab + ab + a + b; |
| contentChanged += a + b; |
| contentUnchanged += ab + ab; |
| } |
| return { |
| metaLines, |
| contentLines, |
| contentUnchanged, |
| contentChanged, |
| height: |
| this.diffElement?.shadowRoot?.querySelector('.diffContainer') |
| ?.clientHeight, |
| }; |
| } |
| |
| private getLayers(path: string, enableTokenHighlight: boolean): DiffLayer[] { |
| const layers = []; |
| if (enableTokenHighlight) { |
| layers.push(new TokenHighlightLayer(this)); |
| } |
| layers.push(this.syntaxLayer); |
| // Get layers from plugins (if any). |
| layers.push(...this.jsAPI.getDiffLayers(path)); |
| return layers; |
| } |
| |
| clear() { |
| if (this.path) this.jsAPI.disposeDiffLayers(this.path); |
| this.layers = []; |
| } |
| |
| /** |
| * This should be called when either `path` or `patchRange` has changed. |
| * We will then subscribe to the checks model and filter the relevant |
| * check results for this diff. Path and patchset must match, and a code |
| * pointer must be included. |
| */ |
| private subscribeToChecks() { |
| if (this.checksSubscription) { |
| this.checksSubscription.unsubscribe(); |
| this.checksSubscription = undefined; |
| this.checksChanged([]); |
| } |
| |
| const path = this.path; |
| const patchNum = this.patchRange?.patchNum; |
| if (!path || !patchNum || patchNum === EDIT) return; |
| this.checksSubscription = this.getChecksModel() |
| .allResults$.pipe( |
| map(results => |
| results.filter(result => { |
| if (result.patchset !== patchNum) return false; |
| if (result.category === Category.SUCCESS) return false; |
| // Only one code pointer is supported. See API docs. |
| const pointer = result.codePointers?.[0]; |
| return pointer?.path === this.path && !!pointer?.range; |
| }) |
| ), |
| distinctUntilChanged(deepEqual) |
| ) |
| .subscribe(results => this.checksChanged(results)); |
| } |
| |
| /** |
| * Similar to threadsChanged(), but a bit simpler. We compare the elements |
| * that are already in <gr-diff> with the current results emitted from the |
| * model. Exists? Update. New? Create and attach. Old? Remove. |
| */ |
| private checksChanged(checks: RunResult[]) { |
| const idToEl = new Map<string, GrDiffCheckResult>(); |
| const checkEls = this.getCheckEls(); |
| const dontRemove = new Set<GrDiffCheckResult>(); |
| let createdCount = 0; |
| let updatedCount = 0; |
| let removedCount = 0; |
| const checksCount = checks.length; |
| const checkElsCount = checks.length; |
| if (checksCount === 0 && checkElsCount === 0) return; |
| for (const el of checkEls) { |
| const id = el.result?.internalResultId; |
| assertIsDefined(id, 'result.internalResultId of gr-diff-check-result'); |
| idToEl.set(id, el); |
| } |
| for (const check of checks) { |
| const id = check.internalResultId; |
| const existingEl = idToEl.get(id); |
| if (existingEl) { |
| existingEl.result = check; |
| dontRemove.add(existingEl); |
| updatedCount++; |
| } else { |
| const newEl = this.createCheckEl(check); |
| dontRemove.add(newEl); |
| createdCount++; |
| } |
| } |
| // Remove all check els that don't have a matching check anymore. |
| for (const el of checkEls) { |
| if (dontRemove.has(el)) continue; |
| el.remove(); |
| removedCount++; |
| } |
| this.reporting.reportInteraction( |
| Interaction.COMMENTS_AUTOCLOSE_CHECKS_UPDATED, |
| {createdCount, updatedCount, removedCount, checksCount, checkElsCount} |
| ); |
| } |
| |
| /** |
| * This is very similar to createThreadElement(). It creates a new |
| * <gr-diff-check-result> element, sets its props/attributes and adds it to |
| * <gr-diff>. |
| */ |
| // Visible for testing |
| createCheckEl(check: RunResult) { |
| const pointer = check.codePointers?.[0]; |
| assertIsDefined(pointer, 'code pointer of check result in diff'); |
| const line: LineNumber = |
| pointer.range?.end_line || pointer.range?.start_line || 'FILE'; |
| const el = document.createElement('gr-diff-check-result'); |
| // This is what gr-diff expects, even though this is a check, not a comment. |
| el.className = 'comment-thread'; |
| el.rootId = check.internalResultId; |
| el.result = check; |
| // These attributes are the "interface" between comments/checks and gr-diff. |
| // <gr-comment-thread> does not care about them and is not affected by them. |
| el.setAttribute('slot', `${Side.RIGHT}-${line}`); |
| el.setAttribute('diff-side', `${Side.RIGHT}`); |
| el.setAttribute('line-num', `${line}`); |
| if ( |
| pointer.range?.start_line > 0 && |
| pointer.range?.end_line > 0 && |
| pointer.range?.start_character >= 0 && |
| pointer.range?.end_character >= 0 |
| ) { |
| el.setAttribute('range', `${JSON.stringify(pointer.range)}`); |
| } |
| assertIsDefined(this.diffElement); |
| this.diffElement.appendChild(el); |
| return el; |
| } |
| |
| private getCoverageData() { |
| assertIsDefined(this.changeNum, 'changeNum'); |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.path, 'path'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| const changeNum = this.changeNum; |
| const change = this.change; |
| 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 |
| .getCoverageAnnotationApis() |
| .then(coverageAnnotationApis => { |
| coverageAnnotationApis.forEach(coverageAnnotationApi => { |
| const provider = coverageAnnotationApi.getCoverageProvider(); |
| if (!provider) return; |
| provider(changeNum, path, basePatchNum, patchNum, change) |
| .then(coverageRanges => { |
| assertIsDefined(this.patchRange, 'patchRange'); |
| if ( |
| !coverageRanges || |
| changeNum !== this.changeNum || |
| change !== this.change || |
| 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 => { |
| this.reporting.error(err); |
| }); |
| }); |
| }) |
| .catch(err => { |
| this.reporting.error(err); |
| }); |
| } |
| |
| private computeFileThreads( |
| changeComments?: ChangeComments, |
| patchRange?: PatchRange, |
| file?: PatchSetFile |
| ) { |
| if (!changeComments || !patchRange || !file) return this.threads; |
| return changeComments.getThreadsBySideForFile(file, patchRange); |
| } |
| |
| private getEditWeblinks(diff: DiffInfo) { |
| if (!this.projectName || !this.commitRange || !this.path) return undefined; |
| return GerritNav.getEditWebLinks( |
| this.projectName, |
| this.commitRange.baseCommit, |
| this.path, |
| {weblinks: diff?.edit_web_links} |
| ); |
| } |
| |
| private getFilesWeblinks(diff: DiffInfo) { |
| if (!this.projectName || !this.commitRange || !this.path) return undefined; |
| return { |
| meta_a: GerritNav.getFileWebLinks( |
| this.projectName, |
| this.commitRange.baseCommit, |
| this.path, |
| {weblinks: diff?.meta_a?.web_links} |
| ), |
| meta_b: GerritNav.getFileWebLinks( |
| this.projectName, |
| this.commitRange.commit, |
| this.path, |
| {weblinks: diff?.meta_b?.web_links} |
| ), |
| }; |
| } |
| |
| /** Cancel any remaining diff builder rendering work. */ |
| cancel() { |
| this.diffElement?.cancel(); |
| } |
| |
| getCursorStops() { |
| assertIsDefined(this.diffElement); |
| return this.diffElement.getCursorStops(); |
| } |
| |
| isRangeSelected() { |
| assertIsDefined(this.diffElement); |
| return this.diffElement.isRangeSelected(); |
| } |
| |
| createRangeComment() { |
| assertIsDefined(this.diffElement); |
| this.diffElement.createRangeComment(); |
| } |
| |
| toggleLeftDiff() { |
| assertIsDefined(this.diffElement); |
| this.diffElement.toggleLeftDiff(); |
| } |
| |
| /** |
| * Load and display blame information for the base of the diff. |
| */ |
| loadBlame(): Promise<BlameInfo[]> { |
| assertIsDefined(this.changeNum, 'changeNum'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| assertIsDefined(this.path, 'path'); |
| return this.restApiService |
| .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true) |
| .then(blame => { |
| if (!blame || !blame.length) { |
| fireAlert(this, EMPTY_BLAME); |
| return Promise.reject(EMPTY_BLAME); |
| } |
| |
| this.blame = blame; |
| return blame; |
| }); |
| } |
| |
| clearBlame() { |
| this.blame = null; |
| } |
| |
| getThreadEls(): GrCommentThread[] { |
| assertIsDefined(this.diffElement); |
| return Array.from(this.diffElement.querySelectorAll('gr-comment-thread')); |
| } |
| |
| getCheckEls(): GrDiffCheckResult[] { |
| return Array.from( |
| this.diffElement?.querySelectorAll('gr-diff-check-result') ?? [] |
| ); |
| } |
| |
| addDraftAtLine(el: Element) { |
| assertIsDefined(this.diffElement); |
| this.diffElement.addDraftAtLine(el); |
| } |
| |
| clearDiffContent() { |
| this.diffElement?.clearDiffContent(); |
| } |
| |
| toggleAllContext() { |
| assertIsDefined(this.diffElement); |
| this.diffElement.toggleAllContext(); |
| } |
| |
| // 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(); |
| } |
| } |
| |
| // Private but used in tests. |
| 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) => { |
| assertIsDefined(this.changeNum, 'changeNum'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| assertIsDefined(this.path, 'path'); |
| this.restApiService |
| .getDiff( |
| this.changeNum, |
| this.patchRange.basePatchNum, |
| this.patchRange.patchNum, |
| this.path, |
| this.getIgnoreWhitespace(), |
| reject |
| ) |
| .then(diff => resolve(diff!)); // reject is called in case of error, so we can't get undefined here |
| }); |
| } |
| |
| // Private but used in tests. |
| 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) { |
| fireServerError(response); |
| return; |
| } |
| |
| if (this.showLoadFailure) { |
| this.errorMessage = [ |
| 'Encountered error when loading the diff:', |
| response.status, |
| response.statusText, |
| ].join(' '); |
| return; |
| } |
| |
| firePageError(response); |
| } |
| |
| /** |
| * Report info about the diff response. |
| * |
| * Private but used in tests. |
| */ |
| 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. |
| assertIsDefined(this.patchRange, '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, |
| }); |
| } |
| } |
| |
| private loadDiffAssets(diff?: DiffInfo) { |
| if (isImageDiff(diff)) { |
| // diff! is justified, because isImageDiff() returns false otherwise |
| return this.getImages(diff!).then(images => { |
| this.baseImage = images.baseImage ?? undefined; |
| this.revisionImage = images.revisionImage ?? undefined; |
| }); |
| } else { |
| this.baseImage = undefined; |
| this.revisionImage = undefined; |
| return Promise.resolve(); |
| } |
| } |
| |
| private threadsChanged(threads: CommentThread[]) { |
| const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>(); |
| const unsavedThreadEls: GrCommentThread[] = []; |
| const threadEls = this.getThreadEls(); |
| for (const threadEl of threadEls) { |
| if (threadEl.rootId) { |
| rootIdToThreadEl.set(threadEl.rootId, threadEl); |
| } else { |
| // Unsaved thread els must have editing:true, just being defensive here. |
| if (threadEl.editing) unsavedThreadEls.push(threadEl); |
| } |
| } |
| const dontRemove = new Set<GrCommentThread>(); |
| let createdCount = 0; |
| let updatedCount = 0; |
| let removedCount = 0; |
| const threadCount = threads.length; |
| const threadElCount = threadEls.length; |
| if (threadCount === 0 && threadElCount === 0) return; |
| |
| for (const thread of threads) { |
| // Let's find an existing DOM element matching the thread. Normally this |
| // is as simple as matching the rootIds. |
| let existingThreadEl = |
| thread.rootId && rootIdToThreadEl.get(thread.rootId); |
| // But unsaved threads don't have rootIds. The incoming thread might be |
| // the saved version of the unsaved thread element. To verify that we |
| // check that the thread only has one comment and that their location is |
| // identical. |
| // TODO(brohlfs): This matching is not perfect. You could quickly create |
| // two new threads on the same line/range. Then this code just makes a |
| // random guess. |
| if (!existingThreadEl && thread.comments?.length === 1) { |
| for (const unsavedThreadEl of unsavedThreadEls) { |
| if (equalLocation(unsavedThreadEl.thread, thread)) { |
| existingThreadEl = unsavedThreadEl; |
| break; |
| } |
| } |
| } |
| // There is a case possible where the rootIds match but the locations |
| // are different. Such as when a thread was originally attached on the |
| // right side of the diff but now should be attached on the left side of |
| // the diff. |
| // There is another case possible where the original thread element was |
| // associated with a ported thread, hence had the LineNum set to LOST. |
| // In this case we cannot reuse the thread element if the same thread |
| // now is being attached in it's proper location since the LineNum needs |
| // to be updated hence create a new thread element. |
| if ( |
| existingThreadEl && |
| existingThreadEl.getAttribute('diff-side') === |
| this.getDiffSide(thread) && |
| existingThreadEl.thread!.ported === thread.ported |
| ) { |
| existingThreadEl.thread = thread; |
| dontRemove.add(existingThreadEl); |
| updatedCount++; |
| } else { |
| const threadEl = this.createThreadElement(thread); |
| this.attachThreadElement(threadEl); |
| dontRemove.add(threadEl); |
| createdCount++; |
| } |
| } |
| // Remove all threads that are no longer existing. |
| for (const threadEl of this.getThreadEls()) { |
| if (dontRemove.has(threadEl)) continue; |
| // The user may have opened a couple of comment boxes for editing. They |
| // might be unsaved and thus not be reflected in `threads` yet, so let's |
| // keep them open. |
| if (threadEl.editing && threadEl.thread?.comments.length === 0) continue; |
| removedCount++; |
| threadEl.remove(); |
| } |
| this.reporting.reportInteraction( |
| Interaction.COMMENTS_AUTOCLOSE_THREADS_UPDATED, |
| {createdCount, updatedCount, removedCount, threadCount, threadElCount} |
| ); |
| const portedThreadsCount = threads.filter(thread => thread.ported).length; |
| const portedThreadsWithoutRange = threads.filter( |
| thread => thread.ported && thread.rangeInfoLost |
| ).length; |
| if (portedThreadsCount > 0) { |
| this.reporting.reportInteraction('ported-threads-shown', { |
| ported: portedThreadsCount, |
| portedThreadsWithoutRange, |
| }); |
| } |
| } |
| |
| private getImages(diff: DiffInfo) { |
| assertIsDefined(this.changeNum, 'changeNum'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| return this.restApiService.getImagesForDiff( |
| this.changeNum, |
| diff, |
| this.patchRange |
| ); |
| } |
| |
| handleCreateThread(e: CustomEvent<CreateCommentEventDetail>) { |
| if (!this.patchRange) throw Error('patch range not set'); |
| |
| const {lineNum, side, range} = e.detail; |
| |
| // Usually, the comment is stored on the patchset shown on the side the |
| // user added the comment on, and the commentSide will be REVISION. |
| // However, if the comment is added on the left side of the diff and the |
| // version shown there is not a patchset that is part the change, but |
| // instead a base (a PARENT or a merge parent commit), the comment is |
| // stored on the patchset shown on the right, and commentSide=PARENT |
| // indicates that the comment should still be shown on the left side. |
| const patchNum = |
| side === Side.LEFT && !isAParent(this.patchRange.basePatchNum) |
| ? this.patchRange.basePatchNum |
| : this.patchRange.patchNum; |
| const commentSide = |
| side === Side.LEFT && isAParent(this.patchRange.basePatchNum) |
| ? CommentSide.PARENT |
| : CommentSide.REVISION; |
| if (!this.canCommentOnPatchSetNum(patchNum)) return; |
| const path = |
| this.file?.basePath && |
| side === Side.LEFT && |
| commentSide === CommentSide.REVISION |
| ? this.file?.basePath |
| : this.path; |
| assertIsDefined(path, 'path'); |
| |
| const parentIndex = this.computeParentIndex(); |
| const newThread: CommentThread = { |
| rootId: undefined, |
| comments: [], |
| patchNum: patchNum as RevisionPatchSetNum, |
| commentSide, |
| // TODO: Maybe just compute from patchRange.base on the fly? |
| mergeParentNum: parentIndex ?? undefined, |
| path, |
| line: lineNum, |
| range, |
| }; |
| const el = this.createThreadElement(newThread); |
| this.attachThreadElement(el); |
| } |
| |
| private canCommentOnPatchSetNum(patchNum: PatchSetNum) { |
| if (!this.loggedIn) { |
| fireEvent(this, 'show-auth-required'); |
| return false; |
| } |
| if (!this.patchRange) { |
| fireAlert(this, 'Cannot create comment. patchRange undefined.'); |
| return false; |
| } |
| |
| const isEdit = patchNum === EDIT; |
| const isEditBase = patchNum === PARENT && this.patchRange.patchNum === EDIT; |
| |
| if (isEdit) { |
| fireAlert(this, 'You cannot comment on an edit.'); |
| return false; |
| } |
| if (isEditBase) { |
| fireAlert(this, 'You cannot comment on the base patchset of an edit.'); |
| return false; |
| } |
| return true; |
| } |
| |
| private attachThreadElement(threadEl: Element) { |
| assertIsDefined(this.diffElement); |
| this.diffElement.appendChild(threadEl); |
| } |
| |
| private getDiffSide(thread: CommentThread) { |
| let diffSide: Side; |
| assertIsDefined(this.patchRange, 'patchRange'); |
| const commentProps = { |
| patch_set: thread.patchNum, |
| side: thread.commentSide, |
| parent: thread.mergeParentNum, |
| }; |
| if (isInBaseOfPatchRange(commentProps, this.patchRange)) { |
| diffSide = Side.LEFT; |
| } else if (isInRevisionOfPatchRange(commentProps, this.patchRange)) { |
| diffSide = Side.RIGHT; |
| } else { |
| const propsStr = JSON.stringify(commentProps); |
| const rangeStr = JSON.stringify(this.patchRange); |
| throw new Error(`comment ${propsStr} not in range ${rangeStr}`); |
| } |
| return diffSide; |
| } |
| |
| private createThreadElement(thread: CommentThread) { |
| const diffSide = this.getDiffSide(thread); |
| |
| const threadEl = document.createElement('gr-comment-thread'); |
| threadEl.className = 'comment-thread'; |
| threadEl.rootId = thread.rootId; |
| threadEl.thread = thread; |
| threadEl.showPatchset = false; |
| threadEl.showPortedComment = !!thread.ported; |
| // These attributes are the "interface" between comment threads and gr-diff. |
| // <gr-comment-thread> does not care about them and is not affected by them. |
| threadEl.setAttribute('slot', `${diffSide}-${thread.line || 'LOST'}`); |
| threadEl.setAttribute('diff-side', `${diffSide}`); |
| threadEl.setAttribute('line-num', `${thread.line || 'LOST'}`); |
| if (thread.range) { |
| threadEl.setAttribute('range', `${JSON.stringify(thread.range)}`); |
| } |
| return threadEl; |
| } |
| |
| // Private but used in tests. |
| filterThreadElsForLocation( |
| threadEls: GrCommentThread[], |
| lineInfo: LineInfo, |
| side: Side |
| ) { |
| function matchesLeftLine(threadEl: GrCommentThread) { |
| return ( |
| getSide(threadEl) === Side.LEFT && |
| getLine(threadEl) === lineInfo.beforeNumber |
| ); |
| } |
| function matchesRightLine(threadEl: GrCommentThread) { |
| return ( |
| getSide(threadEl) === Side.RIGHT && |
| getLine(threadEl) === lineInfo.afterNumber |
| ); |
| } |
| function matchesFileComment(threadEl: GrCommentThread) { |
| return getSide(threadEl) === side && getLine(threadEl) === FILE; |
| } |
| |
| // Select the appropriate matchers for the desired side and line |
| const matchers: ((thread: GrCommentThread) => boolean)[] = []; |
| if (side === Side.LEFT) { |
| matchers.push(matchesLeftLine); |
| } |
| if (side === Side.RIGHT) { |
| matchers.push(matchesRightLine); |
| } |
| if (lineInfo.afterNumber === FILE || lineInfo.beforeNumber === FILE) { |
| matchers.push(matchesFileComment); |
| } |
| return threadEls.filter(threadEl => |
| matchers.some(matcher => matcher(threadEl)) |
| ); |
| } |
| |
| private getIgnoreWhitespace(): IgnoreWhitespaceType { |
| if (!this.prefs || !this.prefs.ignore_whitespace) { |
| return 'IGNORE_NONE'; |
| } |
| return this.prefs.ignore_whitespace; |
| } |
| |
| private whitespaceChanged( |
| preferredWhitespaceLevel: IgnoreWhitespaceType | undefined, |
| loadedWhitespaceLevel: IgnoreWhitespaceType | undefined, |
| noRenderOnPrefsChange: boolean | undefined, |
| path: string | undefined, |
| changeNum: NumericChangeId | undefined |
| ): void | Promise<void> { |
| if (preferredWhitespaceLevel === undefined) return; |
| if (loadedWhitespaceLevel === undefined) return; |
| if (noRenderOnPrefsChange === undefined) return; |
| if (path === undefined) return; |
| if (changeNum === undefined) return; |
| |
| this.fetchDiffPromise = null; |
| if ( |
| preferredWhitespaceLevel !== loadedWhitespaceLevel && |
| !noRenderOnPrefsChange |
| ) { |
| this.reporting.reportInteraction( |
| Interaction.DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE |
| ); |
| return this.reload(); |
| } |
| } |
| |
| private syntaxHighlightingChanged( |
| noRenderOnPrefsChange: boolean | undefined, |
| oldPrefs: DiffPreferencesInfo | undefined, |
| prefs: DiffPreferencesInfo | undefined, |
| path: string | undefined, |
| changeNum: NumericChangeId | undefined |
| ): void | Promise<void> { |
| if (noRenderOnPrefsChange === undefined) return; |
| if (prefs === undefined) return; |
| if (path === undefined) return; |
| if (changeNum === undefined) return; |
| if (oldPrefs?.syntax_highlighting === prefs.syntax_highlighting) return; |
| |
| if (!noRenderOnPrefsChange) { |
| this.reporting.reportInteraction( |
| Interaction.DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX |
| ); |
| return this.reload(); |
| } |
| } |
| |
| private computeParentIndex() { |
| if (!this.patchRange) return null; |
| return isMergeParent(this.patchRange.basePatchNum) |
| ? getParentIndex(this.patchRange.basePatchNum) |
| : null; |
| } |
| |
| private isSyntaxHighlightingEnabled() { |
| if (!this.prefs?.syntax_highlighting || !this.diff) { |
| return false; |
| } |
| if (anyLineTooLong(this.diff)) { |
| fireAlert( |
| this, |
| `Files with line longer than ${SYNTAX_MAX_LINE_LENGTH} characters` + |
| ' will not be syntax highlighted.' |
| ); |
| return false; |
| } |
| assertIsDefined(this.diffElement); |
| if (getDiffLength(this.diff) > CODE_MAX_LINES) { |
| fireAlert( |
| this, |
| `Files with more than ${CODE_MAX_LINES} lines` + |
| ' will not be syntax highlighted.' |
| ); |
| return false; |
| } |
| return true; |
| } |
| |
| private handleDiffContextExpanded( |
| e: CustomEvent<DiffContextExpandedEventDetail> |
| ) { |
| this.reporting.reportInteraction('diff-context-expanded', { |
| numLines: e.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. |
| * |
| * Private but used in tests. |
| */ |
| 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). |
| * |
| * Private but used in tests. |
| */ |
| 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] === ''; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-diff-host': GrDiffHost; |
| } |
| } |