Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/changes/browser/checksWidget.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import './media/checksWidget.css';
7
import * as dom from '../../../../base/browser/dom.js';
8
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
9
import { IListRenderer, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
10
import { Action } from '../../../../base/common/actions.js';
11
import { Codicon } from '../../../../base/common/codicons.js';
12
import { Emitter } from '../../../../base/common/event.js';
13
import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
14
import { autorun } from '../../../../base/common/observable.js';
15
import { ThemeIcon } from '../../../../base/common/themables.js';
16
import { URI } from '../../../../base/common/uri.js';
17
import { localize } from '../../../../nls.js';
18
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
19
import { WorkbenchList } from '../../../../platform/list/browser/listService.js';
20
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
21
import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
22
import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js';
23
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
24
import { GitHubCheckConclusion, GitHubCheckStatus, IGitHubCICheck } from '../../github/common/types.js';
25
import { GitHubPullRequestCIModel, parseWorkflowRunId } from '../../github/browser/models/githubPullRequestCIModel.js';
26
import { CICheckGroup, buildFixChecksPrompt, getCheckGroup, getCheckStateLabel, getFailedChecks } from './checksActions.js';
27
import { ChecksViewModel } from './checksViewModel.js';
28
29
const $ = dom.$;
30
31
interface ICICheckListItem {
32
readonly check: IGitHubCICheck;
33
readonly group: CICheckGroup;
34
}
35
36
interface ICICheckCounts {
37
readonly running: number;
38
readonly pending: number;
39
readonly failed: number;
40
readonly successful: number;
41
}
42
43
class CICheckListDelegate implements IListVirtualDelegate<ICICheckListItem> {
44
static readonly ITEM_HEIGHT = 28;
45
46
getHeight(_element: ICICheckListItem): number {
47
return CICheckListDelegate.ITEM_HEIGHT;
48
}
49
50
getTemplateId(_element: ICICheckListItem): string {
51
return CICheckListRenderer.TEMPLATE_ID;
52
}
53
}
54
55
interface ICICheckTemplateData {
56
readonly row: HTMLElement;
57
readonly label: IResourceLabel;
58
readonly actionBar: ActionBar;
59
readonly templateDisposables: DisposableStore;
60
readonly elementDisposables: DisposableStore;
61
}
62
63
class CICheckListRenderer implements IListRenderer<ICICheckListItem, ICICheckTemplateData> {
64
static readonly TEMPLATE_ID = 'ciCheck';
65
readonly templateId = CICheckListRenderer.TEMPLATE_ID;
66
67
constructor(
68
private readonly _labels: ResourceLabels,
69
private readonly _openerService: IOpenerService,
70
private readonly _getModel: () => GitHubPullRequestCIModel | undefined,
71
) { }
72
73
renderTemplate(container: HTMLElement): ICICheckTemplateData {
74
const templateDisposables = new DisposableStore();
75
const row = dom.append(container, $('.ci-status-widget-check'));
76
77
const labelContainer = dom.append(row, $('.ci-status-widget-check-label'));
78
const label = templateDisposables.add(this._labels.create(labelContainer, { supportIcons: true }));
79
80
const actionBarContainer = dom.append(row, $('.ci-status-widget-check-actions'));
81
const actionBar = templateDisposables.add(new ActionBar(actionBarContainer));
82
83
return {
84
row,
85
label,
86
actionBar,
87
templateDisposables,
88
elementDisposables: templateDisposables.add(new DisposableStore()),
89
};
90
}
91
92
renderElement(element: ICICheckListItem, _index: number, templateData: ICICheckTemplateData): void {
93
templateData.elementDisposables.clear();
94
templateData.actionBar.clear();
95
96
templateData.row.className = `ci-status-widget-check ${getCheckStatusClass(element.check)}`;
97
98
const title = localize('ci.checkTitle', "{0}: {1}", element.check.name, getCheckStateLabel(element.check));
99
templateData.label.setResource({
100
name: element.check.name,
101
resource: URI.from({ scheme: 'github-check', path: `/${element.check.id}/${element.check.name}` }),
102
}, {
103
icon: getCheckIcon(element.check),
104
title,
105
});
106
107
const actions: Action[] = [];
108
109
if (element.group === CICheckGroup.Failed && parseWorkflowRunId(element.check.detailsUrl) !== undefined) {
110
actions.push(templateData.elementDisposables.add(new Action(
111
'ci.rerunCheck',
112
localize('ci.rerunCheck', "Rerun Check"),
113
ThemeIcon.asClassName(Codicon.debugRerun),
114
true,
115
async () => {
116
await this._getModel()?.rerunFailedCheck(element.check);
117
},
118
)));
119
}
120
121
if (element.check.detailsUrl) {
122
actions.push(templateData.elementDisposables.add(new Action(
123
'ci.openOnGitHub',
124
localize('ci.openOnGitHub', "Open on GitHub"),
125
ThemeIcon.asClassName(Codicon.linkExternal),
126
true,
127
async () => {
128
await this._openerService.open(URI.parse(element.check.detailsUrl!));
129
},
130
)));
131
}
132
133
templateData.actionBar.push(actions, { icon: true, label: false });
134
}
135
136
disposeElement(_element: ICICheckListItem, _index: number, templateData: ICICheckTemplateData): void {
137
templateData.elementDisposables.clear();
138
templateData.actionBar.clear();
139
}
140
141
disposeTemplate(templateData: ICICheckTemplateData): void {
142
templateData.templateDisposables.dispose();
143
}
144
}
145
146
/**
147
* A widget that shows the CI status of a PR.
148
* Rendered beneath the changes tree in the changes view as a SplitView pane.
149
*/
150
export class CIStatusWidget extends Disposable {
151
152
static readonly HEADER_HEIGHT = 34; // total header height in px
153
static readonly MIN_BODY_HEIGHT = 84; // at least 3 checks (3 * 28)
154
static readonly PREFERRED_BODY_HEIGHT = 112; // preferred 4 checks (4 * 28)
155
static readonly MAX_BODY_HEIGHT = 240; // at most ~8 checks
156
157
private readonly _domNode: HTMLElement;
158
private readonly _headerNode: HTMLElement;
159
private readonly _titleNode: HTMLElement;
160
private readonly _titleLabelNode: HTMLElement;
161
private readonly _countsNode: HTMLElement;
162
private readonly _headerActionBarContainer: HTMLElement;
163
private readonly _headerActionBar: ActionBar;
164
private readonly _bodyNode: HTMLElement;
165
private readonly _list: WorkbenchList<ICICheckListItem>;
166
private readonly _labels: ResourceLabels;
167
private readonly _headerActionDisposables = this._register(new DisposableStore());
168
169
private readonly _onDidChangeHeight = this._register(new Emitter<void>());
170
readonly onDidChangeHeight = this._onDidChangeHeight.event;
171
172
private readonly _onDidToggleCollapsed = this._register(new Emitter<boolean>());
173
readonly onDidToggleCollapsed = this._onDidToggleCollapsed.event;
174
175
private _checkCount = 0;
176
private _collapsed = false;
177
private _model: GitHubPullRequestCIModel | undefined;
178
private _sessionResource: URI | undefined;
179
private readonly _chevronNode: HTMLElement;
180
181
get element(): HTMLElement {
182
return this._domNode;
183
}
184
185
/** The full content height the widget would like (header + all checks). */
186
get desiredHeight(): number {
187
if (this._checkCount === 0) {
188
return 0;
189
}
190
if (this._collapsed) {
191
return CIStatusWidget.HEADER_HEIGHT;
192
}
193
return CIStatusWidget.HEADER_HEIGHT + this._checkCount * CICheckListDelegate.ITEM_HEIGHT;
194
}
195
196
/** Whether the widget is currently visible (has checks to show). */
197
get visible(): boolean {
198
return this._checkCount > 0;
199
}
200
201
/** Whether the body is collapsed (header-only). */
202
get collapsed(): boolean {
203
return this._collapsed;
204
}
205
206
constructor(
207
container: HTMLElement,
208
@IOpenerService private readonly _openerService: IOpenerService,
209
@IChatWidgetService private readonly _chatWidgetService: IChatWidgetService,
210
@IInstantiationService private readonly _instantiationService: IInstantiationService,
211
) {
212
super();
213
this._labels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER));
214
215
this._domNode = dom.append(container, $('.ci-status-widget'));
216
this._domNode.style.display = 'none';
217
218
// Header (always visible, click to collapse/expand)
219
this._headerNode = dom.append(this._domNode, $('.ci-status-widget-header'));
220
this._titleNode = dom.append(this._headerNode, $('.ci-status-widget-title'));
221
this._titleLabelNode = dom.append(this._titleNode, $('.ci-status-widget-title-label'));
222
this._titleLabelNode.textContent = localize('ci.checksLabel', "Checks");
223
this._countsNode = dom.append(this._titleNode, $('.ci-status-widget-counts'));
224
this._headerActionBarContainer = dom.append(this._headerNode, $('.ci-status-widget-header-actions'));
225
this._headerActionBar = this._register(new ActionBar(this._headerActionBarContainer));
226
this._register(dom.addDisposableListener(this._headerActionBarContainer, dom.EventType.CLICK, e => {
227
e.preventDefault();
228
e.stopPropagation();
229
}));
230
this._chevronNode = dom.append(this._headerNode, $('.group-chevron'));
231
this._chevronNode.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronDown));
232
233
this._headerNode.setAttribute('role', 'button');
234
this._headerNode.setAttribute('aria-label', localize('ci.toggleChecks', "Toggle Checks"));
235
this._headerNode.setAttribute('aria-expanded', 'true');
236
this._headerNode.tabIndex = 0;
237
238
this._register(dom.addDisposableListener(this._headerNode, dom.EventType.CLICK, e => {
239
// Don't toggle when clicking the action bar
240
if (dom.isAncestor(e.target as HTMLElement, this._headerActionBarContainer)) {
241
return;
242
}
243
this._toggleCollapsed();
244
}));
245
this._register(dom.addDisposableListener(this._headerNode, dom.EventType.KEY_DOWN, e => {
246
if ((e.key === 'Enter' || e.key === ' ') && e.target === this._headerNode) {
247
e.preventDefault();
248
this._toggleCollapsed();
249
}
250
}));
251
252
// Body (list of checks)
253
const bodyId = 'ci-status-widget-body';
254
this._bodyNode = dom.append(this._domNode, $(`.${bodyId}`));
255
this._bodyNode.id = bodyId;
256
this._headerNode.setAttribute('aria-controls', bodyId);
257
258
const listContainer = $('.ci-status-widget-list');
259
this._list = this._register(this._instantiationService.createInstance(
260
WorkbenchList<ICICheckListItem>,
261
'CIStatusWidget',
262
listContainer,
263
new CICheckListDelegate(),
264
[new CICheckListRenderer(this._labels, this._openerService, () => this._model)],
265
{
266
multipleSelectionSupport: false,
267
openOnSingleClick: false,
268
accessibilityProvider: {
269
getWidgetAriaLabel: () => localize('ci.checksListAriaLabel', "Checks"),
270
getAriaLabel: item => localize('ci.checkAriaLabel', "{0}, {1}", item.check.name, getCheckStateLabel(item.check)),
271
},
272
keyboardNavigationLabelProvider: {
273
getKeyboardNavigationLabel: item => item.check.name,
274
},
275
},
276
));
277
this._bodyNode.appendChild(listContainer);
278
}
279
280
setInput(input: ChecksViewModel): IDisposable {
281
return autorun(reader => {
282
this._model = input.checksObs.read(reader);
283
this._sessionResource = input.activeSessionResourceObs.read(reader);
284
285
if (!this._model) {
286
this._checkCount = 0;
287
this._setCollapsed(false);
288
this._renderBody([]);
289
this._renderHeaderActions([]);
290
this._domNode.style.display = 'none';
291
this._onDidChangeHeight.fire();
292
return;
293
}
294
295
const checks = this._model.checks.read(reader);
296
297
if (checks.length === 0) {
298
this._checkCount = 0;
299
this._setCollapsed(false);
300
this._renderBody([]);
301
this._renderHeaderActions([]);
302
this._domNode.style.display = 'none';
303
this._onDidChangeHeight.fire();
304
return;
305
}
306
307
const sorted = sortChecks(checks);
308
const oldCount = this._checkCount;
309
this._checkCount = sorted.length;
310
311
this._domNode.style.display = '';
312
this._renderHeader(checks);
313
this._renderHeaderActions(getFailedChecks(checks));
314
this._renderBody(sorted);
315
316
if (this._checkCount !== oldCount) {
317
this._onDidChangeHeight.fire();
318
}
319
});
320
}
321
322
private _renderHeader(checks: readonly IGitHubCICheck[]): void {
323
const counts = getCheckCounts(checks);
324
325
// Update count badges
326
dom.clearNode(this._countsNode);
327
328
if (counts.running > 0) {
329
const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-running'));
330
badge.appendChild(renderIcon(Codicon.circleFilled));
331
dom.append(badge, $('span')).textContent = `${counts.running}`;
332
}
333
334
if (counts.failed > 0) {
335
const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-failure'));
336
badge.appendChild(renderIcon(Codicon.error));
337
dom.append(badge, $('span')).textContent = `${counts.failed}`;
338
}
339
340
if (counts.pending > 0) {
341
const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-pending'));
342
badge.appendChild(renderIcon(Codicon.circleFilled));
343
dom.append(badge, $('span')).textContent = `${counts.pending}`;
344
}
345
346
if (counts.successful > 0) {
347
const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-success'));
348
badge.appendChild(renderIcon(Codicon.passFilled));
349
dom.append(badge, $('span')).textContent = `${counts.successful}`;
350
}
351
}
352
353
private _renderHeaderActions(failedChecks: readonly IGitHubCICheck[]): void {
354
this._headerActionDisposables.clear();
355
this._headerActionBar.clear();
356
357
if (failedChecks.length === 0) {
358
this._headerActionBarContainer.classList.remove('has-actions');
359
this._domNode.classList.remove('has-fix-actions');
360
return;
361
}
362
363
const fixChecksAction = this._headerActionDisposables.add(new Action(
364
'ci.fixChecks',
365
localize('ci.fixChecks', "Fix Checks"),
366
ThemeIcon.asClassName(Codicon.lightbulbAutofix),
367
true,
368
async () => {
369
await this._sendFixChecksPrompt(failedChecks);
370
},
371
));
372
373
this._headerActionBar.push([fixChecksAction], { icon: true, label: false });
374
this._headerActionBarContainer.classList.add('has-actions');
375
this._domNode.classList.add('has-fix-actions');
376
}
377
378
/**
379
* Layout the widget body list to the given height.
380
* Called by the parent view after computing available space.
381
*/
382
layout(height: number): void {
383
if (this._collapsed) {
384
this._bodyNode.style.display = 'none';
385
return;
386
}
387
this._bodyNode.style.display = '';
388
this._list.layout(height);
389
}
390
391
private _toggleCollapsed(): void {
392
this._setCollapsed(!this._collapsed);
393
this._onDidToggleCollapsed.fire(this._collapsed);
394
// Also fires onDidChangeHeight so the SplitView pane updates its min/max constraints
395
this._onDidChangeHeight.fire();
396
}
397
398
private _setCollapsed(collapsed: boolean): void {
399
this._collapsed = collapsed;
400
this._updateChevron();
401
this._headerNode.setAttribute('aria-expanded', String(!collapsed));
402
}
403
404
private _updateChevron(): void {
405
this._chevronNode.className = 'group-chevron';
406
this._chevronNode.classList.add(
407
...ThemeIcon.asClassNameArray(
408
this._collapsed ? Codicon.chevronRight : Codicon.chevronDown
409
)
410
);
411
}
412
413
private _renderBody(checks: readonly ICICheckListItem[]): void {
414
this._list.splice(0, this._list.length, checks);
415
}
416
417
private async _sendFixChecksPrompt(failedChecks: readonly IGitHubCICheck[]): Promise<void> {
418
const model = this._model;
419
const sessionResource = this._sessionResource;
420
if (!model || !sessionResource || failedChecks.length === 0) {
421
return;
422
}
423
424
const failedCheckDetails = await Promise.all(failedChecks.map(async check => {
425
const annotations = await model.getCheckRunAnnotations(check.id);
426
return {
427
check,
428
annotations,
429
};
430
}));
431
432
const prompt = buildFixChecksPrompt(failedCheckDetails);
433
const chatWidget = this._chatWidgetService.getWidgetBySessionResource(sessionResource)
434
?? await this._chatWidgetService.openSession(sessionResource, ChatViewPaneTarget);
435
if (!chatWidget) {
436
return;
437
}
438
439
await chatWidget.acceptInput(prompt, { noCommandDetection: true });
440
}
441
}
442
443
function sortChecks(checks: readonly IGitHubCICheck[]): ICICheckListItem[] {
444
return [...checks]
445
.sort(compareChecks)
446
.map(check => ({ check, group: getCheckGroup(check) }));
447
}
448
449
function compareChecks(a: IGitHubCICheck, b: IGitHubCICheck): number {
450
const groupDiff = getCheckGroup(a) - getCheckGroup(b);
451
if (groupDiff !== 0) {
452
return groupDiff;
453
}
454
455
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
456
}
457
458
function getCheckCounts(checks: readonly IGitHubCICheck[]): ICICheckCounts {
459
let running = 0;
460
let pending = 0;
461
let failed = 0;
462
let successful = 0;
463
464
for (const check of checks) {
465
switch (getCheckGroup(check)) {
466
case CICheckGroup.Running:
467
running++;
468
break;
469
case CICheckGroup.Pending:
470
pending++;
471
break;
472
case CICheckGroup.Failed:
473
failed++;
474
break;
475
case CICheckGroup.Successful:
476
successful++;
477
break;
478
}
479
}
480
481
return { running, pending, failed, successful };
482
}
483
484
function getCheckIcon(check: IGitHubCICheck): ThemeIcon {
485
switch (check.status) {
486
case GitHubCheckStatus.InProgress:
487
return Codicon.sync;
488
case GitHubCheckStatus.Queued:
489
return Codicon.circleFilled;
490
case GitHubCheckStatus.Completed:
491
switch (check.conclusion) {
492
case GitHubCheckConclusion.Success:
493
return Codicon.passFilled;
494
case GitHubCheckConclusion.Failure:
495
case GitHubCheckConclusion.TimedOut:
496
case GitHubCheckConclusion.ActionRequired:
497
return Codicon.error;
498
case GitHubCheckConclusion.Cancelled:
499
return Codicon.circleSlash;
500
case GitHubCheckConclusion.Skipped:
501
return Codicon.debugStepOver;
502
default:
503
return Codicon.circleFilled;
504
}
505
default:
506
return Codicon.circleFilled;
507
}
508
}
509
510
function getCheckStatusClass(check: IGitHubCICheck): string {
511
switch (getCheckGroup(check)) {
512
case CICheckGroup.Running:
513
return 'ci-status-running';
514
case CICheckGroup.Pending:
515
return 'ci-status-pending';
516
case CICheckGroup.Failed:
517
return 'ci-status-failure';
518
case CICheckGroup.Successful:
519
return 'ci-status-success';
520
}
521
}
522
523