Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/issue/browser/baseIssueReporterService.ts
5250 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
import { $, isHTMLInputElement, isHTMLTextAreaElement, reset } from '../../../../base/browser/dom.js';
6
import { createStyleSheet } from '../../../../base/browser/domStylesheets.js';
7
import { Button, ButtonWithDropdown, unthemedButtonStyles } from '../../../../base/browser/ui/button/button.js';
8
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
9
import { Delayer, RunOnceScheduler } from '../../../../base/common/async.js';
10
import { VSBuffer } from '../../../../base/common/buffer.js';
11
import { Codicon } from '../../../../base/common/codicons.js';
12
import { groupBy } from '../../../../base/common/collections.js';
13
import { debounce } from '../../../../base/common/decorators.js';
14
import { CancellationError } from '../../../../base/common/errors.js';
15
import { Disposable } from '../../../../base/common/lifecycle.js';
16
import { Schemas } from '../../../../base/common/network.js';
17
import { isLinuxSnap, isMacintosh } from '../../../../base/common/platform.js';
18
import { IProductConfiguration } from '../../../../base/common/product.js';
19
import { joinPath } from '../../../../base/common/resources.js';
20
import { escape } from '../../../../base/common/strings.js';
21
import { ThemeIcon } from '../../../../base/common/themables.js';
22
import { URI } from '../../../../base/common/uri.js';
23
import { Action } from '../../../../base/common/actions.js';
24
import { localize } from '../../../../nls.js';
25
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
26
import { IFileService } from '../../../../platform/files/common/files.js';
27
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
28
import { getIconsStyleSheet } from '../../../../platform/theme/browser/iconsStyleSheet.js';
29
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
30
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
31
import { IIssueFormService, IssueReporterData, IssueReporterExtensionData, IssueType } from '../common/issue.js';
32
import { normalizeGitHubUrl } from '../common/issueReporterUtil.js';
33
import { IssueReporterModel, IssueReporterData as IssueReporterModelData } from './issueReporterModel.js';
34
import { IAuthenticationService } from '../../../services/authentication/common/authentication.js';
35
36
const MAX_URL_LENGTH = 7500;
37
38
// Github API and issues on web has a limit of 65536. If extension data is too large, we will allow users to downlaod and attach it as a file.
39
// We round down to be safe.
40
// ref https://github.com/github/issues/issues/12858
41
42
const MAX_EXTENSION_DATA_LENGTH = 60000;
43
44
interface SearchResult {
45
html_url: string;
46
title: string;
47
state?: string;
48
}
49
50
enum IssueSource {
51
VSCode = 'vscode',
52
Extension = 'extension',
53
Marketplace = 'marketplace',
54
Unknown = 'unknown'
55
}
56
57
58
export class BaseIssueReporterService extends Disposable {
59
public issueReporterModel: IssueReporterModel;
60
public receivedSystemInfo = false;
61
public numberOfSearchResultsDisplayed = 0;
62
public receivedPerformanceInfo = false;
63
public shouldQueueSearch = false;
64
public hasBeenSubmitted = false;
65
public openReporter = false;
66
public loadingExtensionData = false;
67
public selectedExtension = '';
68
public delayedSubmit = new Delayer<void>(300);
69
public publicGithubButton!: Button | ButtonWithDropdown;
70
public internalGithubButton!: Button | ButtonWithDropdown;
71
public nonGitHubIssueUrl = false;
72
public needsUpdate = false;
73
public acknowledged = false;
74
private createAction: Action;
75
private previewAction: Action;
76
private privateAction: Action;
77
78
constructor(
79
public disableExtensions: boolean,
80
public data: IssueReporterData,
81
public os: {
82
type: string;
83
arch: string;
84
release: string;
85
},
86
public product: IProductConfiguration,
87
public readonly window: Window,
88
public readonly isWeb: boolean,
89
@IIssueFormService public readonly issueFormService: IIssueFormService,
90
@IThemeService public readonly themeService: IThemeService,
91
@IFileService public readonly fileService: IFileService,
92
@IFileDialogService public readonly fileDialogService: IFileDialogService,
93
@IContextMenuService public readonly contextMenuService: IContextMenuService,
94
@IAuthenticationService public readonly authenticationService: IAuthenticationService,
95
@IOpenerService public readonly openerService: IOpenerService
96
) {
97
super();
98
const targetExtension = data.extensionId ? data.enabledExtensions.find(extension => extension.id.toLocaleLowerCase() === data.extensionId?.toLocaleLowerCase()) : undefined;
99
this.issueReporterModel = new IssueReporterModel({
100
...data,
101
issueType: data.issueType || IssueType.Bug,
102
versionInfo: {
103
vscodeVersion: `${product.nameShort} ${!!product.darwinUniversalAssetId ? `${product.version} (Universal)` : product.version} (${product.commit || 'Commit unknown'}, ${product.date || 'Date unknown'})`,
104
os: `${this.os.type} ${this.os.arch} ${this.os.release}${isLinuxSnap ? ' snap' : ''}`
105
},
106
extensionsDisabled: !!this.disableExtensions,
107
fileOnExtension: data.extensionId ? !targetExtension?.isBuiltin : undefined,
108
selectedExtension: targetExtension
109
});
110
111
this._register(this.authenticationService.onDidChangeSessions(async () => {
112
const previousAuthState = !!this.data.githubAccessToken;
113
114
let githubAccessToken = '';
115
try {
116
const githubSessions = await this.authenticationService.getSessions('github');
117
const potentialSessions = githubSessions.filter(session => session.scopes.includes('repo'));
118
githubAccessToken = potentialSessions[0]?.accessToken;
119
} catch (e) {
120
// Ignore
121
}
122
123
this.data.githubAccessToken = githubAccessToken;
124
125
const currentAuthState = !!githubAccessToken;
126
if (previousAuthState !== currentAuthState) {
127
this.updateButtonStates();
128
}
129
}));
130
131
const fileOnMarketplace = data.issueSource === IssueSource.Marketplace;
132
const fileOnProduct = data.issueSource === IssueSource.VSCode;
133
this.issueReporterModel.update({ fileOnMarketplace, fileOnProduct });
134
135
this.createAction = this._register(new Action('issueReporter.create', localize('create', "Create on GitHub"), undefined, true, async () => {
136
this.delayedSubmit.trigger(async () => {
137
this.createIssue(true); // create issue
138
});
139
}));
140
this.previewAction = this._register(new Action('issueReporter.preview', localize('preview', "Preview on GitHub"), undefined, true, async () => {
141
this.delayedSubmit.trigger(async () => {
142
this.createIssue(false); // preview issue
143
});
144
}));
145
this.privateAction = this._register(new Action('issueReporter.privateCreate', localize('privateCreate', "Create Internally"), undefined, true, async () => {
146
this.delayedSubmit.trigger(async () => {
147
this.createIssue(true, true); // create private issue
148
});
149
}));
150
151
const issueTitle = data.issueTitle;
152
if (issueTitle) {
153
// eslint-disable-next-line no-restricted-syntax
154
const issueTitleElement = this.getElementById<HTMLInputElement>('issue-title');
155
if (issueTitleElement) {
156
issueTitleElement.value = issueTitle;
157
}
158
}
159
160
const issueBody = data.issueBody;
161
if (issueBody) {
162
// eslint-disable-next-line no-restricted-syntax
163
const description = this.getElementById<HTMLTextAreaElement>('description');
164
if (description) {
165
description.value = issueBody;
166
this.issueReporterModel.update({ issueDescription: issueBody });
167
}
168
}
169
170
if (this.window.document.documentElement.lang !== 'en') {
171
// eslint-disable-next-line no-restricted-syntax
172
show(this.getElementById('english'));
173
}
174
175
const codiconStyleSheet = createStyleSheet();
176
codiconStyleSheet.id = 'codiconStyles';
177
178
const iconsStyleSheet = this._register(getIconsStyleSheet(this.themeService));
179
function updateAll() {
180
codiconStyleSheet.textContent = iconsStyleSheet.getCSS();
181
}
182
183
const delayer = new RunOnceScheduler(updateAll, 0);
184
this._register(iconsStyleSheet.onDidChange(() => delayer.schedule()));
185
delayer.schedule();
186
187
this.handleExtensionData(data.enabledExtensions);
188
this.setUpTypes();
189
190
// Handle case where extension is pre-selected through the command
191
if ((data.data || data.uri) && targetExtension) {
192
this.updateExtensionStatus(targetExtension);
193
}
194
195
// initialize the reporting button(s)
196
// eslint-disable-next-line no-restricted-syntax
197
const issueReporterElement = this.getElementById('issue-reporter');
198
if (issueReporterElement) {
199
this.updateButtonStates();
200
}
201
}
202
203
render(): void {
204
this.renderBlocks();
205
}
206
207
setInitialFocus() {
208
const { fileOnExtension } = this.issueReporterModel.getData();
209
if (fileOnExtension) {
210
// eslint-disable-next-line no-restricted-syntax
211
const issueTitle = this.window.document.getElementById('issue-title');
212
issueTitle?.focus();
213
} else {
214
// eslint-disable-next-line no-restricted-syntax
215
const issueType = this.window.document.getElementById('issue-type');
216
issueType?.focus();
217
}
218
}
219
220
public updateButtonStates() {
221
// eslint-disable-next-line no-restricted-syntax
222
const issueReporterElement = this.getElementById('issue-reporter');
223
if (!issueReporterElement) {
224
// shouldn't occur -- throw?
225
return;
226
}
227
228
229
// public elements section
230
// eslint-disable-next-line no-restricted-syntax
231
let publicElements = this.getElementById('public-elements');
232
if (!publicElements) {
233
publicElements = document.createElement('div');
234
publicElements.id = 'public-elements';
235
publicElements.classList.add('public-elements');
236
issueReporterElement.appendChild(publicElements);
237
}
238
this.updatePublicGithubButton(publicElements);
239
this.updatePublicRepoLink(publicElements);
240
241
242
// private filing section
243
// eslint-disable-next-line no-restricted-syntax
244
let internalElements = this.getElementById('internal-elements');
245
if (!internalElements) {
246
internalElements = document.createElement('div');
247
internalElements.id = 'internal-elements';
248
internalElements.classList.add('internal-elements');
249
internalElements.classList.add('hidden');
250
issueReporterElement.appendChild(internalElements);
251
}
252
// eslint-disable-next-line no-restricted-syntax
253
let filingRow = this.getElementById('internal-top-row');
254
if (!filingRow) {
255
filingRow = document.createElement('div');
256
filingRow.id = 'internal-top-row';
257
filingRow.classList.add('internal-top-row');
258
internalElements.appendChild(filingRow);
259
}
260
this.updateInternalFilingNote(filingRow);
261
this.updateInternalGithubButton(filingRow);
262
this.updateInternalElementsVisibility();
263
}
264
265
private updateInternalFilingNote(container: HTMLElement) {
266
// eslint-disable-next-line no-restricted-syntax
267
let filingNote = this.getElementById('internal-preview-message');
268
if (!filingNote) {
269
filingNote = document.createElement('span');
270
filingNote.id = 'internal-preview-message';
271
filingNote.classList.add('internal-preview-message');
272
container.appendChild(filingNote);
273
}
274
275
filingNote.textContent = escape(localize('internalPreviewMessage', 'If your copilot debug logs contain private information:'));
276
}
277
278
private updatePublicGithubButton(container: HTMLElement): void {
279
// eslint-disable-next-line no-restricted-syntax
280
const issueReporterElement = this.getElementById('issue-reporter');
281
if (!issueReporterElement) {
282
return;
283
}
284
285
// Dispose of the existing button
286
if (this.publicGithubButton) {
287
this.publicGithubButton.dispose();
288
}
289
290
// setup button + dropdown if applicable
291
if (!this.acknowledged && this.needsUpdate) { // * old version and hasn't ack'd
292
this.publicGithubButton = this._register(new Button(container, unthemedButtonStyles));
293
this.publicGithubButton.label = localize('acknowledge', "Confirm Version Acknowledgement");
294
this.publicGithubButton.enabled = false;
295
} else if (this.data.githubAccessToken && this.isPreviewEnabled()) { // * has access token, create by default, preview dropdown
296
this.publicGithubButton = this._register(new ButtonWithDropdown(container, {
297
contextMenuProvider: this.contextMenuService,
298
actions: [this.previewAction],
299
addPrimaryActionToDropdown: false,
300
...unthemedButtonStyles
301
}));
302
this._register(this.publicGithubButton.onDidClick(() => {
303
this.createAction.run();
304
}));
305
this.publicGithubButton.label = localize('createOnGitHub', "Create on GitHub");
306
this.publicGithubButton.enabled = true;
307
} else if (this.data.githubAccessToken && !this.isPreviewEnabled()) { // * Access token but invalid preview state: simple Button (create only)
308
this.publicGithubButton = this._register(new Button(container, unthemedButtonStyles));
309
this._register(this.publicGithubButton.onDidClick(() => {
310
this.createAction.run();
311
}));
312
this.publicGithubButton.label = localize('createOnGitHub', "Create on GitHub");
313
this.publicGithubButton.enabled = true;
314
} else { // * No access token: simple Button (preview only)
315
this.publicGithubButton = this._register(new Button(container, unthemedButtonStyles));
316
this._register(this.publicGithubButton.onDidClick(() => {
317
this.previewAction.run();
318
}));
319
this.publicGithubButton.label = localize('previewOnGitHub', "Preview on GitHub");
320
this.publicGithubButton.enabled = true;
321
}
322
323
// make sure that the repo link is after the button
324
// eslint-disable-next-line no-restricted-syntax
325
const repoLink = this.getElementById('show-repo-name');
326
if (repoLink) {
327
container.insertBefore(this.publicGithubButton.element, repoLink);
328
}
329
}
330
331
private updatePublicRepoLink(container: HTMLElement): void {
332
// eslint-disable-next-line no-restricted-syntax
333
let issueRepoName = this.getElementById('show-repo-name') as HTMLAnchorElement;
334
if (!issueRepoName) {
335
issueRepoName = document.createElement('a');
336
issueRepoName.id = 'show-repo-name';
337
issueRepoName.classList.add('hidden');
338
container.appendChild(issueRepoName);
339
}
340
341
342
const selectedExtension = this.issueReporterModel.getData().selectedExtension;
343
if (selectedExtension && selectedExtension.uri) {
344
const urlString = URI.revive(selectedExtension.uri).toString();
345
issueRepoName.href = urlString;
346
issueRepoName.addEventListener('click', (e) => this.openLink(e));
347
issueRepoName.addEventListener('auxclick', (e) => this.openLink(<MouseEvent>e));
348
const gitHubInfo = this.parseGitHubUrl(urlString);
349
issueRepoName.textContent = gitHubInfo ? gitHubInfo.owner + '/' + gitHubInfo.repositoryName : urlString;
350
Object.assign(issueRepoName.style, {
351
alignSelf: 'flex-end',
352
display: 'block',
353
fontSize: '13px',
354
padding: '4px 0px',
355
textDecoration: 'none',
356
width: 'auto'
357
});
358
show(issueRepoName);
359
} else if (issueRepoName) {
360
// clear styles
361
issueRepoName.removeAttribute('style');
362
hide(issueRepoName);
363
}
364
}
365
366
private updateInternalGithubButton(container: HTMLElement): void {
367
// eslint-disable-next-line no-restricted-syntax
368
const issueReporterElement = this.getElementById('issue-reporter');
369
if (!issueReporterElement) {
370
return;
371
}
372
373
// Dispose of the existing button
374
if (this.internalGithubButton) {
375
this.internalGithubButton.dispose();
376
}
377
378
if (this.data.githubAccessToken && this.data.privateUri) {
379
this.internalGithubButton = this._register(new Button(container, unthemedButtonStyles));
380
this._register(this.internalGithubButton.onDidClick(() => {
381
this.privateAction.run();
382
}));
383
384
this.internalGithubButton.element.id = 'internal-create-btn';
385
this.internalGithubButton.element.classList.add('internal-create-subtle');
386
this.internalGithubButton.label = localize('createInternally', "Create Internally");
387
this.internalGithubButton.enabled = true;
388
this.internalGithubButton.setTitle(this.data.privateUri.path!.slice(1));
389
}
390
}
391
392
private updateInternalElementsVisibility(): void {
393
// eslint-disable-next-line no-restricted-syntax
394
const container = this.getElementById('internal-elements');
395
if (!container) {
396
// shouldn't happen
397
return;
398
}
399
400
if (this.data.githubAccessToken && this.data.privateUri) {
401
show(container);
402
container.style.display = ''; //todo: necessary even with show?
403
if (this.internalGithubButton) {
404
this.internalGithubButton.enabled = this.publicGithubButton?.enabled ?? false;
405
}
406
} else {
407
hide(container);
408
container.style.display = 'none'; //todo: necessary even with hide?
409
}
410
}
411
412
private async updateIssueReporterUri(extension: IssueReporterExtensionData): Promise<void> {
413
try {
414
if (extension.uri) {
415
const uri = URI.revive(extension.uri);
416
extension.bugsUrl = uri.toString();
417
}
418
} catch (e) {
419
this.renderBlocks();
420
}
421
}
422
423
private handleExtensionData(extensions: IssueReporterExtensionData[]) {
424
const installedExtensions = extensions.filter(x => !x.isBuiltin);
425
const { nonThemes, themes } = groupBy(installedExtensions, ext => {
426
return ext.isTheme ? 'themes' : 'nonThemes';
427
});
428
429
const numberOfThemeExtesions = (themes && themes.length) ?? 0;
430
this.issueReporterModel.update({ numberOfThemeExtesions, enabledNonThemeExtesions: nonThemes, allExtensions: installedExtensions });
431
this.updateExtensionTable(nonThemes ?? [], numberOfThemeExtesions);
432
if (this.disableExtensions || installedExtensions.length === 0) {
433
// eslint-disable-next-line no-restricted-syntax
434
(<HTMLButtonElement>this.getElementById('disableExtensions')).disabled = true;
435
}
436
437
this.updateExtensionSelector(installedExtensions);
438
}
439
440
private updateExtensionSelector(extensions: IssueReporterExtensionData[]): void {
441
interface IOption {
442
name: string;
443
id: string;
444
}
445
446
const extensionOptions: IOption[] = extensions.map(extension => {
447
return {
448
name: extension.displayName || extension.name || '',
449
id: extension.id
450
};
451
});
452
453
// Sort extensions by name
454
extensionOptions.sort((a, b) => {
455
const aName = a.name.toLowerCase();
456
const bName = b.name.toLowerCase();
457
if (aName > bName) {
458
return 1;
459
}
460
461
if (aName < bName) {
462
return -1;
463
}
464
465
return 0;
466
});
467
468
const makeOption = (extension: IOption, selectedExtension?: IssueReporterExtensionData): HTMLOptionElement => {
469
const selected = selectedExtension && extension.id === selectedExtension.id;
470
return $<HTMLOptionElement>('option', {
471
'value': extension.id,
472
'selected': selected || ''
473
}, extension.name);
474
};
475
476
// eslint-disable-next-line no-restricted-syntax
477
const extensionsSelector = this.getElementById<HTMLSelectElement>('extension-selector');
478
if (extensionsSelector) {
479
const { selectedExtension } = this.issueReporterModel.getData();
480
reset(extensionsSelector, this.makeOption('', localize('selectExtension', "Select extension"), true), ...extensionOptions.map(extension => makeOption(extension, selectedExtension)));
481
482
if (!selectedExtension) {
483
extensionsSelector.selectedIndex = 0;
484
}
485
486
this.addEventListener('extension-selector', 'change', async (e: Event) => {
487
this.clearExtensionData();
488
const selectedExtensionId = (<HTMLInputElement>e.target).value;
489
this.selectedExtension = selectedExtensionId;
490
const extensions = this.issueReporterModel.getData().allExtensions;
491
const matches = extensions.filter(extension => extension.id === selectedExtensionId);
492
if (matches.length) {
493
this.issueReporterModel.update({ selectedExtension: matches[0] });
494
const selectedExtension = this.issueReporterModel.getData().selectedExtension;
495
if (selectedExtension) {
496
const iconElement = document.createElement('span');
497
iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin');
498
this.setLoading(iconElement);
499
const openReporterData = await this.sendReporterMenu(selectedExtension);
500
if (openReporterData) {
501
if (this.selectedExtension === selectedExtensionId) {
502
this.removeLoading(iconElement, true);
503
this.data = openReporterData;
504
}
505
}
506
else {
507
if (!this.loadingExtensionData) {
508
iconElement.classList.remove(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin');
509
}
510
this.removeLoading(iconElement);
511
// if not using command, should have no configuration data in fields we care about and check later.
512
this.clearExtensionData();
513
514
// case when previous extension was opened from normal openIssueReporter command
515
selectedExtension.data = undefined;
516
selectedExtension.uri = undefined;
517
}
518
if (this.selectedExtension === selectedExtensionId) {
519
// repopulates the fields with the new data given the selected extension.
520
this.updateExtensionStatus(matches[0]);
521
this.openReporter = false;
522
}
523
} else {
524
this.issueReporterModel.update({ selectedExtension: undefined });
525
this.clearSearchResults();
526
this.clearExtensionData();
527
this.validateSelectedExtension();
528
this.updateExtensionStatus(matches[0]);
529
}
530
}
531
532
// Update internal action visibility after explicit selection
533
this.updateInternalElementsVisibility();
534
});
535
}
536
537
this.addEventListener('problem-source', 'change', (_) => {
538
this.clearExtensionData();
539
this.validateSelectedExtension();
540
});
541
}
542
543
private async sendReporterMenu(extension: IssueReporterExtensionData): Promise<IssueReporterData | undefined> {
544
try {
545
const timeoutPromise = new Promise<undefined>((_, reject) =>
546
setTimeout(() => reject(new Error('sendReporterMenu timed out')), 10000)
547
);
548
const data = await Promise.race([
549
this.issueFormService.sendReporterMenu(extension.id),
550
timeoutPromise
551
]);
552
return data;
553
} catch (e) {
554
console.error(e);
555
return undefined;
556
}
557
}
558
559
private updateAcknowledgementState() {
560
// eslint-disable-next-line no-restricted-syntax
561
const acknowledgementCheckbox = this.getElementById<HTMLInputElement>('includeAcknowledgement');
562
if (acknowledgementCheckbox) {
563
this.acknowledged = acknowledgementCheckbox.checked;
564
this.updateButtonStates();
565
}
566
}
567
568
public setEventHandlers(): void {
569
(['includeSystemInfo', 'includeProcessInfo', 'includeWorkspaceInfo', 'includeExtensions', 'includeExperiments', 'includeExtensionData'] as const).forEach(elementId => {
570
this.addEventListener(elementId, 'click', (event: Event) => {
571
event.stopPropagation();
572
this.issueReporterModel.update({ [elementId]: !this.issueReporterModel.getData()[elementId] });
573
});
574
});
575
576
this.addEventListener('includeAcknowledgement', 'click', (event: Event) => {
577
event.stopPropagation();
578
this.updateAcknowledgementState();
579
});
580
581
// eslint-disable-next-line no-restricted-syntax
582
const showInfoElements = this.window.document.getElementsByClassName('showInfo');
583
for (let i = 0; i < showInfoElements.length; i++) {
584
const showInfo = showInfoElements.item(i)!;
585
(showInfo as HTMLAnchorElement).addEventListener('click', (e: MouseEvent) => {
586
e.preventDefault();
587
const label = (<HTMLDivElement>e.target);
588
if (label) {
589
const containingElement = label.parentElement && label.parentElement.parentElement;
590
const info = containingElement && containingElement.lastElementChild;
591
if (info && info.classList.contains('hidden')) {
592
show(info);
593
label.textContent = localize('hide', "hide");
594
} else {
595
hide(info);
596
label.textContent = localize('show', "show");
597
}
598
}
599
});
600
}
601
602
this.addEventListener('issue-source', 'change', (e: Event) => {
603
const value = (<HTMLInputElement>e.target).value;
604
// eslint-disable-next-line no-restricted-syntax
605
const problemSourceHelpText = this.getElementById('problem-source-help-text')!;
606
if (value === '') {
607
this.issueReporterModel.update({ fileOnExtension: undefined });
608
show(problemSourceHelpText);
609
this.clearSearchResults();
610
this.render();
611
return;
612
} else {
613
hide(problemSourceHelpText);
614
}
615
616
// eslint-disable-next-line no-restricted-syntax
617
const descriptionTextArea = <HTMLInputElement>this.getElementById('issue-title');
618
if (value === IssueSource.VSCode) {
619
descriptionTextArea.placeholder = localize('vscodePlaceholder', "E.g Workbench is missing problems panel");
620
} else if (value === IssueSource.Extension) {
621
descriptionTextArea.placeholder = localize('extensionPlaceholder', "E.g. Missing alt text on extension readme image");
622
} else if (value === IssueSource.Marketplace) {
623
descriptionTextArea.placeholder = localize('marketplacePlaceholder', "E.g Cannot disable installed extension");
624
} else {
625
descriptionTextArea.placeholder = localize('undefinedPlaceholder', "Please enter a title");
626
}
627
628
let fileOnExtension, fileOnMarketplace, fileOnProduct = false;
629
if (value === IssueSource.Extension) {
630
fileOnExtension = true;
631
} else if (value === IssueSource.Marketplace) {
632
fileOnMarketplace = true;
633
} else if (value === IssueSource.VSCode) {
634
fileOnProduct = true;
635
}
636
637
this.issueReporterModel.update({ fileOnExtension, fileOnMarketplace, fileOnProduct });
638
this.render();
639
640
// eslint-disable-next-line no-restricted-syntax
641
const title = (<HTMLInputElement>this.getElementById('issue-title')).value;
642
this.searchIssues(title, fileOnExtension, fileOnMarketplace);
643
});
644
645
this.addEventListener('description', 'input', (e: Event) => {
646
const issueDescription = (<HTMLInputElement>e.target).value;
647
this.issueReporterModel.update({ issueDescription });
648
649
// Only search for extension issues on title change
650
if (this.issueReporterModel.fileOnExtension() === false) {
651
// eslint-disable-next-line no-restricted-syntax
652
const title = (<HTMLInputElement>this.getElementById('issue-title')).value;
653
this.searchVSCodeIssues(title, issueDescription);
654
}
655
});
656
657
this.addEventListener('issue-title', 'input', _ => {
658
// eslint-disable-next-line no-restricted-syntax
659
const titleElement = this.getElementById('issue-title') as HTMLInputElement;
660
if (titleElement) {
661
const title = titleElement.value;
662
this.issueReporterModel.update({ issueTitle: title });
663
}
664
});
665
666
this.addEventListener('issue-title', 'input', (e: Event) => {
667
const title = (<HTMLInputElement>e.target).value;
668
// eslint-disable-next-line no-restricted-syntax
669
const lengthValidationMessage = this.getElementById('issue-title-length-validation-error');
670
const issueUrl = this.getIssueUrl();
671
if (title && this.getIssueUrlWithTitle(title, issueUrl).length > MAX_URL_LENGTH) {
672
show(lengthValidationMessage);
673
} else {
674
hide(lengthValidationMessage);
675
}
676
// eslint-disable-next-line no-restricted-syntax
677
const issueSource = this.getElementById<HTMLSelectElement>('issue-source');
678
if (!issueSource || issueSource.value === '') {
679
return;
680
}
681
682
const { fileOnExtension, fileOnMarketplace } = this.issueReporterModel.getData();
683
this.searchIssues(title, fileOnExtension, fileOnMarketplace);
684
});
685
686
// We handle clicks in the dropdown actions now
687
688
this.addEventListener('disableExtensions', 'click', () => {
689
this.issueFormService.reloadWithExtensionsDisabled();
690
});
691
692
this.addEventListener('extensionBugsLink', 'click', (e: Event) => {
693
const url = (<HTMLElement>e.target).innerText;
694
this.openLink(url);
695
});
696
697
this.addEventListener('disableExtensions', 'keydown', (e: Event) => {
698
e.stopPropagation();
699
if ((e as KeyboardEvent).key === 'Enter' || (e as KeyboardEvent).key === ' ') {
700
this.issueFormService.reloadWithExtensionsDisabled();
701
}
702
});
703
704
this.window.document.onkeydown = async (e: KeyboardEvent) => {
705
const cmdOrCtrlKey = isMacintosh ? e.metaKey : e.ctrlKey;
706
// Cmd/Ctrl+Enter previews issue and closes window
707
if (cmdOrCtrlKey && e.key === 'Enter') {
708
this.delayedSubmit.trigger(async () => {
709
if (await this.createIssue()) {
710
this.close();
711
}
712
});
713
}
714
715
// Cmd/Ctrl + w closes issue window
716
if (cmdOrCtrlKey && e.key === 'w') {
717
e.stopPropagation();
718
e.preventDefault();
719
720
// eslint-disable-next-line no-restricted-syntax
721
const issueTitle = (<HTMLInputElement>this.getElementById('issue-title'))!.value;
722
const { issueDescription } = this.issueReporterModel.getData();
723
if (!this.hasBeenSubmitted && (issueTitle || issueDescription)) {
724
// fire and forget
725
this.issueFormService.showConfirmCloseDialog();
726
} else {
727
this.close();
728
}
729
}
730
731
// With latest electron upgrade, cmd+a is no longer propagating correctly for inputs in this window on mac
732
// Manually perform the selection
733
if (isMacintosh) {
734
if (cmdOrCtrlKey && e.key === 'a' && e.target) {
735
if (isHTMLInputElement(e.target) || isHTMLTextAreaElement(e.target)) {
736
(<HTMLInputElement>e.target).select();
737
}
738
}
739
}
740
};
741
742
// Handle the guidance link specifically to use openerService
743
this.addEventListener('review-guidance-help-text', 'click', (e: Event) => {
744
const target = e.target as HTMLElement;
745
if (target.tagName === 'A' && target.getAttribute('target') === '_blank') {
746
this.openLink(<MouseEvent>e);
747
}
748
});
749
}
750
751
public updatePerformanceInfo(info: Partial<IssueReporterData>) {
752
this.issueReporterModel.update(info);
753
this.receivedPerformanceInfo = true;
754
755
const state = this.issueReporterModel.getData();
756
this.updateProcessInfo(state);
757
this.updateWorkspaceInfo(state);
758
this.updateButtonStates();
759
}
760
761
private isPreviewEnabled() {
762
const issueType = this.issueReporterModel.getData().issueType;
763
764
if (this.loadingExtensionData) {
765
return false;
766
}
767
768
if (this.isWeb) {
769
if (issueType === IssueType.FeatureRequest || issueType === IssueType.PerformanceIssue || issueType === IssueType.Bug) {
770
return true;
771
}
772
} else {
773
if (issueType === IssueType.Bug && this.receivedSystemInfo) {
774
return true;
775
}
776
777
if (issueType === IssueType.PerformanceIssue && this.receivedSystemInfo && this.receivedPerformanceInfo) {
778
return true;
779
}
780
781
if (issueType === IssueType.FeatureRequest) {
782
return true;
783
}
784
}
785
786
return false;
787
}
788
789
private getExtensionRepositoryUrl(): string | undefined {
790
const selectedExtension = this.issueReporterModel.getData().selectedExtension;
791
return selectedExtension && selectedExtension.repositoryUrl;
792
}
793
794
public getExtensionBugsUrl(): string | undefined {
795
const selectedExtension = this.issueReporterModel.getData().selectedExtension;
796
return selectedExtension && selectedExtension.bugsUrl;
797
}
798
799
public searchVSCodeIssues(title: string, issueDescription?: string): void {
800
if (title) {
801
this.searchDuplicates(title, issueDescription);
802
} else {
803
this.clearSearchResults();
804
}
805
}
806
807
public searchIssues(title: string, fileOnExtension: boolean | undefined, fileOnMarketplace: boolean | undefined): void {
808
if (fileOnExtension) {
809
return this.searchExtensionIssues(title);
810
}
811
812
if (fileOnMarketplace) {
813
return this.searchMarketplaceIssues(title);
814
}
815
816
const description = this.issueReporterModel.getData().issueDescription;
817
this.searchVSCodeIssues(title, description);
818
}
819
820
private searchExtensionIssues(title: string): void {
821
const url = this.getExtensionGitHubUrl();
822
if (title) {
823
const matches = /^https?:\/\/github\.com\/(.*)/.exec(url);
824
if (matches && matches.length) {
825
const repo = matches[1];
826
return this.searchGitHub(repo, title);
827
}
828
829
// If the extension has no repository, display empty search results
830
if (this.issueReporterModel.getData().selectedExtension) {
831
this.clearSearchResults();
832
return this.displaySearchResults([]);
833
834
}
835
}
836
837
this.clearSearchResults();
838
}
839
840
private searchMarketplaceIssues(title: string): void {
841
if (title) {
842
const gitHubInfo = this.parseGitHubUrl(this.product.reportMarketplaceIssueUrl!);
843
if (gitHubInfo) {
844
return this.searchGitHub(`${gitHubInfo.owner}/${gitHubInfo.repositoryName}`, title);
845
}
846
}
847
}
848
849
public async close(): Promise<void> {
850
await this.issueFormService.closeReporter();
851
}
852
853
public clearSearchResults(): void {
854
// eslint-disable-next-line no-restricted-syntax
855
const similarIssues = this.getElementById('similar-issues')!;
856
similarIssues.innerText = '';
857
this.numberOfSearchResultsDisplayed = 0;
858
}
859
860
@debounce(300)
861
private searchGitHub(repo: string, title: string): void {
862
const query = `is:issue+repo:${repo}+${title}`;
863
// eslint-disable-next-line no-restricted-syntax
864
const similarIssues = this.getElementById('similar-issues')!;
865
866
fetch(`https://api.github.com/search/issues?q=${query}`).then((response) => {
867
response.json().then(result => {
868
similarIssues.innerText = '';
869
if (result && result.items) {
870
this.displaySearchResults(result.items);
871
}
872
}).catch(_ => {
873
console.warn('Timeout or query limit exceeded');
874
});
875
}).catch(_ => {
876
console.warn('Error fetching GitHub issues');
877
});
878
}
879
880
@debounce(300)
881
private searchDuplicates(title: string, body?: string): void {
882
const url = 'https://vscode-probot.westus.cloudapp.azure.com:7890/duplicate_candidates';
883
const init = {
884
method: 'POST',
885
body: JSON.stringify({
886
title,
887
body
888
}),
889
headers: new Headers({
890
'Content-Type': 'application/json'
891
})
892
};
893
894
fetch(url, init).then((response) => {
895
response.json().then(result => {
896
this.clearSearchResults();
897
898
if (result && result.candidates) {
899
this.displaySearchResults(result.candidates);
900
} else {
901
throw new Error('Unexpected response, no candidates property');
902
}
903
}).catch(_ => {
904
// Ignore
905
});
906
}).catch(_ => {
907
// Ignore
908
});
909
}
910
911
private displaySearchResults(results: SearchResult[]) {
912
// eslint-disable-next-line no-restricted-syntax
913
const similarIssues = this.getElementById('similar-issues')!;
914
if (results.length) {
915
const issues = $('div.issues-container');
916
const issuesText = $('div.list-title');
917
issuesText.textContent = localize('similarIssues', "Similar issues");
918
919
this.numberOfSearchResultsDisplayed = results.length < 5 ? results.length : 5;
920
for (let i = 0; i < this.numberOfSearchResultsDisplayed; i++) {
921
const issue = results[i];
922
const link = $('a.issue-link', { href: issue.html_url });
923
link.textContent = issue.title;
924
link.title = issue.title;
925
link.addEventListener('click', (e) => this.openLink(e));
926
link.addEventListener('auxclick', (e) => this.openLink(<MouseEvent>e));
927
928
let issueState: HTMLElement;
929
let item: HTMLElement;
930
if (issue.state) {
931
issueState = $('span.issue-state');
932
933
const issueIcon = $('span.issue-icon');
934
issueIcon.appendChild(renderIcon(issue.state === 'open' ? Codicon.issueOpened : Codicon.issueClosed));
935
936
const issueStateLabel = $('span.issue-state.label');
937
issueStateLabel.textContent = issue.state === 'open' ? localize('open', "Open") : localize('closed', "Closed");
938
939
issueState.title = issue.state === 'open' ? localize('open', "Open") : localize('closed', "Closed");
940
issueState.appendChild(issueIcon);
941
issueState.appendChild(issueStateLabel);
942
943
item = $('div.issue', undefined, issueState, link);
944
} else {
945
item = $('div.issue', undefined, link);
946
}
947
948
issues.appendChild(item);
949
}
950
951
similarIssues.appendChild(issuesText);
952
similarIssues.appendChild(issues);
953
}
954
}
955
956
private setUpTypes(): void {
957
const makeOption = (issueType: IssueType, description: string) => $('option', { 'value': issueType.valueOf() }, escape(description));
958
959
// eslint-disable-next-line no-restricted-syntax
960
const typeSelect = this.getElementById('issue-type')! as HTMLSelectElement;
961
const { issueType } = this.issueReporterModel.getData();
962
reset(typeSelect,
963
makeOption(IssueType.Bug, localize('bugReporter', "Bug Report")),
964
makeOption(IssueType.FeatureRequest, localize('featureRequest', "Feature Request")),
965
makeOption(IssueType.PerformanceIssue, localize('performanceIssue', "Performance Issue (freeze, slow, crash)"))
966
);
967
968
typeSelect.value = issueType.toString();
969
970
this.setSourceOptions();
971
}
972
973
public makeOption(value: string, description: string, disabled: boolean): HTMLOptionElement {
974
const option: HTMLOptionElement = document.createElement('option');
975
option.disabled = disabled;
976
option.value = value;
977
option.textContent = description;
978
979
return option;
980
}
981
982
public setSourceOptions(): void {
983
// eslint-disable-next-line no-restricted-syntax
984
const sourceSelect = this.getElementById('issue-source')! as HTMLSelectElement;
985
const { issueType, fileOnExtension, selectedExtension, fileOnMarketplace, fileOnProduct } = this.issueReporterModel.getData();
986
let selected = sourceSelect.selectedIndex;
987
if (selected === -1) {
988
if (fileOnExtension !== undefined) {
989
selected = fileOnExtension ? 2 : 1;
990
} else if (selectedExtension?.isBuiltin) {
991
selected = 1;
992
} else if (fileOnMarketplace) {
993
selected = 3;
994
} else if (fileOnProduct) {
995
selected = 1;
996
}
997
}
998
999
sourceSelect.innerText = '';
1000
sourceSelect.append(this.makeOption('', localize('selectSource', "Select source"), true));
1001
sourceSelect.append(this.makeOption(IssueSource.VSCode, localize('vscode', "Visual Studio Code"), false));
1002
sourceSelect.append(this.makeOption(IssueSource.Extension, localize('extension', "A VS Code extension"), false));
1003
if (this.product.reportMarketplaceIssueUrl) {
1004
sourceSelect.append(this.makeOption(IssueSource.Marketplace, localize('marketplace', "Extensions Marketplace"), false));
1005
}
1006
1007
if (issueType !== IssueType.FeatureRequest) {
1008
sourceSelect.append(this.makeOption(IssueSource.Unknown, localize('unknown', "Don't know"), false));
1009
}
1010
1011
if (selected !== -1 && selected < sourceSelect.options.length) {
1012
sourceSelect.selectedIndex = selected;
1013
} else {
1014
sourceSelect.selectedIndex = 0;
1015
// eslint-disable-next-line no-restricted-syntax
1016
hide(this.getElementById('problem-source-help-text'));
1017
}
1018
}
1019
1020
public async renderBlocks(): Promise<void> {
1021
// Depending on Issue Type, we render different blocks and text
1022
const { issueType, fileOnExtension, fileOnMarketplace, selectedExtension } = this.issueReporterModel.getData();
1023
// eslint-disable-next-line no-restricted-syntax
1024
const blockContainer = this.getElementById('block-container');
1025
// eslint-disable-next-line no-restricted-syntax
1026
const systemBlock = this.window.document.querySelector('.block-system');
1027
// eslint-disable-next-line no-restricted-syntax
1028
const processBlock = this.window.document.querySelector('.block-process');
1029
// eslint-disable-next-line no-restricted-syntax
1030
const workspaceBlock = this.window.document.querySelector('.block-workspace');
1031
// eslint-disable-next-line no-restricted-syntax
1032
const extensionsBlock = this.window.document.querySelector('.block-extensions');
1033
// eslint-disable-next-line no-restricted-syntax
1034
const experimentsBlock = this.window.document.querySelector('.block-experiments');
1035
// eslint-disable-next-line no-restricted-syntax
1036
const extensionDataBlock = this.window.document.querySelector('.block-extension-data');
1037
1038
// eslint-disable-next-line no-restricted-syntax
1039
const problemSource = this.getElementById('problem-source')!;
1040
// eslint-disable-next-line no-restricted-syntax
1041
const descriptionTitle = this.getElementById('issue-description-label')!;
1042
// eslint-disable-next-line no-restricted-syntax
1043
const descriptionSubtitle = this.getElementById('issue-description-subtitle')!;
1044
// eslint-disable-next-line no-restricted-syntax
1045
const extensionSelector = this.getElementById('extension-selection')!;
1046
// eslint-disable-next-line no-restricted-syntax
1047
const downloadExtensionDataLink = <HTMLAnchorElement>this.getElementById('extension-data-download')!;
1048
1049
// eslint-disable-next-line no-restricted-syntax
1050
const titleTextArea = this.getElementById('issue-title-container')!;
1051
// eslint-disable-next-line no-restricted-syntax
1052
const descriptionTextArea = this.getElementById('description')!;
1053
// eslint-disable-next-line no-restricted-syntax
1054
const extensionDataTextArea = this.getElementById('extension-data')!;
1055
1056
// Hide all by default
1057
hide(blockContainer);
1058
hide(systemBlock);
1059
hide(processBlock);
1060
hide(workspaceBlock);
1061
hide(extensionsBlock);
1062
hide(experimentsBlock);
1063
hide(extensionSelector);
1064
hide(extensionDataTextArea);
1065
hide(extensionDataBlock);
1066
hide(downloadExtensionDataLink);
1067
1068
show(problemSource);
1069
show(titleTextArea);
1070
show(descriptionTextArea);
1071
1072
if (fileOnExtension) {
1073
show(extensionSelector);
1074
}
1075
1076
const extensionData = this.issueReporterModel.getData().extensionData;
1077
if (extensionData && extensionData.length > MAX_EXTENSION_DATA_LENGTH) {
1078
show(downloadExtensionDataLink);
1079
const date = new Date();
1080
const formattedDate = date.toISOString().split('T')[0]; // YYYY-MM-DD
1081
const formattedTime = date.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS
1082
const fileName = `extensionData_${formattedDate}_${formattedTime}.md`;
1083
const handleLinkClick = async () => {
1084
const downloadPath = await this.fileDialogService.showSaveDialog({
1085
title: localize('saveExtensionData', "Save Extension Data"),
1086
availableFileSystems: [Schemas.file],
1087
defaultUri: joinPath(await this.fileDialogService.defaultFilePath(Schemas.file), fileName),
1088
});
1089
1090
if (downloadPath) {
1091
await this.fileService.writeFile(downloadPath, VSBuffer.fromString(extensionData));
1092
}
1093
};
1094
1095
downloadExtensionDataLink.addEventListener('click', handleLinkClick);
1096
1097
this._register({
1098
dispose: () => downloadExtensionDataLink.removeEventListener('click', handleLinkClick)
1099
});
1100
}
1101
1102
if (selectedExtension && this.nonGitHubIssueUrl) {
1103
hide(titleTextArea);
1104
hide(descriptionTextArea);
1105
reset(descriptionTitle, localize('handlesIssuesElsewhere', "This extension handles issues outside of VS Code"));
1106
reset(descriptionSubtitle, localize('elsewhereDescription', "The '{0}' extension prefers to use an external issue reporter. To be taken to that issue reporting experience, click the button below.", selectedExtension.displayName));
1107
this.publicGithubButton.label = localize('openIssueReporter', "Open External Issue Reporter");
1108
return;
1109
}
1110
1111
if (fileOnExtension && selectedExtension?.data) {
1112
const data = selectedExtension?.data;
1113
(extensionDataTextArea as HTMLElement).innerText = data.toString();
1114
(extensionDataTextArea as HTMLTextAreaElement).readOnly = true;
1115
show(extensionDataBlock);
1116
}
1117
1118
// only if we know comes from the open reporter command
1119
if (fileOnExtension && this.openReporter) {
1120
(extensionDataTextArea as HTMLTextAreaElement).readOnly = true;
1121
setTimeout(() => {
1122
// delay to make sure from command or not
1123
if (this.openReporter) {
1124
show(extensionDataBlock);
1125
}
1126
}, 100);
1127
show(extensionDataBlock);
1128
}
1129
1130
if (issueType === IssueType.Bug) {
1131
if (!fileOnMarketplace) {
1132
show(blockContainer);
1133
show(systemBlock);
1134
show(experimentsBlock);
1135
if (!fileOnExtension) {
1136
show(extensionsBlock);
1137
}
1138
}
1139
1140
reset(descriptionTitle, localize('stepsToReproduce', "Steps to Reproduce") + ' ', $('span.required-input', undefined, '*'));
1141
reset(descriptionSubtitle, localize('bugDescription', "Share the steps needed to reliably reproduce the problem. Please include actual and expected results. We support GitHub-flavored Markdown. You will be able to edit your issue and add screenshots when we preview it on GitHub."));
1142
} else if (issueType === IssueType.PerformanceIssue) {
1143
if (!fileOnMarketplace) {
1144
show(blockContainer);
1145
show(systemBlock);
1146
show(processBlock);
1147
show(workspaceBlock);
1148
show(experimentsBlock);
1149
}
1150
1151
if (fileOnExtension) {
1152
show(extensionSelector);
1153
} else if (!fileOnMarketplace) {
1154
show(extensionsBlock);
1155
}
1156
1157
reset(descriptionTitle, localize('stepsToReproduce', "Steps to Reproduce") + ' ', $('span.required-input', undefined, '*'));
1158
reset(descriptionSubtitle, localize('performanceIssueDesciption', "When did this performance issue happen? Does it occur on startup or after a specific series of actions? We support GitHub-flavored Markdown. You will be able to edit your issue and add screenshots when we preview it on GitHub."));
1159
} else if (issueType === IssueType.FeatureRequest) {
1160
reset(descriptionTitle, localize('description', "Description") + ' ', $('span.required-input', undefined, '*'));
1161
reset(descriptionSubtitle, localize('featureRequestDescription', "Please describe the feature you would like to see. We support GitHub-flavored Markdown. You will be able to edit your issue and add screenshots when we preview it on GitHub."));
1162
}
1163
}
1164
1165
public validateInput(inputId: string): boolean {
1166
// eslint-disable-next-line no-restricted-syntax
1167
const inputElement = (<HTMLInputElement>this.getElementById(inputId));
1168
// eslint-disable-next-line no-restricted-syntax
1169
const inputValidationMessage = this.getElementById(`${inputId}-empty-error`);
1170
// eslint-disable-next-line no-restricted-syntax
1171
const descriptionShortMessage = this.getElementById(`description-short-error`);
1172
if (inputId === 'description' && this.nonGitHubIssueUrl && this.data.extensionId) {
1173
return true;
1174
} else if (!inputElement.value) {
1175
inputElement.classList.add('invalid-input');
1176
inputValidationMessage?.classList.remove('hidden');
1177
descriptionShortMessage?.classList.add('hidden');
1178
return false;
1179
} else if (inputId === 'description' && inputElement.value.length < 10) {
1180
inputElement.classList.add('invalid-input');
1181
descriptionShortMessage?.classList.remove('hidden');
1182
inputValidationMessage?.classList.add('hidden');
1183
return false;
1184
} else {
1185
inputElement.classList.remove('invalid-input');
1186
inputValidationMessage?.classList.add('hidden');
1187
if (inputId === 'description') {
1188
descriptionShortMessage?.classList.add('hidden');
1189
}
1190
return true;
1191
}
1192
}
1193
1194
public validateInputs(): boolean {
1195
let isValid = true;
1196
['issue-title', 'description', 'issue-source'].forEach(elementId => {
1197
isValid = this.validateInput(elementId) && isValid;
1198
});
1199
1200
if (this.issueReporterModel.fileOnExtension()) {
1201
isValid = this.validateInput('extension-selector') && isValid;
1202
}
1203
1204
return isValid;
1205
}
1206
1207
public async submitToGitHub(issueTitle: string, issueBody: string, gitHubDetails: { owner: string; repositoryName: string }): Promise<boolean> {
1208
const url = `https://api.github.com/repos/${gitHubDetails.owner}/${gitHubDetails.repositoryName}/issues`;
1209
const init = {
1210
method: 'POST',
1211
body: JSON.stringify({
1212
title: issueTitle,
1213
body: issueBody
1214
}),
1215
headers: new Headers({
1216
'Content-Type': 'application/json',
1217
'Authorization': `Bearer ${this.data.githubAccessToken}`,
1218
'User-Agent': 'request'
1219
})
1220
};
1221
1222
const response = await fetch(url, init);
1223
if (!response.ok) {
1224
console.error('Invalid GitHub URL provided.');
1225
return false;
1226
}
1227
const result = await response.json();
1228
await this.openLink(result.html_url);
1229
this.close();
1230
return true;
1231
}
1232
1233
public async createIssue(shouldCreate?: boolean, privateUri?: boolean): Promise<boolean> {
1234
const selectedExtension = this.issueReporterModel.getData().selectedExtension;
1235
// Short circuit if the extension provides a custom issue handler
1236
if (this.nonGitHubIssueUrl) {
1237
const url = this.getExtensionBugsUrl();
1238
if (url) {
1239
this.hasBeenSubmitted = true;
1240
return true;
1241
}
1242
}
1243
1244
if (!this.validateInputs()) {
1245
// If inputs are invalid, set focus to the first one and add listeners on them
1246
// to detect further changes
1247
// eslint-disable-next-line no-restricted-syntax
1248
const invalidInput = this.window.document.getElementsByClassName('invalid-input');
1249
if (invalidInput.length) {
1250
(<HTMLInputElement>invalidInput[0]).focus();
1251
}
1252
1253
this.addEventListener('issue-title', 'input', _ => {
1254
this.validateInput('issue-title');
1255
});
1256
1257
this.addEventListener('description', 'input', _ => {
1258
this.validateInput('description');
1259
});
1260
1261
this.addEventListener('issue-source', 'change', _ => {
1262
this.validateInput('issue-source');
1263
});
1264
1265
if (this.issueReporterModel.fileOnExtension()) {
1266
this.addEventListener('extension-selector', 'change', _ => {
1267
this.validateInput('extension-selector');
1268
});
1269
}
1270
1271
return false;
1272
}
1273
1274
this.hasBeenSubmitted = true;
1275
1276
// eslint-disable-next-line no-restricted-syntax
1277
const issueTitle = (<HTMLInputElement>this.getElementById('issue-title')).value;
1278
const issueBody = this.issueReporterModel.serialize();
1279
1280
let issueUrl = privateUri ? this.getPrivateIssueUrl() : this.getIssueUrl();
1281
if (!issueUrl) {
1282
console.error(`No ${privateUri ? 'private ' : ''}issue url found`);
1283
return false;
1284
}
1285
if (selectedExtension?.uri) {
1286
const uri = URI.revive(selectedExtension.uri);
1287
issueUrl = uri.toString();
1288
}
1289
1290
const gitHubDetails = this.parseGitHubUrl(issueUrl);
1291
if (this.data.githubAccessToken && gitHubDetails && shouldCreate) {
1292
return this.submitToGitHub(issueTitle, issueBody, gitHubDetails);
1293
}
1294
1295
// eslint-disable-next-line no-restricted-syntax
1296
const baseUrl = this.getIssueUrlWithTitle((<HTMLInputElement>this.getElementById('issue-title')).value, issueUrl);
1297
let url = baseUrl + `&body=${encodeURIComponent(issueBody)}`;
1298
1299
url = this.addTemplateToUrl(url, gitHubDetails?.owner, gitHubDetails?.repositoryName);
1300
1301
if (url.length > MAX_URL_LENGTH) {
1302
try {
1303
url = await this.writeToClipboard(baseUrl, issueBody);
1304
url = this.addTemplateToUrl(url, gitHubDetails?.owner, gitHubDetails?.repositoryName);
1305
} catch (_) {
1306
console.error('Writing to clipboard failed');
1307
return false;
1308
}
1309
}
1310
1311
await this.openLink(url);
1312
1313
return true;
1314
}
1315
1316
public async writeToClipboard(baseUrl: string, issueBody: string): Promise<string> {
1317
const shouldWrite = await this.issueFormService.showClipboardDialog();
1318
if (!shouldWrite) {
1319
throw new CancellationError();
1320
}
1321
1322
return baseUrl + `&body=${encodeURIComponent(localize('pasteData', "We have written the needed data into your clipboard because it was too large to send. Please paste."))}`;
1323
}
1324
1325
public addTemplateToUrl(baseUrl: string, owner?: string, repositoryName?: string): string {
1326
const isVscode = this.issueReporterModel.getData().fileOnProduct;
1327
const isMicrosoft = owner?.toLowerCase() === 'microsoft';
1328
const needsTemplate = isVscode || (isMicrosoft && (repositoryName === 'vscode' || repositoryName === 'vscode-python'));
1329
1330
if (needsTemplate) {
1331
try {
1332
const url = new URL(baseUrl);
1333
url.searchParams.set('template', 'bug_report.md');
1334
return url.toString();
1335
} catch {
1336
// fallback if baseUrl is not a valid URL
1337
return baseUrl + '&template=bug_report.md';
1338
}
1339
}
1340
return baseUrl;
1341
}
1342
1343
public getIssueUrl(): string {
1344
return this.issueReporterModel.fileOnExtension()
1345
? this.getExtensionGitHubUrl()
1346
: this.issueReporterModel.getData().fileOnMarketplace
1347
? this.product.reportMarketplaceIssueUrl!
1348
: this.product.reportIssueUrl!;
1349
}
1350
1351
// for when command 'workbench.action.openIssueReporter' passes along a
1352
// `privateUri` UriComponents value
1353
public getPrivateIssueUrl(): string | undefined {
1354
return URI.revive(this.data.privateUri)?.toString();
1355
}
1356
1357
public parseGitHubUrl(url: string): undefined | { repositoryName: string; owner: string } {
1358
// Assumes a GitHub url to a particular repo, https://github.com/repositoryName/owner.
1359
// Repository name and owner cannot contain '/'
1360
const match = /^https?:\/\/github\.com\/([^\/]*)\/([^\/]*).*/.exec(url);
1361
if (match && match.length) {
1362
return {
1363
owner: match[1],
1364
repositoryName: match[2]
1365
};
1366
} else {
1367
console.error('No GitHub issues match');
1368
}
1369
1370
return undefined;
1371
}
1372
1373
private getExtensionGitHubUrl(): string {
1374
let repositoryUrl = '';
1375
const bugsUrl = this.getExtensionBugsUrl();
1376
const extensionUrl = this.getExtensionRepositoryUrl();
1377
// If given, try to match the extension's bug url
1378
if (bugsUrl && bugsUrl.match(/^https?:\/\/github\.com\/([^\/]*)\/([^\/]*)\/?(\/issues)?$/)) {
1379
// matches exactly: https://github.com/owner/repo/issues
1380
repositoryUrl = normalizeGitHubUrl(bugsUrl);
1381
} else if (extensionUrl && extensionUrl.match(/^https?:\/\/github\.com\/([^\/]*)\/([^\/]*)$/)) {
1382
// matches exactly: https://github.com/owner/repo
1383
repositoryUrl = normalizeGitHubUrl(extensionUrl);
1384
} else {
1385
this.nonGitHubIssueUrl = true;
1386
repositoryUrl = bugsUrl || extensionUrl || '';
1387
}
1388
1389
return repositoryUrl;
1390
}
1391
1392
public getIssueUrlWithTitle(issueTitle: string, repositoryUrl: string): string {
1393
if (this.issueReporterModel.fileOnExtension()) {
1394
repositoryUrl = repositoryUrl + '/issues/new';
1395
}
1396
1397
const queryStringPrefix = repositoryUrl.indexOf('?') === -1 ? '?' : '&';
1398
return `${repositoryUrl}${queryStringPrefix}title=${encodeURIComponent(issueTitle)}`;
1399
}
1400
1401
public clearExtensionData(): void {
1402
this.nonGitHubIssueUrl = false;
1403
this.issueReporterModel.update({ extensionData: undefined });
1404
this.data.issueBody = this.data.issueBody || '';
1405
this.data.data = undefined;
1406
this.data.uri = undefined;
1407
this.data.privateUri = undefined;
1408
}
1409
1410
public async updateExtensionStatus(extension: IssueReporterExtensionData) {
1411
this.issueReporterModel.update({ selectedExtension: extension });
1412
1413
// uses this.configuuration.data to ensure that data is coming from `openReporter` command.
1414
const template = this.data.issueBody;
1415
if (template) {
1416
// eslint-disable-next-line no-restricted-syntax
1417
const descriptionTextArea = this.getElementById('description')!;
1418
const descriptionText = (descriptionTextArea as HTMLTextAreaElement).value;
1419
if (descriptionText === '' || !descriptionText.includes(template.toString())) {
1420
const fullTextArea = descriptionText + (descriptionText === '' ? '' : '\n') + template.toString();
1421
(descriptionTextArea as HTMLTextAreaElement).value = fullTextArea;
1422
this.issueReporterModel.update({ issueDescription: fullTextArea });
1423
}
1424
}
1425
1426
const data = this.data.data;
1427
if (data) {
1428
this.issueReporterModel.update({ extensionData: data });
1429
extension.data = data;
1430
// eslint-disable-next-line no-restricted-syntax
1431
const extensionDataBlock = this.window.document.querySelector('.block-extension-data')!;
1432
show(extensionDataBlock);
1433
this.renderBlocks();
1434
}
1435
1436
const uri = this.data.uri;
1437
if (uri) {
1438
extension.uri = uri;
1439
this.updateIssueReporterUri(extension);
1440
}
1441
1442
this.validateSelectedExtension();
1443
// eslint-disable-next-line no-restricted-syntax
1444
const title = (<HTMLInputElement>this.getElementById('issue-title')).value;
1445
this.searchExtensionIssues(title);
1446
1447
this.updateButtonStates();
1448
this.renderBlocks();
1449
}
1450
1451
public validateSelectedExtension(): void {
1452
// eslint-disable-next-line no-restricted-syntax
1453
const extensionValidationMessage = this.getElementById('extension-selection-validation-error')!;
1454
// eslint-disable-next-line no-restricted-syntax
1455
const extensionValidationNoUrlsMessage = this.getElementById('extension-selection-validation-error-no-url')!;
1456
hide(extensionValidationMessage);
1457
hide(extensionValidationNoUrlsMessage);
1458
1459
const extension = this.issueReporterModel.getData().selectedExtension;
1460
if (!extension) {
1461
this.publicGithubButton.enabled = true;
1462
return;
1463
}
1464
1465
if (this.loadingExtensionData) {
1466
return;
1467
}
1468
1469
const hasValidGitHubUrl = this.getExtensionGitHubUrl();
1470
if (hasValidGitHubUrl) {
1471
this.publicGithubButton.enabled = true;
1472
} else {
1473
this.setExtensionValidationMessage();
1474
this.publicGithubButton.enabled = false;
1475
}
1476
}
1477
1478
public setLoading(element: HTMLElement) {
1479
// Show loading
1480
this.openReporter = true;
1481
this.loadingExtensionData = true;
1482
this.updateButtonStates();
1483
1484
// eslint-disable-next-line no-restricted-syntax
1485
const extensionDataCaption = this.getElementById('extension-id')!;
1486
hide(extensionDataCaption);
1487
1488
// eslint-disable-next-line no-restricted-syntax
1489
const extensionDataCaption2 = Array.from(this.window.document.querySelectorAll('.ext-parens'));
1490
extensionDataCaption2.forEach(extensionDataCaption2 => hide(extensionDataCaption2));
1491
1492
// eslint-disable-next-line no-restricted-syntax
1493
const showLoading = this.getElementById('ext-loading')!;
1494
show(showLoading);
1495
while (showLoading.firstChild) {
1496
showLoading.firstChild.remove();
1497
}
1498
showLoading.append(element);
1499
1500
this.renderBlocks();
1501
}
1502
1503
public removeLoading(element: HTMLElement, fromReporter: boolean = false) {
1504
this.openReporter = fromReporter;
1505
this.loadingExtensionData = false;
1506
this.updateButtonStates();
1507
1508
// eslint-disable-next-line no-restricted-syntax
1509
const extensionDataCaption = this.getElementById('extension-id')!;
1510
show(extensionDataCaption);
1511
1512
// eslint-disable-next-line no-restricted-syntax
1513
const extensionDataCaption2 = Array.from(this.window.document.querySelectorAll('.ext-parens'));
1514
extensionDataCaption2.forEach(extensionDataCaption2 => show(extensionDataCaption2));
1515
1516
// eslint-disable-next-line no-restricted-syntax
1517
const hideLoading = this.getElementById('ext-loading')!;
1518
hide(hideLoading);
1519
if (hideLoading.firstChild) {
1520
element.remove();
1521
}
1522
this.renderBlocks();
1523
}
1524
1525
private setExtensionValidationMessage(): void {
1526
// eslint-disable-next-line no-restricted-syntax
1527
const extensionValidationMessage = this.getElementById('extension-selection-validation-error')!;
1528
// eslint-disable-next-line no-restricted-syntax
1529
const extensionValidationNoUrlsMessage = this.getElementById('extension-selection-validation-error-no-url')!;
1530
const bugsUrl = this.getExtensionBugsUrl();
1531
if (bugsUrl) {
1532
show(extensionValidationMessage);
1533
// eslint-disable-next-line no-restricted-syntax
1534
const link = this.getElementById('extensionBugsLink')!;
1535
link.textContent = bugsUrl;
1536
return;
1537
}
1538
1539
const extensionUrl = this.getExtensionRepositoryUrl();
1540
if (extensionUrl) {
1541
show(extensionValidationMessage);
1542
// eslint-disable-next-line no-restricted-syntax
1543
const link = this.getElementById('extensionBugsLink');
1544
link!.textContent = extensionUrl;
1545
return;
1546
}
1547
1548
show(extensionValidationNoUrlsMessage);
1549
}
1550
1551
private updateProcessInfo(state: IssueReporterModelData) {
1552
// eslint-disable-next-line no-restricted-syntax
1553
const target = this.window.document.querySelector('.block-process .block-info') as HTMLElement;
1554
if (target) {
1555
reset(target, $('code', undefined, state.processInfo ?? ''));
1556
}
1557
}
1558
1559
private updateWorkspaceInfo(state: IssueReporterModelData) {
1560
// eslint-disable-next-line no-restricted-syntax
1561
this.window.document.querySelector('.block-workspace .block-info code')!.textContent = '\n' + state.workspaceInfo;
1562
}
1563
1564
public updateExtensionTable(extensions: IssueReporterExtensionData[], numThemeExtensions: number): void {
1565
// eslint-disable-next-line no-restricted-syntax
1566
const target = this.window.document.querySelector<HTMLElement>('.block-extensions .block-info');
1567
if (target) {
1568
if (this.disableExtensions) {
1569
reset(target, localize('disabledExtensions', "Extensions are disabled"));
1570
return;
1571
}
1572
1573
const themeExclusionStr = numThemeExtensions ? `\n(${numThemeExtensions} theme extensions excluded)` : '';
1574
extensions = extensions || [];
1575
1576
if (!extensions.length) {
1577
target.innerText = 'Extensions: none' + themeExclusionStr;
1578
return;
1579
}
1580
1581
reset(target, this.getExtensionTableHtml(extensions), document.createTextNode(themeExclusionStr));
1582
}
1583
}
1584
1585
private getExtensionTableHtml(extensions: IssueReporterExtensionData[]): HTMLTableElement {
1586
return $('table', undefined,
1587
$('tr', undefined,
1588
$('th', undefined, 'Extension'),
1589
$('th', undefined, 'Author (truncated)' as string),
1590
$('th', undefined, 'Version')
1591
),
1592
...extensions.map(extension => $('tr', undefined,
1593
$('td', undefined, extension.name),
1594
$('td', undefined, extension.publisher?.substr(0, 3) ?? 'N/A'),
1595
$('td', undefined, extension.version)
1596
))
1597
);
1598
}
1599
1600
private async openLink(eventOrUrl: MouseEvent | string): Promise<void> {
1601
if (typeof eventOrUrl === 'string') {
1602
// Direct URL call
1603
await this.openerService.open(eventOrUrl, { openExternal: true });
1604
} else {
1605
// MouseEvent call
1606
const event = eventOrUrl;
1607
event.preventDefault();
1608
event.stopPropagation();
1609
// Exclude right click
1610
if (event.which < 3) {
1611
await this.openerService.open((<HTMLAnchorElement>event.target).href, { openExternal: true });
1612
}
1613
}
1614
}
1615
1616
public getElementById<T extends HTMLElement = HTMLElement>(elementId: string): T | undefined {
1617
// eslint-disable-next-line no-restricted-syntax
1618
const element = this.window.document.getElementById(elementId) as T | undefined;
1619
if (element) {
1620
return element;
1621
} else {
1622
return undefined;
1623
}
1624
}
1625
1626
public addEventListener(elementId: string, eventType: string, handler: (event: Event) => void): void {
1627
// eslint-disable-next-line no-restricted-syntax
1628
const element = this.getElementById(elementId);
1629
element?.addEventListener(eventType, handler);
1630
}
1631
}
1632
1633
// helper functions
1634
1635
export function hide(el: Element | undefined | null) {
1636
el?.classList.add('hidden');
1637
}
1638
export function show(el: Element | undefined | null) {
1639
el?.classList.remove('hidden');
1640
}
1641
1642