blob: 37e30a0d60b3c23ccfe25660cf0afb647d33cecf [file] [log] [blame]
// Copyright 2020 The LUCI Authors.
//
// 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 '@material/mwc-button';
import { css, customElement, html } from 'lit-element';
import { styleMap } from 'lit-html/directives/style-map';
import { computed, observable, reaction } from 'mobx';
import '../../components/commit_entry';
import '../../components/dot_spinner';
import '../../components/hotkey';
import { CommitEntryElement } from '../../components/commit_entry';
import { MiloBaseElement } from '../../components/milo_base';
import { AppState, consumeAppState } from '../../context/app_state';
import { BuildState, consumeBuildState } from '../../context/build_state';
import { GA_ACTIONS, GA_CATEGORIES, trackEvent } from '../../libs/analytics_utils';
import { getGitilesRepoURL } from '../../libs/build_utils';
import { consumer } from '../../libs/context';
import { errorHandler, forwardWithoutMsg, reportErrorAsync, reportRenderError } from '../../libs/error_handler';
import { GitCommit } from '../../services/milo_internal';
import commonStyle from '../../styles/common_style.css';
@customElement('milo-blamelist-tab')
@errorHandler(forwardWithoutMsg)
@consumer
export class BlamelistTabElement extends MiloBaseElement {
@observable.ref
@consumeAppState()
appState!: AppState;
@observable.ref
@consumeBuildState()
buildState!: BuildState;
@observable.ref private commits: GitCommit[] = [];
@observable.ref private endOfPage = false;
@observable.ref private precedingCommit?: GitCommit;
@observable.ref private isLoading = true;
@computed
private get queryBlamelistResIter() {
const iterFn =
this.buildState.queryBlamelistResIterFns[this.buildState.selectedBlamelistPinIndex] ||
async function* () {
yield Promise.race([]);
};
return iterFn();
}
@computed
get selectedRepoURL() {
if (!this.selectedBlamelistPin) {
return null;
}
return getGitilesRepoURL(this.selectedBlamelistPin);
}
@computed
get selectedBlamelistPin() {
return this.buildState.build?.blamelistPins[this.buildState.selectedBlamelistPinIndex];
}
@computed
private get revisionRange() {
const blamelistPin = this.selectedBlamelistPin;
if (!this.endOfPage || !blamelistPin) {
return '';
}
return this.precedingCommit
? `${this.precedingCommit.id.substring(0, 12)}..${blamelistPin.id?.substring(0, 12) || blamelistPin.ref}`
: blamelistPin.id?.substring(0, 12) || blamelistPin.ref;
}
@computed
private get blamelistSummary() {
if (this.revisionRange) {
return `This build included ${this.commits.length} new revisions from ${this.revisionRange}`;
}
if (this.commits.length > 0) {
return `This build included over ${this.commits.length} new revisions up to ${
this.selectedBlamelistPin!.id?.substring(0, 12) || this.selectedBlamelistPin!.ref
}`;
}
return '';
}
@computed
private get gitilesLink() {
if (this.revisionRange && this.selectedRepoURL) {
return `${this.selectedRepoURL}/+log/${this.revisionRange}`;
}
return '';
}
connectedCallback() {
super.connectedCallback();
this.appState.selectedTabId = 'blamelist';
trackEvent(GA_CATEGORIES.BLAMELIST_TAB, GA_ACTIONS.TAB_VISITED, window.location.href);
this.addDisposer(
reaction(
() => this.queryBlamelistResIter,
() => {
this.commits = [];
this.loadNextPage();
},
{ fireImmediately: true }
)
);
}
private loadNextPage = reportErrorAsync.bind(this)(async () => {
this.isLoading = true;
this.endOfPage = false;
const iter = await this.queryBlamelistResIter.next();
if (iter.done) {
this.endOfPage = true;
} else {
this.commits = this.commits.concat(iter.value.commits);
this.precedingCommit = iter.value.precedingCommit;
this.endOfPage = !iter.value.nextPageToken;
}
this.isLoading = false;
});
private allEntriesWereExpanded = false;
private toggleAllEntries(expand: boolean) {
this.allEntriesWereExpanded = expand;
this.shadowRoot!.querySelectorAll<CommitEntryElement>('milo-commit-entry').forEach((e) => (e.expanded = expand));
}
private readonly toggleAllEntriesByHotkey = () => this.toggleAllEntries(!this.allEntriesWereExpanded);
protected render = reportRenderError.bind(this)(() => {
if (this.buildState.build && !this.selectedBlamelistPin) {
return html`
<div id="no-blamelist">
Blamelist is not available because the build has no associated gitiles commit.<br />
</div>
`;
}
return html`
<div id="header">
<div id="repo-selector">
<label for="repo-select">Repo:</label>
<select
id="repo-select"
@input=${(e: InputEvent) =>
(this.buildState.selectedBlamelistPinIndex = Number((e.target as HTMLOptionElement).value))}
>
${this.buildState.build?.blamelistPins.map(
(pin, i) => html`
<option value=${i} ?selected=${this.buildState.selectedBlamelistPinIndex === i}>
${getGitilesRepoURL(pin)}
</option>
`
)}
</select>
</div>
<milo-hotkey key="x" .handler=${this.toggleAllEntriesByHotkey} title="press x to expand/collapse all entries">
<mwc-button class="action-button" dense unelevated @click=${() => this.toggleAllEntries(true)}>
Expand All
</mwc-button>
<mwc-button class="action-button" dense unelevated @click=${() => this.toggleAllEntries(false)}>
Collapse All
</mwc-button>
</milo-hotkey>
</div>
<div id="main">
<div
id="blamelist-summary"
class="list-entry"
style=${styleMap({ display: this.blamelistSummary ? '' : 'none' })}
>
<span>${this.blamelistSummary}</span>
<a href="${this.gitilesLink}" target="_blank" style=${styleMap({ display: this.gitilesLink ? '' : 'none' })}>
[view in Gitiles]
</a>
<span id="load" style=${styleMap({ display: this.blamelistSummary && !this.endOfPage ? '' : 'none' })}>
<span
id="load-more"
style=${styleMap({ display: this.isLoading ? 'none' : '' })}
@click=${this.loadNextPage}
>
Load More
</span>
<span style=${styleMap({ display: this.isLoading ? '' : 'none' })}>
Loading <milo-dot-spinner></milo-dot-spinner>
</span>
</span>
</div>
<hr class="divider" style=${styleMap({ display: this.blamelistSummary ? '' : 'none' })} />
${this.commits.map(
(commit, i) => html`
<milo-commit-entry
.number=${i + 1}
.repoUrl=${this.selectedRepoURL}
.commit=${commit}
.expanded=${this.commits.length === 1}
></milo-commit-entry>
`
)}
<div
class="list-entry"
style=${styleMap({ display: this.endOfPage && this.commits.length === 0 ? '' : 'none' })}
>
No blamelist.
</div>
<hr
class="divider"
style=${styleMap({ display: !this.endOfPage && this.commits.length === 0 ? 'none' : '' })}
/>
<div class="list-entry">
<span>Showing ${this.commits.length} commits.</span>
<span id="load" style=${styleMap({ display: this.endOfPage ? 'none' : '' })}>
<span
id="load-more"
style=${styleMap({ display: this.isLoading ? 'none' : '' })}
@click=${this.loadNextPage}
>
Load More
</span>
<span style=${styleMap({ display: this.isLoading ? '' : 'none' })}>
Loading <milo-dot-spinner></milo-dot-spinner>
</span>
</span>
</div>
</div>
`;
});
static styles = [
commonStyle,
css`
#no-blamelist {
padding: 10px;
}
#header {
display: grid;
grid-template-columns: 1fr auto;
grid-gap: 5px;
height: 30px;
padding: 5px 10px 3px 10px;
}
mwc-button {
margin-top: 1px;
width: var(--expand-button-width);
}
#repo-selector {
white-space: nowrap;
padding-left: 5px;
}
#repo-select {
display: inline-block;
width: 450px;
padding: 0.27rem 0.5rem;
font-size: 1rem;
color: var(--light-text-color);
background-clip: padding-box;
border: 1px solid var(--divider-color);
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
text-overflow: ellipsis;
}
#blamelist-summary {
padding: 4px 0;
}
#main {
padding-top: 5px;
padding-left: 10px;
border-top: 1px solid var(--divider-color);
}
milo-commit-entry {
margin-bottom: 2px;
}
.list-entry {
margin-top: 5px;
}
.divider {
border: none;
border-top: 1px solid var(--divider-color);
}
#load {
color: var(--active-text-color);
}
#load-more {
color: var(--active-text-color);
cursor: pointer;
}
`,
];
}