blob: 5881cc6b23ab7f5687106d4bd9c184e3e8fc06e0 [file] [log] [blame]
/**
* @license
* Copyright 2015 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../shared/gr-dropdown-list/gr-dropdown-list';
import '../../shared/gr-select/gr-select';
import {convertToString, pluralize} from '../../../utils/string-util';
import {getAppContext} from '../../../services/app-context';
import {
computeLatestPatchNum,
findSortedIndex,
getParentIndex,
getRevisionByPatchNum,
isMergeParent,
sortRevisions,
PatchSet,
convertToPatchSetNum,
} from '../../../utils/patch-set-util';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import {
BasePatchSetNum,
EDIT,
NumericChangeId,
PARENT,
PatchSetNum,
RevisionInfo,
RevisionPatchSetNum,
Timestamp,
} from '../../../types/common';
import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
import {ChangeComments} from '../gr-comment-api/gr-comment-api';
import {
DropdownItem,
GrDropdownList,
} from '../../shared/gr-dropdown-list/gr-dropdown-list';
import {EditRevisionInfo} from '../../../types/types';
import {a11yStyles} from '../../../styles/gr-a11y-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html, nothing} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {subscribe} from '../../lit/subscription-controller';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {resolve} from '../../../models/dependency';
import {ifDefined} from 'lit/directives/if-defined.js';
import {ValueChangedEvent} from '../../../types/events';
import {GeneratedWebLink} from '../../../utils/weblink-util';
import {changeModelToken} from '../../../models/change/change-model';
// Maximum length for patch set descriptions.
const PATCH_DESC_MAX_LENGTH = 500;
function getShaForPatch(patch: PatchSet) {
return patch.sha.substring(0, 10);
}
export interface PatchRangeChangeDetail {
patchNum?: PatchSetNum;
basePatchNum?: BasePatchSetNum;
}
export type PatchRangeChangeEvent = CustomEvent<PatchRangeChangeDetail>;
export interface FilesWebLinks {
meta_a: GeneratedWebLink[];
meta_b: GeneratedWebLink[];
}
declare global {
interface HTMLElementTagNameMap {
'gr-patch-range-select': GrPatchRangeSelect;
}
}
/**
* Fired when the patch range changes
*
* @event patch-range-change
*
* @property {string} patchNum
* @property {string} basePatchNum
*/
@customElement('gr-patch-range-select')
export class GrPatchRangeSelect extends LitElement {
@query('#patchNumDropdown')
patchNumDropdown?: GrDropdownList;
@state()
availablePatches: PatchSet[] = [];
@state()
changeNum?: NumericChangeId;
@property({type: Object})
filesWeblinks?: FilesWebLinks;
@state()
patchNum?: RevisionPatchSetNum;
@state()
basePatchNum?: BasePatchSetNum;
@state()
revisionInfo?: RevisionInfoClass;
@state()
sortedRevisions: (RevisionInfo | EditRevisionInfo)[] = [];
@state()
changeComments?: ChangeComments;
private readonly reporting: ReportingService =
getAppContext().reportingService;
private readonly getCommentsModel = resolve(this, commentsModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
// Private but used in tests.
readonly routerModel = getAppContext().routerModel;
constructor() {
super();
subscribe(
this,
() => this.routerModel.routerChangeNum$,
x => (this.changeNum = x)
);
subscribe(
this,
() => this.getChangeModel().change$,
x => (this.revisionInfo = x ? new RevisionInfoClass(x) : undefined)
);
subscribe(
this,
() => this.getChangeModel().patchNum$,
x => (this.patchNum = x)
);
subscribe(
this,
() => this.getChangeModel().basePatchNum$,
x => (this.basePatchNum = x)
);
subscribe(
this,
() => this.getChangeModel().patchsets$,
x => (this.availablePatches = x)
);
subscribe(
this,
() => this.getChangeModel().revisions$,
x => (this.sortedRevisions = sortRevisions(Object.values(x || {})))
);
subscribe(
this,
() => this.getCommentsModel().changeComments$,
x => (this.changeComments = x)
);
}
static override get styles() {
return [
a11yStyles,
sharedStyles,
css`
:host {
align-items: center;
display: flex;
}
select {
max-width: 15em;
}
.arrow {
color: var(--deemphasized-text-color);
margin: 0 var(--spacing-m);
}
gr-dropdown-list {
--trigger-style-text-color: var(--deemphasized-text-color);
--trigger-style-font-family: var(--font-family);
}
@media screen and (max-width: 50em) {
.filesWeblinks {
display: none;
}
/* prettier formatter removes semi-colons after css mixins. */
/* prettier-ignore */
gr-dropdown-list {
--native-select-style: {
max-width: 5.25em;
};
}
}
`,
];
}
override render() {
if (!this.changeNum || !this.patchNum || !this.basePatchNum) {
return nothing;
}
return html`
<h3 class="assistive-tech-only">Patchset Range Selection</h3>
<span class="patchRange" aria-label="patch range starts with">
<gr-dropdown-list
id="basePatchDropdown"
.value=${convertToString(this.basePatchNum)}
.items=${this.computeBaseDropdownContent()}
@value-change=${this.handlePatchChange}
>
</gr-dropdown-list>
</span>
${this.renderWeblinks(this.filesWeblinks?.meta_a)}
<span aria-hidden="true" class="arrow">→</span>
<span class="patchRange" aria-label="patch range ends with">
<gr-dropdown-list
id="patchNumDropdown"
.value=${convertToString(this.patchNum)}
.items=${this.computePatchDropdownContent()}
@value-change=${this.handlePatchChange}
>
</gr-dropdown-list>
${this.renderWeblinks(this.filesWeblinks?.meta_b)}
</span>
`;
}
private renderWeblinks(fileLinks?: GeneratedWebLink[]) {
if (!fileLinks) return;
return html`<span class="filesWeblinks">
${fileLinks.map(
weblink => html`
<a target="_blank" rel="noopener" href=${ifDefined(weblink.url)}>
${weblink.name}
</a>
`
)}</span
> `;
}
// Private method, but visible for testing.
computeBaseDropdownContent(): DropdownItem[] {
if (
this.patchNum === undefined ||
this.changeComments === undefined ||
this.revisionInfo === undefined
) {
return [];
}
const maxParents = this.revisionInfo.getMaxParents();
const isMerge = this.revisionInfo.isMergeCommit(this.patchNum);
const parentCount = this.revisionInfo.getParentCount(this.patchNum);
const dropdownContent: DropdownItem[] = [];
for (const basePatch of this.availablePatches) {
const basePatchNum = basePatch.num;
const entry: DropdownItem = this.createDropdownEntry(
basePatchNum,
'Patchset ',
getShaForPatch(basePatch)
);
dropdownContent.push({
...entry,
disabled: this.computeLeftDisabled(basePatch.num, this.patchNum),
});
}
dropdownContent.push({
text: isMerge ? 'Auto Merge' : 'Base',
value: PARENT,
});
for (let idx = 0; isMerge && idx < maxParents; idx++) {
dropdownContent.push({
disabled: idx >= parentCount,
triggerText: `Parent ${idx + 1}`,
text: `Parent ${idx + 1}`,
mobileText: `Parent ${idx + 1}`,
value: -(idx + 1),
});
}
return dropdownContent;
}
private computeMobileText(patchNum: PatchSetNum) {
return (
`${patchNum}` +
`${this.computePatchSetCommentsString(patchNum)}` +
`${this.computePatchSetDescription(patchNum, true)}`
);
}
// Private method, but visible for testing.
computePatchDropdownContent(): DropdownItem[] {
if (
this.availablePatches === undefined ||
this.basePatchNum === undefined ||
this.changeComments === undefined
) {
return [];
}
const dropdownContent: DropdownItem[] = [];
for (const patch of this.availablePatches) {
const patchNum = patch.num;
const entry = this.createDropdownEntry(
patchNum,
patchNum === EDIT ? '' : 'Patchset ',
getShaForPatch(patch)
);
dropdownContent.push({
...entry,
disabled: this.computeRightDisabled(this.basePatchNum, patchNum),
});
}
return dropdownContent;
}
private computeText(patchNum: PatchSetNum, prefix: string, sha: string) {
return (
`${prefix}${patchNum}` +
`${this.computePatchSetCommentsString(patchNum)}` +
` | ${sha}`
);
}
private createDropdownEntry(
patchNum: PatchSetNum,
prefix: string,
sha: string
) {
const entry: DropdownItem = {
triggerText: `${prefix}${patchNum}`,
text: this.computeText(patchNum, prefix, sha),
mobileText: this.computeMobileText(patchNum),
bottomText: `${this.computePatchSetDescription(patchNum)}`,
value: patchNum,
};
const date = this.computePatchSetDate(patchNum);
if (date) {
entry.date = date;
}
return entry;
}
/**
* The basePatchNum should always be <= patchNum -- because sortedRevisions
* is sorted in reverse order (higher patchset nums first), invalid base
* patch nums have an index greater than the index of patchNum.
*
* Private method, but visible for testing.
*
* @param basePatchNum The possible base patch num.
* @param patchNum The current selected patch num.
*/
computeLeftDisabled(
basePatchNum: PatchSetNum,
patchNum: PatchSetNum
): boolean {
return (
findSortedIndex(basePatchNum, this.sortedRevisions) <=
findSortedIndex(patchNum, this.sortedRevisions)
);
}
/**
* The basePatchNum should always be <= patchNum -- because sortedRevisions
* is sorted in reverse order (higher patchset nums first), invalid patch
* nums have an index greater than the index of basePatchNum.
*
* In addition, if the current basePatchNum is PARENT, all patchNums are
* valid.
*
* If the current basePatchNum is a parent index, then only patches that have
* at least that many parents are valid.
*
* Private method, but visible for testing.
*
* @param basePatchNum The current selected base patch num.
* @param patchNum The possible patch num.
*/
computeRightDisabled(
basePatchNum: BasePatchSetNum,
patchNum: RevisionPatchSetNum
): boolean {
if (basePatchNum === PARENT) {
return false;
}
if (isMergeParent(basePatchNum)) {
if (!this.revisionInfo) {
return true;
}
// Note: parent indices use 1-offset.
return (
this.revisionInfo.getParentCount(patchNum) <
getParentIndex(basePatchNum)
);
}
return (
findSortedIndex(basePatchNum, this.sortedRevisions) <=
findSortedIndex(patchNum, this.sortedRevisions)
);
}
// TODO(dhruvsri): have ported comments contribute to this count
// Private method, but visible for testing.
computePatchSetCommentsString(patchNum: PatchSetNum): string {
if (!this.changeComments) return '';
const commentThreadCount = this.changeComments.computeCommentThreadCount(
{
patchNum,
},
true
);
const commentThreadString = pluralize(commentThreadCount, 'comment');
const unresolvedCount = this.changeComments.computeUnresolvedNum(
{patchNum},
true
);
const unresolvedString =
unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
if (!commentThreadString.length && !unresolvedString.length) {
return '';
}
return (
` (${commentThreadString}` +
// Add a comma + space if both comment threads and unresolved
(commentThreadString && unresolvedString ? ', ' : '') +
`${unresolvedString})`
);
}
private computePatchSetDescription(
patchNum: PatchSetNum,
addFrontSpace?: boolean
) {
const rev = getRevisionByPatchNum(this.sortedRevisions, patchNum);
return rev?.description
? (addFrontSpace ? ' ' : '') +
rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
: '';
}
private computePatchSetDate(patchNum: PatchSetNum): Timestamp | undefined {
const rev = getRevisionByPatchNum(this.sortedRevisions, patchNum);
return rev ? rev.created : undefined;
}
/**
* Catches value-change events from the patchset dropdowns and determines
* whether or not a patch change event should be fired.
*/
private handlePatchChange(e: ValueChangedEvent<string>) {
const detail: PatchRangeChangeDetail = {
patchNum: this.patchNum,
basePatchNum: this.basePatchNum,
};
const target = e.target;
const patchSetValue = convertToPatchSetNum(e.detail.value)!;
const latestPatchNum = computeLatestPatchNum(this.availablePatches);
if (target === this.patchNumDropdown) {
if (detail.patchNum === patchSetValue) return;
this.reporting.reportInteraction('right-patchset-changed', {
previous: detail.patchNum,
current: patchSetValue,
latest: latestPatchNum,
commentCount: this.changeComments?.computeCommentThreadCount({
patchNum: patchSetValue,
}),
});
detail.patchNum = patchSetValue;
} else {
if (detail.basePatchNum === patchSetValue) return;
this.reporting.reportInteraction('left-patchset-changed', {
previous: detail.basePatchNum,
current: patchSetValue,
commentCount: this.changeComments?.computeCommentThreadCount({
patchNum: patchSetValue,
}),
});
detail.basePatchNum = patchSetValue as BasePatchSetNum;
}
this.dispatchEvent(
new CustomEvent('patch-range-change', {detail, bubbles: false})
);
}
}