Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/issue/electron-browser/issueReporterService.ts
5249 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 { $, reset } from '../../../../base/browser/dom.js';
6
import { VSBuffer } from '../../../../base/common/buffer.js';
7
import { CancellationError } from '../../../../base/common/errors.js';
8
import { Schemas } from '../../../../base/common/network.js';
9
import { IProductConfiguration } from '../../../../base/common/product.js';
10
import { joinPath } from '../../../../base/common/resources.js';
11
import { URI } from '../../../../base/common/uri.js';
12
import { localize } from '../../../../nls.js';
13
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
14
import { isRemoteDiagnosticError } from '../../../../platform/diagnostics/common/diagnostics.js';
15
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
16
import { IFileService } from '../../../../platform/files/common/files.js';
17
import { INativeHostService } from '../../../../platform/native/common/native.js';
18
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
19
import { IProcessService } from '../../../../platform/process/common/process.js';
20
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
21
import { IUpdateService, StateType } from '../../../../platform/update/common/update.js';
22
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
23
import { applyZoom } from '../../../../platform/window/electron-browser/window.js';
24
import { IAuthenticationService } from '../../../services/authentication/common/authentication.js';
25
import { BaseIssueReporterService } from '../browser/baseIssueReporterService.js';
26
import { IssueReporterData as IssueReporterModelData } from '../browser/issueReporterModel.js';
27
import { IIssueFormService, IssueReporterData, IssueType } from '../common/issue.js';
28
29
// GitHub has let us know that we could up our limit here to 8k. We chose 7500 to play it safe.
30
// ref https://github.com/microsoft/vscode/issues/159191
31
const MAX_URL_LENGTH = 7500;
32
33
// Github API and issues on web has a limit of 65536. We chose 65500 to play it safe.
34
// ref https://github.com/github/issues/issues/12858
35
const MAX_GITHUB_API_LENGTH = 65500;
36
37
38
export class IssueReporter extends BaseIssueReporterService {
39
private readonly processService: IProcessService;
40
constructor(
41
disableExtensions: boolean,
42
data: IssueReporterData,
43
os: {
44
type: string;
45
arch: string;
46
release: string;
47
},
48
product: IProductConfiguration,
49
window: Window,
50
@INativeHostService private readonly nativeHostService: INativeHostService,
51
@IIssueFormService issueFormService: IIssueFormService,
52
@IProcessService processService: IProcessService,
53
@IThemeService themeService: IThemeService,
54
@IFileService fileService: IFileService,
55
@IFileDialogService fileDialogService: IFileDialogService,
56
@IUpdateService private readonly updateService: IUpdateService,
57
@IContextKeyService contextKeyService: IContextKeyService,
58
@IContextMenuService contextMenuService: IContextMenuService,
59
@IAuthenticationService authenticationService: IAuthenticationService,
60
@IOpenerService openerService: IOpenerService
61
) {
62
super(disableExtensions, data, os, product, window, false, issueFormService, themeService, fileService, fileDialogService, contextMenuService, authenticationService, openerService);
63
this.processService = processService;
64
this.processService.getSystemInfo().then(info => {
65
this.issueReporterModel.update({ systemInfo: info });
66
this.receivedSystemInfo = true;
67
68
this.updateSystemInfo(this.issueReporterModel.getData());
69
this.updateButtonStates();
70
});
71
if (this.data.issueType === IssueType.PerformanceIssue) {
72
this.processService.getPerformanceInfo().then(info => {
73
this.updatePerformanceInfo(info as Partial<IssueReporterData>);
74
});
75
}
76
77
this.checkForUpdates();
78
this.setEventHandlers();
79
applyZoom(this.data.zoomLevel, this.window);
80
this.updateExperimentsInfo(this.data.experiments);
81
this.updateRestrictedMode(this.data.restrictedMode);
82
this.updateUnsupportedMode(this.data.isUnsupported);
83
}
84
85
private async checkForUpdates(): Promise<void> {
86
const updateState = this.updateService.state;
87
if (updateState.type === StateType.Ready || updateState.type === StateType.Downloaded) {
88
this.needsUpdate = true;
89
// eslint-disable-next-line no-restricted-syntax
90
const includeAcknowledgement = this.getElementById('version-acknowledgements');
91
// eslint-disable-next-line no-restricted-syntax
92
const updateBanner = this.getElementById('update-banner');
93
if (updateBanner && includeAcknowledgement) {
94
includeAcknowledgement.classList.remove('hidden');
95
updateBanner.classList.remove('hidden');
96
updateBanner.textContent = localize('updateAvailable', "A new version of {0} is available.", this.product.nameLong);
97
}
98
}
99
}
100
101
public override setEventHandlers(): void {
102
super.setEventHandlers();
103
104
this.addEventListener('issue-type', 'change', (event: Event) => {
105
const issueType = parseInt((<HTMLInputElement>event.target).value);
106
this.issueReporterModel.update({ issueType: issueType });
107
if (issueType === IssueType.PerformanceIssue && !this.receivedPerformanceInfo) {
108
this.processService.getPerformanceInfo().then(info => {
109
this.updatePerformanceInfo(info as Partial<IssueReporterData>);
110
});
111
}
112
113
// Resets placeholder
114
// eslint-disable-next-line no-restricted-syntax
115
const descriptionTextArea = <HTMLInputElement>this.getElementById('issue-title');
116
if (descriptionTextArea) {
117
descriptionTextArea.placeholder = localize('undefinedPlaceholder', "Please enter a title");
118
}
119
120
this.updateButtonStates();
121
this.setSourceOptions();
122
this.render();
123
});
124
}
125
126
public override async submitToGitHub(issueTitle: string, issueBody: string, gitHubDetails: { owner: string; repositoryName: string }): Promise<boolean> {
127
if (issueBody.length > MAX_GITHUB_API_LENGTH) {
128
const extensionData = this.issueReporterModel.getData().extensionData;
129
if (extensionData) {
130
issueBody = issueBody.replace(extensionData, '');
131
const date = new Date();
132
const formattedDate = date.toISOString().split('T')[0]; // YYYY-MM-DD
133
const formattedTime = date.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS
134
const fileName = `extensionData_${formattedDate}_${formattedTime}.md`;
135
try {
136
const downloadPath = await this.fileDialogService.showSaveDialog({
137
title: localize('saveExtensionData', "Save Extension Data"),
138
availableFileSystems: [Schemas.file],
139
defaultUri: joinPath(await this.fileDialogService.defaultFilePath(Schemas.file), fileName),
140
});
141
142
if (downloadPath) {
143
await this.fileService.writeFile(downloadPath, VSBuffer.fromString(extensionData));
144
}
145
} catch (e) {
146
console.error('Writing extension data to file failed');
147
return false;
148
}
149
} else {
150
console.error('Issue body too large to submit to GitHub');
151
return false;
152
}
153
}
154
const url = `https://api.github.com/repos/${gitHubDetails.owner}/${gitHubDetails.repositoryName}/issues`;
155
const init = {
156
method: 'POST',
157
body: JSON.stringify({
158
title: issueTitle,
159
body: issueBody
160
}),
161
headers: new Headers({
162
'Content-Type': 'application/json',
163
'Authorization': `Bearer ${this.data.githubAccessToken}`
164
})
165
};
166
167
const response = await fetch(url, init);
168
if (!response.ok) {
169
console.error('Invalid GitHub URL provided.');
170
return false;
171
}
172
const result = await response.json();
173
await this.openerService.open(result.html_url, { openExternal: true });
174
this.close();
175
return true;
176
}
177
178
public override async createIssue(shouldCreate?: boolean, privateUri?: boolean): Promise<boolean> {
179
const selectedExtension = this.issueReporterModel.getData().selectedExtension;
180
// Short circuit if the extension provides a custom issue handler
181
if (this.nonGitHubIssueUrl) {
182
const url = this.getExtensionBugsUrl();
183
if (url) {
184
this.hasBeenSubmitted = true;
185
await this.openerService.open(url, { openExternal: true });
186
return true;
187
}
188
}
189
190
if (!this.validateInputs()) {
191
// If inputs are invalid, set focus to the first one and add listeners on them
192
// to detect further changes
193
// eslint-disable-next-line no-restricted-syntax
194
const invalidInput = this.window.document.getElementsByClassName('invalid-input');
195
if (invalidInput.length) {
196
(<HTMLInputElement>invalidInput[0]).focus();
197
}
198
199
this.addEventListener('issue-title', 'input', _ => {
200
this.validateInput('issue-title');
201
});
202
203
this.addEventListener('description', 'input', _ => {
204
this.validateInput('description');
205
});
206
207
this.addEventListener('issue-source', 'change', _ => {
208
this.validateInput('issue-source');
209
});
210
211
if (this.issueReporterModel.fileOnExtension()) {
212
this.addEventListener('extension-selector', 'change', _ => {
213
this.validateInput('extension-selector');
214
this.validateInput('description');
215
});
216
}
217
218
return false;
219
}
220
221
this.hasBeenSubmitted = true;
222
223
// eslint-disable-next-line no-restricted-syntax
224
const issueTitle = (<HTMLInputElement>this.getElementById('issue-title')).value;
225
const issueBody = this.issueReporterModel.serialize();
226
227
let issueUrl = privateUri ? this.getPrivateIssueUrl() : this.getIssueUrl();
228
if (!issueUrl && selectedExtension?.uri) {
229
const uri = URI.revive(selectedExtension.uri);
230
issueUrl = uri.toString();
231
} else if (!issueUrl) {
232
console.error(`No ${privateUri ? 'private ' : ''}issue url found`);
233
return false;
234
}
235
236
const gitHubDetails = this.parseGitHubUrl(issueUrl);
237
238
// eslint-disable-next-line no-restricted-syntax
239
const baseUrl = this.getIssueUrlWithTitle((<HTMLInputElement>this.getElementById('issue-title')).value, issueUrl);
240
let url = baseUrl + `&body=${encodeURIComponent(issueBody)}`;
241
242
url = this.addTemplateToUrl(url, gitHubDetails?.owner, gitHubDetails?.repositoryName);
243
244
if (this.data.githubAccessToken && gitHubDetails && shouldCreate) {
245
if (await this.submitToGitHub(issueTitle, issueBody, gitHubDetails)) {
246
return true;
247
}
248
}
249
250
try {
251
if (url.length > MAX_URL_LENGTH || issueBody.length > MAX_GITHUB_API_LENGTH) {
252
url = await this.writeToClipboard(baseUrl, issueBody);
253
url = this.addTemplateToUrl(url, gitHubDetails?.owner, gitHubDetails?.repositoryName);
254
}
255
} catch (_) {
256
console.error('Writing to clipboard failed');
257
return false;
258
}
259
260
await this.openerService.open(url, { openExternal: true });
261
return true;
262
}
263
264
public override async writeToClipboard(baseUrl: string, issueBody: string): Promise<string> {
265
const shouldWrite = await this.issueFormService.showClipboardDialog();
266
if (!shouldWrite) {
267
throw new CancellationError();
268
}
269
270
await this.nativeHostService.writeClipboardText(issueBody);
271
272
return baseUrl + `&body=${encodeURIComponent(localize('pasteData', "We have written the needed data into your clipboard because it was too large to send. Please paste."))}`;
273
}
274
275
private updateSystemInfo(state: IssueReporterModelData) {
276
// eslint-disable-next-line no-restricted-syntax
277
const target = this.window.document.querySelector<HTMLElement>('.block-system .block-info');
278
279
if (target) {
280
const systemInfo = state.systemInfo!;
281
const renderedDataTable = $('table', undefined,
282
$('tr', undefined,
283
$('td', undefined, 'CPUs'),
284
$('td', undefined, systemInfo.cpus || '')
285
),
286
$('tr', undefined,
287
$('td', undefined, 'GPU Status' as string),
288
$('td', undefined, Object.keys(systemInfo.gpuStatus).map(key => `${key}: ${systemInfo.gpuStatus[key]}`).join('\n'))
289
),
290
$('tr', undefined,
291
$('td', undefined, 'Load (avg)' as string),
292
$('td', undefined, systemInfo.load || '')
293
),
294
$('tr', undefined,
295
$('td', undefined, 'Memory (System)' as string),
296
$('td', undefined, systemInfo.memory)
297
),
298
$('tr', undefined,
299
$('td', undefined, 'Process Argv' as string),
300
$('td', undefined, systemInfo.processArgs)
301
),
302
$('tr', undefined,
303
$('td', undefined, 'Screen Reader' as string),
304
$('td', undefined, systemInfo.screenReader)
305
),
306
$('tr', undefined,
307
$('td', undefined, 'VM'),
308
$('td', undefined, systemInfo.vmHint)
309
)
310
);
311
reset(target, renderedDataTable);
312
313
systemInfo.remoteData.forEach(remote => {
314
target.appendChild($<HTMLHRElement>('hr'));
315
if (isRemoteDiagnosticError(remote)) {
316
const remoteDataTable = $('table', undefined,
317
$('tr', undefined,
318
$('td', undefined, 'Remote'),
319
$('td', undefined, remote.hostName)
320
),
321
$('tr', undefined,
322
$('td', undefined, ''),
323
$('td', undefined, remote.errorMessage)
324
)
325
);
326
target.appendChild(remoteDataTable);
327
} else {
328
const remoteDataTable = $('table', undefined,
329
$('tr', undefined,
330
$('td', undefined, 'Remote'),
331
$('td', undefined, remote.latency ? `${remote.hostName} (latency: ${remote.latency.current.toFixed(2)}ms last, ${remote.latency.average.toFixed(2)}ms average)` : remote.hostName)
332
),
333
$('tr', undefined,
334
$('td', undefined, 'OS'),
335
$('td', undefined, remote.machineInfo.os)
336
),
337
$('tr', undefined,
338
$('td', undefined, 'CPUs'),
339
$('td', undefined, remote.machineInfo.cpus || '')
340
),
341
$('tr', undefined,
342
$('td', undefined, 'Memory (System)' as string),
343
$('td', undefined, remote.machineInfo.memory)
344
),
345
$('tr', undefined,
346
$('td', undefined, 'VM'),
347
$('td', undefined, remote.machineInfo.vmHint)
348
)
349
);
350
target.appendChild(remoteDataTable);
351
}
352
});
353
}
354
}
355
356
private updateRestrictedMode(restrictedMode: boolean) {
357
this.issueReporterModel.update({ restrictedMode });
358
}
359
360
private updateUnsupportedMode(isUnsupported: boolean) {
361
this.issueReporterModel.update({ isUnsupported });
362
}
363
364
private updateExperimentsInfo(experimentInfo: string | undefined) {
365
this.issueReporterModel.update({ experimentInfo });
366
// eslint-disable-next-line no-restricted-syntax
367
const target = this.window.document.querySelector<HTMLElement>('.block-experiments .block-info');
368
if (target) {
369
target.textContent = experimentInfo ? experimentInfo : localize('noCurrentExperiments', "No current experiments.");
370
}
371
}
372
}
373
374