mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-18 21:36:52 +01:00
Issue reporter wizard
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
7d7e9513af
commit
4e538f26ea
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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`
|
||||
: `\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;
|
||||
}
|
||||
}
|
||||
@@ -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> { }
|
||||
|
||||
Reference in New Issue
Block a user