Issue reporter wizard

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Giuseppe Cianci
2026-05-20 19:19:56 +02:00
co-authored by Copilot
parent 7d7e9513af
commit 4e538f26ea
31 changed files with 8921 additions and 127 deletions
+72 -2
View File
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { app, Details, GPUFeatureStatus, powerMonitor, protocol, session, Session, systemPreferences, WebFrameMain } from 'electron';
import { app, BrowserWindow, desktopCapturer, Details, GPUFeatureStatus, powerMonitor, protocol, screen as electronScreen, session, Session, systemPreferences, WebFrameMain } from 'electron';
import { addUNCHostToAllowlist, disableUNCAccessRestrictions } from '../../base/node/unc.js';
import { validatedIpcMain } from '../../base/parts/ipc/electron-main/ipcMain.js';
import { hostname, release } from 'os';
@@ -13,7 +13,7 @@ import { toErrorMessage } from '../../base/common/errorMessage.js';
import { Event } from '../../base/common/event.js';
import { parse } from '../../base/common/jsonc.js';
import { getPathLabel } from '../../base/common/labels.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../base/common/lifecycle.js';
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../base/common/lifecycle.js';
import { Schemas, VSCODE_AUTHORITY } from '../../base/common/network.js';
import { join, posix } from '../../base/common/path.js';
import { IProcessEnvironment, isLinux, isLinuxSnap, isMacintosh, isWindows, OS } from '../../base/common/platform.js';
@@ -228,6 +228,76 @@ export class CodeApplication extends Disposable {
return false;
});
// Without this, starting recording in the issue reporting wizard takes
// a few seconds due to overhead of enumerating sources, so we warm up the sources in advance.
let cachedScreenSources: Electron.DesktopCapturerSource[] | undefined;
const warmUpScreenSources = () => {
desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: 0, height: 0 },
}).then(sources => { cachedScreenSources = sources; }).catch(() => { /* best-effort */ });
};
const invalidateScreenSourceCache = () => {
cachedScreenSources = undefined;
if (!isMacintosh || systemPreferences.getMediaAccessStatus('screen') === 'granted') {
warmUpScreenSources();
}
};
electronScreen.on('display-added', invalidateScreenSourceCache);
electronScreen.on('display-removed', invalidateScreenSourceCache);
electronScreen.on('display-metrics-changed', invalidateScreenSourceCache);
this._register(toDisposable(() => {
electronScreen.off('display-added', invalidateScreenSourceCache);
electronScreen.off('display-removed', invalidateScreenSourceCache);
electronScreen.off('display-metrics-changed', invalidateScreenSourceCache);
}));
if (!isMacintosh || systemPreferences.getMediaAccessStatus('screen') === 'granted') {
warmUpScreenSources();
}
session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => {
try {
const frame = request.frame;
const win = frame ? BrowserWindow.getAllWindows().find(w => w.webContents.mainFrame === frame) : undefined;
const displays = electronScreen.getAllDisplays();
let targetDisplay = displays[0];
if (win) {
const winBounds = win.getBounds();
targetDisplay = electronScreen.getDisplayNearestPoint({
x: winBounds.x + winBounds.width / 2,
y: winBounds.y + winBounds.height / 2,
});
}
if (!cachedScreenSources) {
cachedScreenSources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: 0, height: 0 },
});
}
let match = cachedScreenSources.find(s => s.display_id === String(targetDisplay.id));
if (!match) {
// Cache may be stale even without a topology event
cachedScreenSources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: 0, height: 0 },
});
match = cachedScreenSources.find(s => s.display_id === String(targetDisplay.id));
}
const chosen = match ?? cachedScreenSources[0];
if (!chosen) {
// No screen sources available (permission denied or transient failure).
callback({});
return;
}
callback({ video: chosen });
} catch {
callback({});
}
});
//#endregion
//#region Request filtering
@@ -15,7 +15,7 @@ export const IDiagnosticsService = createDecorator<IDiagnosticsService>(ID);
export interface IDiagnosticsService {
readonly _serviceBrand: undefined;
getPerformanceInfo(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[]): Promise<PerformanceInfo>;
getPerformanceInfo(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[], options?: { skipCache?: boolean; unbounded?: boolean }): Promise<PerformanceInfo>;
getSystemInfo(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[]): Promise<SystemInfo>;
getDiagnostics(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[]): Promise<string>;
getWorkspaceFileExtensions(workspace: IWorkspace): Promise<{ extensions: string[] }>;
@@ -101,7 +101,7 @@ export function isRemoteDiagnosticError(x: unknown): x is IRemoteDiagnosticError
export class NullDiagnosticsService implements IDiagnosticsService {
_serviceBrand: undefined;
async getPerformanceInfo(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[]): Promise<PerformanceInfo> {
async getPerformanceInfo(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[], options?: { skipCache?: boolean; unbounded?: boolean }): Promise<PerformanceInfo> {
return {};
}
@@ -29,11 +29,22 @@ interface ConfigFilePatterns {
}
const workspaceStatsCache = new Map<string, Promise<WorkspaceStats>>();
export async function collectWorkspaceStats(folder: string, filter: string[]): Promise<WorkspaceStats> {
const cacheKey = `${folder}::${filter.join(':')}`;
const cached = workspaceStatsCache.get(cacheKey);
if (cached) {
return cached;
/** Sentinel key in {@link WorkspaceStats.fileTypes} for files with no extension. */
const NO_EXT_KEY = '\0no-extension';
export async function collectWorkspaceStats(folder: string, filter: string[], options?: { skipCache?: boolean; unbounded?: boolean }): Promise<WorkspaceStats> {
// Include `unbounded` in the cache key so a bounded (20k-cap) result is never
// returned for an unbounded request (which would silently truncate counts).
const cacheKey = `${folder}::${filter.join(':')}::${options?.unbounded ? 'unbounded' : 'bounded'}`;
if (!options?.skipCache) {
const cached = workspaceStatsCache.get(cacheKey);
if (cached) {
return cached;
}
} else {
// Drop any in-flight or stale entry so callers can be sure they get fresh data.
workspaceStatsCache.delete(cacheKey);
}
const configFilePatterns: ConfigFilePatterns[] = [
@@ -79,12 +90,21 @@ export async function collectWorkspaceStats(folder: string, filter: string[]): P
const fileTypes = new Map<string, number>();
const configFiles = new Map<string, number>();
const MAX_FILES = 20000;
const MAX_FILES = options?.unbounded ? Number.POSITIVE_INFINITY : 20000;
function collect(root: string, dir: string, filter: string[], token: { count: number; maxReached: boolean; readdirCount: number }): Promise<void> {
const relativePath = dir.substring(root.length + 1);
return Promises.withAsyncBody(async resolve => {
// Bail before touching the filesystem when the cap has already been hit so
// sibling-directory recursion doesn't pay readdir IO after the scan is
// effectively done.
if (token.count >= MAX_FILES) {
token.maxReached = true;
resolve();
return;
}
let files: IDirent[];
token.readdirCount++;
@@ -97,7 +117,6 @@ export async function collectWorkspaceStats(folder: string, filter: string[]): P
}
if (token.count >= MAX_FILES) {
token.count += files.length;
token.maxReached = true;
resolve();
return;
@@ -109,16 +128,7 @@ export async function collectWorkspaceStats(folder: string, filter: string[]): P
return;
}
let filesToRead = files;
if (token.count + files.length > MAX_FILES) {
token.maxReached = true;
pending = MAX_FILES - token.count;
filesToRead = files.slice(0, pending);
}
token.count += files.length;
for (const file of filesToRead) {
for (const file of files) {
if (file.isDirectory()) {
if (!filter.includes(file.name)) {
await collect(root, join(dir, file.name), filter, token);
@@ -129,13 +139,24 @@ export async function collectWorkspaceStats(folder: string, filter: string[]): P
return;
}
} else {
const index = file.name.lastIndexOf('.');
if (index >= 0) {
const fileType = file.name.substring(index + 1);
if (fileType) {
fileTypes.set(fileType, (fileTypes.get(fileType) ?? 0) + 1);
}
if (token.count >= MAX_FILES) {
token.maxReached = true;
resolve();
return;
}
token.count++;
const index = file.name.lastIndexOf('.');
let fileType: string | undefined;
if (index >= 0) {
fileType = file.name.substring(index + 1) || undefined;
}
// Track files with no usable extension under a sentinel key so they
// can be folded into the "other" bucket at render time. Without this,
// extension-less files (Makefile, LICENSE, scripts in bin/, etc.) would
// be silently dropped from the file-type counts and the totals would
// not reconcile with the overall file count.
fileTypes.set(fileType ?? NO_EXT_KEY, (fileTypes.get(fileType ?? NO_EXT_KEY) ?? 0) + 1);
for (const configFile of configFilePatterns) {
if (configFile.relativePathPattern?.test(relativePath) !== false && configFile.filePattern.test(file.name)) {
@@ -271,8 +292,8 @@ export class DiagnosticsService implements IDiagnosticsService {
return output.join('\n');
}
public async getPerformanceInfo(info: IMainProcessDiagnostics, remoteData: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[]): Promise<PerformanceInfo> {
return Promise.all([listProcesses(info.mainPID), this.formatWorkspaceMetadata(info)]).then(async result => {
public async getPerformanceInfo(info: IMainProcessDiagnostics, remoteData: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[], options?: { skipCache?: boolean; unbounded?: boolean }): Promise<PerformanceInfo> {
return Promise.all([listProcesses(info.mainPID), this.formatWorkspaceMetadata(info, options)]).then(async result => {
let [rootProcess, workspaceInfo] = result;
let processInfo = this.formatProcessList(info, rootProcess);
@@ -413,13 +434,26 @@ export class DiagnosticsService implements IDiagnosticsService {
};
// File Types
// Skip the no-extension sentinel from the named list and fold its count into
// the "other" bucket so totals reconcile with fileCount.
let line = '| File types:';
const maxShown = 10;
const max = workspaceStats.fileTypes.length > maxShown ? maxShown : workspaceStats.fileTypes.length;
const namedTypes = workspaceStats.fileTypes.filter(t => t.name !== NO_EXT_KEY);
const noExtCount = workspaceStats.fileTypes
.filter(t => t.name === NO_EXT_KEY)
.reduce((sum, t) => sum + t.count, 0);
const max = Math.min(namedTypes.length, maxShown);
for (let i = 0; i < max; i++) {
const item = workspaceStats.fileTypes[i];
const item = namedTypes[i];
appendAndWrap(item.name, item.count);
}
let otherCount = noExtCount;
for (let i = max; i < namedTypes.length; i++) {
otherCount += namedTypes[i].count;
}
if (otherCount > 0) {
appendAndWrap('other', otherCount);
}
output.push(line);
// Conf Files
@@ -449,7 +483,7 @@ export class DiagnosticsService implements IDiagnosticsService {
return Object.keys(gpuFeatures).map(feature => `${feature}: ${' '.repeat(longestFeatureName - feature.length)} ${gpuFeatures[feature]}`).join('\n ');
}
private formatWorkspaceMetadata(info: IMainProcessDiagnostics): Promise<string> {
private formatWorkspaceMetadata(info: IMainProcessDiagnostics, options?: { skipCache?: boolean; unbounded?: boolean }): Promise<string> {
const output: string[] = [];
const workspaceStatPromises: Promise<void>[] = [];
@@ -464,7 +498,7 @@ export class DiagnosticsService implements IDiagnosticsService {
const folderUri = URI.revive(uriComponents);
if (folderUri.scheme === Schemas.file) {
const folder = folderUri.fsPath;
workspaceStatPromises.push(collectWorkspaceStats(folder, ['node_modules', '.git']).then(stats => {
workspaceStatPromises.push(collectWorkspaceStats(folder, ['node_modules', '.git'], options).then(stats => {
let countMessage = `${stats.fileCount} files`;
if (stats.maxFilesReached) {
countMessage = `more than ${countMessage}`;
@@ -536,7 +570,11 @@ export class DiagnosticsService implements IDiagnosticsService {
const folder = folderUri.fsPath;
try {
const stats = await collectWorkspaceStats(folder, ['node_modules', '.git']);
stats.fileTypes.forEach(item => items.add(item.name));
stats.fileTypes.forEach(item => {
if (item.name !== NO_EXT_KEY) {
items.add(item.name);
}
});
} catch { }
}
return { extensions: [...items] };
@@ -579,6 +617,9 @@ export class DiagnosticsService implements IDiagnosticsService {
count: number;
};
stats.fileTypes.forEach(e => {
if (e.name === NO_EXT_KEY) {
return;
}
this.telemetryService.publicLog2<WorkspaceStatsFileEvent, WorkspaceStatsFileClassification>('workspace.stats.file', {
rendererSessionId: workspace.rendererSessionId,
type: e.name,
+5
View File
@@ -181,6 +181,8 @@ export interface ICommonNativeHostService {
openExternal(url: string, defaultApplication?: string): Promise<boolean>;
moveItemToTrash(fullPath: string): Promise<void>;
getMediaAccessStatus(mediaType: 'microphone' | 'camera' | 'screen'): Promise<'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'>;
isAdmin(): Promise<boolean>;
writeElevated(source: URI, target: URI, options?: { unlock?: boolean }): Promise<void>;
isRunningUnderARM64Translation(): Promise<boolean>;
@@ -196,6 +198,9 @@ export interface ICommonNativeHostService {
// Screenshots
getScreenshot(rect?: IRectangle): Promise<VSBuffer | undefined>;
// GitHub mobile upload API (runs in main process to avoid CORS)
uploadFileViaMobileApi(token: string, repoId: string, fileName: string, fileBytes: VSBuffer, contentType: string): Promise<{ fileName: string; assetUrl: string; contentType: string }>;
// Process
getProcessId(): Promise<number | undefined>;
killProcess(pid: number, code: string): Promise<void>;
@@ -5,7 +5,7 @@
import * as fs from 'fs';
import { exec } from 'child_process';
import { app, BrowserWindow, clipboard, contentTracing, Display, Menu, MessageBoxOptions, MessageBoxReturnValue, Notification, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, powerMonitor, powerSaveBlocker, SaveDialogOptions, SaveDialogReturnValue, screen, shell, webContents } from 'electron';
import { app, BrowserWindow, clipboard, contentTracing, Display, Menu, MessageBoxOptions, MessageBoxReturnValue, Notification, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, powerMonitor, powerSaveBlocker, SaveDialogOptions, SaveDialogReturnValue, screen, shell, systemPreferences, webContents } from 'electron';
import { arch, cpus, freemem, loadavg, platform, release, totalmem, type } from 'os';
import { promisify } from 'util';
import { memoize } from '../../../base/common/decorators.js';
@@ -732,6 +732,17 @@ export class NativeHostMainService extends Disposable implements INativeHostMain
return shell.trashItem(fullPath);
}
async getMediaAccessStatus(windowId: number | undefined, mediaType: 'microphone' | 'camera' | 'screen'): Promise<'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'> {
// systemPreferences.getMediaAccessStatus is implemented on macOS only.
// On Linux and Windows there's no per-app screen-recording permission
// concept; the OS handles capture without an app-level gate, so report
// 'granted' so the renderer can proceed straight to getDisplayMedia.
if (isMacintosh) {
return systemPreferences.getMediaAccessStatus(mediaType);
}
return 'granted';
}
async isAdmin(): Promise<boolean> {
let isAdmin: boolean;
if (isWindows) {
@@ -870,6 +881,82 @@ export class NativeHostMainService extends Disposable implements INativeHostMain
//#endregion
//#region GitHub mobile upload API
async uploadFileViaMobileApi(_windowId: number | undefined, token: string, repoId: string, fileName: string, fileBytes: VSBuffer, contentType: string): Promise<{ fileName: string; assetUrl: string; contentType: string }> {
const { net } = await import('electron');
// Step 1: Get upload policy
const policyResponse = await net.fetch('https://api.github.com/mobile/upload/policy', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
name: fileName,
size: fileBytes.byteLength,
content_type: contentType,
repository_id: parseInt(repoId, 10),
}),
});
if (!policyResponse.ok) {
const text = await policyResponse.text();
throw new Error(`Policy request failed ${policyResponse.status}: ${text.substring(0, 300)}`);
}
const policy = await policyResponse.json();
const asset = policy.asset as Record<string, unknown>;
// Step 2: Upload to S3 (uses net.fetch which bypasses CORS)
const formFields = policy.form as Record<string, string>;
const boundary = `----VSCodeUpload${Date.now()}`;
let multipartBody = '';
for (const [key, value] of Object.entries(formFields)) {
multipartBody += `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`;
}
// Sanitize the filename for multipart header safety: strip CR/LF (which would
// terminate the header / inject extra fields) and escape backslashes and double
// quotes (RFC 2616 quoted-string semantics).
const safeName = String(asset.name).replace(/[\r\n]+/g, ' ').replace(/[\\"]/g, '_');
multipartBody += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${safeName}"\r\nContent-Type: ${contentType}\r\n\r\n`;
const epilogue = `\r\n--${boundary}--\r\n`;
const preambleBytes = Buffer.from(multipartBody, 'utf-8');
const epilogueBytes = Buffer.from(epilogue, 'utf-8');
// Pass fileBytes.buffer (Uint8Array) directly to Buffer.concat instead of wrapping
// in Buffer.from(...) which would force an extra full-size copy of the payload.
const bodyBuffer = Buffer.concat([preambleBytes, fileBytes.buffer, epilogueBytes]);
const s3Response = await net.fetch(policy.upload_url as string, {
method: 'POST',
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
body: bodyBuffer,
});
if (s3Response.status !== 204 && s3Response.status !== 201) {
const text = await s3Response.text();
throw new Error(`S3 upload failed ${s3Response.status}: ${text.substring(0, 300)}`);
}
// Step 3: Confirm upload
const confirmResponse = await net.fetch(`https://api.github.com${policy.asset_upload_url}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
},
});
if (!confirmResponse.ok) {
const text = await confirmResponse.text();
throw new Error(`Asset upload confirmation failed ${confirmResponse.status}: ${text.substring(0, 300)}`);
}
return { fileName, assetUrl: asset.href as string, contentType };
}
//#endregion
//#region Process
async getProcessId(windowId: number | undefined): Promise<number | undefined> {
+1 -1
View File
@@ -47,5 +47,5 @@ export interface IProcessService {
getSystemStatus(): Promise<string>;
getSystemInfo(): Promise<SystemInfo>;
getPerformanceInfo(): Promise<PerformanceInfo>;
getPerformanceInfo(options?: { skipCache?: boolean; unbounded?: boolean }): Promise<PerformanceInfo>;
}
@@ -75,10 +75,10 @@ export class ProcessMainService implements IProcessService {
return msg;
}
async getPerformanceInfo(): Promise<PerformanceInfo> {
async getPerformanceInfo(options?: { skipCache?: boolean; unbounded?: boolean }): Promise<PerformanceInfo> {
try {
const [info, remoteData] = await Promise.all([this.diagnosticsMainService.getMainDiagnostics(), this.diagnosticsMainService.getRemoteDiagnostics({ includeProcesses: true, includeWorkspaceMetadata: true })]);
return await this.diagnosticsService.getPerformanceInfo(info, remoteData);
return await this.diagnosticsService.getPerformanceInfo(info, remoteData, options);
} catch (error) {
this.logService.warn('issueService#getPerformanceInfo ', error.message);
+1
View File
@@ -1708,6 +1708,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi
this.parent, // in that case the workbench will span the entire site
this.contextService.getWorkbenchState() === WorkbenchState.EMPTY ? DEFAULT_EMPTY_WINDOW_DIMENSIONS : DEFAULT_WORKSPACE_WINDOW_DIMENSIONS // running with fallback to ensure no error is thrown (https://github.com/microsoft/vscode/issues/240242)
);
this.logService.trace(`Layout#layout, height: ${this._mainContainerDimension.height}, width: ${this._mainContainerDimension.width}`);
size(this.mainContainer, this._mainContainerDimension.width, this._mainContainerDimension.height);
@@ -0,0 +1,30 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
export interface IGitHubUploadResult {
readonly fileName: string;
readonly assetUrl: string;
readonly contentType: string;
}
export const IGitHubUploadService = createDecorator<IGitHubUploadService>('githubUploadService');
export interface IGitHubUploadService {
readonly _serviceBrand: undefined;
resolveRepositoryId(owner: string, repo: string, token?: string): Promise<string>;
uploadViaMobileApi(token: string, repoId: string, files: { name: string; bytes: Uint8Array; contentType: string }[]): Promise<IGitHubUploadResult[]>;
}
/**
* Browser fallback, upload not yet supported in web.
*/
export class BrowserGitHubUploadService implements IGitHubUploadService {
readonly _serviceBrand: undefined;
async resolveRepositoryId(): Promise<string> { throw new Error('Not supported in browser'); }
async uploadViaMobileApi(): Promise<IGitHubUploadResult[]> { throw new Error('Not supported in browser'); }
}
@@ -17,6 +17,9 @@ import './issueTroubleshoot.js';
import { IIssueFormService, IWorkbenchIssueService } from '../common/issue.js';
import { BaseIssueContribution } from '../common/issue.contribution.js';
import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js';
import { BrowserScreenshotService, IScreenshotService } from './screenshotService.js';
import { BrowserRecordingService, IRecordingService } from './recordingService.js';
import { BrowserGitHubUploadService, IGitHubUploadService } from './githubUploadService.js';
class WebIssueContribution extends BaseIssueContribution {
@@ -38,6 +41,9 @@ Registry.as<IWorkbenchContributionsRegistry>(Extensions.Workbench).registerWorkb
registerSingleton(IWorkbenchIssueService, BrowserIssueService, InstantiationType.Delayed);
registerSingleton(IIssueFormService, IssueFormService, InstantiationType.Delayed);
registerSingleton(IScreenshotService, BrowserScreenshotService, InstantiationType.Delayed);
registerSingleton(IRecordingService, BrowserRecordingService, InstantiationType.Delayed);
registerSingleton(IGitHubUploadService, BrowserGitHubUploadService, InstantiationType.Delayed);
CommandsRegistry.registerCommand('_issues.getSystemStatus', (accessor) => {
return nls.localize('statusUnsupported', "The --status argument is not yet supported in browsers.");
@@ -5,31 +5,43 @@
import { safeSetInnerHtml } from '../../../../base/browser/domSanitize.js';
import { createStyleSheet } from '../../../../base/browser/domStylesheets.js';
import { getMenuWidgetCSS, Menu, unthemedMenuStyles } from '../../../../base/browser/ui/menu/menu.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
import { isLinux, isWindows } from '../../../../base/common/platform.js';
import Severity from '../../../../base/common/severity.js';
import { localize } from '../../../../nls.js';
import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js';
import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { ExtensionIdentifier, ExtensionIdentifierSet } from '../../../../platform/extensions/common/extensions.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import product from '../../../../platform/product/common/product.js';
import { IRectangle } from '../../../../platform/window/common/window.js';
import { AuxiliaryWindowMode, IAuxiliaryWindowService } from '../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js';
import { IHostService } from '../../../services/host/browser/host.js';
import { IIssueFormService, IssueReporterData } from '../common/issue.js';
import { IIssueFormService, IIssueSubmissionHost, IssueReporterData, IssueReporterExtensionData, IssueSource } from '../common/issue.js';
import { normalizeGitHubUrl } from '../common/issueReporterUtil.js';
import BaseHtml from './issueReporterPage.js';
import { IssueWebReporter } from './issueReporterService.js';
import { IGitHubUploadService } from './githubUploadService.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { URI } from '../../../../base/common/uri.js';
import { LRUCache } from '../../../../base/common/map.js';
import { hash } from '../../../../base/common/hash.js';
import { decodeBase64 } from '../../../../base/common/buffer.js';
import './media/issueReporter.css';
export interface IssuePassData {
issueTitle: string;
issueBody: string;
}
const MAX_URL_LENGTH = 7500;
const GENERATED_BY_ISSUE_REPORTER_MARKER = '<!-- generated by issue reporter -->';
const ISSUE_DATA_ATTACHMENT_NAME = 'issue-data.md';
export class IssueFormService implements IIssueFormService {
type IssueUploadFile = { key: string; name: string; bytes: Uint8Array; contentType: string };
type ExtractedIssueData = { body: string; fileContent: string };
export class IssueFormService extends Disposable implements IIssueFormService {
readonly _serviceBrand: undefined;
@@ -42,6 +54,9 @@ export class IssueFormService implements IIssueFormService {
protected release: string = '';
protected type: string = '';
/** Bounded cache of already-uploaded attachments to avoid re-uploading on retry within a session. Uses a content hash so large data URLs aren't retained as keys. */
private readonly uploadCache = new LRUCache<string, import('./githubUploadService.js').IGitHubUploadResult>(32);
constructor(
@IInstantiationService protected readonly instantiationService: IInstantiationService,
@IAuxiliaryWindowService protected readonly auxiliaryWindowService: IAuxiliaryWindowService,
@@ -49,14 +64,356 @@ export class IssueFormService implements IIssueFormService {
@IContextKeyService protected readonly contextKeyService: IContextKeyService,
@ILogService protected readonly logService: ILogService,
@IDialogService protected readonly dialogService: IDialogService,
@IHostService protected readonly hostService: IHostService
) { }
@IHostService protected readonly hostService: IHostService,
@IOpenerService protected readonly openerService: IOpenerService,
@IFileService protected readonly fileService: IFileService,
@IGitHubUploadService protected readonly githubUploadService: IGitHubUploadService,
@IEditorService protected readonly editorService: IEditorService,
@IClipboardService protected readonly clipboardService: IClipboardService,
) {
super();
}
async openReporter(data: IssueReporterData): Promise<void> {
if (this.hasToReload(data)) {
return;
}
// Web only ever opens the legacy reporter. The wizard editor pane is
// registered only by the native contribution, and its dependencies
// (screenshot, recording, GitHub upload, native diagnostics) aren't
// implemented for the browser yet. NativeIssueFormService overrides
// this method to route to the wizard when `issueReporter.wizard.enabled`
// is set.
return this.openAuxIssueReporterLegacy(data);
}
async submitIssue(host: IIssueSubmissionHost, data: IssueReporterData, title: string, body: string): Promise<boolean> {
const screenshots = host.getScreenshots();
const recordings = host.getRecordings();
const issueTarget = this.getIssueTarget(data);
if (!issueTarget?.url) {
return false;
}
if (issueTarget.external) {
return this.openerService.open(issueTarget.url, { openExternal: true });
}
const gitHubDetails = this.parseGitHubUrl(issueTarget.url);
let repoId: string | undefined;
const resolveRepoId = async (): Promise<string | undefined> => {
if (!gitHubDetails) {
return undefined;
}
repoId ??= await this.githubUploadService.resolveRepositoryId(gitHubDetails.owner, gitHubDetails.repositoryName, data.githubAccessToken);
return repoId;
};
let mediaMarkdown = '';
const hasAttachments = screenshots.length > 0 || recordings.length > 0;
// Only attempt the Mobile Upload API when the issue target actually resolves to a
// GitHub repo. Otherwise we'd upload attachments to an unrelated repository (and
// potentially leak them) just because a GitHub token is present.
if (hasAttachments && data.githubAccessToken && gitHubDetails) {
host.setUploading(true);
try {
// Collect files, keyed for cache lookup. We hash the data URL / file path so the
// (potentially very large) screenshot/recording payloads aren't retained as Map keys.
const filesToProcess: IssueUploadFile[] = [];
for (let i = 0; i < screenshots.length; i++) {
const dataUrl = screenshots[i].annotatedDataUrl ?? screenshots[i].dataUrl;
const bytes = this.dataUrlToBytes(dataUrl);
if (bytes) {
// Screenshots are either annotated (always PNG via canvas.toDataURL)
// or raw native captures (always JPEG); fall back to PNG.
const isJpeg = dataUrl.startsWith('data:image/jpeg');
const extension = isJpeg ? 'jpg' : 'png';
const contentType = isJpeg ? 'image/jpeg' : 'image/png';
filesToProcess.push({ key: `screenshot:${hash(dataUrl)}`, name: `screenshot-${i + 1}.${extension}`, bytes, contentType });
}
}
for (let i = 0; i < recordings.length; i++) {
const rec = recordings[i];
const fileContent = await this.fileService.readFile(URI.file(rec.filePath));
const ext = rec.filePath.endsWith('.mp4') ? 'mp4' : 'webm';
const contentType = ext === 'mp4' ? 'video/mp4' : 'video/webm';
filesToProcess.push({ key: `recording:${rec.filePath}`, name: `recording-${i + 1}.${ext}`, bytes: fileContent.value.buffer, contentType });
}
if (filesToProcess.length > 0) {
for (let i = 0; i < filesToProcess.length; i++) {
host.setAttachmentUploadState(i, 'pending');
}
const uploadResults: import('./githubUploadService.js').IGitHubUploadResult[] = [];
for (let i = 0; i < filesToProcess.length; i++) {
const file = filesToProcess[i];
const cached = this.uploadCache.get(file.key);
if (cached) {
uploadResults.push(cached);
host.setAttachmentUploadState(i, 'done');
continue;
}
const resolvedRepoId = await resolveRepoId();
if (!resolvedRepoId) {
throw new Error('No GitHub repository resolved for attachment upload.');
}
host.setAttachmentUploadState(i, 'uploading');
const [result] = await this.githubUploadService.uploadViaMobileApi(
data.githubAccessToken, resolvedRepoId, [file]
);
if (!result) {
throw new Error(`Upload returned no result for ${file.name}.`);
}
this.uploadCache.set(file.key, result);
uploadResults.push(result);
host.setAttachmentUploadState(i, 'done');
}
mediaMarkdown = `\n\n### ${localize('issueReporter.attachmentsHeading', "Attachments")}\n\n`;
for (const r of uploadResults) {
mediaMarkdown += r.contentType.startsWith('video/')
? `${r.assetUrl}\n\n`
: `![${r.fileName}](${r.assetUrl})\n\n`;
}
}
} catch (err) {
this.logService.error('[IssueFormService] Upload failed:', err);
mediaMarkdown = `\n\n### ${localize('issueReporter.attachmentsHeading', "Attachments")}\n\n> ${localize('issueReporter.attachmentsUploadFailed', "Upload failed. Please drag and drop attachments manually.")}\n\n`;
} finally {
host.setUploading(false);
}
}
const issueBody = body + mediaMarkdown;
const baseUrl = this.getIssueUrlWithTitle(title, issueTarget.url, data.issueSource === IssueSource.Extension);
let previewBody = issueBody;
let url = this.createIssuePreviewUrl(baseUrl, previewBody, gitHubDetails, data.issueSource);
if (url.length > MAX_URL_LENGTH && data.githubAccessToken && gitHubDetails) {
const shortenedBody = await this.tryCreateBodyWithIssueDataAttachment(host, issueBody, baseUrl, gitHubDetails, data.issueSource, data.githubAccessToken, resolveRepoId);
if (shortenedBody) {
previewBody = shortenedBody;
url = this.createIssuePreviewUrl(baseUrl, previewBody, gitHubDetails, data.issueSource);
}
}
if (url.length > MAX_URL_LENGTH) {
const shouldWrite = await this.showClipboardDialog();
if (!shouldWrite) {
return false;
}
try {
await this.clipboardService.writeText(issueBody);
} catch (error) {
this.logService.error('Writing issue data to clipboard failed', error);
return false;
}
url = this.createIssuePreviewUrl(baseUrl, localize('pasteData', "We have written the needed data into your clipboard because it was too large to send. Please paste."), gitHubDetails, data.issueSource);
}
// Skip the trusted-domains prompt for github.com URLs the issue reporter
// itself generates. The auto-populated body can produce extremely long
// URLs that overflow the native confirmation dialog on macOS, and the
// destination is one we constructed ourselves so the trust check would
// always be a yes anyway.
//
// Pass the URL as a string (not as a URI). When `IOpenerService.open` is
// handed a URI, it rebuilds the href via `encodeURI(uri.toString(true))`,
// which double-encodes our already-percent-encoded query (`%23` becomes
// `%2523`, so GitHub renders `### Description` as literal `%23%23%23 Description`).
const uri = URI.parse(url);
const skipValidation = uri.scheme === 'https' && uri.authority === 'github.com';
return this.openerService.open(url, { openExternal: true, skipValidation });
}
private async tryCreateBodyWithIssueDataAttachment(
host: IIssueSubmissionHost,
issueBody: string,
baseUrl: string,
gitHubDetails: { owner: string; repositoryName: string },
issueSource: IssueSource | undefined,
githubAccessToken: string,
resolveRepoId: () => Promise<string | undefined>
): Promise<string | undefined> {
const extracted = this.extractIssueData(issueBody);
if (!extracted) {
return undefined;
}
host.setUploading(true);
try {
const repoId = await resolveRepoId();
if (!repoId) {
return undefined;
}
const result = await this.uploadIssueDataFile(githubAccessToken, repoId, extracted.fileContent);
const bodyWithLink = this.createBodyWithIssueDataLink(extracted.body, result.assetUrl);
if (this.createIssuePreviewUrl(baseUrl, bodyWithLink, gitHubDetails, issueSource).length > MAX_URL_LENGTH) {
return undefined;
}
return bodyWithLink;
} catch (error) {
this.logService.error('Uploading issue data attachment failed', error);
return undefined;
} finally {
host.setUploading(false);
}
}
private async uploadIssueDataFile(githubAccessToken: string, repoId: string, fileContent: string): Promise<import('./githubUploadService.js').IGitHubUploadResult> {
const key = `${ISSUE_DATA_ATTACHMENT_NAME}:${hash(fileContent)}`;
const cached = this.uploadCache.get(key);
if (cached) {
return cached;
}
const file: IssueUploadFile = {
key,
name: ISSUE_DATA_ATTACHMENT_NAME,
bytes: new TextEncoder().encode(fileContent),
contentType: 'text/plain',
};
const [result] = await this.githubUploadService.uploadViaMobileApi(githubAccessToken, repoId, [file]);
if (!result) {
throw new Error('Issue data upload did not return a result.');
}
this.uploadCache.set(key, result);
return result;
}
private extractIssueData(issueBody: string): ExtractedIssueData | undefined {
const detailsBlocks: string[] = [];
const body = issueBody.replace(/\n*<details\b[\s\S]*?<\/details>\n*/gi, match => {
detailsBlocks.push(match.trim());
return '\n\n';
}).replace(/\n{3,}/g, '\n\n').trimEnd();
if (!detailsBlocks.length) {
return undefined;
}
return {
body,
fileContent: `# ${localize('issueData', "Issue Data")}\n\n${detailsBlocks.join('\n\n')}\n`,
};
}
private createBodyWithIssueDataLink(body: string, issueDataUrl: string): string {
const attachmentMarkdown = `\n\n### ${localize('additionalIssueData', "Additional Issue Data")}\n\n[${ISSUE_DATA_ATTACHMENT_NAME}](${issueDataUrl})`;
const markerIndex = body.indexOf(GENERATED_BY_ISSUE_REPORTER_MARKER);
if (markerIndex === -1) {
return `${body.trimEnd()}${attachmentMarkdown}\n`;
}
return `${body.slice(0, markerIndex).trimEnd()}${attachmentMarkdown}\n\n${body.slice(markerIndex).trimStart()}`;
}
private createIssuePreviewUrl(baseUrl: string, body: string, gitHubDetails: { owner: string; repositoryName: string } | undefined, issueSource: IssueSource | undefined): string {
const url = `${baseUrl}&body=${encodeURIComponent(body)}`;
return this.addTemplateToUrl(url, gitHubDetails?.owner, gitHubDetails?.repositoryName, issueSource);
}
private getIssueTarget(data: IssueReporterData): { url: string; external: boolean } | undefined {
const selectedExtension = this.getSelectedExtension(data);
if (data.issueSource === IssueSource.Extension && selectedExtension) {
const extensionUrl = this.getExtensionIssueUrl(selectedExtension);
if (!extensionUrl) {
return undefined;
}
return { url: extensionUrl, external: !this.isGitHubUrl(extensionUrl) };
}
if (data.issueSource === IssueSource.Marketplace) {
const marketplaceIssueUrl = product.reportMarketplaceIssueUrl ?? product.reportIssueUrl;
return marketplaceIssueUrl ? { url: marketplaceIssueUrl, external: false } : undefined;
}
if (data.uri) {
const url = URI.revive(data.uri).toString();
return { url, external: !this.isGitHubUrl(url) };
}
if (data.privateUri) {
const url = URI.revive(data.privateUri).toString();
return { url, external: !this.isGitHubUrl(url) };
}
return product.reportIssueUrl ? { url: product.reportIssueUrl, external: false } : undefined;
}
private getSelectedExtension(data: IssueReporterData): IssueReporterExtensionData | undefined {
return data.extensionId
? data.enabledExtensions.find(ext => ext.id.toLowerCase() === data.extensionId?.toLowerCase())
: undefined;
}
private getExtensionIssueUrl(extension: IssueReporterExtensionData): string | undefined {
if (extension.uri) {
return URI.revive(extension.uri).toString();
}
if (extension.bugsUrl && /^https?:\/\/github\.com\/([^\/]*)\/([^\/]*)\/?(\/issues)?\/?$/.test(extension.bugsUrl)) {
return `${normalizeGitHubUrl(extension.bugsUrl)}/issues/new`;
}
if (extension.repositoryUrl && /^https?:\/\/github\.com\/([^\/]*)\/([^\/]*)\/?$/.test(extension.repositoryUrl)) {
return `${normalizeGitHubUrl(extension.repositoryUrl)}/issues/new`;
}
return extension.bugsUrl || extension.repositoryUrl;
}
private isGitHubUrl(url: string): boolean {
return /^https?:\/\/github\.com\//i.test(url);
}
private parseGitHubUrl(url: string): { owner: string; repositoryName: string } | undefined {
const match = /^https?:\/\/github\.com\/([^\/?#]+)\/([^\/?#]+).*/i.exec(url);
if (!match) {
return undefined;
}
return { owner: match[1], repositoryName: match[2] };
}
private getIssueUrlWithTitle(issueTitle: string, issueUrl: string, fileOnExtension: boolean): string {
if (fileOnExtension && !/\/issues\/new(?:[?#].*)?$/i.test(issueUrl)) {
issueUrl = `${normalizeGitHubUrl(issueUrl)}/issues/new`;
}
const queryStringPrefix = issueUrl.indexOf('?') === -1 ? '?' : '&';
return `${issueUrl}${queryStringPrefix}title=${encodeURIComponent(issueTitle)}`;
}
private addTemplateToUrl(baseUrl: string, owner?: string, repositoryName?: string, issueSource?: IssueSource): string {
const needsTemplate = issueSource === IssueSource.VSCode || (owner?.toLowerCase() === 'microsoft' && repositoryName?.toLowerCase() === 'vscode');
if (!needsTemplate) {
return baseUrl;
}
try {
const url = new URL(baseUrl);
url.searchParams.set('template', 'bug_report.md');
return url.toString();
} catch {
return `${baseUrl}&template=bug_report.md`;
}
}
private dataUrlToBytes(dataUrl: string): Uint8Array | undefined {
const commaIndex = dataUrl.indexOf(',');
if (commaIndex === -1) {
return undefined;
}
try {
return decodeBase64(dataUrl.substring(commaIndex + 1)).buffer;
} catch {
return undefined;
}
}
/** Opens the classic non-wizard reporter in an auxiliary window. */
async openAuxIssueReporterLegacy(data: IssueReporterData): Promise<void> {
await this.openAuxIssueReporter(data);
if (this.issueReporterWindow) {
@@ -69,8 +426,10 @@ export class IssueFormService implements IIssueFormService {
let issueReporterBounds: Partial<IRectangle> = { width: 700, height: 800 };
// Center Issue Reporter Window based on bounds from native host service
if (bounds && bounds.x && bounds.y) {
// Center Issue Reporter Window based on bounds from native host service.
// Use typeof checks so an active window at x:0 / y:0 (very common on the primary
// display) still gets centered — a truthy check would miss that case.
if (bounds && typeof bounds.x === 'number' && typeof bounds.y === 'number') {
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
issueReporterBounds = { ...issueReporterBounds, x: centerX - 350, y: centerY - 400 };
@@ -0,0 +1,78 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { URI } from '../../../../base/common/uri.js';
import { EditorInput, IEditorCloseHandler } from '../../../common/editor/editorInput.js';
import { EditorInputCapabilities } from '../../../common/editor.js';
import { ConfirmResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { IssueReporterData } from '../common/issue.js';
import { localize } from '../../../../nls.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
const issueReporterIcon = registerIcon('issue-reporter', Codicon.report, localize('issueReporterIcon', "Icon for the issue reporter editor."));
export class IssueReporterEditorInput extends EditorInput {
static readonly ID = 'workbench.input.issueReporter';
static readonly RESOURCE = URI.from({ scheme: 'vscode-issue-reporter', path: 'reporter' });
readonly data: IssueReporterData | undefined;
/** Set by the editor pane to check if user has entered data */
hasUserInputFn: (() => boolean) | undefined;
override readonly closeHandler: IEditorCloseHandler;
constructor(
data: IssueReporterData | undefined,
@IDialogService private readonly dialogService: IDialogService,
) {
super();
this.data = data;
this.closeHandler = {
showConfirm: () => !!this.hasUserInputFn?.(),
confirm: async () => {
const { confirmed } = await this.dialogService.confirm({
message: localize('discardIssue', "Discard issue report?"),
detail: localize('discardIssueDetail', "Your issue report has unsaved changes that will be lost."),
primaryButton: localize('discard', "Discard"),
type: 'warning',
});
return confirmed ? ConfirmResult.DONT_SAVE : ConfirmResult.CANCEL;
},
};
}
override get typeId(): string {
return IssueReporterEditorInput.ID;
}
override get editorId(): string | undefined {
return this.typeId;
}
override get resource(): URI | undefined {
return IssueReporterEditorInput.RESOURCE;
}
override getName(): string {
return localize('issueReporterEditorInputName', "Report Issue");
}
override getIcon(): ThemeIcon | undefined {
return issueReporterIcon;
}
override matches(other: EditorInput | unknown): boolean {
return other instanceof IssueReporterEditorInput;
}
override get capabilities(): EditorInputCapabilities {
return EditorInputCapabilities.Singleton | EditorInputCapabilities.Readonly;
}
}
@@ -0,0 +1,626 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/issueReporterOverlay.css';
import { $, append, clearNode, Dimension } from '../../../../base/browser/dom.js';
import { mainWindow } from '../../../../base/browser/window.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { localize } from '../../../../nls.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { IStorageService } from '../../../../platform/storage/common/storage.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
import { EditorPane } from '../../../browser/parts/editor/editorPane.js';
import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';
import { IEditorOpenContext } from '../../../common/editor.js';
import { EditorActivation, IEditorOptions } from '../../../../platform/editor/common/editor.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js';
import { URI } from '../../../../base/common/uri.js';
import { FileAccess } from '../../../../base/common/network.js';
import { IssueReporterEditorInput } from './issueReporterEditorInput.js';
import { IssueReporterOverlay } from './issueReporterOverlay.js';
import { IRecordingService, IRecordingData, RecordingState } from './recordingService.js';
import { IScreenshotService } from './screenshotService.js';
import { IIssueFormService } from '../common/issue.js';
import { IProcessService } from '../../../../platform/process/common/process.js';
import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js';
import product from '../../../../platform/product/common/product.js';
import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js';
import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js';
import { ChatMessageRole, ILanguageModelsService, getTextResponseFromStream } from '../../chat/common/languageModels.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { IUpdateService, StateType } from '../../../../platform/update/common/update.js';
import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
import { isMacintosh } from '../../../../base/common/platform.js';
/** Context key that's `true` whenever any IssueReporter editor is open in any group, even when not focused. */
export const IssueReporterOpenContext = new RawContextKey<boolean>('issueReporterOpen', false);
/**
* Editor pane that hosts the issue reporter wizard inside an editor tab.
*/
export class IssueReporterEditorPane extends EditorPane {
static readonly ID = 'workbench.editor.issueReporter';
/**
* Live registry of issue reporter panes so commands can target the wizard
* even when its tab is not the active editor in its group.
* (IEditorService.visibleEditorPanes only exposes the active pane per group.)
*/
private static readonly liveInstances = new Set<IssueReporterEditorPane>();
static getAnyLiveInstance(): IssueReporterEditorPane | undefined {
for (const inst of IssueReporterEditorPane.liveInstances) {
if (inst.wizard) {
return inst;
}
}
return undefined;
}
private container: HTMLElement | undefined;
private wizard: IssueReporterOverlay | undefined;
/** Survives the framework calling clearInput() when the user switches away. */
private wizardInput: IssueReporterEditorInput | undefined;
private readonly inputDisposables = this._register(new DisposableStore());
constructor(
group: IEditorGroup,
@ITelemetryService telemetryService: ITelemetryService,
@IThemeService themeService: IThemeService,
@IStorageService storageService: IStorageService,
@IRecordingService private readonly recordingService: IRecordingService,
@IScreenshotService private readonly screenshotService: IScreenshotService,
@ILogService private readonly logService: ILogService,
@IFileService private readonly fileService: IFileService,
@IEnvironmentService private readonly environmentService: IEnvironmentService,
@IEditorService private readonly editorService: IEditorService,
@IIssueFormService private readonly issueFormService: IIssueFormService,
@IProcessService private readonly processService: IProcessService,
@IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService,
@IUserDataProfileService private readonly userDataProfileService: IUserDataProfileService,
@IContextMenuService private readonly contextMenuService: IContextMenuService,
@IContextViewService private readonly contextViewService: IContextViewService,
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
@INotificationService private readonly notificationService: INotificationService,
@IOpenerService private readonly openerService: IOpenerService,
@IUpdateService private readonly updateService: IUpdateService,
@IKeybindingService private readonly keybindingService: IKeybindingService,
@IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService,
@IExtensionService private readonly extensionService: IExtensionService,
@IConfigurationService private readonly configurationService: IConfigurationService,
) {
super(IssueReporterEditorPane.ID, group, telemetryService, themeService, storageService);
IssueReporterEditorPane.liveInstances.add(this);
this._register({ dispose: () => IssueReporterEditorPane.liveInstances.delete(this) });
}
getWizard(): IssueReporterOverlay | undefined {
return this.wizard;
}
/**
* Bring this pane's tab to the front of its group and activate that group
* so the wizard receives keyboard focus.
*/
async revealAndActivate(): Promise<void> {
const input = this.wizardInput;
if (!input) {
return;
}
this.editorGroupsService.activateGroup(this.group);
await this.editorService.openEditor(input, { activation: EditorActivation.ACTIVATE }, this.group);
}
protected override createEditor(parent: HTMLElement): void {
this.container = append(parent, $('div.issue-reporter-editor-tab'));
this.container.style.height = '100%';
this.container.style.overflow = 'auto';
}
private shouldShowUpdateBanner(): boolean {
return this.updateService.state.type === StateType.AvailableForDownload
|| this.updateService.state.type === StateType.Ready
|| this.updateService.state.type === StateType.Downloaded;
}
override async setInput(
input: IssueReporterEditorInput,
options: IEditorOptions | undefined,
context: IEditorOpenContext,
token: CancellationToken,
): Promise<void> {
await super.setInput(input, options, context, token);
if (token.isCancellationRequested || !this.container) {
return;
}
// Keep our own input reference for revealAndActivate() after clearInput().
this.wizardInput = input;
// If the wizard is already built and its DOM is still attached, re-parent floating bar if needed
if (this.wizard && this.container.contains(this.wizard.getPanel())) {
this.wizard.reparentFloatingBar();
this.wizard.showFloatingBar();
this.wizard.setUpdateAvailable(this.shouldShowUpdateBanner());
return;
}
this.inputDisposables.clear();
clearNode(this.container);
const data = input.data;
if (!data) {
const msg = append(this.container, $('p'));
msg.textContent = localize('noData', "No issue reporter data available.");
return;
}
// Create the wizard — renders inside this container
this.wizard = new IssueReporterOverlay(
data,
this.recordingService.isSupported,
this.container,
this.contextViewService,
this.contextMenuService,
this.markdownRendererService,
true,
extensionId => this.issueFormService.sendReporterMenu(extensionId),
async url => { await this.openerService.open(URI.parse(url), { openExternal: true }); },
this.shouldShowUpdateBanner(),
() => this.refreshPerformanceInfo(),
commandId => this.keybindingService.lookupKeybinding(commandId),
);
this.inputDisposables.add(this.wizard);
this.inputDisposables.add(this.updateService.onStateChange(() => this.wizard?.setUpdateAvailable(this.shouldShowUpdateBanner())));
// Let the input check wizard state for close confirmation
input.hasUserInputFn = () => this.wizard?.hasUnsavedChanges() ?? false;
// Close the editor tab when the user discards
this.inputDisposables.add(this.wizard.onDidClose(() => {
// Reset so close handler doesn't prompt again
input.hasUserInputFn = undefined;
this.group.closeEditor(this.input!);
}));
this.inputDisposables.add(input.onWillDispose(() => {
this.destroyWizard();
}));
this.wizard.show();
// Populate system info in background (non-blocking)
void this.populateSystemInfo();
// Wire screenshot capture
this.inputDisposables.add(this.wizard.onDidRequestScreenshot(async () => {
try {
// Conditionally hide the floating bar based on user setting
const shouldHide = this.wizard?.shouldHideToolbarForCapture ?? true;
if (shouldHide) {
this.wizard?.hideFloatingBar();
// Small delay to let the bar disappear before capture
await new Promise(r => setTimeout(r, 100));
}
const dataUrl = await this.screenshotService.captureScreenshot();
// Show bar again after capture
if (shouldHide) {
setTimeout(() => this.wizard?.showFloatingBar(), 1000);
}
if (!dataUrl || !this.wizard) {
return;
}
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const image = mainWindow.document.createElement('img');
image.onload = () => resolve(image);
image.onerror = reject;
image.src = dataUrl;
});
this.wizard.addScreenshot({ dataUrl, width: img.naturalWidth, height: img.naturalHeight });
// Bring the wizard back into focus after the capture in case
// the user switched editors/groups while setting up the shot.
await this.revealAndActivate();
} catch (err) {
setTimeout(() => this.wizard?.showFloatingBar(), 1000);
this.logService.error('[IssueReporterEditorPane] Screenshot failed:', err);
}
}));
// Wire recording start
this.inputDisposables.add(this.wizard.onDidRequestStartRecording(async () => {
// macOS-only: skip getDisplayMedia when permission is denied and
// surface the grant-permission notification instead.
const permissionState = await this.recordingService.getScreenCapturePermissionStatus();
if (permissionState === 'denied' || permissionState === 'restricted') {
this.showScreenRecordingPermissionNotification();
this.wizard?.setRecordingState(RecordingState.Idle);
return;
}
try {
await this.recordingService.startRecording('video/mp4');
this.wizard?.setRecordingState(RecordingState.Recording);
} catch (err) {
this.logService.error('[IssueReporterEditorPane] Recording failed:', err);
this.wizard?.setRecordingState(RecordingState.Idle);
// Only nudge the user to System Settings on an explicit deny/restrict. On macOS,
// `not-determined` can also mean the user just cancelled the getDisplayMedia
// picker (no TCC decision recorded) — surfacing a permission prompt then would
// be misleading, so we treat that as a silent cancel.
const postState = await this.recordingService.getScreenCapturePermissionStatus();
if (postState === 'denied' || postState === 'restricted') {
this.showScreenRecordingPermissionNotification();
}
}
}));
// Wire recording stop (user-initiated)
this.inputDisposables.add(this.wizard.onDidRequestStopRecording(async () => {
try {
const recordingData = await this.recordingService.stopRecording();
if (recordingData) {
await this.saveRecordingAndAdd(recordingData);
}
this.wizard?.setRecordingState(RecordingState.Idle);
} catch (err) {
this.logService.error('[IssueReporterEditorPane] Stop recording failed:', err);
this.wizard?.setRecordingState(RecordingState.Idle);
}
}));
// Handle auto-stop triggered by the recording service (e.g. size limit reached)
this.inputDisposables.add(this.recordingService.onDidChangeState(async (state) => {
// Only handle auto-stop: if the service stopped on its own while the wizard
// still thinks we're recording (user didn't press Stop manually)
if (state === RecordingState.Stopped && this.wizard?.recordingState === RecordingState.Recording) {
try {
const recordingData = await this.recordingService.stopRecording();
if (recordingData) {
await this.saveRecordingAndAdd(recordingData);
if (recordingData.stoppedBySize) {
this.notificationService.notify({
severity: Severity.Warning,
message: localize('recordingTooLarge', "Recording stopped automatically: the 100 MB upload limit was reached."),
});
}
}
} catch (err) {
this.logService.error('[IssueReporterEditorPane] Auto-stop recording failed:', err);
}
this.wizard?.setRecordingState(RecordingState.Idle);
}
}));
// Wire open screenshot — save to temp file and open in editor
this.inputDisposables.add(this.wizard.onDidRequestOpenScreenshot(async (screenshot) => {
try {
const dataUrl = screenshot.annotatedDataUrl ?? screenshot.dataUrl;
const commaIndex = dataUrl.indexOf(',');
if (commaIndex === -1) {
return;
}
// Screenshots are either annotated (always PNG via canvas.toDataURL)
// or raw native captures (always JPEG); fall back to PNG.
const extension = dataUrl.startsWith('data:image/jpeg') ? 'jpg' : 'png';
const folder = URI.joinPath(this.environmentService.userRoamingDataHome, 'issue-screenshots');
const target = URI.joinPath(folder, `screenshot-${Date.now()}.${extension}`);
await this.fileService.createFolder(folder);
await this.fileService.writeFile(target, decodeBase64(dataUrl.substring(commaIndex + 1)));
await this.editorService.openEditor({ resource: target });
} catch (err) {
this.logService.error('[IssueReporterEditorPane] Open screenshot failed:', err);
}
}));
// Wire open recording — open file in editor
this.inputDisposables.add(this.wizard.onDidRequestOpenRecording(async (filePath) => {
try {
await this.editorService.openEditor({ resource: URI.file(filePath) });
} catch (err) {
this.logService.error('[IssueReporterEditorPane] Open recording failed:', err);
}
}));
// Wire submit — delegate to form service for upload + open URL
this.inputDisposables.add(this.wizard.onDidSubmit(async ({ title, body }) => {
if (!this.wizard) {
return;
}
const opened = await this.issueFormService.submitIssue(this.wizard, data, title, body);
if (opened) {
// User opened the link — keep the wizard editable, but offer an explicit close action.
this.wizard.markPreviewOpened();
this.wizard.showCloseButton();
}
}));
// Wire AI title generation
this.inputDisposables.add(this.wizard.onDidRequestGenerateTitle(async (description) => {
try {
// Wait for installed extensions to be registered so the Copilot Chat
// extension has had a chance to contribute its `copilot` language
// model vendor before we try to resolve a model. (Other call sites
// like the chat thinking title generator are reached after Copilot
// has already activated; we're the only place that can be invoked
// before it has.)
await this.extensionService.whenInstalledExtensionsRegistered();
// `copilot-utility-small` matches what other utility callers in the
// workbench use (chat thinking summaries, tool-risk assessment,
// chat-edit explanations). The earlier `copilot-fast` id never
// existed and was the root cause of the empty-result regression.
const modelIds = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-utility-small' });
if (modelIds.length === 0) {
this.logService.warn('[IssueReporterEditorPane] No language models available for title generation');
this.wizard?.resetGenerateButton();
return;
}
const modelId = modelIds[0];
const response = await this.languageModelsService.sendChatRequest(
modelId,
undefined,
[{
role: ChatMessageRole.User,
content: [{
type: 'text',
value: `Generate a concise issue title (max 10 words, no quotes, no prefix like "Bug:" or "Feature:") for this bug report description:\n\n${description}`,
}],
}],
{},
CancellationToken.None,
);
const title = (await getTextResponseFromStream(response)).trim().replace(/^["']|["']$/g, '');
if (title && this.wizard) {
this.wizard.setGeneratedTitle(title);
} else {
this.wizard?.resetGenerateButton();
}
} catch (err) {
this.logService.error('[IssueReporterEditorPane] Title generation failed:', err);
this.wizard?.resetGenerateButton();
}
}));
}
private async fetchPerformanceInfo(options?: { skipCache?: boolean; unbounded?: boolean }): Promise<void> {
if (!this.wizard) {
return;
}
try {
const performanceInfo = await this.processService.getPerformanceInfo(options);
this.wizard.updateModel({
processInfo: performanceInfo.processInfo,
workspaceInfo: performanceInfo.workspaceInfo,
});
} catch (err) {
this.logService.error('[IssueReporterEditorPane] Failed to fetch performance info:', err);
} finally {
this.wizard?.markPerformanceInfoLoaded();
}
}
private async refreshPerformanceInfo(): Promise<void> {
// User-initiated refresh: bypass the workspace-stats cache and walk the
// full filesystem (no cap) so the reported file counts and file-type
// breakdown reflect the actual workspace.
await this.fetchPerformanceInfo({ skipCache: true, unbounded: true });
}
private async populateSystemInfo(): Promise<void> {
if (!this.wizard) {
return;
}
const input = this.input as IssueReporterEditorInput | undefined;
const data = input?.data;
try {
// Version info
const vscodeVersion = `${product.nameShort} ${!!product.darwinUniversalAssetId ? `${product.version} (Universal)` : product.version} (${product.commit || 'Commit unknown'}, ${product.date || 'Date unknown'})`;
const systemInfo = await this.processService.getSystemInfo();
this.wizard.updateModel({
versionInfo: { vscodeVersion, os: systemInfo.os },
systemInfo,
systemInfoWeb: navigator.userAgent,
});
// Honour `issueReporter.wizard.fullWorkspaceScan` only on the automatic
// (initial) collection. The user-initiated refresh below is always
// unbounded — the user has explicitly asked for fresh data and the
// button shows a spinner while it runs.
const fullScan = this.configurationService.getValue<boolean>('issueReporter.wizard.fullWorkspaceScan') !== false;
await this.fetchPerformanceInfo({ unbounded: fullScan });
} catch (err) {
this.logService.error('[IssueReporterEditorPane] Failed to collect system info:', err);
this.wizard?.markPerformanceInfoLoaded();
}
// Experiments (independent from system info)
try {
const experiments = await this.experimentService.getCurrentExperiments();
this.wizard?.updateModel({ experimentInfo: experiments?.join('\n') ?? localize('noExperiments', "No current experiments.") });
} catch {
// Ignore
}
// Wait for the issue service to finish enumerating installed extensions
// (it kicks off enumeration in parallel with this pane opening).
await data?.whenExtensionsLoaded;
if (data && data.enabledExtensions.length > 0) {
const nonTheme = data.enabledExtensions.filter(e => !e.isTheme && !e.isBuiltin);
const themeCount = data.enabledExtensions.filter(e => e.isTheme).length;
this.wizard?.updateModel({
allExtensions: data.enabledExtensions,
enabledNonThemeExtesions: nonTheme,
numberOfThemeExtesions: themeCount,
});
}
// User settings
try {
const settingsUri = this.userDataProfileService.currentProfile.settingsResource;
const settingsContent = await this.fileService.readFile(settingsUri);
this.wizard?.setSettingsContent(settingsContent.value.toString());
} catch {
// Ignore — no settings file
}
}
private destroyWizard(): void {
// Stop any active recording to avoid memory leaks
if (this.recordingService.state === RecordingState.Recording) {
this.recordingService.discardRecording();
}
this.inputDisposables.clear();
this.wizard = undefined;
this.wizardInput = undefined;
if (this.container) {
clearNode(this.container);
}
}
/**
* Surface a notification telling the user how to grant Screen Recording
* permission. On macOS, includes a deep-link to System Settings.
*/
private showScreenRecordingPermissionNotification(): void {
if (isMacintosh) {
this.notificationService.prompt(
Severity.Warning,
localize('screenRecordingPermissionDenied', "{0} needs Screen Recording permission to record videos. Grant access in System Settings, then click Record again.", product.nameShort),
[
{
label: localize('openSystemSettings', "Open System Settings"),
run: () => {
this.recordingService.openScreenCapturePermissionSettings();
},
},
],
);
} else {
this.notificationService.warn(
localize('screenRecordingPermissionDeniedGeneric', "Screen recording permission was denied. Allow {0} to record the screen and try again.", product.nameShort)
);
}
}
override focus(): void {
super.focus();
this.wizard?.focus();
}
private async saveRecordingAndAdd(data: IRecordingData): Promise<void> {
try {
const extension = data.mimeType.startsWith('video/mp4') ? 'mp4' : 'webm';
const fileName = `vscode-recording-${new Date().toISOString().replace(/[:.]/g, '-')}.${extension}`;
const folder = URI.joinPath(this.environmentService.userRoamingDataHome, 'issue-recordings');
const target = URI.joinPath(folder, fileName);
const arrayBuffer = await data.blob.arrayBuffer();
await this.fileService.createFolder(folder);
await this.fileService.writeFile(target, VSBuffer.wrap(new Uint8Array(arrayBuffer)));
this.logService.info(`[IssueReporterEditorPane] Recording saved to ${target.toString()}`);
// Generate thumbnail from the saved file — blob URLs are blocked by
// Electron's CSP for media elements, so we use the saved file via
// the vscode-file:// protocol which the renderer can load.
const thumbnailDataUrl = await this.generateVideoThumbnail(target);
this.wizard?.addRecording(target.fsPath, data.durationMs, thumbnailDataUrl);
} catch (err) {
this.logService.error('[IssueReporterEditorPane] Failed to save recording:', err);
}
}
private generateVideoThumbnail(fileUri: URI): Promise<string | undefined> {
// The fileUri may use the vscode-userdata: scheme. Convert to a real
// file:// URI via fsPath, then to vscode-file://vscode-app/ so the
// renderer's CSP allows loading it as a media source.
const browserUri = FileAccess.uriToBrowserUri(URI.file(fileUri.fsPath));
return new Promise(resolve => {
const video = mainWindow.document.createElement('video');
const timeout = setTimeout(() => finish(undefined), 5000);
let resolved = false;
const finish = (result: string | undefined) => {
if (resolved) { return; }
resolved = true;
clearTimeout(timeout);
video.pause();
video.removeAttribute('src');
video.load();
video.remove();
resolve(result);
};
const captureFrame = () => {
try {
if (!video.videoWidth || !video.videoHeight) {
finish(undefined);
return;
}
const canvas = mainWindow.document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
finish(undefined);
return;
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
finish(canvas.toDataURL('image/jpeg', 0.7));
} catch {
finish(undefined);
}
};
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:320px;height:240px;opacity:0;pointer-events:none;';
mainWindow.document.body.appendChild(video);
video.src = browserUri.toString(true);
video.addEventListener('loadeddata', () => {
video.pause();
const duration = Number.isFinite(video.duration) ? video.duration : 0;
if (duration > 0.5) {
video.addEventListener('seeked', () => captureFrame(), { once: true });
try {
video.currentTime = Math.min(0.5, duration / 2);
} catch {
captureFrame();
}
return;
}
captureFrame();
}, { once: true });
video.addEventListener('error', () => finish(undefined), { once: true });
video.load();
});
}
override layout(dimension: Dimension): void {
if (this.container) {
this.container.style.width = `${dimension.width}px`;
this.container.style.height = `${dimension.height}px`;
}
}
}
@@ -0,0 +1,112 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as dom from '../../../../base/browser/dom.js';
import { mainWindow } from '../../../../base/browser/window.js';
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
import { Event } from '../../../../base/common/event.js';
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { isMacintosh } from '../../../../base/common/platform.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { IssueReporterEditorInput } from './issueReporterEditorInput.js';
import { IssueReporterEditorPane, IssueReporterOpenContext } from './issueReporterEditorPane.js';
import { IssueReporterOverlay } from './issueReporterOverlay.js';
export const ISSUE_REPORTER_CAPTURE_SCREENSHOT_COMMAND_ID = 'workbench.action.issueReporter.captureScreenshot';
export const ISSUE_REPORTER_TOGGLE_RECORDING_COMMAND_ID = 'workbench.action.issueReporter.toggleRecording';
/**
* Watches the editor service to keep the `issueReporterOpen` context key in
* sync, and installs a capture-phase key listener on every window so the issue
* reporter shortcuts beat overlays/widgets that swallow key events (e.g. the
* keybinding-recording widget inside the Keyboard Shortcuts editor or any
* focused input that calls `stopPropagation()`). The capture-phase listener
* only intercepts when the issue reporter is actually open, otherwise default
* behavior (Save As, etc.) is preserved.
*/
class IssueReporterOpenStateContribution extends Disposable {
static readonly ID = 'workbench.contrib.issueReporterOpenState';
private issueReporterOpen = false;
constructor(
@IEditorService private readonly editorService: IEditorService,
@IContextKeyService contextKeyService: IContextKeyService,
@ICommandService private readonly commandService: ICommandService,
) {
super();
const ctx = IssueReporterOpenContext.bindTo(contextKeyService);
const update = () => {
this.issueReporterOpen = this.editorService.editors.some(e => e instanceof IssueReporterEditorInput);
ctx.set(this.issueReporterOpen);
};
this._register(this.editorService.onDidEditorsChange(update));
update();
this._register(Event.runAndSubscribe(dom.onDidRegisterWindow, ({ window, disposables }) => {
disposables.add(dom.addDisposableListener(window, dom.EventType.KEY_DOWN, e => this.dispatchCapturePhase(e), true /* capture */));
}, { window: mainWindow, disposables: this._store }));
}
private dispatchCapturePhase(e: KeyboardEvent): void {
if (!this.issueReporterOpen) {
return;
}
const evt = new StandardKeyboardEvent(e);
const primaryMod = isMacintosh ? evt.metaKey : evt.ctrlKey;
const otherMod = isMacintosh ? evt.ctrlKey : evt.metaKey;
if (!primaryMod || !evt.shiftKey || evt.altKey || otherMod) {
return;
}
let commandId: string | undefined;
if (evt.keyCode === KeyCode.KeyS) {
commandId = ISSUE_REPORTER_CAPTURE_SCREENSHOT_COMMAND_ID;
} else if (evt.keyCode === KeyCode.KeyR) {
commandId = ISSUE_REPORTER_TOGGLE_RECORDING_COMMAND_ID;
}
if (!commandId) {
return;
}
e.preventDefault();
e.stopPropagation();
void this.commandService.executeCommand(commandId);
}
}
registerWorkbenchContribution2(IssueReporterOpenStateContribution.ID, IssueReporterOpenStateContribution, WorkbenchPhase.AfterRestored);
function withWizard(fn: (pane: IssueReporterEditorPane, wizard: IssueReporterOverlay) => void): void {
// Look up any live issue reporter pane regardless of whether its tab is the
// active editor in its group. visibleEditorPanes only exposes the active
// pane per group, so we can't rely on it when the user has switched to
// another tab to set up a screenshot.
const pane = IssueReporterEditorPane.getAnyLiveInstance();
const wizard = pane?.getWizard();
if (pane && wizard) {
fn(pane, wizard);
}
}
KeybindingsRegistry.registerCommandAndKeybindingRule({
id: ISSUE_REPORTER_CAPTURE_SCREENSHOT_COMMAND_ID,
weight: KeybindingWeight.WorkbenchContrib,
when: IssueReporterOpenContext,
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyS,
handler: () => withWizard((_pane, wizard) => wizard.triggerCaptureScreenshot()),
});
KeybindingsRegistry.registerCommandAndKeybindingRule({
id: ISSUE_REPORTER_TOGGLE_RECORDING_COMMAND_ID,
weight: KeybindingWeight.WorkbenchContrib,
when: IssueReporterOpenContext,
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyR,
handler: () => withWizard((_pane, wizard) => wizard.triggerToggleRecording()),
});
@@ -5,7 +5,7 @@
import { mainWindow } from '../../../../base/browser/window.js';
import { isRemoteDiagnosticError, SystemInfo } from '../../../../platform/diagnostics/common/diagnostics.js';
import { ISettingSearchResult, IssueReporterExtensionData, IssueType } from '../common/issue.js';
import { ISettingSearchResult, IssueReporterExtensionData, IssueSource, IssueType } from '../common/issue.js';
interface VersionInfo {
vscodeVersion: string;
@@ -14,6 +14,7 @@ interface VersionInfo {
export interface IssueReporterData {
issueType: IssueType;
issueSource?: IssueSource;
issueDescription?: string;
issueTitle?: string;
extensionData?: string;
@@ -44,7 +45,7 @@ export interface IssueReporterData {
filterResultCount?: number;
experimentInfo?: string;
restrictedMode?: boolean;
isUnsupported?: boolean;
isInstallationPure?: boolean;
isSessionsWindow?: boolean;
}
@@ -88,7 +89,7 @@ export class IssueReporterModel {
if (this._data.restrictedMode) {
modes.push('Restricted');
}
if (this._data.isUnsupported) {
if (this._data.isInstallationPure === false) {
modes.push('Unsupported');
}
return `
@@ -146,20 +147,12 @@ ${this.getInfos()}
return info;
}
const isBugOrPerformanceIssue = this._data.issueType === IssueType.Bug || this._data.issueType === IssueType.PerformanceIssue;
if (this._data.includeExtensionData && this._data.extensionData) {
info += this.getExtensionData();
}
if (isBugOrPerformanceIssue) {
if (this._data.includeExtensionData && this._data.extensionData) {
info += this.getExtensionData();
}
if (this._data.includeSystemInfo && this._data.systemInfo) {
info += this.generateSystemInfoMd();
}
if (this._data.includeSystemInfo && this._data.systemInfoWeb) {
info += 'System Info: ' + this._data.systemInfoWeb;
}
if (this._data.includeSystemInfo && this._data.systemInfo) {
info += this.generateSystemInfoMd();
}
if (this._data.issueType === IssueType.PerformanceIssue) {
@@ -172,14 +165,12 @@ ${this.getInfos()}
}
}
if (isBugOrPerformanceIssue) {
if (!this._data.fileOnExtension && this._data.includeExtensions) {
info += this.generateExtensionsMd();
}
if (!this._data.fileOnExtension && this._data.includeExtensions) {
info += this.generateExtensionsMd();
}
if (this._data.includeExperiments && this._data.experimentInfo) {
info += this.generateExperimentsInfoMd();
}
if (this._data.includeExperiments && this._data.experimentInfo) {
info += this.generateExperimentsInfoMd();
}
return info;
@@ -207,6 +198,10 @@ ${this.getInfos()}
|Screen Reader|${this._data.systemInfo.screenReader}|
|VM|${this._data.systemInfo.vmHint}|`;
if (this._data.systemInfoWeb) {
md += `\n|User Agent|${this._data.systemInfoWeb}|`;
}
if (this._data.systemInfo.linuxEnv) {
md += `\n|DESKTOP_SESSION|${this._data.systemInfo.linuxEnv.desktopSession}|
|XDG_CURRENT_DESKTOP|${this._data.systemInfo.linuxEnv.xdgCurrentDesktop}|
@@ -277,24 +272,29 @@ ${this._data.experimentInfo}
return 'Extensions disabled';
}
const themeExclusionStr = this._data.numberOfThemeExtesions ? `\n(${this._data.numberOfThemeExtesions} theme extensions excluded)` : '';
if (!this._data.enabledNonThemeExtesions) {
return 'Extensions: none' + themeExclusionStr;
if (!this._data.enabledNonThemeExtesions || this._data.enabledNonThemeExtesions.length === 0) {
if (!this._data.numberOfThemeExtesions) {
return 'Extensions: none';
}
}
const tableHeader = `Extension|Author (truncated)|Version
---|---|---`;
const table = this._data.enabledNonThemeExtesions.map(e => {
return `${e.name}|${e.publisher?.substr(0, 3) ?? 'N/A'}|${e.version}`;
}).join('\n');
let md = '';
const tableHeader = `Name|Identifier|Author|Version
---|---|---|---`;
return `<details><summary>Extensions (${this._data.enabledNonThemeExtesions.length})</summary>
if (this._data.enabledNonThemeExtesions && this._data.enabledNonThemeExtesions.length > 0) {
const table = this._data.enabledNonThemeExtesions.map(e => {
return `${e.displayName || e.name}|${e.id}|${e.publisher ?? 'N/A'}|${e.version}`;
}).join('\n');
md += `<details><summary>Extensions (${this._data.enabledNonThemeExtesions.length})</summary>
${tableHeader}
${table}
${themeExclusionStr}
</details>`;
}
return md;
}
}
File diff suppressed because it is too large Load Diff
@@ -81,10 +81,11 @@ export class BrowserIssueService implements IWorkbenchIssueService {
// Ignore
}
// air on the side of caution and have false be the default
let isUnsupported = false;
// Default to true (pure) so an integrity-check failure doesn't push an
// inaccurate `Modes: ..., Unsupported` line into the issue body.
let isInstallationPure = true;
try {
isUnsupported = !(await this.integrityService.isPure()).isPure;
isInstallationPure = (await this.integrityService.isPure()).isPure;
} catch (e) {
// Ignore
}
@@ -134,7 +135,7 @@ export class BrowserIssueService implements IWorkbenchIssueService {
enabledExtensions: extensionData,
experiments: experiments?.join('\n'),
restrictedMode: !this.workspaceTrustManagementService.isWorkspaceTrusted(),
isUnsupported,
isInstallationPure,
isSessionsWindow: this.environmentService.isSessionsWindow,
githubAccessToken
}, options);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Event } from '../../../../base/common/event.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
export interface IRecordingData {
/** The raw video data as a Blob. */
readonly blob: Blob;
/** MIME type of the recording, e.g. 'video/webm'. */
readonly mimeType: string;
/** Duration in milliseconds. */
readonly durationMs: number;
/** File size in bytes. */
readonly sizeBytes: number;
/** True if the recording was automatically stopped because it hit the file size limit. */
readonly stoppedBySize?: boolean;
}
export const enum RecordingState {
Idle = 'idle',
Recording = 'recording',
Stopped = 'stopped',
}
export const IRecordingService = createDecorator<IRecordingService>('recordingService');
export interface IRecordingService {
readonly _serviceBrand: undefined;
/** Whether recording is supported on this platform. */
readonly isSupported: boolean;
/** Current recording state. */
readonly state: RecordingState;
/** Fires when recording state changes. */
readonly onDidChangeState: Event<RecordingState>;
/**
* Returns the list of supported recording MIME types on this platform.
*/
getSupportedFormats(): { mimeType: string; label: string; extension: string }[];
/**
* Start recording the current window.
* @param mimeType Optional preferred MIME type (e.g. 'video/mp4'). Falls back to default if unsupported.
* Rejects if recording is not supported or already in progress.
*/
startRecording(mimeType?: string): Promise<void>;
/**
* Stop the current recording.
* Returns the recorded data, or undefined if no recording was in progress.
*/
stopRecording(): Promise<IRecordingData | undefined>;
/**
* Discard the current recording without saving.
*/
discardRecording(): void;
/**
* Returns the current OS screen-capture permission status. On platforms where this
* concept doesn't apply (e.g. web) implementations return 'granted' so callers can
* proceed straight to the recording flow.
*/
getScreenCapturePermissionStatus(): Promise<'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'>;
/**
* Opens the OS-level UI for granting screen-capture permission. No-op on platforms
* where this isn't applicable.
*/
openScreenCapturePermissionSettings(): void;
}
/**
* Browser fallback recording not available in web.
*/
export class BrowserRecordingService implements IRecordingService {
readonly _serviceBrand: undefined;
readonly isSupported = false;
readonly state = RecordingState.Idle;
readonly onDidChangeState = Event.None;
getSupportedFormats(): { mimeType: string; label: string; extension: string }[] {
return [];
}
async startRecording(_mimeType?: string): Promise<void> {
throw new Error('Recording is not supported in web browsers.');
}
async stopRecording(): Promise<IRecordingData | undefined> {
return undefined;
}
discardRecording(): void {
// No-op
}
async getScreenCapturePermissionStatus(): Promise<'granted'> {
return 'granted';
}
openScreenCapturePermissionSettings(): void {
// No-op
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IRectangle } from '../../../../platform/window/common/window.js';
export const IScreenshotService = createDecorator<IScreenshotService>('screenshotService');
export interface IScreenshotService {
readonly _serviceBrand: undefined;
/**
* Captures a screenshot of the current window, optionally within a specified rectangle.
* Returns a JPEG data URL, or undefined if capture is not supported.
*/
captureScreenshot(rect?: IRectangle): Promise<string | undefined>;
}
/**
* Browser fallback screenshot not available in web.
*/
export class BrowserScreenshotService implements IScreenshotService {
readonly _serviceBrand: undefined;
async captureScreenshot(_rect?: IRectangle): Promise<string | undefined> {
// Screen capture is not available in web browsers without permission APIs.
return undefined;
}
}
+23 -2
View File
@@ -26,7 +26,8 @@ export const enum IssueType {
export enum IssueSource {
VSCode = 'vscode',
Extension = 'extension',
Marketplace = 'marketplace'
Marketplace = 'marketplace',
Unknown = 'unknown'
}
export interface IssueReporterStyles extends WindowStyles {
@@ -67,12 +68,18 @@ export interface IssueReporterExtensionData {
export interface IssueReporterData extends WindowData {
styles: IssueReporterStyles;
enabledExtensions: IssueReporterExtensionData[];
/**
* Resolves once `enabledExtensions` has been populated (or failed to populate).
* Lets the wizard pane wait for the async extension enumeration in
* `NativeIssueService` to finish before rendering the extensions section.
*/
whenExtensionsLoaded?: Promise<void>;
issueType?: IssueType;
issueSource?: IssueSource;
extensionId?: string;
experiments?: string;
restrictedMode: boolean;
isUnsupported: boolean;
isInstallationPure: boolean;
isSessionsWindow: boolean;
githubAccessToken: string;
issueTitle?: string;
@@ -90,6 +97,19 @@ export interface ISettingSearchResult {
export const IIssueFormService = createDecorator<IIssueFormService>('issueFormService');
/**
* Narrow surface of the issue reporter wizard that `IIssueFormService.submitIssue`
* relies on. Keeping this in `common/` (rather than depending on the browser-side
* `IssueReporterOverlay` class) lets the service interface be implemented and
* consumed cleanly across layers.
*/
export interface IIssueSubmissionHost {
getScreenshots(): readonly { readonly dataUrl: string; readonly annotatedDataUrl?: string }[];
getRecordings(): readonly { readonly filePath: string }[];
setUploading(uploading: boolean): void;
setAttachmentUploadState(index: number, state: 'pending' | 'uploading' | 'done'): void;
}
export interface IIssueFormService {
readonly _serviceBrand: undefined;
@@ -100,6 +120,7 @@ export interface IIssueFormService {
showClipboardDialog(): Promise<boolean>;
sendReporterMenu(extensionId: string): Promise<IssueReporterData | undefined>;
closeReporter(): Promise<void>;
submitIssue(host: IIssueSubmissionHost, data: IssueReporterData, title: string, body: string): Promise<boolean>;
}
export const IWorkbenchIssueService = createDecorator<IWorkbenchIssueService>('workbenchIssueService');
@@ -9,6 +9,7 @@ import { Categories } from '../../../../platform/action/common/actionCommonCateg
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { CommandsRegistry } from '../../../../platform/commands/common/commands.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { IProcessService } from '../../../../platform/process/common/process.js';
@@ -16,17 +17,61 @@ import { IProductService } from '../../../../platform/product/common/productServ
import { IQuickAccessRegistry, Extensions as QuickAccessExtensions } from '../../../../platform/quickinput/common/quickAccess.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
import { Extensions, IWorkbenchContributionsRegistry } from '../../../common/contributions.js';
import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js';
import { EditorExtensions } from '../../../common/editor.js';
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js';
import { IssueQuickAccess } from '../browser/issueQuickAccess.js';
import '../browser/issueTroubleshoot.js';
import '../browser/issueReporterKeybindings.js';
import { BaseIssueContribution } from '../common/issue.contribution.js';
import { IIssueFormService, IWorkbenchIssueService, IssueType } from '../common/issue.js';
import { NativeIssueService } from './issueService.js';
import { NativeIssueFormService } from './nativeIssueFormService.js';
import { IScreenshotService } from '../browser/screenshotService.js';
import { NativeScreenshotService } from './nativeScreenshotService.js';
import { IRecordingService } from '../browser/recordingService.js';
import { NativeRecordingService } from './nativeRecordingService.js';
import { IGitHubUploadService } from '../browser/githubUploadService.js';
import { NativeGitHubUploadService } from './nativeGitHubUploadService.js';
import { IssueReporterEditorPane } from '../browser/issueReporterEditorPane.js';
import { IssueReporterEditorInput } from '../browser/issueReporterEditorInput.js';
//#region Issue Contribution
registerSingleton(IWorkbenchIssueService, NativeIssueService, InstantiationType.Delayed);
registerSingleton(IIssueFormService, NativeIssueFormService, InstantiationType.Delayed);
registerSingleton(IScreenshotService, NativeScreenshotService, InstantiationType.Delayed);
registerSingleton(IRecordingService, NativeRecordingService, InstantiationType.Delayed);
registerSingleton(IGitHubUploadService, NativeGitHubUploadService, InstantiationType.Delayed);
// Settings
Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({
id: 'issueReporter',
title: localize('issueReporterConfigurationTitle', "Issue Reporter"),
type: 'object',
properties: {
'issueReporter.wizard.enabled': {
type: 'boolean',
default: false,
description: localize('issueReporter.wizard.enabled', "Enable the new issue reporter wizard instead of the classic issue reporter."),
},
'issueReporter.wizard.fullWorkspaceScan': {
type: 'boolean',
default: true,
description: localize('issueReporter.wizard.fullWorkspaceScan', "When auto-collecting performance diagnostics for the issue reporter wizard, walk the full workspace instead of stopping at the default 20,000-file cap. Set to false on very large workspaces if the scan slows the initial wizard render."),
},
}
});
// Editor pane for tab display mode
Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(
EditorPaneDescriptor.create(
IssueReporterEditorPane,
IssueReporterEditorPane.ID,
localize('issueReporterEditorPaneTitle', "Issue Reporter")
),
[new SyncDescriptor(IssueReporterEditorInput)]
);
class NativeIssueContribution extends BaseIssueContribution {
@@ -79,7 +79,7 @@ export class IssueReporter extends BaseIssueReporterService {
applyZoom(this.data.zoomLevel, this.window);
this.updateExperimentsInfo(this.data.experiments);
this.updateRestrictedMode(this.data.restrictedMode);
this.updateUnsupportedMode(this.data.isUnsupported);
this.updateInstallationPureMode(this.data.isInstallationPure);
}
private async checkForUpdates(): Promise<void> {
@@ -357,8 +357,8 @@ export class IssueReporter extends BaseIssueReporterService {
this.issueReporterModel.update({ restrictedMode });
}
private updateUnsupportedMode(isUnsupported: boolean) {
this.issueReporterModel.update({ isUnsupported });
private updateInstallationPureMode(isInstallationPure: boolean) {
this.issueReporterModel.update({ isInstallationPure });
}
private updateExperimentsInfo(experimentInfo: string | undefined) {
@@ -5,6 +5,8 @@
import { getZoomLevel } from '../../../../base/browser/browser.js';
import { mainWindow } from '../../../../base/browser/window.js';
import { DeferredPromise } from '../../../../base/common/async.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IExtensionManagementService } from '../../../../platform/extensionManagement/common/extensionManagement.js';
import { ExtensionType } from '../../../../platform/extensions/common/extensions.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
@@ -32,9 +34,41 @@ export class NativeIssueService implements IWorkbenchIssueService {
@IAuthenticationService private readonly authenticationService: IAuthenticationService,
@IIntegrityService private readonly integrityService: IIntegrityService,
@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,
@IConfigurationService private readonly configurationService: IConfigurationService,
) { }
async openReporter(dataOverrides: Partial<IssueReporterData> = {}): Promise<void> {
const useWizard = this.configurationService.getValue<boolean>('issueReporter.wizard.enabled');
if (useWizard) {
// New wizard: show UI immediately, load data in background
return this.openWizardReporter(dataOverrides);
} else {
// Old reporter: collect all data first, then open
return this.openLegacyReporter(dataOverrides);
}
}
private async openWizardReporter(dataOverrides: Partial<IssueReporterData>): Promise<void> {
const theme = this.themeService.getColorTheme();
const extensionsLoaded = new DeferredPromise<void>();
const issueReporterData: IssueReporterData = Object.assign({
styles: getIssueReporterStyles(theme),
zoomLevel: getZoomLevel(mainWindow),
enabledExtensions: [],
whenExtensionsLoaded: extensionsLoaded.p,
restrictedMode: !this.workspaceTrustManagementService.isWorkspaceTrusted(),
isInstallationPure: true,
isSessionsWindow: this.environmentService.isSessionsWindow,
githubAccessToken: '',
}, dataOverrides);
const openPromise = this.issueFormService.openReporter(issueReporterData);
void this.populateReporterDataAsync(issueReporterData, dataOverrides, extensionsLoaded);
return openPromise;
}
private async openLegacyReporter(dataOverrides: Partial<IssueReporterData>): Promise<void> {
const extensionData: IssueReporterExtensionData[] = [];
try {
const extensions = await this.extensionManagementService.getInstalled();
@@ -60,34 +94,36 @@ export class NativeIssueService implements IWorkbenchIssueService {
};
}));
} catch (e) {
// Surface the load failure in the issue body so triage doesn't mistake an
// empty list for "no extensions installed".
extensionData.push({
name: 'Workbench Issue Service',
publisher: 'Unknown',
version: '0.0.0',
name: 'Extensions not loaded',
publisher: undefined,
version: '',
id: 'extensions-load-error',
isTheme: false,
isBuiltin: false,
displayName: undefined,
repositoryUrl: undefined,
bugsUrl: undefined,
extensionData: 'Extensions data loading',
displayName: `Extensions not loaded: ${e}`,
id: 'workbench.issue',
isTheme: false,
isBuiltin: true
extensionData: `Extensions could not be loaded: ${e instanceof Error ? e.message : String(e)}`,
});
}
const experiments = await this.experimentService.getCurrentExperiments();
let githubAccessToken = '';
try {
const githubSessions = await this.authenticationService.getSessions('github');
const potentialSessions = githubSessions.filter(session => session.scopes.includes('repo'));
githubAccessToken = potentialSessions[0]?.accessToken;
const repoSession = githubSessions.find(session => session.scopes.includes('repo'));
githubAccessToken = repoSession?.accessToken ?? '';
} catch (e) {
// Ignore
}
// air on the side of caution and have false be the default
let isUnsupported = false;
let isInstallationPure = true;
try {
isUnsupported = !(await this.integrityService.isPure()).isPure;
isInstallationPure = (await this.integrityService.isPure()).isPure;
} catch (e) {
// Ignore
}
@@ -99,14 +135,70 @@ export class NativeIssueService implements IWorkbenchIssueService {
enabledExtensions: extensionData,
experiments: experiments?.join('\n'),
restrictedMode: !this.workspaceTrustManagementService.isWorkspaceTrusted(),
isUnsupported,
isInstallationPure,
isSessionsWindow: this.environmentService.isSessionsWindow,
githubAccessToken
githubAccessToken,
}, dataOverrides);
return this.issueFormService.openReporter(issueReporterData);
}
private async populateReporterDataAsync(data: IssueReporterData, dataOverrides: Partial<IssueReporterData>, extensionsLoaded?: DeferredPromise<void>): Promise<void> {
// Extensions
try {
const extensions = await this.extensionManagementService.getInstalled();
const enabledExtensions = extensions.filter(extension => this.extensionEnablementService.isEnabled(extension) || (dataOverrides.extensionId && extension.identifier.id === dataOverrides.extensionId));
data.enabledExtensions = enabledExtensions.map((extension): IssueReporterExtensionData => {
const { manifest } = extension;
const manifestKeys = manifest.contributes ? Object.keys(manifest.contributes) : [];
const isTheme = !manifest.main && !manifest.browser && manifestKeys.length === 1 && manifestKeys[0] === 'themes';
const isBuiltin = extension.type === ExtensionType.System;
return {
name: manifest.name,
publisher: manifest.publisher,
version: manifest.version,
repositoryUrl: manifest.repository && manifest.repository.url,
bugsUrl: manifest.bugs && manifest.bugs.url,
displayName: manifest.displayName,
id: extension.identifier.id,
data: dataOverrides.data,
uri: dataOverrides.uri,
isTheme,
isBuiltin,
extensionData: 'Extensions data loading',
};
});
} catch (e) {
// Ignore — extensions will be empty
} finally {
extensionsLoaded?.complete();
}
// Experiments
try {
const experiments = await this.experimentService.getCurrentExperiments();
data.experiments = experiments?.join('\n');
} catch (e) {
// Ignore
}
// GitHub access token — only fetch existing sessions, never prompt
try {
const githubSessions = await this.authenticationService.getSessions('github');
const repoSession = githubSessions.find(session => session.scopes.includes('repo'));
data.githubAccessToken = repoSession?.accessToken ?? '';
} catch (e) {
// Ignore
}
// Integrity check
try {
data.isInstallationPure = (await this.integrityService.isPure()).isPure;
} catch (e) {
// Ignore
}
}
}
export function getIssueReporterStyles(theme: IColorTheme): IssueReporterStyles {
@@ -0,0 +1,53 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { VSBuffer } from '../../../../base/common/buffer.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { INativeHostService } from '../../../../platform/native/common/native.js';
import { IGitHubUploadResult, IGitHubUploadService } from '../browser/githubUploadService.js';
/**
* GitHub upload service using the Mobile Upload API.
*
* Uploads files via the main process (Electron net.fetch) to bypass CORS.
*/
export class NativeGitHubUploadService extends Disposable implements IGitHubUploadService {
readonly _serviceBrand: undefined;
constructor(
@ILogService private readonly logService: ILogService,
@INativeHostService private readonly nativeHostService: INativeHostService,
) {
super();
}
async resolveRepositoryId(owner: string, repo: string, token?: string): Promise<string> {
const headers: Record<string, string> = { 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const r = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { headers });
if (!r.ok) {
const body = await r.text().catch(() => '');
throw new Error(`Repo ID lookup failed for ${owner}/${repo}: ${r.status} ${r.statusText}${body ? `${body.substring(0, 300)}` : ''}`);
}
const json = await r.json();
return String(json.id);
}
async uploadViaMobileApi(token: string, repoId: string, files: { name: string; bytes: Uint8Array; contentType: string }[]): Promise<IGitHubUploadResult[]> {
const results: IGitHubUploadResult[] = [];
for (const file of files) {
const result = await this.nativeHostService.uploadFileViaMobileApi(
token, repoId, file.name, VSBuffer.wrap(file.bytes), file.contentType
);
this.logService.info(`[GitHubUpload] Uploaded ${file.name} (${file.bytes.length} bytes) -> ${result.assetUrl}`);
results.push(result);
}
return results;
}
}
@@ -3,23 +3,36 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { IMenuService } from '../../../../platform/actions/common/actions.js';
import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { INativeHostService } from '../../../../platform/native/common/native.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import product from '../../../../platform/product/common/product.js';
import { MutableDisposable } from '../../../../base/common/lifecycle.js';
import { IAuxiliaryWindowService } from '../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js';
import { IHostService } from '../../../services/host/browser/host.js';
import { IssueFormService } from '../browser/issueFormService.js';
import { IGitHubUploadService } from '../browser/githubUploadService.js';
import { IssueReporterEditorInput } from '../browser/issueReporterEditorInput.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { IIssueFormService, IssueReporterData } from '../common/issue.js';
import { IssueReporter } from './issueReporterService.js';
export class NativeIssueFormService extends IssueFormService implements IIssueFormService {
private readonly store = new DisposableStore();
/**
* Holds the currently-rendered legacy IssueReporter so its listeners on long-lived services
* (e.g. authentication onDidChangeSessions) are released when the aux window closes or a new
* reporter is opened.
*/
private readonly legacyReporter = this._register(new MutableDisposable<IssueReporter>());
constructor(
@IInstantiationService instantiationService: IInstantiationService,
@@ -29,36 +42,60 @@ export class NativeIssueFormService extends IssueFormService implements IIssueFo
@IMenuService menuService: IMenuService,
@IContextKeyService contextKeyService: IContextKeyService,
@IHostService hostService: IHostService,
@IOpenerService openerService: IOpenerService,
@IFileService fileService: IFileService,
@IEnvironmentService private readonly environmentService: IEnvironmentService,
@IGitHubUploadService githubUploadService: IGitHubUploadService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IEditorService editorService: IEditorService,
@IClipboardService clipboardService: IClipboardService,
@INativeHostService private readonly nativeHostService: INativeHostService,
@INativeEnvironmentService private readonly environmentService: INativeEnvironmentService,) {
super(instantiationService, auxiliaryWindowService, menuService, contextKeyService, logService, dialogService, hostService);
) {
super(instantiationService, auxiliaryWindowService, menuService, contextKeyService, logService, dialogService, hostService, openerService, fileService, githubUploadService, editorService, clipboardService);
}
// override to grab platform info
override async openReporter(data: IssueReporterData): Promise<void> {
if (this.hasToReload(data)) {
return;
}
const bounds = await this.nativeHostService.getActiveWindowPosition();
if (!bounds) {
return;
const useWizard = this.configurationService.getValue<boolean>('issueReporter.wizard.enabled');
if (!useWizard) {
// Legacy reporter needs OS properties synchronously for the issue body.
const { arch, release, type } = await this.nativeHostService.getOSProperties();
this.arch = arch;
this.release = release;
this.type = type;
return this.openAuxIssueReporterLegacy(data);
}
// Wizard path pulls system info from IProcessService.getSystemInfo() inside
// the editor pane, so it does not depend on arch/release/type here.
const input = this.instantiationService.createInstance(IssueReporterEditorInput, data);
await this.editorService.openEditor(input, { pinned: true });
}
/**
* Desktop legacy path uses the native `IssueReporter` (so it can populate
* system/performance info via `IProcessService`) and centers the auxiliary
* window on the active window via `getActiveWindowPosition()`.
*/
override async openAuxIssueReporterLegacy(data: IssueReporterData): Promise<void> {
const bounds = await this.nativeHostService.getActiveWindowPosition();
await this.openAuxIssueReporter(data, bounds);
// Get platform information
const { arch, release, type } = await this.nativeHostService.getOSProperties();
this.arch = arch;
this.release = release;
this.type = type;
// create issue reporter and instantiate
if (this.issueReporterWindow) {
const issueReporter = this.store.add(this.instantiationService.createInstance(IssueReporter, !!this.environmentService.disableExtensions, data, { type: this.type, arch: this.arch, release: this.release }, product, this.issueReporterWindow));
const issueReporter = this.instantiationService.createInstance(
IssueReporter,
!!this.environmentService.disableExtensions,
data,
{ type: this.type, arch: this.arch, release: this.release },
product,
this.issueReporterWindow,
);
this.legacyReporter.value = issueReporter;
this.issueReporterWindow.addEventListener('beforeunload', () => this.legacyReporter.clear(), { once: true });
issueReporter.render();
} else {
this.store.dispose();
}
}
}
@@ -0,0 +1,253 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { isMacintosh } from '../../../../base/common/platform.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { INativeHostService } from '../../../../platform/native/common/native.js';
import { IRecordingData, IRecordingService, RecordingState } from '../browser/recordingService.js';
const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB — GitHub upload limit
const SIZE_LIMIT_THRESHOLD = 0.9; // Stop at 90% to account for chunk overshoot
export class NativeRecordingService extends Disposable implements IRecordingService {
readonly _serviceBrand: undefined;
// MediaRecorder + getDisplayMedia may be absent if the renderer is run with reduced
// APIs (e.g. some test/runtime configurations); derive support from feature detection
// so startRecording can early-reject rather than blowing up with ReferenceError.
readonly isSupported = typeof MediaRecorder !== 'undefined'
&& typeof navigator !== 'undefined'
&& !!navigator.mediaDevices?.getDisplayMedia;
private _state = RecordingState.Idle;
private readonly _onDidChangeState = this._register(new Emitter<RecordingState>());
readonly onDidChangeState: Event<RecordingState> = this._onDidChangeState.event;
private mediaRecorder: MediaRecorder | undefined;
private mediaStream: MediaStream | undefined;
private chunks: Blob[] = [];
private bytesRecorded = 0;
private stoppedBySize = false;
private startTime = 0;
constructor(
@ILogService private readonly logService: ILogService,
@INativeHostService private readonly nativeHostService: INativeHostService,
) {
super();
this._register(toDisposable(() => this.cleanup()));
}
getScreenCapturePermissionStatus(): Promise<'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'> {
return this.nativeHostService.getMediaAccessStatus('screen');
}
openScreenCapturePermissionSettings(): void {
if (isMacintosh) {
// Deep-link to the Screen Recording pane in macOS Privacy & Security.
void this.nativeHostService.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
}
}
get state(): RecordingState {
return this._state;
}
private setState(state: RecordingState): void {
if (this._state !== state) {
this._state = state;
this._onDidChangeState.fire(state);
}
}
getSupportedFormats(): { mimeType: string; label: string; extension: string }[] {
const formats: { mimeType: string; label: string; extension: string }[] = [];
if (typeof MediaRecorder !== 'undefined') {
if (MediaRecorder.isTypeSupported('video/mp4')) {
formats.push({ mimeType: 'video/mp4', label: 'MP4', extension: 'mp4' });
}
if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) {
formats.push({ mimeType: 'video/webm;codecs=vp9', label: 'WebM', extension: 'webm' });
} else if (MediaRecorder.isTypeSupported('video/webm')) {
formats.push({ mimeType: 'video/webm', label: 'WebM', extension: 'webm' });
}
}
return formats;
}
async startRecording(preferredMimeType?: string): Promise<void> {
if (!this.isSupported) {
throw new Error('Recording is not supported in this environment (MediaRecorder / getDisplayMedia unavailable).');
}
if (this._state === RecordingState.Recording) {
throw new Error('Recording already in progress.');
}
this.cleanup();
// Use getDisplayMedia — on Electron desktop the main process handler
// auto-selects the screen containing the VS Code window via
// desktopCapturer.getSources() (cached for subsequent recordings).
try {
this.mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
} catch (err) {
this.logService.error('[RecordingService] Failed to get display media:', err);
throw new Error('Failed to start recording. The user may have cancelled the source picker.');
}
// Select mime type: prefer caller's choice, fall back to best available
let mimeType: string;
if (preferredMimeType && MediaRecorder.isTypeSupported(preferredMimeType)) {
mimeType = preferredMimeType;
} else if (MediaRecorder.isTypeSupported('video/mp4')) {
mimeType = 'video/mp4';
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) {
mimeType = 'video/webm;codecs=vp9';
} else {
mimeType = 'video/webm';
}
this.chunks = [];
this.bytesRecorded = 0;
this.stoppedBySize = false;
this.startTime = Date.now();
try {
this.mediaRecorder = new MediaRecorder(this.mediaStream, {
mimeType,
videoBitsPerSecond: 2_500_000, // 2.5 Mbps — good quality, reasonable file size
});
} catch (err) {
this.logService.error('[RecordingService] Failed to create MediaRecorder:', err);
this.stopTracks();
throw new Error('Failed to create media recorder.');
}
this.mediaRecorder.ondataavailable = e => {
if (e.data && e.data.size > 0) {
if (this.stoppedBySize) {
return;
}
// Always accept the current chunk, then check if we've hit the limit.
// This means the file may overshoot by up to one 1000ms chunk,
// which is small enough for the 100 MB GitHub limit.
this.chunks.push(e.data);
this.bytesRecorded += e.data.size;
if (this.bytesRecorded >= MAX_FILE_SIZE_BYTES * SIZE_LIMIT_THRESHOLD && this._state === RecordingState.Recording) {
this.logService.info('[RecordingService] Max file size reached, stopping recording.');
this.stoppedBySize = true;
this.mediaRecorder?.stop();
}
}
};
// If the user stops sharing via the browser/OS UI, treat it as stop
this.mediaRecorder.onstop = () => {
// Only move to Stopped if we were Recording (avoid double transition)
if (this._state === RecordingState.Recording) {
this.stopTracks();
this.setState(RecordingState.Stopped);
}
};
// Also handle the stream ending externally (user clicked "Stop sharing")
for (const track of this.mediaStream.getTracks()) {
track.onended = () => {
if (this._state === RecordingState.Recording && this.mediaRecorder?.state === 'recording') {
this.mediaRecorder.stop();
}
};
}
this.mediaRecorder.start(1000); // 1-second timeslice for size tracking
this.setState(RecordingState.Recording);
}
async stopRecording(): Promise<IRecordingData | undefined> {
if (this._state !== RecordingState.Recording && this._state !== RecordingState.Stopped) {
return undefined;
}
// If still recording, stop the recorder and wait for it to finish
if (this._state === RecordingState.Recording && this.mediaRecorder?.state === 'recording') {
const recorder = this.mediaRecorder;
await new Promise<void>(resolve => {
// Replace onstop entirely so the original "external stop" handler doesn't
// emit setState(Stopped) here. That event would re-enter the auto-stop
// listener (IssueReporterEditorPane) and recursively call stopRecording.
// Explicit stops own the state transitions themselves and end with
// setState(Idle) below, which still satisfies the IRecordingService
// contract by emitting the terminal Idle transition.
recorder.onstop = () => {
resolve();
};
// Flush any buffered data before stopping
recorder.requestData();
recorder.stop();
});
}
this.stopTracks();
if (this.chunks.length === 0) {
this.setState(RecordingState.Idle);
return undefined;
}
const mimeType = this.mediaRecorder?.mimeType ?? 'video/webm';
const blob = new Blob(this.chunks, { type: mimeType });
const durationMs = Date.now() - this.startTime;
const data: IRecordingData = {
blob,
mimeType,
durationMs,
sizeBytes: blob.size,
stoppedBySize: this.stoppedBySize,
};
this.chunks = [];
this.mediaRecorder = undefined;
this.setState(RecordingState.Idle);
return data;
}
discardRecording(): void {
if (this.mediaRecorder) {
// Clear handlers BEFORE stop() so any final ondataavailable fired after stop()
// does not append a chunk that we'd then have to GC explicitly.
this.mediaRecorder.ondataavailable = null;
this.mediaRecorder.onstop = null;
if (this._state === RecordingState.Recording && this.mediaRecorder.state === 'recording') {
this.mediaRecorder.stop();
}
}
this.cleanup();
this.setState(RecordingState.Idle);
}
private stopTracks(): void {
if (this.mediaStream) {
for (const track of this.mediaStream.getTracks()) {
track.stop();
}
this.mediaStream = undefined;
}
}
private cleanup(): void {
this.stopTracks();
this.chunks = [];
this.bytesRecorded = 0;
this.stoppedBySize = false;
this.mediaRecorder = undefined;
}
}
@@ -0,0 +1,26 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { INativeHostService } from '../../../../platform/native/common/native.js';
import { IRectangle } from '../../../../platform/window/common/window.js';
import { IScreenshotService } from '../browser/screenshotService.js';
import { encodeBase64 } from '../../../../base/common/buffer.js';
export class NativeScreenshotService implements IScreenshotService {
readonly _serviceBrand: undefined;
constructor(
@INativeHostService private readonly nativeHostService: INativeHostService,
) { }
async captureScreenshot(rect?: IRectangle): Promise<string | undefined> {
const buffer = await this.nativeHostService.getScreenshot(rect);
if (!buffer) {
return undefined;
}
return `data:image/jpeg;base64,${encodeBase64(buffer)}`;
}
}
@@ -291,7 +291,7 @@ Modes:
test('should supply mode if applicable', () => {
const issueReporterModel = new IssueReporterModel({
isUnsupported: true,
isInstallationPure: false,
restrictedMode: true
});
assert.strictEqual(issueReporterModel.serialize(),
@@ -146,6 +146,7 @@ export class TestNativeHostService implements INativeHostService {
async openExternal(url: string, defaultApplication?: string): Promise<boolean> { return false; }
async updateTouchBar(): Promise<void> { }
async moveItemToTrash(): Promise<void> { }
async getMediaAccessStatus(_mediaType: 'microphone' | 'camera' | 'screen'): Promise<'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'> { return 'granted'; }
async newWindowTab(): Promise<void> { }
async showPreviousWindowTab(): Promise<void> { }
async showNextWindowTab(): Promise<void> { }
@@ -186,6 +187,7 @@ export class TestNativeHostService implements INativeHostService {
async profileRenderer(): Promise<any> { throw new Error(); }
async startTracing(): Promise<void> { throw new Error(); }
async getScreenshot(rect?: IRectangle): Promise<VSBuffer | undefined> { return undefined; }
async uploadFileViaMobileApi(_token: string, _repoId: string, fileName: string, _fileBytes: VSBuffer, contentType: string): Promise<{ fileName: string; assetUrl: string; contentType: string }> { return { fileName, assetUrl: '', contentType }; }
async showToast(options: IToastOptions): Promise<IToastResult> { return { supported: false, clicked: false }; }
async clearToast(id: string): Promise<void> { }
async clearToasts(): Promise<void> { }