blob: 178083995540071332df385cf7b0fc74855c9a8f [file] [log] [blame]
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../embed/diff/gr-diff/gr-diff';
import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {getAppContext} from '../../services/app-context';
import {EDIT, BasePatchSetNum, RepoName} from '../../types/common';
import {anyLineTooLong} from '../../utils/diff-util';
import {Timing} from '../../constants/reporting';
import {
DiffInfo,
DiffLayer,
DiffPreferencesInfo,
DiffViewMode,
RenderPreferences,
} from '../../api/diff';
import {GrSyntaxLayerWorker} from '../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
import {resolve} from '../../models/dependency';
import {highlightServiceToken} from '../../services/highlight/highlight-service';
import {
FixSuggestionInfo,
NumericChangeId,
PatchSetNumber,
} from '../../api/rest-api';
import {changeModelToken} from '../../models/change/change-model';
import {subscribe} from '../lit/subscription-controller';
import {DiffPreview} from '../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
import {userModelToken} from '../../models/user/user-model';
import {navigationToken} from '../core/gr-navigation/gr-navigation';
import {fire} from '../../utils/event-util';
import {createChangeUrl} from '../../models/views/change';
import {OpenFixPreviewEventDetail} from '../../types/events';
/**
* This component renders a <gr-diff> and an "apply fix" button and can be used
* when showing check results that have a fix for an easy preview and a quick
* apply-fix experience.
*
* There is a certain overlap with similar components for comment fixes:
* GrSuggestionDiffPreview also renders a <gr-diff> and fetches a diff preview,
* it relies on a `comment` (and the comment model) to be available. It supports
* both a `string` fix and `FixSuggestionInfo`. It also differs in logging and
* event handling. And it misses the header component that we need for the
* buttons.
*
* There is also `GrUserSuggestionsFix` which wraps `GrSuggestionDiffPreview`
* and has the header that we also need. But it is very targeted to be used for
* user suggestions and inside comments.
*
* So there is certainly an opportunity for cleanup and unification, but at the
* time of component creation it did not feel wortwhile investing into this
* effort. This is tracked in b/360288262.
*/
@customElement('gr-checks-fix-preview')
export class GrChecksFixPreview extends LitElement {
@property({type: Object})
fixSuggestionInfo?: FixSuggestionInfo;
@property({type: Number})
patchSet?: PatchSetNumber;
@state()
layers: DiffLayer[] = [];
@state()
repo?: RepoName;
@state()
changeNum?: NumericChangeId;
@state()
latestPatchNum?: PatchSetNumber;
@state()
diff?: DiffPreview;
@state()
applyingFix = false;
@state()
diffPrefs?: DiffPreferencesInfo;
@state()
renderPrefs: RenderPreferences = {
disable_context_control_buttons: true,
show_file_comment_button: false,
hide_line_length_indicator: true,
};
private readonly reporting = getAppContext().reportingService;
private readonly restApiService = getAppContext().restApiService;
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getUserModel = resolve(this, userModelToken);
private readonly getNavigation = resolve(this, navigationToken);
private readonly syntaxLayer = new GrSyntaxLayerWorker(
resolve(this, highlightServiceToken),
() => getAppContext().reportingService
);
constructor() {
super();
subscribe(
this,
() => this.getChangeModel().changeNum$,
changeNum => (this.changeNum = changeNum)
);
subscribe(
this,
() => this.getChangeModel().latestPatchNum$,
x => (this.latestPatchNum = x)
);
subscribe(
this,
() => this.getUserModel().diffPreferences$,
diffPreferences => {
if (!diffPreferences) return;
this.diffPrefs = diffPreferences;
this.syntaxLayer.setEnabled(!!this.diffPrefs.syntax_highlighting);
}
);
subscribe(
this,
() => this.getChangeModel().repo$,
x => (this.repo = x)
);
}
static override get styles() {
return [
css`
:host {
display: block;
}
.header {
background-color: var(--background-color-primary);
border: 1px solid var(--border-color);
border-bottom: none;
padding: var(--spacing-xs) var(--spacing-xl);
display: flex;
align-items: center;
}
.header .title {
flex: 1;
}
.diff-container {
border: 1px solid var(--border-color);
border-top: none;
border-bottom: none;
}
.loading {
border: 1px solid var(--border-color);
padding: var(--spacing-xl);
}
`,
];
}
override willUpdate(changed: PropertyValues) {
if (changed.has('fixSuggestionInfo')) {
this.fetchDiffPreview().then(diff => (this.diff = diff));
}
}
override render() {
if (!this.fixSuggestionInfo) return nothing;
return html`${this.renderHeader()}${this.renderDiff()}`;
}
private renderHeader() {
return html`
<div class="header">
<div class="title">
<span>Attached Fix</span>
</div>
<div>
<gr-button
class="showFix"
secondary
flatten
.disabled=${!this.diff}
@click=${this.showFix}
>
Show fix side-by-side
</gr-button>
<gr-button
class="applyFix"
primary
flatten
.loading=${this.applyingFix}
.disabled=${this.isApplyEditDisabled()}
@click=${this.applyFix}
.title=${this.computeApplyFixTooltip()}
>
Apply fix
</gr-button>
</div>
</div>
`;
}
private renderDiff() {
if (!this.diff) {
return html`<div class="loading">Loading fix preview ...</div>`;
}
const diff = this.diff.preview;
if (!anyLineTooLong(diff)) {
this.syntaxLayer.process(diff);
}
return html`
<div class="diff-container">
<gr-diff
.prefs=${this.getDiffPrefs()}
.path=${this.diff.filepath}
.diff=${diff}
.layers=${this.layers}
.renderPrefs=${this.renderPrefs}
.viewMode=${DiffViewMode.UNIFIED}
></gr-diff>
</div>
`;
}
/**
* Calls the REST API to convert the fix into a DiffInfo.
*/
private async fetchDiffPreview(): Promise<DiffPreview | undefined> {
if (!this.changeNum || !this.patchSet || !this.fixSuggestionInfo) return;
const pathsToDiffs: {[path: string]: DiffInfo} | undefined =
await this.restApiService.getFixPreview(
this.changeNum,
this.patchSet,
this.fixSuggestionInfo.replacements
);
if (!pathsToDiffs) return;
const diffs = Object.keys(pathsToDiffs).map(filepath => {
const diff = pathsToDiffs[filepath];
return {filepath, preview: diff};
});
// Showing diff for one file only.
return diffs?.[0];
}
private showFix() {
if (!this.patchSet || !this.fixSuggestionInfo) return;
const eventDetail: OpenFixPreviewEventDetail = {
patchNum: this.patchSet,
fixSuggestions: [this.fixSuggestionInfo],
onCloseFixPreviewCallbacks: [],
};
fire(this, 'open-fix-preview', eventDetail);
}
/**
* Applies the fix and then navigates to the EDIT patchset.
*/
private async applyFix() {
const changeNum = this.changeNum;
const basePatchNum = this.patchSet as BasePatchSetNum;
if (!changeNum || !basePatchNum || !this.fixSuggestionInfo) return;
this.applyingFix = true;
this.reporting.time(Timing.APPLY_FIX_LOAD);
const res = await this.restApiService.applyFixSuggestion(
changeNum,
basePatchNum,
this.fixSuggestionInfo.replacements,
this.latestPatchNum
);
this.applyingFix = false;
this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
method: '1-click',
description: this.fixSuggestionInfo.description,
});
if (res?.ok) this.navigateToEditPatchset();
}
private navigateToEditPatchset() {
const changeNum = this.changeNum;
const repo = this.repo;
const basePatchNum = this.patchSet;
if (!changeNum || !repo || !basePatchNum) return;
const url = createChangeUrl({
changeNum,
repo,
patchNum: EDIT,
basePatchNum,
// We have to force reload, because the EDIT patchset is otherwise not yet known.
forceReload: true,
});
this.getNavigation().setUrl(url);
}
/**
* We have to override some diff prefs of the user, because for example in the context of showing
* an inline diff for fixes we do not want to show context lines around the changes lines of code
* as we would normally do for a diff.
*/
private getDiffPrefs() {
if (!this.diffPrefs) return undefined;
return {
...this.diffPrefs,
context: 0,
line_length: Math.min(this.diffPrefs.line_length, 100),
line_wrapping: true,
};
}
private isApplyEditDisabled() {
if (this.patchSet === undefined) return true;
return !this.diff;
}
private computeApplyFixTooltip() {
if (this.patchSet === undefined) return '';
if (!this.diff) return 'Fix is still loading ...';
return '';
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-checks-fix-preview': GrChecksFixPreview;
}
}