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