Path: blob/main/src/vs/sessions/contrib/changes/browser/checksWidget.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/checksWidget.css';6import * as dom from '../../../../base/browser/dom.js';7import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';8import { IListRenderer, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';9import { Action } from '../../../../base/common/actions.js';10import { Codicon } from '../../../../base/common/codicons.js';11import { Emitter } from '../../../../base/common/event.js';12import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';13import { autorun } from '../../../../base/common/observable.js';14import { ThemeIcon } from '../../../../base/common/themables.js';15import { URI } from '../../../../base/common/uri.js';16import { localize } from '../../../../nls.js';17import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';18import { WorkbenchList } from '../../../../platform/list/browser/listService.js';19import { IOpenerService } from '../../../../platform/opener/common/opener.js';20import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';21import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js';22import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';23import { GitHubCheckConclusion, GitHubCheckStatus, IGitHubCICheck } from '../../github/common/types.js';24import { GitHubPullRequestCIModel, parseWorkflowRunId } from '../../github/browser/models/githubPullRequestCIModel.js';25import { CICheckGroup, buildFixChecksPrompt, getCheckGroup, getCheckStateLabel, getFailedChecks } from './checksActions.js';26import { ChecksViewModel } from './checksViewModel.js';2728const $ = dom.$;2930interface ICICheckListItem {31readonly check: IGitHubCICheck;32readonly group: CICheckGroup;33}3435interface ICICheckCounts {36readonly running: number;37readonly pending: number;38readonly failed: number;39readonly successful: number;40}4142class CICheckListDelegate implements IListVirtualDelegate<ICICheckListItem> {43static readonly ITEM_HEIGHT = 28;4445getHeight(_element: ICICheckListItem): number {46return CICheckListDelegate.ITEM_HEIGHT;47}4849getTemplateId(_element: ICICheckListItem): string {50return CICheckListRenderer.TEMPLATE_ID;51}52}5354interface ICICheckTemplateData {55readonly row: HTMLElement;56readonly label: IResourceLabel;57readonly actionBar: ActionBar;58readonly templateDisposables: DisposableStore;59readonly elementDisposables: DisposableStore;60}6162class CICheckListRenderer implements IListRenderer<ICICheckListItem, ICICheckTemplateData> {63static readonly TEMPLATE_ID = 'ciCheck';64readonly templateId = CICheckListRenderer.TEMPLATE_ID;6566constructor(67private readonly _labels: ResourceLabels,68private readonly _openerService: IOpenerService,69private readonly _getModel: () => GitHubPullRequestCIModel | undefined,70) { }7172renderTemplate(container: HTMLElement): ICICheckTemplateData {73const templateDisposables = new DisposableStore();74const row = dom.append(container, $('.ci-status-widget-check'));7576const labelContainer = dom.append(row, $('.ci-status-widget-check-label'));77const label = templateDisposables.add(this._labels.create(labelContainer, { supportIcons: true }));7879const actionBarContainer = dom.append(row, $('.ci-status-widget-check-actions'));80const actionBar = templateDisposables.add(new ActionBar(actionBarContainer));8182return {83row,84label,85actionBar,86templateDisposables,87elementDisposables: templateDisposables.add(new DisposableStore()),88};89}9091renderElement(element: ICICheckListItem, _index: number, templateData: ICICheckTemplateData): void {92templateData.elementDisposables.clear();93templateData.actionBar.clear();9495templateData.row.className = `ci-status-widget-check ${getCheckStatusClass(element.check)}`;9697const title = localize('ci.checkTitle', "{0}: {1}", element.check.name, getCheckStateLabel(element.check));98templateData.label.setResource({99name: element.check.name,100resource: URI.from({ scheme: 'github-check', path: `/${element.check.id}/${element.check.name}` }),101}, {102icon: getCheckIcon(element.check),103title,104});105106const actions: Action[] = [];107108if (element.group === CICheckGroup.Failed && parseWorkflowRunId(element.check.detailsUrl) !== undefined) {109actions.push(templateData.elementDisposables.add(new Action(110'ci.rerunCheck',111localize('ci.rerunCheck', "Rerun Check"),112ThemeIcon.asClassName(Codicon.debugRerun),113true,114async () => {115await this._getModel()?.rerunFailedCheck(element.check);116},117)));118}119120if (element.check.detailsUrl) {121actions.push(templateData.elementDisposables.add(new Action(122'ci.openOnGitHub',123localize('ci.openOnGitHub', "Open on GitHub"),124ThemeIcon.asClassName(Codicon.linkExternal),125true,126async () => {127await this._openerService.open(URI.parse(element.check.detailsUrl!));128},129)));130}131132templateData.actionBar.push(actions, { icon: true, label: false });133}134135disposeElement(_element: ICICheckListItem, _index: number, templateData: ICICheckTemplateData): void {136templateData.elementDisposables.clear();137templateData.actionBar.clear();138}139140disposeTemplate(templateData: ICICheckTemplateData): void {141templateData.templateDisposables.dispose();142}143}144145/**146* A widget that shows the CI status of a PR.147* Rendered beneath the changes tree in the changes view as a SplitView pane.148*/149export class CIStatusWidget extends Disposable {150151static readonly HEADER_HEIGHT = 34; // total header height in px152static readonly MIN_BODY_HEIGHT = 84; // at least 3 checks (3 * 28)153static readonly PREFERRED_BODY_HEIGHT = 112; // preferred 4 checks (4 * 28)154static readonly MAX_BODY_HEIGHT = 240; // at most ~8 checks155156private readonly _domNode: HTMLElement;157private readonly _headerNode: HTMLElement;158private readonly _titleNode: HTMLElement;159private readonly _titleLabelNode: HTMLElement;160private readonly _countsNode: HTMLElement;161private readonly _headerActionBarContainer: HTMLElement;162private readonly _headerActionBar: ActionBar;163private readonly _bodyNode: HTMLElement;164private readonly _list: WorkbenchList<ICICheckListItem>;165private readonly _labels: ResourceLabels;166private readonly _headerActionDisposables = this._register(new DisposableStore());167168private readonly _onDidChangeHeight = this._register(new Emitter<void>());169readonly onDidChangeHeight = this._onDidChangeHeight.event;170171private readonly _onDidToggleCollapsed = this._register(new Emitter<boolean>());172readonly onDidToggleCollapsed = this._onDidToggleCollapsed.event;173174private _checkCount = 0;175private _collapsed = false;176private _model: GitHubPullRequestCIModel | undefined;177private _sessionResource: URI | undefined;178private readonly _chevronNode: HTMLElement;179180get element(): HTMLElement {181return this._domNode;182}183184/** The full content height the widget would like (header + all checks). */185get desiredHeight(): number {186if (this._checkCount === 0) {187return 0;188}189if (this._collapsed) {190return CIStatusWidget.HEADER_HEIGHT;191}192return CIStatusWidget.HEADER_HEIGHT + this._checkCount * CICheckListDelegate.ITEM_HEIGHT;193}194195/** Whether the widget is currently visible (has checks to show). */196get visible(): boolean {197return this._checkCount > 0;198}199200/** Whether the body is collapsed (header-only). */201get collapsed(): boolean {202return this._collapsed;203}204205constructor(206container: HTMLElement,207@IOpenerService private readonly _openerService: IOpenerService,208@IChatWidgetService private readonly _chatWidgetService: IChatWidgetService,209@IInstantiationService private readonly _instantiationService: IInstantiationService,210) {211super();212this._labels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER));213214this._domNode = dom.append(container, $('.ci-status-widget'));215this._domNode.style.display = 'none';216217// Header (always visible, click to collapse/expand)218this._headerNode = dom.append(this._domNode, $('.ci-status-widget-header'));219this._titleNode = dom.append(this._headerNode, $('.ci-status-widget-title'));220this._titleLabelNode = dom.append(this._titleNode, $('.ci-status-widget-title-label'));221this._titleLabelNode.textContent = localize('ci.checksLabel', "Checks");222this._countsNode = dom.append(this._titleNode, $('.ci-status-widget-counts'));223this._headerActionBarContainer = dom.append(this._headerNode, $('.ci-status-widget-header-actions'));224this._headerActionBar = this._register(new ActionBar(this._headerActionBarContainer));225this._register(dom.addDisposableListener(this._headerActionBarContainer, dom.EventType.CLICK, e => {226e.preventDefault();227e.stopPropagation();228}));229this._chevronNode = dom.append(this._headerNode, $('.group-chevron'));230this._chevronNode.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronDown));231232this._headerNode.setAttribute('role', 'button');233this._headerNode.setAttribute('aria-label', localize('ci.toggleChecks', "Toggle Checks"));234this._headerNode.setAttribute('aria-expanded', 'true');235this._headerNode.tabIndex = 0;236237this._register(dom.addDisposableListener(this._headerNode, dom.EventType.CLICK, e => {238// Don't toggle when clicking the action bar239if (dom.isAncestor(e.target as HTMLElement, this._headerActionBarContainer)) {240return;241}242this._toggleCollapsed();243}));244this._register(dom.addDisposableListener(this._headerNode, dom.EventType.KEY_DOWN, e => {245if ((e.key === 'Enter' || e.key === ' ') && e.target === this._headerNode) {246e.preventDefault();247this._toggleCollapsed();248}249}));250251// Body (list of checks)252const bodyId = 'ci-status-widget-body';253this._bodyNode = dom.append(this._domNode, $(`.${bodyId}`));254this._bodyNode.id = bodyId;255this._headerNode.setAttribute('aria-controls', bodyId);256257const listContainer = $('.ci-status-widget-list');258this._list = this._register(this._instantiationService.createInstance(259WorkbenchList<ICICheckListItem>,260'CIStatusWidget',261listContainer,262new CICheckListDelegate(),263[new CICheckListRenderer(this._labels, this._openerService, () => this._model)],264{265multipleSelectionSupport: false,266openOnSingleClick: false,267accessibilityProvider: {268getWidgetAriaLabel: () => localize('ci.checksListAriaLabel', "Checks"),269getAriaLabel: item => localize('ci.checkAriaLabel', "{0}, {1}", item.check.name, getCheckStateLabel(item.check)),270},271keyboardNavigationLabelProvider: {272getKeyboardNavigationLabel: item => item.check.name,273},274},275));276this._bodyNode.appendChild(listContainer);277}278279setInput(input: ChecksViewModel): IDisposable {280return autorun(reader => {281this._model = input.checksObs.read(reader);282this._sessionResource = input.activeSessionResourceObs.read(reader);283284if (!this._model) {285this._checkCount = 0;286this._setCollapsed(false);287this._renderBody([]);288this._renderHeaderActions([]);289this._domNode.style.display = 'none';290this._onDidChangeHeight.fire();291return;292}293294const checks = this._model.checks.read(reader);295296if (checks.length === 0) {297this._checkCount = 0;298this._setCollapsed(false);299this._renderBody([]);300this._renderHeaderActions([]);301this._domNode.style.display = 'none';302this._onDidChangeHeight.fire();303return;304}305306const sorted = sortChecks(checks);307const oldCount = this._checkCount;308this._checkCount = sorted.length;309310this._domNode.style.display = '';311this._renderHeader(checks);312this._renderHeaderActions(getFailedChecks(checks));313this._renderBody(sorted);314315if (this._checkCount !== oldCount) {316this._onDidChangeHeight.fire();317}318});319}320321private _renderHeader(checks: readonly IGitHubCICheck[]): void {322const counts = getCheckCounts(checks);323324// Update count badges325dom.clearNode(this._countsNode);326327if (counts.running > 0) {328const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-running'));329badge.appendChild(renderIcon(Codicon.circleFilled));330dom.append(badge, $('span')).textContent = `${counts.running}`;331}332333if (counts.failed > 0) {334const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-failure'));335badge.appendChild(renderIcon(Codicon.error));336dom.append(badge, $('span')).textContent = `${counts.failed}`;337}338339if (counts.pending > 0) {340const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-pending'));341badge.appendChild(renderIcon(Codicon.circleFilled));342dom.append(badge, $('span')).textContent = `${counts.pending}`;343}344345if (counts.successful > 0) {346const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-success'));347badge.appendChild(renderIcon(Codicon.passFilled));348dom.append(badge, $('span')).textContent = `${counts.successful}`;349}350}351352private _renderHeaderActions(failedChecks: readonly IGitHubCICheck[]): void {353this._headerActionDisposables.clear();354this._headerActionBar.clear();355356if (failedChecks.length === 0) {357this._headerActionBarContainer.classList.remove('has-actions');358this._domNode.classList.remove('has-fix-actions');359return;360}361362const fixChecksAction = this._headerActionDisposables.add(new Action(363'ci.fixChecks',364localize('ci.fixChecks', "Fix Checks"),365ThemeIcon.asClassName(Codicon.lightbulbAutofix),366true,367async () => {368await this._sendFixChecksPrompt(failedChecks);369},370));371372this._headerActionBar.push([fixChecksAction], { icon: true, label: false });373this._headerActionBarContainer.classList.add('has-actions');374this._domNode.classList.add('has-fix-actions');375}376377/**378* Layout the widget body list to the given height.379* Called by the parent view after computing available space.380*/381layout(height: number): void {382if (this._collapsed) {383this._bodyNode.style.display = 'none';384return;385}386this._bodyNode.style.display = '';387this._list.layout(height);388}389390private _toggleCollapsed(): void {391this._setCollapsed(!this._collapsed);392this._onDidToggleCollapsed.fire(this._collapsed);393// Also fires onDidChangeHeight so the SplitView pane updates its min/max constraints394this._onDidChangeHeight.fire();395}396397private _setCollapsed(collapsed: boolean): void {398this._collapsed = collapsed;399this._updateChevron();400this._headerNode.setAttribute('aria-expanded', String(!collapsed));401}402403private _updateChevron(): void {404this._chevronNode.className = 'group-chevron';405this._chevronNode.classList.add(406...ThemeIcon.asClassNameArray(407this._collapsed ? Codicon.chevronRight : Codicon.chevronDown408)409);410}411412private _renderBody(checks: readonly ICICheckListItem[]): void {413this._list.splice(0, this._list.length, checks);414}415416private async _sendFixChecksPrompt(failedChecks: readonly IGitHubCICheck[]): Promise<void> {417const model = this._model;418const sessionResource = this._sessionResource;419if (!model || !sessionResource || failedChecks.length === 0) {420return;421}422423const failedCheckDetails = await Promise.all(failedChecks.map(async check => {424const annotations = await model.getCheckRunAnnotations(check.id);425return {426check,427annotations,428};429}));430431const prompt = buildFixChecksPrompt(failedCheckDetails);432const chatWidget = this._chatWidgetService.getWidgetBySessionResource(sessionResource)433?? await this._chatWidgetService.openSession(sessionResource, ChatViewPaneTarget);434if (!chatWidget) {435return;436}437438await chatWidget.acceptInput(prompt, { noCommandDetection: true });439}440}441442function sortChecks(checks: readonly IGitHubCICheck[]): ICICheckListItem[] {443return [...checks]444.sort(compareChecks)445.map(check => ({ check, group: getCheckGroup(check) }));446}447448function compareChecks(a: IGitHubCICheck, b: IGitHubCICheck): number {449const groupDiff = getCheckGroup(a) - getCheckGroup(b);450if (groupDiff !== 0) {451return groupDiff;452}453454return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });455}456457function getCheckCounts(checks: readonly IGitHubCICheck[]): ICICheckCounts {458let running = 0;459let pending = 0;460let failed = 0;461let successful = 0;462463for (const check of checks) {464switch (getCheckGroup(check)) {465case CICheckGroup.Running:466running++;467break;468case CICheckGroup.Pending:469pending++;470break;471case CICheckGroup.Failed:472failed++;473break;474case CICheckGroup.Successful:475successful++;476break;477}478}479480return { running, pending, failed, successful };481}482483function getCheckIcon(check: IGitHubCICheck): ThemeIcon {484switch (check.status) {485case GitHubCheckStatus.InProgress:486return Codicon.sync;487case GitHubCheckStatus.Queued:488return Codicon.circleFilled;489case GitHubCheckStatus.Completed:490switch (check.conclusion) {491case GitHubCheckConclusion.Success:492return Codicon.passFilled;493case GitHubCheckConclusion.Failure:494case GitHubCheckConclusion.TimedOut:495case GitHubCheckConclusion.ActionRequired:496return Codicon.error;497case GitHubCheckConclusion.Cancelled:498return Codicon.circleSlash;499case GitHubCheckConclusion.Skipped:500return Codicon.debugStepOver;501default:502return Codicon.circleFilled;503}504default:505return Codicon.circleFilled;506}507}508509function getCheckStatusClass(check: IGitHubCICheck): string {510switch (getCheckGroup(check)) {511case CICheckGroup.Running:512return 'ci-status-running';513case CICheckGroup.Pending:514return 'ci-status-pending';515case CICheckGroup.Failed:516return 'ci-status-failure';517case CICheckGroup.Successful:518return 'ci-status-success';519}520}521522523