diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index d886313c688..5c9f3f00af5 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -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 diff --git a/src/vs/platform/diagnostics/common/diagnostics.ts b/src/vs/platform/diagnostics/common/diagnostics.ts index bdb36c2a799..011a2c87b50 100644 --- a/src/vs/platform/diagnostics/common/diagnostics.ts +++ b/src/vs/platform/diagnostics/common/diagnostics.ts @@ -15,7 +15,7 @@ export const IDiagnosticsService = createDecorator(ID); export interface IDiagnosticsService { readonly _serviceBrand: undefined; - getPerformanceInfo(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[]): Promise; + getPerformanceInfo(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[], options?: { skipCache?: boolean; unbounded?: boolean }): Promise; getSystemInfo(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[]): Promise; getDiagnostics(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[]): Promise; 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 { + async getPerformanceInfo(mainProcessInfo: IMainProcessDiagnostics, remoteInfo: (IRemoteDiagnosticInfo | IRemoteDiagnosticError)[], options?: { skipCache?: boolean; unbounded?: boolean }): Promise { return {}; } diff --git a/src/vs/platform/diagnostics/node/diagnosticsService.ts b/src/vs/platform/diagnostics/node/diagnosticsService.ts index 5695a7125d2..f26f23cc667 100644 --- a/src/vs/platform/diagnostics/node/diagnosticsService.ts +++ b/src/vs/platform/diagnostics/node/diagnosticsService.ts @@ -29,11 +29,22 @@ interface ConfigFilePatterns { } const workspaceStatsCache = new Map>(); -export async function collectWorkspaceStats(folder: string, filter: string[]): Promise { - 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 { + // 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(); const configFiles = new Map(); - 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 { 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 { - 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 { + 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 { + private formatWorkspaceMetadata(info: IMainProcessDiagnostics, options?: { skipCache?: boolean; unbounded?: boolean }): Promise { const output: string[] = []; const workspaceStatPromises: Promise[] = []; @@ -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('workspace.stats.file', { rendererSessionId: workspace.rendererSessionId, type: e.name, diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 968960dc58b..c90465cb99d 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -181,6 +181,8 @@ export interface ICommonNativeHostService { openExternal(url: string, defaultApplication?: string): Promise; moveItemToTrash(fullPath: string): Promise; + getMediaAccessStatus(mediaType: 'microphone' | 'camera' | 'screen'): Promise<'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'>; + isAdmin(): Promise; writeElevated(source: URI, target: URI, options?: { unlock?: boolean }): Promise; isRunningUnderARM64Translation(): Promise; @@ -196,6 +198,9 @@ export interface ICommonNativeHostService { // Screenshots getScreenshot(rect?: IRectangle): Promise; + // 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; killProcess(pid: number, code: string): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index a809171d570..cf195008bbb 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -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 { 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; + + // Step 2: Upload to S3 (uses net.fetch which bypasses CORS) + const formFields = policy.form as Record; + 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 { diff --git a/src/vs/platform/process/common/process.ts b/src/vs/platform/process/common/process.ts index cba183b4ab0..17385744ce4 100644 --- a/src/vs/platform/process/common/process.ts +++ b/src/vs/platform/process/common/process.ts @@ -47,5 +47,5 @@ export interface IProcessService { getSystemStatus(): Promise; getSystemInfo(): Promise; - getPerformanceInfo(): Promise; + getPerformanceInfo(options?: { skipCache?: boolean; unbounded?: boolean }): Promise; } diff --git a/src/vs/platform/process/electron-main/processMainService.ts b/src/vs/platform/process/electron-main/processMainService.ts index e008159714c..b052e8916ae 100644 --- a/src/vs/platform/process/electron-main/processMainService.ts +++ b/src/vs/platform/process/electron-main/processMainService.ts @@ -75,10 +75,10 @@ export class ProcessMainService implements IProcessService { return msg; } - async getPerformanceInfo(): Promise { + async getPerformanceInfo(options?: { skipCache?: boolean; unbounded?: boolean }): Promise { 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); diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 3480283a217..7b3d493ba52 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -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); diff --git a/src/vs/workbench/contrib/issue/browser/githubUploadService.ts b/src/vs/workbench/contrib/issue/browser/githubUploadService.ts new file mode 100644 index 00000000000..6146dfc5f65 --- /dev/null +++ b/src/vs/workbench/contrib/issue/browser/githubUploadService.ts @@ -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('githubUploadService'); + +export interface IGitHubUploadService { + readonly _serviceBrand: undefined; + resolveRepositoryId(owner: string, repo: string, token?: string): Promise; + uploadViaMobileApi(token: string, repoId: string, files: { name: string; bytes: Uint8Array; contentType: string }[]): Promise; +} + +/** + * Browser fallback, upload not yet supported in web. + */ +export class BrowserGitHubUploadService implements IGitHubUploadService { + readonly _serviceBrand: undefined; + + async resolveRepositoryId(): Promise { throw new Error('Not supported in browser'); } + async uploadViaMobileApi(): Promise { throw new Error('Not supported in browser'); } +} diff --git a/src/vs/workbench/contrib/issue/browser/issue.contribution.ts b/src/vs/workbench/contrib/issue/browser/issue.contribution.ts index 596fcda8891..f547604d5c0 100644 --- a/src/vs/workbench/contrib/issue/browser/issue.contribution.ts +++ b/src/vs/workbench/contrib/issue/browser/issue.contribution.ts @@ -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(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."); diff --git a/src/vs/workbench/contrib/issue/browser/issueFormService.ts b/src/vs/workbench/contrib/issue/browser/issueFormService.ts index b655bb773a8..88158f55956 100644 --- a/src/vs/workbench/contrib/issue/browser/issueFormService.ts +++ b/src/vs/workbench/contrib/issue/browser/issueFormService.ts @@ -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 = ''; +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(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 { 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 { + 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 => { + if (!gitHubDetails) { + return undefined; + } + repoId ??= await this.githubUploadService.resolveRepositoryId(gitHubDetails.owner, gitHubDetails.repositoryName, data.githubAccessToken); + return repoId; + }; + + let mediaMarkdown = ''; + const hasAttachments = screenshots.length > 0 || recordings.length > 0; + + // Only attempt the Mobile Upload API when the issue target actually resolves to a + // GitHub repo. Otherwise we'd upload attachments to an unrelated repository (and + // potentially leak them) just because a GitHub token is present. + if (hasAttachments && data.githubAccessToken && gitHubDetails) { + host.setUploading(true); + + try { + // Collect files, keyed for cache lookup. We hash the data URL / file path so the + // (potentially very large) screenshot/recording payloads aren't retained as Map keys. + const filesToProcess: IssueUploadFile[] = []; + for (let i = 0; i < screenshots.length; i++) { + const dataUrl = screenshots[i].annotatedDataUrl ?? screenshots[i].dataUrl; + const bytes = this.dataUrlToBytes(dataUrl); + if (bytes) { + // Screenshots are either annotated (always PNG via canvas.toDataURL) + // or raw native captures (always JPEG); fall back to PNG. + const isJpeg = dataUrl.startsWith('data:image/jpeg'); + const extension = isJpeg ? 'jpg' : 'png'; + const contentType = isJpeg ? 'image/jpeg' : 'image/png'; + filesToProcess.push({ key: `screenshot:${hash(dataUrl)}`, name: `screenshot-${i + 1}.${extension}`, bytes, contentType }); + } + } + for (let i = 0; i < recordings.length; i++) { + const rec = recordings[i]; + const fileContent = await this.fileService.readFile(URI.file(rec.filePath)); + const ext = rec.filePath.endsWith('.mp4') ? 'mp4' : 'webm'; + const contentType = ext === 'mp4' ? 'video/mp4' : 'video/webm'; + filesToProcess.push({ key: `recording:${rec.filePath}`, name: `recording-${i + 1}.${ext}`, bytes: fileContent.value.buffer, contentType }); + } + + if (filesToProcess.length > 0) { + for (let i = 0; i < filesToProcess.length; i++) { + host.setAttachmentUploadState(i, 'pending'); + } + + const uploadResults: import('./githubUploadService.js').IGitHubUploadResult[] = []; + for (let i = 0; i < filesToProcess.length; i++) { + const file = filesToProcess[i]; + const cached = this.uploadCache.get(file.key); + if (cached) { + uploadResults.push(cached); + host.setAttachmentUploadState(i, 'done'); + continue; + } + + const resolvedRepoId = await resolveRepoId(); + if (!resolvedRepoId) { + throw new Error('No GitHub repository resolved for attachment upload.'); + } + host.setAttachmentUploadState(i, 'uploading'); + const [result] = await this.githubUploadService.uploadViaMobileApi( + data.githubAccessToken, resolvedRepoId, [file] + ); + if (!result) { + throw new Error(`Upload returned no result for ${file.name}.`); + } + this.uploadCache.set(file.key, result); + uploadResults.push(result); + host.setAttachmentUploadState(i, 'done'); + } + + mediaMarkdown = `\n\n### ${localize('issueReporter.attachmentsHeading', "Attachments")}\n\n`; + for (const r of uploadResults) { + mediaMarkdown += r.contentType.startsWith('video/') + ? `${r.assetUrl}\n\n` + : `![${r.fileName}](${r.assetUrl})\n\n`; + } + } + } catch (err) { + this.logService.error('[IssueFormService] Upload failed:', err); + mediaMarkdown = `\n\n### ${localize('issueReporter.attachmentsHeading', "Attachments")}\n\n> ${localize('issueReporter.attachmentsUploadFailed', "Upload failed. Please drag and drop attachments manually.")}\n\n`; + } finally { + host.setUploading(false); + } + } + + const issueBody = body + mediaMarkdown; + + const baseUrl = this.getIssueUrlWithTitle(title, issueTarget.url, data.issueSource === IssueSource.Extension); + let previewBody = issueBody; + let url = this.createIssuePreviewUrl(baseUrl, previewBody, gitHubDetails, data.issueSource); + + if (url.length > MAX_URL_LENGTH && data.githubAccessToken && gitHubDetails) { + const shortenedBody = await this.tryCreateBodyWithIssueDataAttachment(host, issueBody, baseUrl, gitHubDetails, data.issueSource, data.githubAccessToken, resolveRepoId); + if (shortenedBody) { + previewBody = shortenedBody; + url = this.createIssuePreviewUrl(baseUrl, previewBody, gitHubDetails, data.issueSource); + } + } + + if (url.length > MAX_URL_LENGTH) { + const shouldWrite = await this.showClipboardDialog(); + if (!shouldWrite) { + return false; + } + try { + await this.clipboardService.writeText(issueBody); + } catch (error) { + this.logService.error('Writing issue data to clipboard failed', error); + return false; + } + url = this.createIssuePreviewUrl(baseUrl, localize('pasteData', "We have written the needed data into your clipboard because it was too large to send. Please paste."), gitHubDetails, data.issueSource); + } + + // Skip the trusted-domains prompt for github.com URLs the issue reporter + // itself generates. The auto-populated body can produce extremely long + // URLs that overflow the native confirmation dialog on macOS, and the + // destination is one we constructed ourselves so the trust check would + // always be a yes anyway. + // + // Pass the URL as a string (not as a URI). When `IOpenerService.open` is + // handed a URI, it rebuilds the href via `encodeURI(uri.toString(true))`, + // which double-encodes our already-percent-encoded query (`%23` becomes + // `%2523`, so GitHub renders `### Description` as literal `%23%23%23 Description`). + const uri = URI.parse(url); + const skipValidation = uri.scheme === 'https' && uri.authority === 'github.com'; + return this.openerService.open(url, { openExternal: true, skipValidation }); + } + + private async tryCreateBodyWithIssueDataAttachment( + host: IIssueSubmissionHost, + issueBody: string, + baseUrl: string, + gitHubDetails: { owner: string; repositoryName: string }, + issueSource: IssueSource | undefined, + githubAccessToken: string, + resolveRepoId: () => Promise + ): Promise { + 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 { + 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*\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 { await this.openAuxIssueReporter(data); if (this.issueReporterWindow) { @@ -69,8 +426,10 @@ export class IssueFormService implements IIssueFormService { let issueReporterBounds: Partial = { 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 }; diff --git a/src/vs/workbench/contrib/issue/browser/issueReporterEditorInput.ts b/src/vs/workbench/contrib/issue/browser/issueReporterEditorInput.ts new file mode 100644 index 00000000000..909790a6948 --- /dev/null +++ b/src/vs/workbench/contrib/issue/browser/issueReporterEditorInput.ts @@ -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; + } +} diff --git a/src/vs/workbench/contrib/issue/browser/issueReporterEditorPane.ts b/src/vs/workbench/contrib/issue/browser/issueReporterEditorPane.ts new file mode 100644 index 00000000000..63f614bd2f5 --- /dev/null +++ b/src/vs/workbench/contrib/issue/browser/issueReporterEditorPane.ts @@ -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('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(); + 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 { + 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 { + 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((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 { + 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 { + // 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 { + 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('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 { + 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 { + // 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`; + } + } +} diff --git a/src/vs/workbench/contrib/issue/browser/issueReporterKeybindings.ts b/src/vs/workbench/contrib/issue/browser/issueReporterKeybindings.ts new file mode 100644 index 00000000000..1c3f84e4e74 --- /dev/null +++ b/src/vs/workbench/contrib/issue/browser/issueReporterKeybindings.ts @@ -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()), +}); diff --git a/src/vs/workbench/contrib/issue/browser/issueReporterModel.ts b/src/vs/workbench/contrib/issue/browser/issueReporterModel.ts index 2a56272ee23..72061543dba 100644 --- a/src/vs/workbench/contrib/issue/browser/issueReporterModel.ts +++ b/src/vs/workbench/contrib/issue/browser/issueReporterModel.ts @@ -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 `
Extensions (${this._data.enabledNonThemeExtesions.length}) + 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 += `
Extensions (${this._data.enabledNonThemeExtesions.length}) ${tableHeader} ${table} -${themeExclusionStr}
`; + } + + return md; } } diff --git a/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts b/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts new file mode 100644 index 00000000000..ae9a918809a --- /dev/null +++ b/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts @@ -0,0 +1,2500 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeybindingLabel } from '../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; +import { ResolvedKeybinding } from '../../../../base/common/keybindings.js'; +import { OS } from '../../../../base/common/platform.js'; +import './media/issueReporterOverlay.css'; +import { $, addDisposableListener, append, EventType, getWindow } from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { IContextMenuProvider } from '../../../../base/browser/contextmenu.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { ISelectOptionItem, SelectBox } from '../../../../base/browser/ui/selectBox/selectBox.js'; +import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; +import { Action, Separator } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { isRemoteDiagnosticError } from '../../../../platform/diagnostics/common/diagnostics.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, defaultKeybindingLabelStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import product from '../../../../platform/product/common/product.js'; +import { URI } from '../../../../base/common/uri.js'; +import { normalizeGitHubUrl } from '../common/issueReporterUtil.js'; +import { IssueReporterData, IssueReporterExtensionData, IssueSource, IssueType } from '../common/issue.js'; +import { IssueReporterModel } from './issueReporterModel.js'; +import { RecordingState } from './recordingService.js'; +import { IAnnotationEditorState, ScreenshotAnnotationEditor } from './screenshotAnnotation.js'; + +const MAX_ATTACHMENTS = 5; +const MAX_SIMILAR_ISSUES = 5; + +interface ISimilarIssue { + readonly html_url: string; + readonly title: string; + readonly state?: string; +} + +const enum WizardStep { + Attachments = 0, + Describe = 1, + Review = 2, +} + +const STEP_COUNT = 3; + +export interface IScreenshot { + readonly dataUrl: string; + readonly width: number; + readonly height: number; + annotatedDataUrl?: string; + annotationState?: IAnnotationEditorState; +} + +export class IssueReporterOverlay { + + private readonly disposables = new DisposableStore(); + private readonly _onDidClose = new Emitter(); + readonly onDidClose: Event = this._onDidClose.event; + private readonly _onDidSubmit = new Emitter<{ title: string; body: string }>(); + readonly onDidSubmit: Event<{ title: string; body: string }> = this._onDidSubmit.event; + private readonly _onDidRequestScreenshot = new Emitter(); + readonly onDidRequestScreenshot: Event = this._onDidRequestScreenshot.event; + private readonly _onDidRequestStartRecording = new Emitter(); + readonly onDidRequestStartRecording: Event = this._onDidRequestStartRecording.event; + private readonly _onDidRequestStopRecording = new Emitter(); + readonly onDidRequestStopRecording: Event = this._onDidRequestStopRecording.event; + private readonly _onDidRequestOpenRecording = new Emitter(); + readonly onDidRequestOpenRecording: Event = this._onDidRequestOpenRecording.event; + private readonly _onDidRequestOpenScreenshot = new Emitter(); + readonly onDidRequestOpenScreenshot: Event = this._onDidRequestOpenScreenshot.event; + + private wizardPanel!: HTMLElement; + private updateBanner!: HTMLElement; + private stepContainer!: HTMLElement; + private readonly stepPages: HTMLElement[] = []; + + // Step 1: Describe (category + description + title) + private readonly issueTypeButtons: Button[] = []; + private readonly issueSourceButtons: Button[] = []; + private selectedIssueType: IssueType | undefined; + private selectedIssueSource: IssueSource | undefined; + private selectedExtension: IssueReporterExtensionData | undefined; + private sourceButtonGroup!: HTMLElement; + private sourceError!: HTMLElement; + private targetStatus!: HTMLElement; + private extensionField!: HTMLElement; + private extensionSelect!: SelectBox; + private extensionOptions: { label: string; value: string | undefined; hidden?: boolean }[] = []; + private extensionError!: HTMLElement; + private extensionStatus!: HTMLElement; + private didAttemptDescribeSubmit = false; + private similarIssuesContainer!: HTMLElement; + private similarIssuesRequest = 0; + private extensionDataRequest = 0; + private similarIssuesHandle: ReturnType | undefined; + private typeButtonGroup!: HTMLElement; + private typeError!: HTMLElement; + private descriptionTextarea!: HTMLTextAreaElement; + private descriptionGuidance!: HTMLElement; + private descriptionError!: HTMLElement; + private titleInput!: InputBox; + private titleError!: HTMLElement; + private generateTitleBtn!: Button; + private readonly _onDidRequestGenerateTitle = new Emitter(); + readonly onDidRequestGenerateTitle: Event = this._onDidRequestGenerateTitle.event; + + // Step 0: Screenshots & Recording + private screenshotContainer!: HTMLElement; + private screenshotDelay = 0; + private recordingElapsedTimer: number | undefined; + private recordingStartTime = 0; + private currentRecordingState = RecordingState.Idle; + private delayedScreenshotPending = false; + private readonly recordings: { filePath: string; durationMs: number; thumbnailDataUrl?: string }[] = []; + + // Step 2: Review + private reviewThumbCards: HTMLElement[] = []; + private readonly reviewRenderDisposables = new DisposableStore(); + private readonly similarIssuesDisposables = new DisposableStore(); + private uploading = false; + private includeSystemInfo = true; + private includeProcessInfo = true; + private includeWorkspaceInfo = true; + private includeExtensions = true; + private includeExperiments = true; + private includeExtensionData = false; + private includeSettings = true; + private settingsContent: string | undefined; + private diagnosticBulkToggleButton: Button | undefined; + private diagnosticSectionStates: (() => boolean)[] = []; + private performanceInfoLoaded = false; + private performanceInfoRefreshing = false; + + // Navigation + private stepIndicator!: HTMLElement; + private stepLabel!: HTMLElement; + private backButton!: Button; + private nextButton!: Button; + + // Progress dots + private readonly progressDots: HTMLElement[] = []; + + private currentStep: WizardStep = WizardStep.Attachments; + private readonly screenshots: IScreenshot[] = []; + private readonly model: IssueReporterModel; + private visible = false; + private floatingBar: HTMLElement | undefined; + private previewOpened = false; + private previewedDraftKey: string | undefined; + private closeButton: Button | undefined; + private _hideToolbarInScreenshots = true; + + constructor( + private data: IssueReporterData, + private readonly recordingSupported: boolean = false, + private readonly container: HTMLElement, + private readonly contextViewService: IContextViewService, + private readonly contextMenuProvider?: IContextMenuProvider, + private readonly markdownRendererService?: IMarkdownRendererService, + initialHideToolbar: boolean = true, + private readonly resolveExtensionIssueData?: (extensionId: string) => Promise, + private readonly openExternalLink?: (url: string) => Promise, + private showUpdateBanner = false, + private readonly refreshPerformanceInfo?: () => Promise, + /** Returns the user's currently-bound keybinding for the given command id, or undefined when unbound. */ + private readonly resolveKeybinding?: (commandId: string) => ResolvedKeybinding | undefined, + ) { + this._hideToolbarInScreenshots = initialHideToolbar; + this.model = new IssueReporterModel({ + ...data, + issueType: data.issueType || IssueType.Bug, + allExtensions: data.enabledExtensions, + includeSystemInfo: true, + includeWorkspaceInfo: true, + includeProcessInfo: true, + includeExtensions: true, + includeExperiments: true, + includeExtensionData: false, + }); + this.selectedIssueType = data.issueType; + this.selectedIssueSource = data.issueSource ?? (data.extensionId ? IssueSource.Extension : undefined); + + this.createWizard(); + } + + private createWizard(): void { + this.wizardPanel = $('div.issue-reporter-wizard'); + this.wizardPanel.setAttribute('role', 'dialog'); + this.wizardPanel.setAttribute('aria-label', localize('reportIssue', "Report Issue")); + this.wizardPanel.setAttribute('tabindex', '-1'); + + // Toolbar (drag region + step indicator + discard) + const toolbar = append(this.wizardPanel, $('div.wizard-toolbar')); + + // Progress indicator area + const progressArea = append(toolbar, $('div.wizard-progress-area')); + const progressDotsContainer = append(progressArea, $('div.wizard-progress-dots')); + for (let i = 0; i < STEP_COUNT; i++) { + const dot = append(progressDotsContainer, $('div.wizard-progress-dot')); + this.progressDots.push(dot); + } + this.stepIndicator = append(progressArea, $('span.wizard-step-indicator')); + append(progressArea, $('span.wizard-step-separator')); + this.stepLabel = append(progressArea, $('span.wizard-step-label')); + + append(toolbar, $('div.spacer')); + + this.updateBanner = append(this.wizardPanel, $('div.wizard-update-banner')); + this.updateBanner.setAttribute('role', 'status'); + this.updateBanner.setAttribute('aria-live', 'polite'); + this.updateBanner.textContent = localize('updateAvailable', "A new version of {0} is available.", product.nameLong); + this.setUpdateAvailable(this.showUpdateBanner); + + // Step content area + this.stepContainer = append(this.wizardPanel, $('div.wizard-step-container')); + this.createStep0Attachments(); + this.createStep1Describe(); + this.createStep2Review(); + + // Bottom navigation + const nav = append(this.wizardPanel, $('div.wizard-nav')); + + this.backButton = this.disposables.add(new Button(nav, { ...defaultButtonStyles, secondary: true })); + this.backButton.label = localize('back', "Back"); + this.backButton.element.classList.add('wizard-back'); + this.backButton.element.title = localize('back', "Back"); + + this.nextButton = this.disposables.add(new Button(nav, { ...defaultButtonStyles, supportIcons: true })); + this.nextButton.label = localize('next', "Next"); + this.nextButton.element.classList.add('wizard-next'); + this.nextButton.element.title = localize('next', "Next"); + + this.registerEventHandlers(); + if (this.data.extensionId) { + void this.updateSelectedExtension(this.data.extensionId, false); + } + this.updateStepUI(); + } + + // Step 0: Attachments + private createStep0Attachments(): void { + const page = append(this.stepContainer, $('div.wizard-step')); + this.stepPages.push(page); + + const heading = append(page, $('h2.wizard-heading')); + heading.textContent = localize('screenshotsHeading', "Add attachments for better context"); + + const subtitle = append(page, $('p.wizard-subtitle')); + subtitle.textContent = localize('screenshotsSubtitle', "You can add up to {0} screenshots or videos. Navigate VS Code and choose when to capture.", MAX_ATTACHMENTS); + + const captureShortcut = this.resolveKeybinding?.('workbench.action.issueReporter.captureScreenshot'); + const recordShortcut = this.recordingSupported ? this.resolveKeybinding?.('workbench.action.issueReporter.toggleRecording') : undefined; + if (captureShortcut || recordShortcut) { + const targetDocument = getWindow(this.container).document; + const hint = append(page, $('p.wizard-subtitle.wizard-shortcut-hint')); + const intro = localize('shortcutHintIntro', "Use the floating capture bar, or press"); + hint.appendChild(targetDocument.createTextNode(`${intro} `)); + if (captureShortcut) { + this.renderShortcutKeycap(hint, captureShortcut); + hint.appendChild(targetDocument.createTextNode(` ${localize('toCapture', "to capture a screenshot")}`)); + } + if (captureShortcut && recordShortcut) { + hint.appendChild(targetDocument.createTextNode(` ${localize('or', "or")} `)); + } + if (recordShortcut) { + this.renderShortcutKeycap(hint, recordShortcut); + hint.appendChild(targetDocument.createTextNode(` ${localize('toRecord', "to start or stop recording")}`)); + } + hint.appendChild(targetDocument.createTextNode('.')); + } + + this.screenshotContainer = append(page, $('div.wizard-screenshots')); + this.updateScreenshotThumbnails(); + + this.createFloatingCaptureBar(); + } + + private captureStripCaptureBtn: Button | undefined; + private captureStripDelayBtn: Button | undefined; + private captureStripRecordBtn: Button | undefined; + + private createFloatingCaptureBar(): void { + const targetWindow = getWindow(this.container); + // Mount inside .monaco-workbench so VS Code's color theme CSS vars + // (--vscode-debugToolBar-background, etc.) cascade and the bar matches the + // active theme. body is outside that scope and the vars wouldn't resolve. + // eslint-disable-next-line no-restricted-syntax + const workbench = targetWindow.document.querySelector('.monaco-workbench') as HTMLElement | null; + const mountTarget = workbench ?? targetWindow.document.body; + + this.floatingBar = $('div.wizard-floating-bar'); + + // Drag handle + const dragArea = append(this.floatingBar, $('div.wizard-floating-drag')); + dragArea.appendChild(renderIcon(Codicon.gripper)); + + // Segmented screenshot button: [Screenshot | options] + const segmented = append(this.floatingBar, $('div.wizard-segmented-btn')); + const floatingButtonStyles = this.getFloatingBarButtonStyles(targetWindow); + + const captureBtn = this.disposables.add(new Button(segmented, { ...floatingButtonStyles, supportIcons: true })); + captureBtn.element.classList.add('wizard-segmented-main'); + captureBtn.label = `$(device-camera) ${localize('screenshot', "Screenshot")}`; + this.captureStripCaptureBtn = captureBtn; + + // Delay/options dropdown using VS Code's context menu + const delayOptions = this.getScreenshotDelayOptions(); + const delayDropdownButton = this.disposables.add(new Button(segmented, { ...floatingButtonStyles, supportIcons: true })); + delayDropdownButton.element.classList.add('wizard-segmented-dropdown'); + delayDropdownButton.element.title = localize('captureOptions', "Capture options"); + delayDropdownButton.element.setAttribute('aria-label', localize('captureOptions', "Capture options")); + delayDropdownButton.label = '$(chevron-down)'; + this.captureStripDelayBtn = delayDropdownButton; + + if (this.contextMenuProvider) { + let menuOpen = false; + this.disposables.add(delayDropdownButton.onDidClick(() => { + if (!delayDropdownButton.enabled || menuOpen) { + return; + } + // Hide-toolbar-in-screenshots toggle (first) + const hideAction = new Action( + 'hide-toolbar', + localize('hideToolbarInScreenshots', "Hide Toolbar in Screenshots"), + undefined, + true, + async () => { + this._hideToolbarInScreenshots = !this._hideToolbarInScreenshots; + } + ); + hideAction.checked = this._hideToolbarInScreenshots; + + const actions = delayOptions.map(opt => { + const action = new Action( + `delay-${opt.value}`, + opt.label, + undefined, + true, + async () => { this.screenshotDelay = opt.value; } + ); + action.checked = opt.value === this.screenshotDelay; + return action; + }); + + const allActions = [hideAction, new Separator(), ...actions]; + menuOpen = true; + this.contextMenuProvider!.showContextMenu({ + getAnchor: () => this.floatingBar!, + getActions: () => allActions, + skipTelemetry: true, + onHide: () => { + menuOpen = false; + hideAction.dispose(); + for (const a of actions) { a.dispose(); } + }, + }); + })); + + // Close the delay menu when drag starts. + // The drag handler calls e.preventDefault() on pointerdown which + // suppresses the mousedown event that the context menu uses for + // outside-click detection, so we dispatch a synthetic one. + this.disposables.add(addDisposableListener(dragArea, EventType.POINTER_DOWN, () => { + dragArea.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + })); + } + + this.disposables.add(captureBtn.onDidClick(() => { + if (this.getTotalAttachments() >= MAX_ATTACHMENTS || !captureBtn.enabled) { + return; + } + if (this.screenshotDelay > 0) { + // Lock width so button doesn't shrink during countdown + captureBtn.element.style.minWidth = `${captureBtn.element.offsetWidth}px`; + captureBtn.enabled = false; + this.delayedScreenshotPending = true; + this.updateScreenshotThumbnails(); + this.updateAttachmentButtons(); + let remaining = this.screenshotDelay; + captureBtn.label = `${remaining}...`; + const targetWindow = getWindow(this.container); + const interval = targetWindow.setInterval(() => { + remaining--; + if (remaining > 0) { + captureBtn.label = `${remaining}...`; + } else { + targetWindow.clearInterval(interval); + captureBtn.label = `$(device-camera) ${localize('screenshot', "Screenshot")}`; + captureBtn.element.style.minWidth = ''; + captureBtn.enabled = true; + this.delayedScreenshotPending = false; + this.updateScreenshotThumbnails(); + this.updateAttachmentButtons(); + this._onDidRequestScreenshot.fire(); + } + }, 1000); + } else { + this._onDidRequestScreenshot.fire(); + } + })); + + // Record button + if (this.recordingSupported) { + this.captureStripRecordBtn = this.disposables.add(new Button(this.floatingBar, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + this.captureStripRecordBtn.label = `$(record) ${localize('recordVideo', "Record video")}`; + this.captureStripRecordBtn.element.classList.add('wizard-record-btn'); + this.disposables.add(this.captureStripRecordBtn.onDidClick(() => { + if (this.currentRecordingState === RecordingState.Recording) { + this._onDidRequestStopRecording.fire(); + } else if (this.currentRecordingState === RecordingState.Idle && this.getTotalAttachments() < MAX_ATTACHMENTS) { + this._onDidRequestStartRecording.fire(); + } + })); + } + + mountTarget.appendChild(this.floatingBar); + + // Dragging (clamped to window bounds) + let dragStartX = 0; + let dragStartY = 0; + let barStartX = 0; + let barStartY = 0; + + const onPointerMove = (e: PointerEvent) => { + const dx = e.clientX - dragStartX; + const dy = e.clientY - dragStartY; + const barW = this.floatingBar!.offsetWidth; + const barH = this.floatingBar!.offsetHeight; + const maxX = targetWindow.innerWidth - barW; + const maxY = targetWindow.innerHeight - barH; + const newX = Math.max(0, Math.min(barStartX + dx, maxX)); + const newY = Math.max(0, Math.min(barStartY + dy, maxY)); + this.floatingBar!.style.left = `${newX}px`; + this.floatingBar!.style.top = `${newY}px`; + this.floatingBar!.style.right = 'auto'; + }; + + const onPointerUp = () => { + dragArea.classList.remove('dragged'); + targetWindow.document.removeEventListener('pointermove', onPointerMove); + targetWindow.document.removeEventListener('pointerup', onPointerUp); + }; + + this.disposables.add(addDisposableListener(dragArea, EventType.POINTER_DOWN, (e: PointerEvent) => { + e.preventDefault(); + dragArea.classList.add('dragged'); + dragStartX = e.clientX; + dragStartY = e.clientY; + const rect = this.floatingBar!.getBoundingClientRect(); + barStartX = rect.left; + barStartY = rect.top; + targetWindow.document.addEventListener('pointermove', onPointerMove); + targetWindow.document.addEventListener('pointerup', onPointerUp); + })); + + // Keep the bar fully within the visible viewport when the window is + // resized. Without this, narrowing the window can clip the bar off the + // right edge — see screenshot in issue. The bar stays in its current + // relative position; we only nudge it inward when it would otherwise + // fall off-screen. + const clampIntoView = () => { + if (!this.floatingBar) { + return; + } + const rect = this.floatingBar.getBoundingClientRect(); + const winW = targetWindow.innerWidth; + const winH = targetWindow.innerHeight; + const margin = 8; + let needsClamp = false; + let nextLeft = rect.left; + let nextTop = rect.top; + if (rect.right > winW - margin) { + nextLeft = Math.max(margin, winW - margin - rect.width); + needsClamp = true; + } + if (rect.left < margin) { + nextLeft = margin; + needsClamp = true; + } + if (rect.bottom > winH - margin) { + nextTop = Math.max(margin, winH - margin - rect.height); + needsClamp = true; + } + if (rect.top < margin) { + nextTop = margin; + needsClamp = true; + } + if (needsClamp) { + this.floatingBar.style.left = `${nextLeft}px`; + this.floatingBar.style.top = `${nextTop}px`; + this.floatingBar.style.right = 'auto'; + } + }; + this.disposables.add(addDisposableListener(targetWindow, 'resize', clampIntoView)); + + this.disposables.add(toDisposable(() => { + this.floatingBar?.remove(); + })); + } + + private updateCaptureStripVisibility(): void { + if (!this.floatingBar) { + return; + } + // Show on all steps so the user can capture screenshots of the wizard itself + this.floatingBar.style.display = ''; + } + + // Step 1: Describe (category + description + title) + private createStep1Describe(): void { + const page = append(this.stepContainer, $('div.wizard-step')); + this.stepPages.push(page); + + const heading = append(page, $('h2.wizard-heading')); + heading.textContent = localize('describeHeading', "Describe your feedback"); + + // Issue source selection + extension dropdown share a row when both are visible + const targetRow = append(page, $('div.wizard-target-row')); + const sourceField = append(targetRow, $('div.wizard-field.wizard-source-field')); + const sourceLabel = append(sourceField, $('label.wizard-field-label')); + sourceLabel.textContent = localize('target', "Target"); + this.sourceButtonGroup = append(sourceField, $('div.wizard-type-buttons.wizard-source-buttons')); + for (const option of this.getSourceOptions()) { + const btn = this.disposables.add(new Button(this.sourceButtonGroup, { ...defaultButtonStyles, secondary: true })); + btn.element.classList.add('wizard-type-btn', 'wizard-source-btn'); + btn.element.setAttribute('data-source', option.value); + btn.element.setAttribute('aria-pressed', 'false'); + btn.label = option.label; + this.issueSourceButtons.push(btn); + this.disposables.add(btn.onDidClick(() => { + this.setIssueSource(option.value); + if (option.value === IssueSource.Extension && this.selectedExtension) { + void this.updateSelectedExtension(this.selectedExtension.id); + } + })); + } + this.sourceError = this.createFieldError(sourceField, localize('targetRequired', "Select a target to continue.")); + this.targetStatus = append(sourceField, $('div.wizard-target-status')); + + this.extensionField = append(targetRow, $('div.wizard-field.wizard-extension-field')); + const extensionLabel = append(this.extensionField, $('label.wizard-field-label')); + extensionLabel.textContent = localize('extension', "Extension"); + const extensionSelectContainer = append(this.extensionField, $('div.wizard-extension-select')); + this.extensionOptions = this.getExtensionOptions(); + this.extensionSelect = this.disposables.add(new SelectBox( + this.getExtensionSelectItems(), + this.getSelectedExtensionIndex(), + this.contextViewService, + defaultSelectBoxStyles, + { ariaLabel: localize('extension', "Extension"), useCustomDrawn: true, optionsAsChildren: true } + )); + this.extensionSelect.render(extensionSelectContainer); + this.disposables.add(this.extensionSelect.onDidSelect(e => { + void this.updateSelectedExtension(this.extensionOptions[e.index]?.value); + })); + this.extensionError = this.createFieldError(this.extensionField, localize('extensionRequired', "Select an extension to continue.")); + this.extensionStatus = append(this.extensionField, $('div.wizard-extension-status')); + this.updateExtensionOptions(); + this.updateExtensionFieldVisibility(); + this.updateIssueSourceButtons(); + + // Category selection + const catLabel = append(page, $('label.wizard-field-label')); + catLabel.textContent = localize('feedbackCategory', "Category"); + + this.typeButtonGroup = append(page, $('div.wizard-type-buttons')); + const types = [ + { type: IssueType.Bug, label: localize('bug', "Bug"), icon: Codicon.bug }, + { type: IssueType.FeatureRequest, label: localize('featureRequest', "Feature Request"), icon: Codicon.lightbulb }, + { type: IssueType.PerformanceIssue, label: localize('performanceIssue', "Performance Issue"), icon: Codicon.dashboard }, + ]; + + const selectType = (type: IssueType) => { + this.selectedIssueType = type; + this.model.update({ issueType: type }); + this.setFieldError(this.typeButtonGroup, this.typeError, false); + for (const b of this.issueTypeButtons) { + const isSelected = b.element.getAttribute('data-type') === String(type); + b.element.classList.toggle('selected', isSelected); + b.element.setAttribute('aria-pressed', String(isSelected)); + } + this.updateDescriptionGuidance(); + this.updateIssueSourceButtons(); + if (this.currentStep === WizardStep.Review) { + this.updateReviewDetails(); + } + this.searchSimilarIssues(); + }; + + for (const { type, label, icon } of types) { + const btn = this.disposables.add(new Button(this.typeButtonGroup, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + btn.element.classList.add('wizard-type-btn'); + btn.element.setAttribute('data-type', String(type)); + btn.element.setAttribute('aria-pressed', 'false'); + btn.label = `$(${icon.id}) ${label}`; + this.issueTypeButtons.push(btn); + this.disposables.add(btn.onDidClick(() => selectType(type))); + } + this.typeError = this.createFieldError(page, localize('categoryRequired', "Select a category to continue.")); + + // Title field with AI generate button next to label + const titleGroup = append(page, $('div.wizard-field.wizard-title-field')); + const titleLabelRow = append(titleGroup, $('div.wizard-title-label-row')); + const titleLabel = append(titleLabelRow, $('label.wizard-field-label')); + titleLabel.textContent = localize('issueTitle', "Title"); + + const aiBtn = this.disposables.add(new Button(titleLabelRow, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + aiBtn.label = `$(sparkle) ${localize('generateTitleBtn', "Generate from description")}`; + aiBtn.element.classList.add('wizard-ai-title-btn'); + aiBtn.element.title = localize('generateTitle', "Generate title from description"); + aiBtn.enabled = !!this.data.issueBody?.trim(); + this.disposables.add(aiBtn.onDidClick(() => { + const desc = this.descriptionTextarea.value.trim(); + if (desc && !aiBtn.element.classList.contains('loading')) { + // Lock width to prevent layout shift during loading + aiBtn.element.style.minWidth = `${aiBtn.element.offsetWidth}px`; + aiBtn.enabled = false; + aiBtn.label = `$(loading~spin) ${localize('generatingTitle', "Generating...")}`; + aiBtn.element.classList.add('loading'); + this._onDidRequestGenerateTitle.fire(desc); + } + })); + this.generateTitleBtn = aiBtn; + + this.titleInput = this.disposables.add(new InputBox(titleGroup, undefined, { + placeholder: localize('issueTitlePlaceholder', "Brief summary of the issue"), + inputBoxStyles: defaultInputBoxStyles, + })); + this.updateTitlePlaceholder(); + if (this.data.issueTitle) { + this.titleInput.value = this.data.issueTitle; + } + this.disposables.add(this.titleInput.onDidChange(() => { + if (this.titleInput.value.trim()) { + this.setFieldError(this.titleInput.element, this.titleError, false); + } + this.searchSimilarIssues(); + })); + this.titleError = this.createFieldError(titleGroup, localize('titleRequired', "Enter a title to continue.")); + + // Description field with guidance and auto-growing textarea + const descriptionGroup = append(page, $('div.wizard-field')); + const descLabel = append(descriptionGroup, $('label.wizard-field-label')); + descLabel.textContent = localize('description', "Description"); + + this.descriptionGuidance = append(descriptionGroup, $('p.wizard-subtitle.wizard-description-guidance')); + this.updateDescriptionGuidance(); + + this.descriptionTextarea = append(descriptionGroup, $('textarea.wizard-textarea')) as HTMLTextAreaElement; + this.descriptionTextarea.placeholder = localize('descriptionPlaceholder', "Describe the issue in detail..."); + this.descriptionTextarea.rows = 6; + if (this.data.issueBody) { + this.descriptionTextarea.value = this.data.issueBody; + } + const autoGrowTextarea = () => { + this.descriptionTextarea.style.height = '0'; + const newHeight = Math.max(this.descriptionTextarea.scrollHeight, 120); + this.descriptionTextarea.style.height = `${newHeight}px`; + }; + autoGrowTextarea(); + this.disposables.add(addDisposableListener(this.descriptionTextarea, EventType.INPUT, () => { + if (this.descriptionTextarea.value.trim()) { + this.setFieldError(this.descriptionTextarea, this.descriptionError, false); + } + autoGrowTextarea(); + this.searchSimilarIssues(); + this.updateGenerateTitleButtonState(); + })); + this.descriptionError = this.createFieldError(descriptionGroup, localize('descriptionRequired', "Enter a description to continue.")); + + this.updateIssueSourceFlags(); + this.updateTargetStatus(); + } + + private getSourceOptions(): { label: string; value: IssueSource }[] { + const options: { label: string; value: IssueSource }[] = [ + { label: product.nameLong || localize('vscode', "Visual Studio Code"), value: IssueSource.VSCode }, + { label: localize('extensionSource', "A VS Code extension"), value: IssueSource.Extension }, + { label: localize('marketplace', "Extensions Marketplace"), value: IssueSource.Marketplace }, + ]; + return options; + } + + private updateIssueSourceButtons(): void { + const availableSources = new Set(this.getSourceOptions().map(option => option.value)); + if (this.selectedIssueSource && !availableSources.has(this.selectedIssueSource)) { + this.selectedIssueSource = undefined; + this.updateIssueSourceFlags(); + this.updateExtensionValidation(); + } + + for (const button of this.issueSourceButtons) { + const source = button.element.getAttribute('data-source') as IssueSource; + const isAvailable = availableSources.has(source); + const isSelected = source === this.selectedIssueSource; + button.element.classList.toggle('hidden', !isAvailable); + button.element.classList.toggle('selected', isSelected); + button.element.setAttribute('aria-pressed', String(isSelected)); + } + + this.updateExtensionFieldVisibility(); + } + + private setIssueSource(source: IssueSource | undefined): void { + this.selectedIssueSource = source; + this.setFieldError(this.sourceButtonGroup, this.sourceError, this.didAttemptDescribeSubmit && !source); + this.updateIssueSourceFlags(); + this.updateIssueSourceButtons(); + this.updateExtensionValidation(); + this.updateTitlePlaceholder(); + this.updateTargetStatus(); + this.searchSimilarIssues(); + } + + private updateIssueSourceFlags(): void { + const fileOnExtension = this.selectedIssueSource === IssueSource.Extension; + const fileOnMarketplace = this.selectedIssueSource === IssueSource.Marketplace; + const fileOnProduct = this.selectedIssueSource === IssueSource.VSCode || this.selectedIssueSource === IssueSource.Unknown; + this.model.update({ + issueSource: this.selectedIssueSource, + fileOnExtension, + fileOnMarketplace, + fileOnProduct, + selectedExtension: this.selectedExtension, + }); + this.data.issueSource = this.selectedIssueSource; + this.data.extensionId = fileOnExtension ? this.selectedExtension?.id : undefined; + } + + private updateTitlePlaceholder(): void { + if (!this.titleInput) { + return; + } + switch (this.selectedIssueSource) { + case IssueSource.Extension: + this.titleInput.setPlaceHolder(localize('extensionPlaceholder', "E.g. Missing alt text on extension readme image")); + break; + case IssueSource.Marketplace: + this.titleInput.setPlaceHolder(localize('marketplacePlaceholder', "E.g. Cannot disable installed extension")); + break; + case IssueSource.VSCode: + this.titleInput.setPlaceHolder(localize('vscodePlaceholder', "E.g. Workbench is missing problems panel")); + break; + default: + this.titleInput.setPlaceHolder(localize('issueTitlePlaceholder', "Brief summary of the issue")); + break; + } + } + + private getExtensionOptions(): { label: string; value: string | undefined; hidden?: boolean }[] { + const modelData = this.model.getData(); + const sourceExtensions = modelData.enabledNonThemeExtesions ?? modelData.allExtensions ?? []; + const extensions = [...sourceExtensions] + .filter(extension => !extension.isTheme && !extension.isBuiltin) + .sort((a, b) => (a.displayName || a.name || a.id).localeCompare(b.displayName || b.name || b.id)); + return [ + { label: localize('selectExtension', "Select extension"), value: undefined, hidden: true }, + ...extensions.map(extension => ({ label: extension.displayName || extension.name || extension.id, value: extension.id })), + ]; + } + + private getExtensionSelectItems(): ISelectOptionItem[] { + return this.extensionOptions.map(option => ({ text: option.label, isDisabled: option.hidden })); + } + + private getSelectedExtensionIndex(): number { + return Math.max(0, this.extensionOptions.findIndex(option => option.value === this.selectedExtension?.id || option.value === this.data.extensionId)); + } + + private updateExtensionOptions(): void { + if (!this.extensionSelect) { + return; + } + this.extensionOptions = this.getExtensionOptions(); + this.extensionSelect.setOptions(this.getExtensionSelectItems(), this.getSelectedExtensionIndex()); + if (!this.selectedExtension && this.data.extensionId) { + void this.updateSelectedExtension(this.data.extensionId, false); + } + } + + private updateExtensionFieldVisibility(): void { + if (!this.extensionField) { + return; + } + this.extensionField.classList.toggle('hidden', this.selectedIssueSource !== IssueSource.Extension); + } + + private updateExtensionValidation(): void { + const hasExtension = this.selectedIssueSource !== IssueSource.Extension || !!this.selectedExtension; + const hasExtensionIssueUrl = this.selectedIssueSource !== IssueSource.Extension || !this.selectedExtension || !!this.getSelectedExtensionIssueUrl(); + this.setFieldError(this.extensionField, this.extensionError, this.didAttemptDescribeSubmit && (!hasExtension || !hasExtensionIssueUrl)); + } + + private async updateSelectedExtension(extensionId: string | undefined, loadExtensionData = true): Promise { + const extension = extensionId + ? this.model.getData().allExtensions.find(candidate => candidate.id.toLowerCase() === extensionId.toLowerCase()) + : undefined; + this.selectedExtension = extension; + this.data.extensionId = extension?.id; + if (this.extensionSelect) { + this.extensionSelect.select(this.getSelectedExtensionIndex()); + } + this.updateExtensionValidation(); + this.updateIssueSourceFlags(); + + if (!extension) { + this.updateTargetStatus(); + this.searchSimilarIssues(); + return; + } + + if (extension.isBuiltin && this.selectedIssueSource === IssueSource.Extension && !this.data.issueSource) { + this.setIssueSource(IssueSource.VSCode); + return; + } + + if (loadExtensionData && this.resolveExtensionIssueData) { + const request = ++this.extensionDataRequest; + this.extensionStatus.textContent = localize('loadingExtensionData', "Loading extension issue data..."); + const issueData = await this.resolveExtensionIssueData(extension.id); + if (request !== this.extensionDataRequest) { + return; + } + if (issueData) { + this.applyExtensionIssueData(extension, issueData); + } + } + + this.updateTargetStatus(); + this.searchSimilarIssues(); + } + + private applyExtensionIssueData(extension: IssueReporterExtensionData, issueData: IssueReporterData): void { + extension.data = issueData.data; + extension.uri = issueData.uri; + extension.privateUri = issueData.privateUri; + this.data.data = issueData.data; + this.data.uri = issueData.uri; + this.data.privateUri = issueData.privateUri; + this.data.issueBody = issueData.issueBody ?? this.data.issueBody; + this.data.issueTitle = issueData.issueTitle ?? this.data.issueTitle; + if (issueData.issueTitle && !this.titleInput.value.trim()) { + this.titleInput.value = issueData.issueTitle; + } + if (issueData.issueBody && !this.descriptionTextarea.value.includes(issueData.issueBody)) { + this.descriptionTextarea.value = this.descriptionTextarea.value + ? `${this.descriptionTextarea.value}\n${issueData.issueBody}` + : issueData.issueBody; + } + if (issueData.data) { + extension.extensionData = issueData.data; + this.model.update({ extensionData: issueData.data, includeExtensionData: true }); + this.includeExtensionData = true; + } + } + + private updateTargetStatus(): void { + if (!this.targetStatus) { + return; + } + + this.targetStatus.textContent = ''; + this.extensionStatus.textContent = ''; + if (!this.selectedIssueSource) { + return; + } + + if (this.selectedIssueSource !== IssueSource.Extension) { + const repo = this.getIssueTargetRepo(); + this.targetStatus.textContent = repo + ? localize('issueTargetRepo', "Issue will be created in {0}/{1}.", repo.owner, repo.repositoryName) + : ''; + return; + } + + if (!this.selectedExtension) { + return; + } + + const issueUrl = this.getSelectedExtensionIssueUrl(); + if (!issueUrl) { + this.extensionStatus.textContent = localize('extensionNoIssueUrl', "This extension does not provide an issue reporting URL."); + } else if (!this.isGitHubUrl(issueUrl)) { + this.extensionStatus.textContent = localize('extensionExternalIssueUrl', "This extension uses an external issue reporter. Preview will open that issue reporter."); + } else { + const repo = this.getIssueTargetRepo(); + this.extensionStatus.textContent = repo + ? localize('issueTargetRepo', "Issue will be created in {0}/{1}.", repo.owner, repo.repositoryName) + : ''; + } + } + + private getIssueTargetRepo(): { owner: string; repositoryName: string } | undefined { + const targetUrl = this.getIssueTargetUrl(); + return targetUrl ? this.parseGitHubUrl(targetUrl) : undefined; + } + + private getSelectedExtensionIssueUrl(): string | undefined { + const extension = this.selectedExtension; + if (!extension) { + return 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 getIssueSourceLabel(): string { + switch (this.selectedIssueSource) { + case IssueSource.VSCode: + return product.nameLong || localize('vscode', "Visual Studio Code"); + case IssueSource.Extension: + return this.selectedExtension?.displayName || this.selectedExtension?.name || localize('extensionSource', "A VS Code extension"); + case IssueSource.Marketplace: + return localize('marketplace', "Extensions Marketplace"); + case IssueSource.Unknown: + return localize('unknownSource', "Don't know"); + default: + return localize('unknown', "Unknown"); + } + } + + private getIssueTargetUrl(): string | undefined { + if (this.selectedIssueSource === IssueSource.Extension) { + return this.getSelectedExtensionIssueUrl(); + } + if (this.selectedIssueSource === IssueSource.Marketplace) { + return product.reportMarketplaceIssueUrl ?? product.reportIssueUrl; + } + if (this.data.uri) { + return URI.revive(this.data.uri).toString(); + } + if (this.data.privateUri) { + return URI.revive(this.data.privateUri).toString(); + } + return product.reportIssueUrl; + } + + 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 searchSimilarIssues(): void { + if (this.currentStep !== WizardStep.Review || !this.similarIssuesContainer) { + return; + } + if (this.similarIssuesHandle) { + clearTimeout(this.similarIssuesHandle); + } + this.renderSimilarIssuesMessage(localize('searchingSimilarIssues', "Searching similar issues...")); + this.similarIssuesHandle = setTimeout(() => this.doSearchSimilarIssues(), 300); + } + + private async doSearchSimilarIssues(): Promise { + const title = this.titleInput.value.trim(); + const request = ++this.similarIssuesRequest; + if (!title || !this.selectedIssueSource) { + this.renderSimilarIssuesMessage(localize('similarIssuesNeedsTitle', "Enter a title to search for similar issues.")); + return; + } + + this.renderSimilarIssuesMessage(localize('searchingSimilarIssues', "Searching similar issues...")); + try { + let results: ISimilarIssue[] = []; + if (this.selectedIssueSource === IssueSource.Extension) { + const extensionIssueUrl = this.getSelectedExtensionIssueUrl(); + const repo = extensionIssueUrl && this.parseGitHubUrl(extensionIssueUrl); + results = repo ? await this.searchGitHubIssues(`${repo.owner}/${repo.repositoryName}`, title) : []; + } else if (this.selectedIssueSource === IssueSource.Marketplace) { + const marketplaceIssueUrl = product.reportMarketplaceIssueUrl ?? product.reportIssueUrl; + const repo = marketplaceIssueUrl && this.parseGitHubUrl(marketplaceIssueUrl); + results = repo ? await this.searchGitHubIssues(`${repo.owner}/${repo.repositoryName}`, title) : []; + } else { + results = await this.searchVSCodeSimilarIssues(title, this.descriptionTextarea.value.trim()); + } + if (request === this.similarIssuesRequest) { + this.renderSimilarIssues(results); + } + } catch { + if (request === this.similarIssuesRequest) { + this.renderSimilarIssuesMessage(localize('similarIssuesSearchFailed', "Unable to search for similar issues.")); + } + } + } + + private async searchGitHubIssues(repo: string, title: string): Promise { + const query = `is:issue repo:${repo} ${title}`; + const response = await fetch(`https://api.github.com/search/issues?q=${encodeURIComponent(query)}`); + const result = await response.json(); + return Array.isArray(result?.items) ? result.items : []; + } + + private async searchVSCodeDuplicates(title: string, body: string): Promise { + const response = await fetch('https://vscode-probot.westus.cloudapp.azure.com:7890/duplicate_candidates', { + method: 'POST', + body: JSON.stringify({ title, body }), + headers: new Headers({ 'Content-Type': 'application/json' }), + }); + const result = await response.json(); + return Array.isArray(result?.candidates) ? result.candidates : []; + } + + private async searchVSCodeSimilarIssues(title: string, body: string): Promise { + try { + const duplicates = await this.searchVSCodeDuplicates(title, body); + if (duplicates.length) { + return duplicates; + } + } catch { + // Fall back to GitHub search below. + } + + const repo = this.getIssueTargetRepo(); + return repo ? this.searchGitHubIssues(`${repo.owner}/${repo.repositoryName}`, title) : []; + } + + private renderSimilarIssuesMessage(message: string): void { + this.resetSimilarIssuesContainer(); + const status = append(this.similarIssuesContainer, $('div.wizard-similar-status')); + status.textContent = message; + } + + private renderSimilarIssues(results: ISimilarIssue[]): void { + if (!results.length) { + this.renderSimilarIssuesMessage(localize('noSimilarIssues', "No similar issues found.")); + return; + } + + this.resetSimilarIssuesContainer(); + const list = append(this.similarIssuesContainer, $('ul.wizard-similar-list')); + for (const issue of results.slice(0, MAX_SIMILAR_ISSUES)) { + const item = append(list, $('li.wizard-similar-item')); + const link = append(item, $('a.wizard-similar-link')) as HTMLAnchorElement; + link.href = issue.html_url; + link.textContent = issue.title; + link.title = issue.title; + this.similarIssuesDisposables.add(addDisposableListener(link, EventType.CLICK, e => { + e.preventDefault(); + this.openExternalLink?.(issue.html_url); + })); + if (issue.state) { + const state = append(item, $('span.wizard-similar-state')); + state.textContent = issue.state; + } + } + } + + /** Clear the similar-issues container and re-render the section heading. */ + private resetSimilarIssuesContainer(): void { + this.similarIssuesDisposables.clear(); + this.similarIssuesContainer.textContent = ''; + const heading = append(this.similarIssuesContainer, $('div.wizard-similar-heading')); + heading.textContent = localize('similarIssues', "Similar Issues"); + } + + /** Update the guidance text above the description based on selected category */ + private updateDescriptionGuidance(): void { + if (!this.descriptionGuidance) { + return; + } + const markdownHint = localize('markdownSupported', "Markdown formatting is supported."); + switch (this.selectedIssueType) { + case IssueType.Bug: + this.descriptionGuidance.textContent = `${localize('bugGuidance', + "Describe what happened, the steps to reproduce, what you expected, and what you observed instead.")}\n${markdownHint}`; + break; + case IssueType.FeatureRequest: + this.descriptionGuidance.textContent = `${localize('featureGuidance', + "Describe the feature you'd like to see, what problem it would solve, and any alternatives you've considered.")}\n${markdownHint}`; + break; + case IssueType.PerformanceIssue: + this.descriptionGuidance.textContent = `${localize('perfGuidance', + "Describe what is slow, when it happens, whether it's consistent or intermittent, and any patterns you've noticed.")}\n${markdownHint}`; + break; + default: + this.descriptionGuidance.textContent = `${localize('defaultGuidance', + "Select a category above, then describe your feedback in detail.")}\n${markdownHint}`; + break; + } + } + + private hasDescriptionContent(): boolean { + return !!this.descriptionTextarea.value.trim(); + } + + private updateGenerateTitleButtonState(): void { + if (!this.generateTitleBtn || this.generateTitleBtn.element.classList.contains('loading')) { + return; + } + this.generateTitleBtn.enabled = this.hasDescriptionContent(); + } + + private createFieldError(parent: HTMLElement, message: string): HTMLElement { + const error = append(parent, $('div.wizard-field-error.hidden')); + error.textContent = message; + error.setAttribute('role', 'alert'); + return error; + } + + private setFieldError(field: HTMLElement, error: HTMLElement, hasError: boolean): void { + field.classList.toggle('invalid-input', hasError); + error.classList.toggle('hidden', !hasError); + } + + // Step 2: Review & Submit + private createStep2Review(): void { + const page = append(this.stepContainer, $('div.wizard-step.wizard-step-review')); + this.stepPages.push(page); + + const heading = append(page, $('h2.wizard-heading')); + heading.textContent = localize('reviewSubmit', "Review and submit"); + + // Review details (filled dynamically) with compact horizontal layout + append(page, $('div.wizard-review-details')); + } + + private registerEventHandlers(): void { + // Back + this.disposables.add(this.backButton.onDidClick(() => this.goBack())); + + // Next + this.disposables.add(this.nextButton.onDidClick(() => this.goNext())); + } + + private goBack(): void { + if (this.currentStep > WizardStep.Attachments) { + this.setStep(this.currentStep - 1); + } + } + + private goNext(): void { + if (this.currentStep === WizardStep.Describe) { + this.didAttemptDescribeSubmit = true; + const hasIssueSource = this.selectedIssueSource !== undefined; + const hasExtension = this.selectedIssueSource !== IssueSource.Extension || !!this.selectedExtension; + const hasExtensionIssueUrl = this.selectedIssueSource !== IssueSource.Extension || !this.selectedExtension || !!this.getSelectedExtensionIssueUrl(); + const hasIssueType = this.selectedIssueType !== undefined; + const hasDescription = this.hasDescriptionContent(); + const title = this.titleInput.value.trim(); + + this.setFieldError(this.sourceButtonGroup, this.sourceError, !hasIssueSource); + this.setFieldError(this.extensionField, this.extensionError, !hasExtension || !hasExtensionIssueUrl); + this.setFieldError(this.typeButtonGroup, this.typeError, !hasIssueType); + this.setFieldError(this.descriptionTextarea, this.descriptionError, !hasDescription); + this.setFieldError(this.titleInput.element, this.titleError, !title); + + if (!hasIssueSource || !hasExtension || !hasExtensionIssueUrl || !hasIssueType || !hasDescription || !title) { + if (!hasIssueSource) { + this.issueSourceButtons.find(button => !button.element.classList.contains('hidden'))?.element.focus(); + } else if (!hasExtension || !hasExtensionIssueUrl) { + this.extensionSelect.focus(); + } else if (!hasIssueType) { + this.issueTypeButtons[0]?.element.focus(); + } else if (!hasDescription) { + this.descriptionTextarea.focus(); + } else { + this.titleInput.focus(); + } + return; + } + this.updateIssueSourceFlags(); + this.model.update({ issueDescription: this.descriptionTextarea.value.trim() }); + } + + if (this.currentStep === WizardStep.Review) { + // Defensive: if user managed to invoke goNext while diagnostics are + // still loading (e.g. via Cmd/Ctrl+Enter), block the submit. The + // Preview button is also visually disabled in this state. + if (this.selectedIssueType === IssueType.PerformanceIssue && (!this.performanceInfoLoaded || this.performanceInfoRefreshing)) { + return; + } + this.submit(); + return; + } + + if (this.currentStep < WizardStep.Review) { + this.setStep(this.currentStep + 1); + } + } + + private setStep(step: WizardStep): void { + const oldStep = this.currentStep; + this.currentStep = step; + + const oldPage = this.stepPages[oldStep]; + const newPage = this.stepPages[step]; + + // Immediate transition with no animation + oldPage.style.display = 'none'; + newPage.style.display = 'flex'; + + this.updateStepUI(); + + if (step === WizardStep.Describe) { + this.descriptionTextarea.focus(); + } else if (step === WizardStep.Review) { + this.updateReviewDetails(); + this.searchSimilarIssues(); + this.wizardPanel.focus(); + } else { + // Attachments: focus the panel so keyboard shortcuts work + this.wizardPanel.focus(); + } + } + + private updateStepUI(): void { + const stepNum = this.currentStep + 1; + this.stepIndicator.textContent = localize('stepOf', "Step {0} of {1}", stepNum, STEP_COUNT); + + const stepNames = [ + localize('screenshots', "Attachments"), + localize('composeMessage', "Describe"), + localize('submit', "Review"), + ]; + this.stepLabel.textContent = stepNames[this.currentStep]; + + // Update progress dots + for (let i = 0; i < this.progressDots.length; i++) { + this.progressDots[i].classList.toggle('active', i === this.currentStep); + this.progressDots[i].classList.toggle('completed', i < this.currentStep); + } + + // Show/hide pages + for (let i = 0; i < this.stepPages.length; i++) { + if (i === this.currentStep) { + this.stepPages[i].style.display = 'flex'; + } else if (!this.stepPages[i].classList.contains('slide-out-left') && !this.stepPages[i].classList.contains('slide-out-right')) { + this.stepPages[i].style.display = 'none'; + } + } + + // Back button visibility + this.backButton.element.style.display = this.currentStep === WizardStep.Attachments ? 'none' : ''; + if (this.closeButton) { + const currentDraftPreviewed = this.previewedDraftKey === this.getDraftKey(); + this.closeButton.element.style.display = this.previewOpened && currentDraftPreviewed && this.currentStep === WizardStep.Review ? '' : 'none'; + } + + // Next button label + if (this.currentStep === WizardStep.Review) { + const externalExtensionUrl = this.selectedIssueSource === IssueSource.Extension && this.getIssueTargetUrl() && !this.isGitHubUrl(this.getIssueTargetUrl()!); + const waitingForData = this.selectedIssueType === IssueType.PerformanceIssue && (!this.performanceInfoLoaded || this.performanceInfoRefreshing); + if (waitingForData) { + this.nextButton.label = `$(loading~spin) ${localize('loadingDiagnostics', "Loading diagnostics...")}`; + this.nextButton.element.title = localize('waitingForDiagnostics', "Waiting for performance diagnostics to finish loading"); + this.nextButton.enabled = false; + } else { + this.nextButton.label = externalExtensionUrl + ? localize('openExternalIssueReporter', "Open External Issue Reporter") + : localize('previewOnGitHub', "Preview on GitHub"); + this.nextButton.element.title = this.nextButton.label; + this.nextButton.enabled = true; + } + } else if (this.currentStep === WizardStep.Attachments) { + this.nextButton.label = this.getTotalAttachments() === 0 + ? localize('skip', "Skip") + : localize('next', "Next"); + this.nextButton.element.title = this.nextButton.label; + } else { + this.nextButton.label = localize('next', "Next"); + this.nextButton.element.title = localize('next', "Next"); + } + + // Show/hide capture strip (only on attachments step) + this.updateCaptureStripVisibility(); + // Reflect recording state on next button + this.updateNextButtonForRecording(); + } + + private updateReviewDetails(): void { + const page = this.stepPages[WizardStep.Review]; + // eslint-disable-next-line no-restricted-syntax + const details = page.querySelector('.wizard-review-details'); + if (!details) { + return; + } + this.reviewRenderDisposables.clear(); + details.textContent = ''; + + const similarSection = append(details as HTMLElement, $('div.review-section.wizard-review-similar-section')); + this.similarIssuesContainer = append(similarSection, $('div.wizard-similar-issues')); + this.similarIssuesContainer.setAttribute('aria-live', 'polite'); + this.renderSimilarIssuesMessage(localize('searchingSimilarIssues', "Searching similar issues...")); + + const sourceSection = append(details as HTMLElement, $('div.review-section')); + const sourceLabel = append(sourceSection, $('div.review-label')); + sourceLabel.textContent = localize('target', "Target"); + const sourceValue = append(sourceSection, $('div.review-value')); + sourceValue.textContent = this.getIssueSourceLabel(); + + const catSection = append(details as HTMLElement, $('div.review-section')); + const catLabel = append(catSection, $('div.review-label')); + catLabel.textContent = localize('category', "Category"); + const catValue = append(catSection, $('div.review-value')); + const typeLabels: Record = { + [IssueType.Bug]: localize('bug', "Bug"), + [IssueType.FeatureRequest]: localize('featureRequest', "Feature Request"), + [IssueType.PerformanceIssue]: localize('performanceIssue', "Performance Issue"), + }; + catValue.textContent = (this.selectedIssueType !== undefined ? typeLabels[this.selectedIssueType] : undefined) ?? localize('unknown', "Unknown"); + + const titleSection = append(details as HTMLElement, $('div.review-section')); + const titleLabel = append(titleSection, $('div.review-label')); + titleLabel.textContent = localize('issueTitle', "Title"); + const titleValue = append(titleSection, $('div.review-value')); + titleValue.textContent = this.titleInput.value.trim() || localize('noTitle', "(no title)"); + + const descSection = append(details as HTMLElement, $('div.review-section')); + const descLabel = append(descSection, $('div.review-label')); + descLabel.textContent = localize('description', "Description"); + const descValue = append(descSection, $('div.review-value.review-description')); + const description = this.descriptionTextarea.value.trim(); + if (description && this.markdownRendererService) { + const renderedMarkdown = this.markdownRendererService.render( + new MarkdownString(description), + { markedOptions: { breaks: true } }, + ); + append(descValue, renderedMarkdown.element); + this.reviewRenderDisposables.add(renderedMarkdown); + } else { + descValue.textContent = description || localize('noDescription', "(no description)"); + } + + // Attachments row with full-size clickable thumbnails + const totalAttachments = this.screenshots.length + this.recordings.length; + if (totalAttachments > 0) { + const attachSection = append(details as HTMLElement, $('div.review-section')); + const attachLabel = append(attachSection, $('div.review-label')); + attachLabel.textContent = localize('attachments', "Attachments ({0})", totalAttachments); + const thumbRow = append(attachSection, $('div.review-thumbnails')); + this.reviewThumbCards = []; + + for (let i = 0; i < this.screenshots.length; i++) { + const s = this.screenshots[i]; + const card = append(thumbRow, $('div.wizard-screenshot-card.review-attachment-card')); + const img = append(card, $('img')) as HTMLImageElement; + img.src = s.annotatedDataUrl ?? s.dataUrl; + img.alt = localize('screenshotAlt', "Screenshot {0}", i + 1); + + // Progress overlay (hidden initially) + const progressOverlay = append(card, $('div.review-progress-overlay')); + append(progressOverlay, $('div.review-progress-ring')); + + this.disposables.add(addDisposableListener(card, EventType.CLICK, () => { + if (!this.uploading) { + this._onDidRequestOpenScreenshot.fire(s); + } + })); + this.reviewThumbCards.push(card); + } + + for (let i = 0; i < this.recordings.length; i++) { + const rec = this.recordings[i]; + const card = this.renderRecordingCard(thumbRow, rec, i); + card.classList.add('review-attachment-card'); + + const progressOverlay = append(card, $('div.review-progress-overlay')); + append(progressOverlay, $('div.review-progress-ring')); + + this.disposables.add(addDisposableListener(card, EventType.CLICK, () => { + if (!this.uploading) { + this._onDidRequestOpenRecording.fire(rec.filePath); + } + })); + this.reviewThumbCards.push(card); + } + } + + // Diagnostic data sections with checkboxes and collapsible details + const diagContainer = append(details as HTMLElement, $('div.review-diagnostics')); + + const modelData = this.model.getData(); + let diagnosticSectionCount = 0; + const diagnosticSectionStates: (() => boolean)[] = []; + this.diagnosticBulkToggleButton = undefined; + this.diagnosticSectionStates = diagnosticSectionStates; + + // System Info + if (modelData.versionInfo || modelData.systemInfo) { + diagnosticSectionCount++; + diagnosticSectionStates.push(() => this.includeSystemInfo); + this.createDiagSection(diagContainer, { + id: 'system-info', + label: localize('systemInformation', "System Information"), + checked: this.includeSystemInfo, + onToggle: (checked) => { + this.includeSystemInfo = checked; + this.model.update({ includeSystemInfo: checked }); + }, + renderContent: (container) => { + const sysTable = append(container, $('table.review-diag-table')); + if (modelData.versionInfo) { + this.addDiagRow(sysTable, 'VS Code', modelData.versionInfo.vscodeVersion); + this.addDiagRow(sysTable, 'OS', modelData.versionInfo.os); + } + if (modelData.systemInfo) { + this.addDiagRow(sysTable, 'CPUs', modelData.systemInfo.cpus ?? ''); + this.addDiagRow(sysTable, 'Memory', modelData.systemInfo.memory); + this.addDiagRow(sysTable, 'VM', modelData.systemInfo.vmHint); + this.addDiagRow(sysTable, 'Screen Reader', modelData.systemInfo.screenReader); + } + this.addDiagRow(sysTable, 'User Agent', navigator.userAgent); + this.addDiagRow(sysTable, 'Installation pure', String(modelData.isInstallationPure ?? true)); + if (modelData.restrictedMode) { + this.addDiagRow(sysTable, 'Mode', 'Restricted'); + } + }, + }); + } else { + const loading = append(diagContainer, $('div.review-diag-loading')); + loading.textContent = localize('loadingSystemInfo', "Loading system information..."); + } + + if (modelData.fileOnExtension && modelData.extensionData) { + diagnosticSectionCount++; + diagnosticSectionStates.push(() => this.includeExtensionData); + this.createDiagSection(diagContainer, { + id: 'extension-data', + label: localize('extensionData', "Extension Data"), + checked: this.includeExtensionData, + onToggle: (checked) => { + this.includeExtensionData = checked; + this.model.update({ includeExtensionData: checked }); + }, + renderContent: (container) => { + const pre = append(container, $('pre.review-diag-pre')); + pre.textContent = modelData.extensionData!; + }, + }); + } + + // Extensions (non-theme only) + const nonThemeExtensions = (modelData.allExtensions ?? []).filter(e => !e.isTheme && !e.isBuiltin); + if (!modelData.fileOnExtension && !modelData.fileOnMarketplace && nonThemeExtensions.length > 0) { + diagnosticSectionCount++; + diagnosticSectionStates.push(() => this.includeExtensions); + this.createDiagSection(diagContainer, { + id: 'extensions', + label: localize('extensions', "Extensions ({0})", nonThemeExtensions.length), + checked: this.includeExtensions, + onToggle: (checked) => { + this.includeExtensions = checked; + this.model.update({ includeExtensions: checked }); + }, + renderContent: (container) => { + const extTable = append(container, $('table.review-diag-table.review-ext-table')); + const header = append(extTable, $('tr')); + for (const h of ['Name', 'Identifier', 'Author', 'Version']) { + const th = append(header, $('th.review-ext-th')); + th.textContent = h; + } + for (const ext of nonThemeExtensions) { + const row = append(extTable, $('tr')); + append(row, $('td')).textContent = ext.displayName || ext.name; + append(row, $('td')).textContent = ext.id; + append(row, $('td')).textContent = ext.publisher ?? ''; + append(row, $('td')).textContent = ext.version; + } + }, + }); + } + + // Experiments + if (modelData.experimentInfo) { + diagnosticSectionCount++; + diagnosticSectionStates.push(() => this.includeExperiments); + this.createDiagSection(diagContainer, { + id: 'experiments', + label: localize('abExperiments', "A/B Experiments"), + checked: this.includeExperiments, + onToggle: (checked) => { + this.includeExperiments = checked; + this.model.update({ includeExperiments: checked }); + }, + renderContent: (container) => { + const pre = append(container, $('pre.review-diag-pre')); + pre.textContent = modelData.experimentInfo!; + }, + }); + } + + // Settings + if (this.settingsContent) { + diagnosticSectionCount++; + diagnosticSectionStates.push(() => this.includeSettings); + this.createDiagSection(diagContainer, { + id: 'settings', + label: localize('settings', "Settings"), + checked: this.includeSettings, + onToggle: (checked) => { + this.includeSettings = checked; + }, + renderContent: (container) => { + const userLabel = append(container, $('div.review-diag-sublabel')); + userLabel.textContent = localize('userSettings', "User Settings"); + const userPre = append(container, $('pre.review-diag-pre')); + userPre.textContent = this.settingsContent!; + }, + }); + } + + if (this.selectedIssueType === IssueType.PerformanceIssue && !modelData.fileOnMarketplace) { + const performanceContainer = append(diagContainer, $('div.review-performance-data')); + if (this.performanceInfoRefreshing) { + performanceContainer.classList.add('refreshing'); + } + const performanceTitleRow = append(performanceContainer, $('div.review-performance-title-row')); + const performanceTitle = append(performanceTitleRow, $('div.review-performance-title')); + performanceTitle.textContent = localize('additionalPerformanceData', "Additional Performance Data"); + if (this.refreshPerformanceInfo) { + const refreshBtn = this.disposables.add(new Button(performanceTitleRow, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + refreshBtn.element.classList.add('review-performance-refresh'); + refreshBtn.label = `$(refresh) ${localize('refresh', "Refresh")}`; + refreshBtn.element.title = localize('refreshPerformanceData', "Reload running processes and workspace metadata"); + refreshBtn.enabled = !this.performanceInfoRefreshing; + this.disposables.add(refreshBtn.onDidClick(async () => { + if (!this.refreshPerformanceInfo || this.performanceInfoRefreshing) { + return; + } + this.performanceInfoRefreshing = true; + refreshBtn.enabled = false; + performanceContainer.classList.add('refreshing'); + this.updateStepUI(); + try { + await this.refreshPerformanceInfo(); + } finally { + this.performanceInfoRefreshing = false; + // updateModel inside refreshPerformanceInfo already re-renders the + // review step, so the previous performanceContainer/refreshBtn may + // be stale by now. Re-rendering once more here ensures the + // "refreshing" class is cleared and the button is re-enabled even + // if the model didn't update (e.g. error path). + if (this.currentStep === WizardStep.Review) { + this.updateReviewDetails(); + } + this.updateStepUI(); + } + })); + } + const performanceDescription = append(performanceContainer, $('div.review-performance-description')); + performanceDescription.textContent = localize('additionalPerformanceDataDescription', "Optionally include currently running processes and workspace metadata to help diagnose performance issues."); + + if (modelData.processInfo) { + diagnosticSectionCount++; + diagnosticSectionStates.push(() => this.includeProcessInfo); + this.createDiagSection(performanceContainer, { + id: 'process-info', + label: localize('runningProcesses', "Running Processes"), + checked: this.includeProcessInfo, + onToggle: (checked) => { + this.includeProcessInfo = checked; + this.model.update({ includeProcessInfo: checked }); + }, + renderContent: (container) => { + const pre = append(container, $('pre.review-diag-pre')); + pre.textContent = modelData.processInfo!; + }, + }); + } else if (!this.performanceInfoLoaded) { + const loading = append(performanceContainer, $('div.review-diag-loading')); + loading.textContent = localize('loadingProcessInfo', "Loading currently running processes..."); + } + + if (modelData.workspaceInfo) { + diagnosticSectionCount++; + diagnosticSectionStates.push(() => this.includeWorkspaceInfo); + this.createDiagSection(performanceContainer, { + id: 'workspace-info', + label: localize('workspaceMetadata', "Workspace Metadata"), + checked: this.includeWorkspaceInfo, + onToggle: (checked) => { + this.includeWorkspaceInfo = checked; + this.model.update({ includeWorkspaceInfo: checked }); + }, + renderContent: (container) => { + const pre = append(container, $('pre.review-diag-pre')); + pre.textContent = modelData.workspaceInfo!; + }, + }); + } else if (!this.performanceInfoLoaded) { + const loading = append(performanceContainer, $('div.review-diag-loading')); + loading.textContent = localize('loadingWorkspaceInfo', "Loading workspace metadata..."); + } + } + + if (diagnosticSectionCount > 0) { + const heading = document.createElement('div'); + heading.className = 'review-diag-heading'; + const title = append(heading, $('h3.review-diag-heading-title')); + title.textContent = localize('additionalInformation', "Additional Information"); + if (diagnosticSectionCount > 1) { + const bulkActions = append(heading, $('div.review-diag-bulk-actions')); + const toggleAllButton = this.disposables.add(new Button(bulkActions, { ...defaultButtonStyles, secondary: true })); + toggleAllButton.element.classList.add('review-diag-toggle-all'); + this.diagnosticBulkToggleButton = toggleAllButton; + this.updateDiagnosticBulkToggleButton(); + this.disposables.add(toggleAllButton.onDidClick(() => { + this.setAllDiagnosticSectionsIncluded(!this.areAllVisibleDiagnosticSectionsIncluded()); + })); + } + diagContainer.prepend(heading); + } + + // Align all title widths dynamically to the widest title + // eslint-disable-next-line no-restricted-syntax + const titles = diagContainer.querySelectorAll('.review-diag-title'); + let maxWidth = 0; + for (const t of titles) { + (t as HTMLElement).style.minWidth = ''; + } + for (const t of titles) { + maxWidth = Math.max(maxWidth, (t as HTMLElement).offsetWidth); + } + if (maxWidth > 0) { + for (const t of titles) { + (t as HTMLElement).style.minWidth = `${maxWidth}px`; + } + } + + // Align all toggle button widths to the widest + // eslint-disable-next-line no-restricted-syntax + const toggles = diagContainer.querySelectorAll('.review-diag-toggle'); + let maxToggleWidth = 0; + for (const t of toggles) { + (t as HTMLElement).style.minWidth = ''; + } + for (const t of toggles) { + maxToggleWidth = Math.max(maxToggleWidth, (t as HTMLElement).offsetWidth); + } + if (maxToggleWidth > 0) { + for (const t of toggles) { + (t as HTMLElement).style.minWidth = `${maxToggleWidth}px`; + } + } + } + + private areAllVisibleDiagnosticSectionsIncluded(): boolean { + return this.diagnosticSectionStates.length > 0 && this.diagnosticSectionStates.every(getState => getState()); + } + + private updateDiagnosticBulkToggleButton(): void { + if (!this.diagnosticBulkToggleButton) { + return; + } + const allChecked = this.areAllVisibleDiagnosticSectionsIncluded(); + this.diagnosticBulkToggleButton.label = allChecked + ? localize('excludeAllExtraAttachments', "Exclude All") + : localize('includeAllExtraAttachments', "Include All"); + this.diagnosticBulkToggleButton.element.setAttribute('aria-label', allChecked + ? localize('excludeAllExtraAttachmentsAria', "Exclude all additional issue data from this issue") + : localize('includeAllExtraAttachmentsAria', "Include all additional issue data in this issue")); + } + + private setAllDiagnosticSectionsIncluded(included: boolean): void { + this.includeSystemInfo = included; + this.includeExtensionData = included; + this.includeExtensions = included; + this.includeExperiments = included; + this.includeSettings = included; + this.includeProcessInfo = included; + this.includeWorkspaceInfo = included; + this.model.update({ + includeSystemInfo: included, + includeExtensionData: included, + includeExtensions: included, + includeExperiments: included, + includeProcessInfo: included, + includeWorkspaceInfo: included, + }); + this.updateReviewDetails(); + } + + private createDiagSection(parent: HTMLElement, opts: { + id: string; + label: string; + checked: boolean; + onToggle: (checked: boolean) => void; + renderContent: (container: HTMLElement) => void; + }): void { + const group = append(parent, $('div.review-diag-group')); + + // Header: title | "Include in issue" checkbox | Minimize/Expand button + const header = append(group, $('div.review-diag-header')); + + const title = append(header, $('span.review-diag-title')); + title.textContent = opts.label; + + const checkWrap = append(header, $('div.review-diag-check-wrap')); + const checkbox = this.disposables.add(new Checkbox(localize('includeInIssue', "Include in issue"), opts.checked, defaultCheckboxStyles)); + checkWrap.appendChild(checkbox.domNode); + const checkLabel = append(checkWrap, $('label.review-diag-check-label')); + checkLabel.textContent = localize('includeInIssue', "Include in issue"); + this.disposables.add(checkbox.onChange(() => { + opts.onToggle(checkbox.checked); + this.updateDiagnosticBulkToggleButton(); + this.updateStepUI(); + })); + + const toggleBtn = this.disposables.add(new Button(header, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + toggleBtn.label = `$(chevron-up) ${localize('minimize', "Minimize")}`; + toggleBtn.element.classList.add('review-diag-toggle'); + + // Content + const content = append(group, $('div.review-diag-content')); + opts.renderContent(content); + + let expanded = true; + this.disposables.add(toggleBtn.onDidClick(() => { + expanded = !expanded; + content.style.display = expanded ? '' : 'none'; + toggleBtn.label = expanded + ? `$(chevron-up) ${localize('minimize', "Minimize")}` + : `$(chevron-down) ${localize('expand', "Expand")}`; + })); + } + + private addDiagRow(table: HTMLElement, label: string, value: string): void { + const row = append(table, $('tr')); + const th = append(row, $('td.review-diag-key')); + th.textContent = label; + const td = append(row, $('td.review-diag-val')); + td.textContent = value; + } + + /** Called by the form service to show upload progress */ + setUploading(uploading: boolean): void { + this.uploading = uploading; + + if (uploading) { + this.nextButton.element.classList.add('uploading'); + this.nextButton.label = localize('uploading', "Uploading..."); + this.nextButton.enabled = false; + this.backButton.element.style.display = 'none'; + } else { + this.nextButton.element.classList.remove('uploading'); + this.nextButton.enabled = true; + this.updateStepUI(); + } + } + + /** Mark a specific attachment as uploading / done */ + setAttachmentUploadState(index: number, state: 'pending' | 'uploading' | 'done'): void { + const card = this.reviewThumbCards[index]; + if (!card) { + return; + } + card.classList.remove('upload-pending', 'upload-uploading', 'upload-done'); + card.classList.add(`upload-${state}`); + + // eslint-disable-next-line no-restricted-syntax + const overlay = card.querySelector('.review-progress-overlay') as HTMLElement | null; + if (!overlay) { + return; + } + + if (state === 'done') { + // Replace ring with checkmark + overlay.textContent = ''; + const check = $('span.review-progress-check'); + check.appendChild(renderIcon(Codicon.check)); + overlay.appendChild(check); + } + } + + private submit(): void { + const title = this.titleInput.value.trim(); + if (!title) { + // Should not happen: validated in goNext() on Describe step + return; + } + + const description = this.descriptionTextarea.value.trim(); + this.updateIssueSourceFlags(); + this.model.update({ issueDescription: description, issueTitle: title, ...(this.selectedIssueType !== undefined ? { issueType: this.selectedIssueType } : {}) }); + + const body = this.buildIssueBody(); + this._onDidSubmit.fire({ title, body }); + } + + show(): void { + if (this.visible) { + return; + } + this.visible = true; + + this.wizardPanel.classList.add('open', 'wizard-embedded'); + this.wizardPanel.style.maxHeight = 'none'; + append(this.container, this.wizardPanel); + this.wizardPanel.focus(); + } + + private getTotalAttachments(): number { + return this.screenshots.length + this.recordings.length; + } + + private getScreenshotDelayOptions(): { label: string; value: number }[] { + return [ + { label: localize('noDelay', "No delay"), value: 0 }, + { label: localize('threeSeconds', "3 seconds"), value: 3 }, + { label: localize('fiveSeconds', "5 seconds"), value: 5 }, + { label: localize('tenSeconds', "10 seconds"), value: 10 }, + ]; + } + + private getFloatingBarButtonStyles(targetWindow: Window): typeof defaultButtonStyles { + const containerStyles = targetWindow.getComputedStyle(this.container); + const cssVar = (name: string, fallback: string): string => containerStyles.getPropertyValue(name).trim() || fallback; + return { + ...defaultButtonStyles, + buttonForeground: cssVar('--vscode-button-foreground', '#fff'), + buttonBackground: cssVar('--vscode-button-background', '#0e639c'), + buttonHoverBackground: cssVar('--vscode-button-hoverBackground', '#1177bb'), + buttonBorder: cssVar('--vscode-button-border', 'transparent'), + }; + } + + addScreenshot(screenshot: IScreenshot): void { + if (this.getTotalAttachments() >= MAX_ATTACHMENTS) { + return; + } + this.screenshots.push(screenshot); + this.updateAttachmentViews(); + this.updateAttachmentButtons(); + this.updateStepUI(); + + // Immediately open the annotation editor for the new screenshot + this.openAnnotationEditor(this.screenshots.length - 1); + } + + private updateAttachmentButtons(): void { + const atMax = this.getTotalAttachments() >= MAX_ATTACHMENTS; + const maxMsg = localize('maxAttachmentsReached', "Max attachments reached"); + const wouldReachMax = this.getTotalAttachments() >= MAX_ATTACHMENTS - 1; + + // Screenshot disabled when: at max, OR recording will fill the last slot, OR delayed screenshot pending + const screenshotDisabled = atMax || (wouldReachMax && this.currentRecordingState === RecordingState.Recording) || this.delayedScreenshotPending; + // Record disabled when: at max, OR delayed screenshot will fill the last slot + const recordDisabled = atMax || (wouldReachMax && this.delayedScreenshotPending); + + if (this.captureStripCaptureBtn) { + this.captureStripCaptureBtn.enabled = !screenshotDisabled; + this.captureStripCaptureBtn.element.title = screenshotDisabled ? maxMsg : localize('screenshot', "Screenshot"); + } + if (this.captureStripDelayBtn) { + // Delay dropdown also disabled while countdown is running + this.captureStripDelayBtn.enabled = !screenshotDisabled; + this.captureStripDelayBtn.element.title = screenshotDisabled ? maxMsg : localize('captureOptions', "Capture options"); + } + if (this.captureStripRecordBtn) { + if (this.currentRecordingState !== RecordingState.Recording) { + this.captureStripRecordBtn.enabled = !recordDisabled; + this.captureStripRecordBtn.element.title = recordDisabled ? maxMsg : localize('recordVideo', "Record video"); + } + } + + // Disable "Preview on GitHub" while recording + this.updateNextButtonForRecording(); + } + + private updateNextButtonForRecording(): void { + if (this.currentStep !== WizardStep.Review) { + return; + } + const recording = this.currentRecordingState === RecordingState.Recording; + this.nextButton.enabled = !recording; + this.nextButton.element.title = recording + ? localize('recordingActive', "Recording active") + : localize('previewOnGitHub', "Preview on GitHub"); + } + + private renderRecordingCard(parent: HTMLElement, rec: { filePath: string; durationMs: number; thumbnailDataUrl?: string }, index: number): HTMLElement { + const card = append(parent, $('div.wizard-screenshot-card.wizard-recording-card')); + + if (rec.thumbnailDataUrl) { + const thumbImg = append(card, $('img.wizard-screenshot-img')) as HTMLImageElement; + thumbImg.setAttribute('src', rec.thumbnailDataUrl); + thumbImg.alt = localize('recordingThumbnailAlt', "Recording {0}", index + 1); + thumbImg.setAttribute('draggable', 'false'); + } + + const playOverlay = append(card, $('div.wizard-recording-play')); + playOverlay.appendChild(renderIcon(Codicon.play)); + + const durSec = Math.floor(rec.durationMs / 1000); + const durLabel = append(card, $('div.wizard-recording-duration')); + durLabel.textContent = `${Math.floor(durSec / 60)}:${(durSec % 60).toString().padStart(2, '0')}`; + + return card; + } + + private updateScreenshotThumbnails(): void { + this.screenshotContainer.textContent = ''; + + for (let i = 0; i < this.screenshots.length; i++) { + const screenshot = this.screenshots[i]; + const card = append(this.screenshotContainer, $('div.wizard-screenshot-card')); + + const img = append(card, $('img')) as HTMLImageElement; + img.src = screenshot.annotatedDataUrl ?? screenshot.dataUrl; + img.alt = localize('screenshotAlt', "Screenshot {0}", i + 1); + + card.setAttribute('role', 'button'); + card.setAttribute('tabindex', '0'); + card.title = localize('editScreenshot', "Click to edit screenshot"); + const openEditor = () => this.openAnnotationEditor(i); + this.disposables.add(addDisposableListener(card, EventType.CLICK, openEditor)); + this.disposables.add(addDisposableListener(card, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { + e.preventDefault(); + openEditor(); + } + })); + + const deleteBtn = append(card, $('div.wizard-screenshot-delete')); + deleteBtn.setAttribute('role', 'button'); + deleteBtn.setAttribute('aria-label', localize('deleteScreenshot', "Delete screenshot")); + deleteBtn.appendChild(renderIcon(Codicon.close)); + this.disposables.add(addDisposableListener(deleteBtn, EventType.CLICK, e => { + e.stopPropagation(); + this.screenshots.splice(i, 1); + this.updateScreenshotThumbnails(); + this.updateAttachmentButtons(); + this.updateStepUI(); + })); + } + + // Recording thumbnails + for (let i = 0; i < this.recordings.length; i++) { + const rec = this.recordings[i]; + const card = this.renderRecordingCard(this.screenshotContainer, rec, i); + + // Click to open from OS + this.disposables.add(addDisposableListener(card, EventType.CLICK, () => { + this._onDidRequestOpenRecording.fire(rec.filePath); + })); + + const deleteBtn = append(card, $('div.wizard-screenshot-delete')); + deleteBtn.setAttribute('role', 'button'); + deleteBtn.setAttribute('aria-label', localize('deleteRecording', "Remove recording")); + deleteBtn.appendChild(renderIcon(Codicon.close)); + this.disposables.add(addDisposableListener(deleteBtn, EventType.CLICK, e => { + e.stopPropagation(); + this.recordings.splice(i, 1); + this.updateScreenshotThumbnails(); + this.updateAttachmentButtons(); + this.updateStepUI(); + })); + } + + if (this.getTotalAttachments() < MAX_ATTACHMENTS) { + const wouldReachMax = this.getTotalAttachments() >= MAX_ATTACHMENTS - 1; + const addDisabled = wouldReachMax && (this.currentRecordingState === RecordingState.Recording || this.delayedScreenshotPending); + const addCard = append(this.screenshotContainer, $('div.wizard-screenshot-card.wizard-screenshot-add')); + if (addDisabled) { + addCard.classList.add('disabled'); + addCard.title = localize('maxAttachmentsReached', "Max attachments reached"); + } + const plus = append(addCard, $('div.wizard-screenshot-plus')); + plus.appendChild(renderIcon(Codicon.add)); + this.disposables.add(addDisposableListener(addCard, EventType.CLICK, () => { + if (!addCard.classList.contains('disabled')) { + this._onDidRequestScreenshot.fire(); + } + })); + } + } + + private openAnnotationEditor(index: number): void { + if (index < 0 || index >= this.screenshots.length) { + return; + } + + // Per-editor lifecycle: each call creates a new editor that mounts an + // absolutely-positioned overlay on top of any previously-open editor and + // disposes itself on save/cancel. This gives us the stacking behavior the + // user expects when taking multiple screenshots in a row — the topmost + // editor handles save/cancel, then the previous one becomes visible + // again. + const screenshot = this.screenshots[index]; + const editor = new ScreenshotAnnotationEditor(screenshot, this.wizardPanel, screenshot.annotationState); + this.disposables.add(editor); + + this.disposables.add(editor.onDidSave(({ dataUrl, state }) => { + screenshot.annotatedDataUrl = dataUrl; + screenshot.annotationState = state; + this.updateAttachmentViews(); + })); + + this.disposables.add(editor.onDidCancel(() => { + // nothing to do, editor disposes itself + })); + } + + getScreenshots(): readonly IScreenshot[] { + return this.screenshots; + } + + getRecordings(): readonly { filePath: string; durationMs: number; thumbnailDataUrl?: string }[] { + return this.recordings; + } + + private buildIssueBody(): string { + const description = this.descriptionTextarea.value.trim(); + this.model.update({ + issueDescription: description, + issueType: this.selectedIssueType ?? IssueType.Bug, + includeSystemInfo: this.includeSystemInfo, + includeProcessInfo: this.includeProcessInfo, + includeWorkspaceInfo: this.includeWorkspaceInfo, + includeExtensions: this.includeExtensions, + includeExperiments: this.includeExperiments, + includeExtensionData: this.includeExtensionData, + }); + + const modelData = this.model.getData(); + const sections: string[] = [ + `### Description\n\n${description}`, + this.generateIssueDetailsMd(), + ]; + + if (this.includeExtensionData && modelData.extensionData) { + sections.push(this.createDetails('Extension Data', this.createCodeBlock(modelData.extensionData))); + } + + if (this.includeSystemInfo && (modelData.versionInfo || modelData.systemInfo || modelData.systemInfoWeb)) { + sections.push(this.generateSystemInfoMd()); + } + + if (!modelData.fileOnExtension && !modelData.fileOnMarketplace && this.includeExtensions) { + sections.push(this.generateExtensionsMd()); + } + + if (this.includeExperiments && modelData.experimentInfo) { + sections.push(this.createDetails('A/B Experiments', this.createCodeBlock(modelData.experimentInfo))); + } + + if (this.includeSettings && this.settingsContent) { + sections.push(this.generateSettingsMd()); + } + + if (this.selectedIssueType === IssueType.PerformanceIssue && !modelData.fileOnMarketplace) { + if (this.includeProcessInfo && modelData.processInfo) { + sections.push(this.createDetails('Running Processes', this.createCodeBlock(modelData.processInfo))); + } + if (this.includeWorkspaceInfo && modelData.workspaceInfo) { + sections.push(this.createDetails('Workspace Metadata', this.createCodeBlock(modelData.workspaceInfo))); + } + } + + sections.push(''); + + return sections.join('\n\n'); + } + + private generateIssueDetailsMd(): string { + const modelData = this.model.getData(); + const rows: [string, string | undefined][] = [ + ['Issue Category', this.getIssueTypeTitle(this.selectedIssueType ?? IssueType.Bug)], + ['Target', this.getIssueSourceLabel()], + ['VS Code Version', modelData.versionInfo?.vscodeVersion ?? product.version], + ['OS Version', modelData.versionInfo?.os ?? modelData.systemInfo?.os], + ]; + + if (this.selectedIssueSource === IssueSource.Extension && this.selectedExtension) { + rows.push( + ['Extension Identifier', this.selectedExtension.id], + ['Extension Version', this.selectedExtension.version], + ['Extension Publisher', this.selectedExtension.publisher], + ); + } + + return `### Issue Details\n\n${this.createMarkdownTable(rows)}`; + } + + private generateSystemInfoMd(): string { + const modelData = this.model.getData(); + const rows: [string, string | undefined][] = []; + + if (modelData.versionInfo) { + rows.push( + ['VS Code Version', modelData.versionInfo.vscodeVersion], + ['OS Version', modelData.versionInfo.os], + ); + } + + if (modelData.systemInfo) { + rows.push( + ['CPUs', modelData.systemInfo.cpus], + ['GPU Status', Object.keys(modelData.systemInfo.gpuStatus).map(key => `${key}: ${modelData.systemInfo!.gpuStatus[key]}`).join('
')], + ['Load (avg)', modelData.systemInfo.load], + ['Memory (System)', modelData.systemInfo.memory], + ['Process Argv', modelData.systemInfo.processArgs], + ['Screen Reader', modelData.systemInfo.screenReader], + ['VM', modelData.systemInfo.vmHint], + ); + + if (modelData.systemInfo.linuxEnv) { + rows.push( + ['DESKTOP_SESSION', modelData.systemInfo.linuxEnv.desktopSession], + ['XDG_CURRENT_DESKTOP', modelData.systemInfo.linuxEnv.xdgCurrentDesktop], + ['XDG_SESSION_DESKTOP', modelData.systemInfo.linuxEnv.xdgSessionDesktop], + ['XDG_SESSION_TYPE', modelData.systemInfo.linuxEnv.xdgSessionType], + ); + } + + for (const remote of modelData.systemInfo.remoteData) { + if (isRemoteDiagnosticError(remote)) { + rows.push(['Remote Error', remote.errorMessage]); + } else { + rows.push( + ['Remote', remote.latency ? `${remote.hostName} (latency: ${remote.latency.current.toFixed(2)}ms last, ${remote.latency.average.toFixed(2)}ms average)` : remote.hostName], + ['Remote OS', remote.machineInfo.os], + ['Remote CPUs', remote.machineInfo.cpus], + ['Remote Memory (System)', remote.machineInfo.memory], + ['Remote VM', remote.machineInfo.vmHint], + ); + } + } + } + + if (modelData.systemInfoWeb) { + rows.push(['User Agent', modelData.systemInfoWeb]); + } + rows.push(['Installation pure', String(modelData.isInstallationPure ?? true)]); + + return this.createDetails('System Info', this.createMarkdownTable(rows)); + } + + private generateExtensionsMd(): string { + const modelData = this.model.getData(); + const nonThemeExtensions = (modelData.enabledNonThemeExtesions ?? modelData.allExtensions.filter(extension => !extension.isTheme && !extension.isBuiltin)); + if (modelData.extensionsDisabled) { + return '### Extensions\n\nExtensions disabled.'; + } + + if (!nonThemeExtensions.length && !modelData.numberOfThemeExtesions) { + return '### Extensions\n\nExtensions: none'; + } + + const rows = nonThemeExtensions.map(extension => [ + extension.displayName || extension.name, + extension.id, + extension.publisher ?? 'N/A', + extension.version, + ] as [string, string, string, string]); + const details: string[] = []; + if (rows.length) { + details.push(this.createMarkdownTable(rows, ['Name', 'Identifier', 'Author', 'Version'])); + } + if (modelData.numberOfThemeExtesions) { + details.push(`Theme extensions: ${modelData.numberOfThemeExtesions}`); + } + + return this.createDetails(`Extensions (${nonThemeExtensions.length})`, details.join('\n\n')); + } + + private generateSettingsMd(): string { + const details = [`#### User Settings\n\n${this.createCodeBlock(this.settingsContent ?? '', 'json')}`]; + return this.createDetails('Settings', details.join('\n\n')); + } + + private getIssueTypeTitle(issueType: IssueType): string { + switch (issueType) { + case IssueType.Bug: + return 'Bug'; + case IssueType.PerformanceIssue: + return 'Performance Issue'; + case IssueType.FeatureRequest: + return 'Feature Request'; + } + } + + private createDetails(summary: string, content: string): string { + return `
+${summary} + +${content} + +
`; + } + + private createCodeBlock(content: string, language = ''): string { + return `\`\`\`${language} +${content.trimEnd()} +\`\`\``; + } + + private createMarkdownTable(rows: readonly (readonly (string | undefined)[])[], headers: readonly string[] = ['Item', 'Value']): string { + return `${headers.map(header => this.escapeMarkdownTableCell(header)).join('|')} +${headers.map(() => '---').join('|')} +${rows.map(row => row.map(value => this.escapeMarkdownTableCell(value ?? '')).join('|')).join('\n')}`; + } + + private escapeMarkdownTableCell(value: string): string { + return value.replace(/\r?\n/g, '
').replace(/\|/g, '\\|'); + } + + setUpdateAvailable(showUpdateBanner: boolean): void { + this.showUpdateBanner = showUpdateBanner; + if (this.updateBanner) { + this.updateBanner.style.display = showUpdateBanner ? '' : 'none'; + } + } + + focus(): void { + this.wizardPanel.focus(); + } + + getPanel(): HTMLElement { + return this.wizardPanel; + } + + get recordingState(): RecordingState { + return this.currentRecordingState; + } + + hideFloatingBar(): void { + if (this.floatingBar) { + this.floatingBar.style.display = 'none'; + } + } + + showFloatingBar(): void { + if (this.floatingBar) { + this.floatingBar.style.display = ''; + } + } + + get shouldHideToolbarForCapture(): boolean { + return this._hideToolbarInScreenshots; + } + + /** Re-parent the floating bar into the wizard's current window. */ + reparentFloatingBar(): void { + if (!this.floatingBar) { + return; + } + const targetWindow = getWindow(this.container); + // Mount inside .monaco-workbench so theme CSS vars cascade. Fall back to + // document.body when no workbench root is present (shouldn't happen in + // practice but keeps the bar visible regardless). + // eslint-disable-next-line no-restricted-syntax + const workbench = targetWindow.document.querySelector('.monaco-workbench') as HTMLElement | null; + const mountTarget = workbench ?? targetWindow.document.body; + if (this.floatingBar.parentElement !== mountTarget) { + this.floatingBar.remove(); + mountTarget.appendChild(this.floatingBar); + // Reset position so it appears in the new window + this.floatingBar.style.left = ''; + this.floatingBar.style.top = ''; + this.floatingBar.style.right = '30%'; + } + } + + /** Update the internal model with additional data loaded asynchronously */ + updateModel(newData: Record): void { + this.model.update(newData); + if (Array.isArray(newData.allExtensions)) { + this.data.enabledExtensions = newData.allExtensions as IssueReporterExtensionData[]; + this.updateExtensionOptions(); + this.updateIssueSourceFlags(); + } + // Refresh review details if we're on the review step (async data may have arrived) + if (this.currentStep === WizardStep.Review) { + this.updateReviewDetails(); + } + } + + /** Called once performance info has resolved; suppresses "Loading…" placeholders. */ + markPerformanceInfoLoaded(): void { + this.performanceInfoLoaded = true; + if (this.currentStep === WizardStep.Review) { + this.updateReviewDetails(); + // Re-enable the Preview button now that diagnostics are ready. + this.updateStepUI(); + } + } + + setSettingsContent(userSettings: string): void { + this.settingsContent = userSettings; + if (this.currentStep === WizardStep.Review) { + this.updateReviewDetails(); + } + } + + hasUnsavedChanges(): boolean { + if (this.previewOpened && this.previewedDraftKey === this.getDraftKey()) { + return false; + } + return this.hasUserInput(); + } + + private hasUserInput(): boolean { + return !!( + this.hasDescriptionContent() || + this.titleInput.value.trim() || + this.selectedIssueType !== undefined || + this.screenshots.length > 0 || + this.recordings.length > 0 + ); + } + + markPreviewOpened(): void { + this.previewOpened = true; + this.previewedDraftKey = this.getDraftKey(); + this.updateStepUI(); + } + + private getDraftKey(): string { + return JSON.stringify({ + title: this.titleInput.value.trim(), + description: this.descriptionTextarea.value.trim(), + issueType: this.selectedIssueType, + issueSource: this.selectedIssueSource, + extensionId: this.selectedExtension?.id, + includeSystemInfo: this.includeSystemInfo, + includeProcessInfo: this.includeProcessInfo, + includeWorkspaceInfo: this.includeWorkspaceInfo, + includeExtensions: this.includeExtensions, + includeExperiments: this.includeExperiments, + includeExtensionData: this.includeExtensionData, + includeSettings: this.includeSettings, + settingsContent: this.settingsContent, + screenshots: this.screenshots.map(screenshot => screenshot.annotatedDataUrl ?? screenshot.dataUrl), + recordings: this.recordings.map(recording => recording.filePath), + }); + } + + /** Set the title input value (e.g., from AI generation) */ + setGeneratedTitle(title: string): void { + this.titleInput.value = title; + if (title.trim()) { + this.setFieldError(this.titleInput.element, this.titleError, false); + } + this.resetGenerateButton(); + } + + resetGenerateButton(): void { + this.generateTitleBtn.label = `$(sparkle) ${localize('generateTitleBtn', "Generate from description")}`; + this.generateTitleBtn.element.classList.remove('loading'); + this.generateTitleBtn.element.style.minWidth = ''; + this.generateTitleBtn.enabled = this.hasDescriptionContent(); + } + + /** Show a "Close" button next to the submit button after successful submission */ + showCloseButton(): void { + // Add close button next to the existing preview button + const nav = this.nextButton.element.parentElement; + // eslint-disable-next-line no-restricted-syntax + if (nav && !nav.querySelector('.wizard-close-btn')) { + this.closeButton = this.disposables.add(new Button(nav, { ...defaultButtonStyles, secondary: true })); + this.closeButton.label = localize('closeTab', "Close"); + this.closeButton.element.classList.add('wizard-close-btn'); + this.disposables.add(this.closeButton.onDidClick(() => { + this._onDidClose.fire(); + })); + } + this.updateStepUI(); + } + + setRecordingState(state: RecordingState): void { + this.currentRecordingState = state; + + if (state === RecordingState.Recording) { + this.recordingStartTime = Date.now(); + + const formatTime = () => { + const elapsed = Math.floor((Date.now() - this.recordingStartTime) / 1000); + const mins = Math.floor(elapsed / 60).toString().padStart(2, '0'); + const secs = (elapsed % 60).toString().padStart(2, '0'); + return `${mins}:${secs}`; + }; + + const stopLabel = localize('stopRecording', "Stop recording"); + const makeLabel = () => `$(stop-circle) ${stopLabel} ${formatTime()}`; + + if (this.captureStripRecordBtn) { + this.captureStripRecordBtn.element.classList.add('recording'); + this.captureStripRecordBtn.element.title = stopLabel; + this.captureStripRecordBtn.label = makeLabel(); + } + + this.recordingElapsedTimer = getWindow(this.container).setInterval(() => { + if (this.captureStripRecordBtn) { + this.captureStripRecordBtn.label = makeLabel(); + } + }, 1000); + } else { + // Back to idle + if (this.recordingElapsedTimer !== undefined) { + getWindow(this.container).clearInterval(this.recordingElapsedTimer); + this.recordingElapsedTimer = undefined; + } + + if (this.captureStripRecordBtn) { + this.captureStripRecordBtn.element.classList.remove('recording'); + this.captureStripRecordBtn.element.title = localize('recordVideo', "Record video"); + this.captureStripRecordBtn.label = `$(record) ${localize('recordVideo', "Record video")}`; + } + } + + this.updateScreenshotThumbnails(); + this.updateAttachmentButtons(); + } + + addRecording(filePath: string, durationMs: number, thumbnailDataUrl?: string): void { + this.recordings.push({ filePath, durationMs, thumbnailDataUrl }); + this.updateAttachmentViews(); + this.updateAttachmentButtons(); + this.updateStepUI(); + } + + private updateAttachmentViews(): void { + this.updateScreenshotThumbnails(); + if (this.currentStep === WizardStep.Review) { + this.updateReviewDetails(); + } + } + + /** + * Trigger a screenshot capture as if the user clicked the screenshot button + * on the floating capture bar. The floating bar is mounted at the workbench + * root and the button is enabled regardless of the current wizard step, so + * the shortcut works from any step without changing it. The existing + * capture flow opens the annotation editor and re-activates the issue + * reporter editor when the screenshot is added. + * + * No-op when the capture button is disabled (e.g. at the attachment limit). + */ + triggerCaptureScreenshot(): void { + const btn = this.captureStripCaptureBtn; + if (!btn?.enabled) { + return; + } + btn.element.click(); + } + + /** + * Toggle screen recording on/off as if the user clicked the record button. + * Works from any step without changing it. No-op when recording isn't + * supported or the record button is disabled. + */ + triggerToggleRecording(): void { + if (!this.recordingSupported) { + return; + } + const btn = this.captureStripRecordBtn; + if (!btn?.enabled) { + return; + } + btn.element.click(); + } + + private renderShortcutKeycap(parent: HTMLElement, keybinding: ResolvedKeybinding): void { + const label = this.disposables.add(new KeybindingLabel(parent, OS, { ...defaultKeybindingLabelStyles })); + label.set(keybinding); + label.element.classList.add('wizard-shortcut'); + } + + dispose(): void { + if (this.recordingElapsedTimer !== undefined) { + getWindow(this.container).clearInterval(this.recordingElapsedTimer); + } + this.reviewRenderDisposables.dispose(); + this.similarIssuesDisposables.dispose(); + this.disposables.dispose(); + this._onDidClose.dispose(); + this._onDidSubmit.dispose(); + this._onDidRequestScreenshot.dispose(); + this._onDidRequestStartRecording.dispose(); + this._onDidRequestStopRecording.dispose(); + this._onDidRequestOpenRecording.dispose(); + this._onDidRequestOpenScreenshot.dispose(); + this._onDidRequestGenerateTitle.dispose(); + } +} diff --git a/src/vs/workbench/contrib/issue/browser/issueService.ts b/src/vs/workbench/contrib/issue/browser/issueService.ts index 269a34d765f..1304430ebca 100644 --- a/src/vs/workbench/contrib/issue/browser/issueService.ts +++ b/src/vs/workbench/contrib/issue/browser/issueService.ts @@ -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); diff --git a/src/vs/workbench/contrib/issue/browser/media/issueReporterOverlay.css b/src/vs/workbench/contrib/issue/browser/media/issueReporterOverlay.css new file mode 100644 index 00000000000..34af20f4633 --- /dev/null +++ b/src/vs/workbench/contrib/issue/browser/media/issueReporterOverlay.css @@ -0,0 +1,1780 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Issue Reporter - Wizard UI (editor tab mode). + * The wizard renders inside an editor pane container. + * A floating capture bar appears on step 3. + */ + +.spacer { + flex: 1; +} + +/* Wizard Panel - renders inside the editor tab */ +.issue-reporter-wizard { + position: relative; + display: flex; + flex-direction: column; + max-height: none; + overflow: visible; + height: 100%; + background: var(--vscode-editor-background, #1e1e1e); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', system-ui, sans-serif; + font-size: 13px; + color: var(--vscode-foreground, #ccc); +} + +.issue-reporter-wizard .wizard-toolbar { + -webkit-app-region: no-drag; + padding-right: 0; +} + +.wizard-update-banner { + padding: 8px 16px; + text-align: center; + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); + flex-shrink: 0; +} + +.issue-reporter-wizard .wizard-step-container { + flex: 1; + overflow: auto; +} + +/* VS Code styled scrollbars for any scrollable region in the wizard */ +.issue-reporter-wizard *::-webkit-scrollbar, +.issue-reporter-wizard ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.issue-reporter-wizard *::-webkit-scrollbar-corner, +.issue-reporter-wizard ::-webkit-scrollbar-corner, +.issue-reporter-wizard *::-webkit-scrollbar-track, +.issue-reporter-wizard ::-webkit-scrollbar-track { + background: transparent; +} + +.issue-reporter-wizard *::-webkit-scrollbar-thumb, +.issue-reporter-wizard ::-webkit-scrollbar-thumb { + background: var(--vscode-scrollbarSlider-background, rgba(121, 121, 121, 0.4)); + border-radius: 0; +} + +.issue-reporter-wizard *::-webkit-scrollbar-thumb:hover, +.issue-reporter-wizard ::-webkit-scrollbar-thumb:hover { + background: var(--vscode-scrollbarSlider-hoverBackground, rgba(100, 100, 100, 0.7)); +} + +.issue-reporter-wizard *::-webkit-scrollbar-thumb:active, +.issue-reporter-wizard ::-webkit-scrollbar-thumb:active { + background: var(--vscode-scrollbarSlider-activeBackground, rgba(191, 191, 191, 0.4)); +} + +.issue-reporter-editor-tab { + display: flex; + flex-direction: column; + height: 100%; +} + +.issue-reporter-editor-tab, +.issue-reporter-editor-tab:focus, +.issue-reporter-editor-tab:focus-visible, +.issue-reporter-wizard:focus, +.issue-reporter-wizard:focus-visible { + outline: none !important; +} + +/* Floating Capture Bar (debug toolbar style) */ +.wizard-floating-bar { + position: fixed; + top: 35px; + right: 30%; + z-index: 2520; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 2px; + border-radius: var(--vscode-cornerRadius-large, 6px); + background: var(--vscode-debugToolBar-background); + border: 1px solid var(--vscode-debugToolBar-border, transparent); + box-shadow: var(--vscode-shadow-lg, 0 2px 8px rgba(0, 0, 0, 0.2)); + -webkit-app-region: no-drag; + user-select: none; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', system-ui, sans-serif; + font-size: 13px; + color: var(--vscode-foreground); + /* Lock against compression — the bar's width is determined by its content + and stays constant as the window resizes. Position-clamping (in JS) + handles keeping it inside the viewport. */ + flex-shrink: 0; + white-space: nowrap; + width: max-content; + max-width: none; +} + +.wizard-floating-bar * { + flex-shrink: 0; +} + +.wizard-floating-drag { + cursor: grab; + width: 20px; + opacity: 0.5; + display: flex; + align-items: center; + justify-content: center; +} + +.wizard-floating-drag.dragged { + cursor: grabbing; +} + +.wizard-floating-bar .wizard-nav-btn { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + padding: 3px 10px; + font-size: 12px; + line-height: 1; +} + +.wizard-floating-bar .monaco-button { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + padding: 4px 10px; + font-size: 12px; + line-height: 1; + width: auto; + flex: none; + height: 26px; + box-sizing: border-box; +} + +.wizard-floating-bar .wizard-nav-btn > * { + display: flex; + align-items: center; +} + +.wizard-floating-delay { + height: 24px; + padding: 0 6px; + border: 1px solid var(--vscode-input-border, #555); + border-radius: 4px; + background: var(--vscode-input-background, #3c3c3c); + color: var(--vscode-input-foreground, #ccc); + font-size: 12px; + cursor: pointer; + outline: none; +} + +/* Segmented screenshot button */ +.wizard-segmented-btn { + display: flex; + align-items: stretch; + border-radius: 4px; + overflow: hidden; +} + +.wizard-segmented-main { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + cursor: pointer; + font-size: 12px; + border-radius: 4px 0 0 4px; + color: var(--vscode-button-foreground, #fff); + background: var(--vscode-button-background, #0e639c); + border: 1px solid var(--vscode-button-background, #0e639c); + border-right: none; + height: 26px; + box-sizing: border-box; +} + +.monaco-button.wizard-segmented-main { + display: inline-flex; + width: auto; + flex: 0 0 auto; + padding: 4px 10px; + height: 26px; + border-radius: 4px 0 0 4px; + border-right: none !important; + font-size: 12px; + line-height: 18px; + min-width: 0; + position: relative; +} + +/* Thin white divider between the two segments */ +.monaco-button.wizard-segmented-main::after { + content: ''; + position: absolute; + right: 0; + top: 4px; + bottom: 4px; + width: 1px; + background: rgba(255, 255, 255, 0.3); +} + +.wizard-segmented-main:hover { + background: var(--vscode-button-hoverBackground, #1177bb); +} + +.wizard-segmented-main.disabled:hover { + background: var(--vscode-button-background, #0e639c); +} + +.wizard-segmented-main > * { + display: flex; + align-items: center; +} + +.wizard-segmented-dropdown { + display: flex; + align-items: center; + padding: 4px 4px; + cursor: pointer; + border-radius: 0 4px 4px 0; + color: var(--vscode-button-foreground, #fff); + background: var(--vscode-button-background, #0e639c); + border: 1px solid var(--vscode-button-background, #0e639c); + border-left: 1px solid rgba(255, 255, 255, 0.2); + height: 26px; + box-sizing: border-box; +} + +.monaco-button.wizard-segmented-dropdown { + display: inline-flex; + width: auto; + flex: 0 0 auto; + padding: 4px 6px; + height: 26px; + border-radius: 0 4px 4px 0; + border-left: none !important; + min-width: 0; +} + +.wizard-segmented-dropdown:hover { + background: var(--vscode-button-hoverBackground, #1177bb); +} + +.wizard-segmented-dropdown.disabled:hover { + background: var(--vscode-button-background, #0e639c); +} + +.wizard-segmented-dropdown > * { + display: flex; + align-items: center; +} + +/* Dim screenshot button in floating bar during recording (keep drag handle interactive) */ +/* Only dimmed via JS disabled class at N-1 attachments */ + +/* Disabled state for segmented buttons when max attachments reached */ +.wizard-segmented-main.disabled, +.wizard-segmented-dropdown.disabled, +.monaco-button.wizard-segmented-main.disabled, +.monaco-button.wizard-segmented-dropdown.disabled { + opacity: 0.4; + cursor: default; +} + +/* Recording state in floating bar */ +.wizard-floating-bar .wizard-nav-btn.recording, +.wizard-floating-bar .monaco-button.recording { + color: var(--vscode-testing-iconErrored, #f14c4c); + animation: recording-pulse 1.5s ease-in-out infinite; +} + +/* Toolbar */ +.wizard-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--vscode-titleBar-activeBackground, #3c3c3c); + border-bottom: 1px solid var(--vscode-panel-border, #444); + flex-shrink: 0; +} + +/* Progress area */ +.wizard-progress-area { + display: flex; + align-items: center; + gap: 10px; +} + +.wizard-progress-dots { + display: flex; + gap: 6px; + align-items: center; +} + +.wizard-progress-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--vscode-foreground, #888); + background: transparent; + transition: background 0.2s ease, border-color 0.2s ease, opacity 0.2s ease; + opacity: 0.4; +} + +.wizard-progress-dot.active { + border-color: var(--vscode-focusBorder, #007acc); + background: var(--vscode-focusBorder, #007acc); + opacity: 1; +} + +.wizard-progress-dot.completed { + border-color: var(--vscode-focusBorder, #007acc); + background: var(--vscode-focusBorder, #007acc); + opacity: 0.7; +} + +.wizard-step-indicator { + font-size: 12px; + font-weight: 600; + white-space: nowrap; + color: var(--vscode-titleBar-activeForeground, #ccc); + font-variant-numeric: tabular-nums; +} + +.wizard-step-separator { + display: inline-block; + width: 2px; + height: 14px; + margin: 0 10px; + background: var(--vscode-titleBar-activeForeground, #888); + opacity: 0.6; + vertical-align: middle; +} + +.wizard-step-label { + font-size: 12px; + font-weight: 600; + white-space: nowrap; + color: var(--vscode-titleBar-activeForeground, #ccc); +} + +/* Step Container */ +.wizard-step-container { + flex: 1; + position: relative; + overflow: hidden; + min-height: 150px; +} + +/* Individual Steps */ +.wizard-step { + display: flex; + flex-direction: column; + padding: 24px 40px; + gap: 12px; + overflow-y: auto; + position: absolute; + inset: 0; +} + +/* Wizard Typography */ +.wizard-heading { + margin: 0; + font-size: 22px; + font-weight: 600; + color: var(--vscode-foreground, #eee); + line-height: 1.3; +} + +.wizard-subtitle { + margin: 0; + font-size: 13px; + color: var(--vscode-descriptionForeground, #999); + line-height: 1.4; +} + +.wizard-shortcut-hint { + margin-top: 4px; + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.wizard-shortcut.monaco-keybinding { + margin: 0 2px; + vertical-align: middle; +} + +/* Wizard Inputs */ +.wizard-textarea { + width: 100%; + min-height: 120px; + font-size: 13px; + font-family: inherit; + line-height: 1.5; + color: var(--vscode-input-foreground, #ccc); + background: var(--vscode-input-background, #3c3c3c); + border: 1px solid var(--vscode-input-border, #555); + border-radius: 4px; + padding: 10px 12px; + outline: none; + resize: none; + overflow: hidden; + box-sizing: border-box; +} + +.wizard-textarea:focus { + border-color: var(--vscode-focusBorder, #007acc); +} + +.wizard-textarea::placeholder { + color: var(--vscode-input-placeholderForeground, #888); +} + +.wizard-textarea.invalid-input { + border-color: var(--vscode-inputValidation-errorBorder, #be1100) !important; + background: var(--vscode-inputValidation-errorBackground, rgba(190, 17, 0, 0.1)); +} + +/* Category Buttons (Step 1) */ +.wizard-type-buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 8px; +} + +/* Inside a flex-column .wizard-field the parent already supplies the +label/control gap, so the buttons must not add their own margin. */ +.wizard-field > .wizard-type-buttons { + margin-top: 0; +} + +.monaco-button.wizard-type-btn { + display: inline-flex; + flex: 0 0 auto; + width: auto; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: transparent !important; + color: var(--vscode-foreground, #ccc) !important; + border: 1px solid transparent; + border-radius: 4px; + font-size: 13px; + line-height: normal; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.monaco-button.wizard-type-btn:hover { + background: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06)) !important; +} + +.monaco-button.wizard-type-btn .codicon { + margin-right: 0; +} + +/* Radio circle before each category button */ +.monaco-button.wizard-type-btn::before { + content: ''; + display: inline-block; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--vscode-input-border, #555); + background: transparent; + flex-shrink: 0; + transition: border-color 0.15s ease; +} + +.monaco-button.wizard-type-btn:hover::before { + border-color: var(--vscode-focusBorder, #007acc); +} + +.monaco-button.wizard-type-btn.selected::before { + border-color: var(--vscode-focusBorder, #007acc); + background: var(--vscode-focusBorder, #007acc); + box-shadow: inset 0 0 0 2px var(--vscode-editor-background, #1e1e1e); +} + +.monaco-button.wizard-type-btn.selected { + border-color: transparent; + background: transparent !important; +} + +.monaco-button.wizard-type-btn:focus-visible { + outline: 1px solid var(--vscode-focusBorder, #007acc); + outline-offset: -1px; +} + +.wizard-type-buttons.invalid-input .monaco-button.wizard-type-btn::before { + border-color: var(--vscode-inputValidation-errorBorder, #be1100) !important; +} + +.monaco-button.wizard-type-btn.hidden { + display: none; +} + +/* Shortcut badge - shared style for keyboard shortcut indicators */ +.wizard-shortcut-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 4px; + min-width: 18px; + height: 18px; + border-radius: 4px; + border: 1px solid var(--vscode-input-border, #555); + font-size: 11px; + font-family: monospace; + color: var(--vscode-descriptionForeground, #888); + opacity: 0.7; + margin-left: 2px; +} + +.wizard-nav-btn.primary .wizard-shortcut-badge, +.monaco-button .wizard-shortcut-badge { + border-color: rgba(255, 255, 255, 0.3); + color: var(--vscode-button-foreground, #fff); + opacity: 0.7; +} + +.wizard-type-buttons.invalid-input .wizard-type-btn { + border-color: var(--vscode-inputValidation-errorBorder, #be1100); +} + +.wizard-field-error { + color: var(--vscode-inputValidation-errorForeground, var(--vscode-errorForeground, #f48771)) !important; + font-size: 12px; + line-height: 1.4; + min-height: 1.4em; +} + +.wizard-field-error.hidden { + visibility: hidden; + min-height: 1.4em; +} + +.wizard-similar-issues { + margin-top: 8px; + font-size: 12px; + color: var(--vscode-descriptionForeground, #999); +} + +.wizard-similar-heading { + font-weight: 600; + color: var(--vscode-foreground, #ccc); + margin-bottom: 4px; +} + +.wizard-similar-status { + line-height: 1.4; +} + +.wizard-similar-list { + margin: 0; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.wizard-similar-item { + line-height: 1.4; +} + +.wizard-similar-link { + color: var(--vscode-textLink-foreground, #3794ff); + text-decoration: none; +} + +.wizard-similar-link:hover { + color: var(--vscode-textLink-activeForeground, #3794ff); + text-decoration: underline; +} + +.wizard-similar-state { + margin-left: 6px; + color: var(--vscode-descriptionForeground, #999); +} + +.wizard-type-icon { + display: flex; + align-items: center; + font-size: 16px; +} + +/* Screenshot Cards (Step 3) */ +.wizard-screenshots { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-content: flex-start; + min-height: 50px; +} + +.wizard-screenshots-empty { + color: var(--vscode-descriptionForeground, #888); + font-style: italic; + display: flex; + align-items: center; +} + +.wizard-screenshot-card { + position: relative; + width: 120px; + height: 75px; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--vscode-input-border, #555); + cursor: pointer; + transition: border-color 0.15s; + flex-shrink: 0; +} + +.wizard-screenshot-card:hover, +.wizard-screenshot-card:focus-visible { + border-color: var(--vscode-focusBorder, #007acc); + outline: none; +} + +.wizard-screenshot-card img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.wizard-screenshot-delete { + position: absolute; + top: 4px; + right: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); + border: 1px solid var(--vscode-contrastBorder, transparent); + box-shadow: 0 0 0 1px rgba(0, 0, 0, .25); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + cursor: pointer; + opacity: 0; + transition: opacity .15s; +} + +.wizard-screenshot-delete:hover { + background: var(--vscode-button-hoverBackground, var(--vscode-button-background, #1177bb)); +} + +.wizard-screenshot-delete .codicon { + color: inherit; +} + +.wizard-screenshot-card:hover .wizard-screenshot-delete, +.wizard-screenshot-card:focus-within .wizard-screenshot-delete { + opacity: 1; +} + +.wizard-screenshot-add { + display: flex; + align-items: center; + justify-content: center; + border-style: dashed; + background: transparent; +} + +.wizard-screenshot-plus { + font-size: 28px; + color: var(--vscode-descriptionForeground, #888); +} + +.wizard-screenshot-add:hover .wizard-screenshot-plus { + color: var(--vscode-focusBorder, #007acc); +} + +.wizard-screenshot-add.disabled { + opacity: 0.4; + cursor: default; +} + +.wizard-screenshot-add.disabled:hover .wizard-screenshot-plus { + color: var(--vscode-descriptionForeground, #888); +} + +/* Screenshot Controls */ + +.wizard-screenshot-actions { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.wizard-delay-group { + display: flex; + align-items: center; + gap: 6px; +} + +.wizard-delay-label { + font-size: 12px; + white-space: nowrap; + color: var(--vscode-descriptionForeground, #999); +} + +.wizard-delay-select { + min-width: 120px; +} + +.wizard-delay-select .monaco-select-box { + height: 28px; + padding: 2px 23px 2px 8px; +} + +.wizard-capture-btn { + gap: 6px; +} + +.wizard-capture-btn.disabled, +.monaco-button.wizard-capture-btn:disabled { + opacity: 0.5; + pointer-events: none; +} + +.wizard-capture-icon { + display: flex; + font-size: 14px; +} + +/* Review Step (Step 4) */ +.wizard-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wizard-field.hidden { + display: none; +} + +.wizard-field-label { + font-size: 12px; + font-weight: 600; + color: var(--vscode-foreground, #ccc); + margin-top: 8px; + margin-bottom: 0; + display: block; +} + +.wizard-field-label:first-child { + margin-top: 0; +} + +.wizard-describe-content { + display: flex; + flex-direction: column; +} + +.wizard-field .monaco-inputbox.invalid-input, +.wizard-field .monaco-inputbox.invalid-input > .ibwrapper, +.wizard-field .monaco-inputbox.invalid-input > .ibwrapper > .input, +.wizard-field .monaco-inputbox.invalid-input .input { + border-color: var(--vscode-inputValidation-errorBorder, #be1100) !important; + background: var(--vscode-inputValidation-errorBackground, rgba(190, 17, 0, 0.1)) !important; +} + +.wizard-extension-field.invalid-input .monaco-select-box { + border-color: var(--vscode-inputValidation-errorBorder, #be1100) !important; + background: var(--vscode-inputValidation-errorBackground, rgba(190, 17, 0, 0.1)) !important; +} + +.wizard-extension-select { + max-width: 420px; +} + +.wizard-extension-select .monaco-select-box { + min-height: 32px; + padding: 5px 23px 5px 8px; +} + +.wizard-extension-select .monaco-select-box-dropdown-container .monaco-list-row.option-disabled { + display: none !important; +} + +.wizard-extension-select .monaco-select-box-dropdown-container .monaco-list-row[data-index="0"].option-disabled ~ .monaco-list-row { + transform: translateY(-22px); +} + +/* Status messages are absolutely positioned in the same slot as the field +error (the 1.4em reserved line directly below the input/buttons). The error +takes precedence when shown; otherwise the status text fills that slot. +This keeps the row height constant regardless of which is visible. */ +.wizard-source-field, +.wizard-extension-field { + position: relative; +} + +.wizard-target-status, +.wizard-extension-status { + position: absolute; + left: 0; + right: 0; + bottom: 0; + color: var(--vscode-descriptionForeground, #999); + font-size: 12px; + line-height: 1.4; + pointer-events: none; +} + +/* Hide the status while its sibling field-error is visible to avoid overlap. */ +.wizard-source-field:has(> .wizard-field-error:not(.hidden)) .wizard-target-status, +.wizard-extension-field:has(> .wizard-field-error:not(.hidden)) .wizard-extension-status { + display: none; +} + +/* Target row: source field + extension dropdown sit side-by-side. +Wraps to a new line on narrow widths; collapses naturally when the +extension field is hidden (no fixed-width placeholder needed). */ +.wizard-target-row { + display: flex; + flex-wrap: wrap; + gap: 0 24px; + align-items: flex-start; +} + +.wizard-target-row .wizard-source-field { + flex: 0 0 auto; +} + +.wizard-target-row .wizard-extension-field { + flex: 1 1 320px; + min-width: 240px; + max-width: 480px; +} + +.wizard-extension-field.hidden { + display: none; +} + +/* Title label row: label + AI generate button */ +.wizard-title-label-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; +} + +.wizard-title-label-row .wizard-field-label { + margin-top: 0; + margin-bottom: 0; +} + +.wizard-ai-title-btn.monaco-button { + width: auto; + padding: 2px 6px 2px 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + gap: 4px; + white-space: nowrap; + font-size: 11px; + height: auto; + line-height: 1.4; +} + +.wizard-ai-title-btn.monaco-button[disabled], +.wizard-ai-title-btn.monaco-button.disabled { + opacity: 0.55; + cursor: default; +} + +/* Description guidance text */ +.wizard-description-guidance { + margin-top: 0; + margin-bottom: 4px; + font-style: italic; + min-height: 1.4em; + white-space: pre-line; +} + +.wizard-md-hint { + margin-top: 0; + margin-bottom: 4px; +} + +/* Review: description rendered content */ +.review-value.review-description { + white-space: normal; + max-height: none; + overflow: visible; + padding: 8px 12px; + border: 1px solid var(--vscode-input-border, #444); + border-radius: 4px; +} + +.review-description .rendered-markdown { + color: inherit; +} + +.review-description .rendered-markdown p:first-child { + margin-top: 0; +} + +.review-description .rendered-markdown p:last-child { + margin-bottom: 0; +} + +.wizard-review-details { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; +} + +.wizard-review-similar-section.hidden { + display: none; +} + +.review-thumbnails { + display: flex; + gap: 10px; + margin-top: 4px; + flex-wrap: wrap; +} + +/* Review attachment cards - same size as step 3 cards */ +.review-attachment-card { + cursor: pointer; +} + +/* Upload Progress Overlay */ +.review-progress-overlay { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + border-radius: 5px; + z-index: 2; +} + +.upload-pending .review-progress-overlay, +.upload-uploading .review-progress-overlay, +.upload-done .review-progress-overlay { + display: flex; +} + +/* Spinning ring */ +.review-progress-ring { + width: 28px; + height: 28px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top-color: var(--vscode-focusBorder, #007acc); + border-radius: 50%; +} + +.upload-uploading .review-progress-ring { + animation: spin 0.8s linear infinite; +} + +.upload-pending .review-progress-ring { + border-top-color: rgba(255, 255, 255, 0.3); +} + +/* Checkmark */ +.review-progress-check { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--vscode-testing-runAction, #73c991); + color: #000; + font-size: 16px; +} + +/* Button spinner */ +.wizard-nav-btn.uploading, +.monaco-button.uploading { + pointer-events: none; + opacity: 0.8; +} + +.wizard-btn-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + vertical-align: middle; + margin-left: 6px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.wizard-submit-buttons { + display: none; +} + +.wizard-submit-btn { + padding: 8px 16px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 4px; + font-size: 13px; + cursor: pointer; + font-family: inherit; +} + +.wizard-submit-gist { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); +} + +.wizard-submit-gist:hover { + background: var(--vscode-button-hoverBackground, #1177bb); +} + +.wizard-submit-playwright { + background: var(--vscode-button-secondaryBackground, #3a3d41); + color: var(--vscode-button-secondaryForeground, #fff); +} + +.wizard-submit-playwright:hover { + background: var(--vscode-button-secondaryHoverBackground, #45494e); +} + +.wizard-submit-preview, +.wizard-submit-manual { + background: transparent; + color: var(--vscode-foreground, #ccc); + border: 1px solid var(--vscode-input-border, #3c3c3c); +} + +.wizard-submit-preview:hover, +.wizard-submit-manual:hover { + background: var(--vscode-list-hoverBackground, #2a2d2e); +} + +.wizard-step-review { + gap: 8px; +} + +.review-section { + display: flex; + flex-direction: column; + gap: 2px; +} + +.review-label { + font-size: 12px; + font-weight: 600; + color: var(--vscode-foreground, #ccc); +} + +.review-value { + font-size: 13px; + color: var(--vscode-descriptionForeground, #999); + white-space: pre-wrap; + word-break: break-word; + max-height: 60px; + overflow: hidden; + text-overflow: ellipsis; +} + +.review-target-repo { + color: var(--vscode-descriptionForeground, #999); + font-size: 12px; +} + +/* Diagnostic data sections (System Info, Extensions, Experiments) */ +.review-diagnostics { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.review-diag-heading { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.review-diag-heading-title { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground, #ccc); +} + +.review-diag-bulk-actions { + display: flex; + justify-content: flex-start; + margin-bottom: 2px; +} + +.review-diag-toggle-all.monaco-button { + font-size: 12px; + padding: 2px 8px; + width: auto; + flex: none; +} + +.review-diag-group { + border: 1px solid var(--vscode-input-border, #444); + border-radius: 4px; + padding: 6px 12px; +} + +.review-performance-data { + border: 1px solid var(--vscode-input-border, #444); + border-radius: 4px; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 6px; + position: relative; +} + +.review-performance-data.refreshing::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 4px; + background: var(--vscode-editor-background, #1e1e1e); + opacity: 0.6; + pointer-events: none; + z-index: 1; +} + +.review-performance-data.refreshing::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 18px; + height: 18px; + margin: -9px 0 0 -9px; + border: 2px solid var(--vscode-progressBar-background, var(--vscode-focusBorder, #007acc)); + border-top-color: transparent; + border-radius: 50%; + animation: review-performance-spin 0.8s linear infinite; + z-index: 2; + pointer-events: none; +} + +@keyframes review-performance-spin { + to { + transform: rotate(360deg); + } +} + +.review-performance-data .review-diag-group { + border: 0; + border-top: 1px solid var(--vscode-input-border, #444); + border-radius: 0; + padding: 6px 0 0; +} + +.review-performance-title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.review-performance-title { + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground, #ccc); +} + +.review-performance-refresh.monaco-button { + flex: none; + width: auto; + font-size: 12px; + padding: 2px 8px; +} + +.review-performance-description { + font-size: 12px; + color: var(--vscode-descriptionForeground, #888); +} + +.review-diag-header { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; +} + +.review-diag-check-wrap { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + white-space: nowrap; +} + +.review-diag-checkbox { + margin: 0; + flex-shrink: 0; +} + +.review-diag-check-label { + font-size: 12px; + color: var(--vscode-descriptionForeground, #888); + cursor: pointer; + white-space: nowrap; +} + +.review-diag-title { + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground, #ccc); + white-space: nowrap; + flex-shrink: 0; +} + +.review-diag-toggle.monaco-button { + flex-shrink: 0; + font-size: 12px; + padding: 2px 8px 2px 4px; + width: auto; + flex: none; +} + +.review-diag-content { + margin-top: 6px; + font-size: 12px; + color: var(--vscode-descriptionForeground, #888); + max-height: 200px; + overflow: auto; +} + +.review-diag-table { + width: 100%; + border-collapse: collapse; +} + +.review-diag-table td, +.review-diag-table th { + padding: 2px 8px 2px 0; + vertical-align: top; + text-align: left; + font-size: 12px; +} + +.review-diag-key { + white-space: nowrap; + font-weight: 600; + color: var(--vscode-foreground, #ccc); + width: 120px; +} + +.review-ext-table th { + font-weight: 600; + color: var(--vscode-foreground, #ccc); + border-bottom: 1px solid var(--vscode-input-border, #444); + padding-bottom: 4px; +} + +.review-ext-table td { + font-size: 11px; + padding: 2px 12px 2px 0; +} + +.review-diag-sublabel { + font-size: 12px; + font-weight: 600; + color: var(--vscode-foreground, #ccc); + margin-top: 6px; + margin-bottom: 2px; +} + +.review-diag-sublabel:first-child { + margin-top: 0; +} + +.review-diag-pre { + margin: 0; + font-size: 11px; + white-space: pre-wrap; + word-break: break-all; +} + +.review-diag-loading { + font-size: 12px; + color: var(--vscode-descriptionForeground, #888); + font-style: italic; + padding: 4px 0; +} + +/* Bottom Navigation */ +.wizard-nav { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 40px; + flex-shrink: 0; + background: var(--vscode-editor-background, #1e1e1e); +} + +/* Button widget overrides for wizard nav */ +.wizard-nav .monaco-button { + padding: 6px 16px; + white-space: nowrap; + width: auto; + flex: none; +} + +/* Navigation buttons */ +.wizard-nav-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + user-select: none; + white-space: nowrap; + color: var(--vscode-foreground, #ccc); + border: 1px solid var(--vscode-input-border, #555); + background: transparent; + transition: all 0.15s ease; +} + +.wizard-nav-btn:hover { + background: rgba(255, 255, 255, 0.06); + border-color: var(--vscode-foreground, #999); +} + +.wizard-nav-btn.primary { + color: var(--vscode-button-foreground, #fff); + background: var(--vscode-button-background, #0e639c); + border-color: var(--vscode-button-background, #0e639c); +} + +.wizard-nav-btn.primary:hover { + background: var(--vscode-button-hoverBackground, #1177bb); + border-color: var(--vscode-button-hoverBackground, #1177bb); +} + +.wizard-nav-btn.submit { + background: var(--vscode-testing-runAction, #73c991); + border-color: var(--vscode-testing-runAction, #73c991); + color: #000; +} + +.wizard-nav-btn.submit:hover { + filter: brightness(1.1); +} + +/* Annotation Editor Buttons */ +.issue-reporter-annotation-overlay .monaco-button { + border-radius: 4px; + height: 28px; + font-size: 13px; + min-width: 80px; + padding: 0 14px; + cursor: pointer; +} + +/* Annotation Editor */ +.issue-reporter-annotation-overlay { + position: absolute; + inset: 0; + z-index: 3; + background: var(--vscode-editor-background, #1e1e1e); + display: flex; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', system-ui, sans-serif; + font-size: 13px; + color: var(--vscode-foreground, #ccc); + outline: none !important; +} + +.issue-reporter-annotation-overlay:focus, +.issue-reporter-annotation-overlay:focus-visible, +.issue-reporter-annotation-overlay:focus-within { + outline: none !important; + box-shadow: none !important; +} + +.issue-reporter-annotation-overlay .annotation-toolbar { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 3px; + padding: 4px 10px; + min-height: 34px; + background: var(--vscode-titleBar-activeBackground, #3c3c3c); + border-bottom: 1px solid var(--vscode-panel-border, #444); +} + +.issue-reporter-annotation-overlay .annotation-crop-toolbar { + gap: 8px; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .tool-btn { + width: 28px; + height: 28px; + min-width: 28px; + padding: 0; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + background: transparent; + color: var(--vscode-foreground, #ccc); + font-size: 14px; + flex-shrink: 0; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .tool-btn:hover { + background: rgba(255, 255, 255, .1); +} + +.issue-reporter-annotation-overlay .annotation-toolbar .tool-btn:focus { + outline: none; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .tool-btn:focus-visible { + outline: 1px solid var(--vscode-focusBorder, #007acc); + outline-offset: -1px; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .tool-btn:disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .tool-btn:disabled:hover { + background: transparent; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .tool-btn.active { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); + border-color: var(--vscode-button-background, #0e639c); +} + +.issue-reporter-annotation-overlay .annotation-toolbar .tool-btn.active, +.issue-reporter-annotation-overlay .annotation-toolbar .tool-btn.active .codicon { + color: var(--vscode-button-foreground, #fff) !important; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .toolbar-separator { + width: 1px; + height: 20px; + background: var(--vscode-panel-border, #555); + margin: 0 4px; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .toolbar-spacer { + flex: 1; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .monaco-button { + height: 26px; + min-width: 70px; + padding: 0 12px; + font-size: 12px; + width: auto; + flex: none; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .color-btn { + position: relative; +} + +.issue-reporter-annotation-overlay .annotation-tool-options-popover { + position: absolute; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + background: var(--vscode-editorWidget-background, #2d2d2d); + border: 1px solid var(--vscode-editorWidget-border, #555); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + z-index: 4; + min-width: 220px; +} + +.issue-reporter-annotation-overlay .annotation-tool-options-popover::before { + content: ''; + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: var(--vscode-editorWidget-background, #2d2d2d); + border-left: 1px solid var(--vscode-editorWidget-border, #555); + border-top: 1px solid var(--vscode-editorWidget-border, #555); +} + +.issue-reporter-annotation-overlay .annotation-tool-options-group { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.issue-reporter-annotation-overlay .annotation-tool-options-label { + color: var(--vscode-descriptionForeground, #999); + font-size: 11px; + min-width: 76px; +} + +.issue-reporter-annotation-overlay .annotation-color-swatches, +.issue-reporter-annotation-overlay .annotation-size-buttons { + display: flex; + gap: 4px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.issue-reporter-annotation-overlay .annotation-color-swatch { + position: relative; + width: 22px; + height: 22px; + border-radius: 50%; + cursor: pointer; + border: 2px solid transparent; + flex-shrink: 0; + padding: 0; + background-clip: padding-box; +} + +.issue-reporter-annotation-overlay .annotation-color-swatch.transparent { + background: + linear-gradient(45deg, transparent 46%, #d04b4b 48%, #d04b4b 52%, transparent 54%), + linear-gradient(45deg, #858585 25%, transparent 25%, transparent 75%, #858585 75%), + linear-gradient(45deg, #858585 25%, transparent 25%, transparent 75%, #858585 75%); + background-color: #fff; + background-position: 0 0, 0 0, 5px 5px; + background-size: 100% 100%, 10px 10px, 10px 10px; +} + +.issue-reporter-annotation-overlay .annotation-color-swatch:hover, +.issue-reporter-annotation-overlay .annotation-size-button:hover { + border-color: var(--vscode-focusBorder, rgba(255, 255, 255, .5)); +} + +.issue-reporter-annotation-overlay .annotation-color-swatch:focus-visible, +.issue-reporter-annotation-overlay .annotation-size-button:focus-visible, +.issue-reporter-annotation-overlay .annotation-opacity-slider:focus-visible { + outline: 1px solid var(--vscode-focusBorder, #007acc); + outline-offset: 2px; +} + +.issue-reporter-annotation-overlay .annotation-color-swatch.active::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 10px; + height: 6px; + border-left: 2px solid #fff; + border-bottom: 2px solid #fff; + transform: translate(-50%, -65%) rotate(-45deg); + pointer-events: none; +} + +.issue-reporter-annotation-overlay .annotation-color-swatch.active.light-swatch::after { + border-color: #000; +} + +.issue-reporter-annotation-overlay .annotation-size-button.active { + background: var(--vscode-list-activeSelectionBackground, rgba(0, 122, 204, 0.25)); + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground, #fff)); + border-color: transparent; +} + +.issue-reporter-annotation-overlay .annotation-size-button { + min-width: 28px; + height: 24px; + border-radius: 4px; + border: 1px solid transparent; + background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08)); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground, #ccc)); + cursor: pointer; + font-size: 11px; + padding: 0 6px; +} + +.issue-reporter-annotation-overlay .annotation-opacity-options { + align-items: center; +} + +.issue-reporter-annotation-overlay .annotation-opacity-slider { + flex: 1; + min-width: 92px; + accent-color: var(--vscode-focusBorder, #007acc); +} + +.issue-reporter-annotation-overlay .annotation-opacity-value { + color: var(--vscode-descriptionForeground, #999); + font-size: 11px; + width: 34px; + text-align: right; +} + +.issue-reporter-annotation-overlay .annotation-hint { + text-align: center; + padding: 6px 0; + font-size: 12px; + color: var(--vscode-descriptionForeground, #999); + border-bottom: 1px solid var(--vscode-panel-border, #444); +} + +.issue-reporter-annotation-overlay .annotation-canvas-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background, #1e1e1e)); +} + +.issue-reporter-annotation-overlay .annotation-canvas-container canvas { + cursor: crosshair; +} + +.issue-reporter-annotation-overlay .annotation-toolbar .crop-btn { + font-size: 16px; + line-height: 1; +} + +/* Recording UI */ + +.wizard-record-btn, +.monaco-button.wizard-record-btn { + gap: 6px; + border-color: #c33 !important; + color: #c33 !important; +} + +.wizard-record-btn:hover, +.monaco-button.wizard-record-btn:hover { + background: rgba(204, 51, 51, 0.1) !important; +} + +.wizard-record-btn .wizard-record-icon { + display: flex; + font-size: 14px; + color: var(--vscode-charts-red, #c33); +} + +.wizard-record-btn.recording, +.monaco-button.wizard-record-btn.recording { + background: var(--vscode-statusBarItem-errorBackground, #c33) !important; + border-color: var(--vscode-statusBarItem-errorBackground, #c33) !important; + color: var(--vscode-statusBarItem-errorForeground, var(--vscode-button-foreground, #fff)) !important; + animation: recording-pulse 1.5s ease-in-out infinite; + font-variant-numeric: tabular-nums; + min-width: 160px; +} + +.wizard-record-btn.recording .wizard-record-icon { + color: var(--vscode-statusBarItem-errorForeground, var(--vscode-button-foreground, #fff)); +} + +.wizard-record-btn.recording:hover, +.monaco-button.wizard-record-btn.recording:hover { + background: var(--vscode-statusBarItem-errorBackground, #c33) !important; + border-color: var(--vscode-statusBarItem-errorBackground, #c33) !important; + color: var(--vscode-statusBarItem-errorForeground, var(--vscode-button-foreground, #fff)) !important; + filter: brightness(1.15); +} + +.wizard-recording-elapsed { + font-size: 11px; + font-variant-numeric: tabular-nums; + opacity: 0.9; +} + +@keyframes recording-pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.7; + } +} + +/* -- Recording card -- */ +.wizard-recording-card { + background: var(--vscode-editor-background, #1a1a1a); + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.wizard-recording-card > .wizard-screenshot-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; +} + +.wizard-recording-play { + font-size: 28px; + color: #fff; + opacity: 0.8; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} + +.wizard-recording-card:hover .wizard-recording-play { + opacity: 1; +} + +.wizard-recording-duration { + position: absolute; + bottom: 4px; + right: 6px; + font-size: 10px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + padding: 1px 4px; + border-radius: 2px; + z-index: 1; +} diff --git a/src/vs/workbench/contrib/issue/browser/recordingService.ts b/src/vs/workbench/contrib/issue/browser/recordingService.ts new file mode 100644 index 00000000000..c2c6af83871 --- /dev/null +++ b/src/vs/workbench/contrib/issue/browser/recordingService.ts @@ -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('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; + + /** + * 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; + + /** + * Stop the current recording. + * Returns the recorded data, or undefined if no recording was in progress. + */ + stopRecording(): Promise; + + /** + * 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 { + throw new Error('Recording is not supported in web browsers.'); + } + + async stopRecording(): Promise { + return undefined; + } + + discardRecording(): void { + // No-op + } + + async getScreenCapturePermissionStatus(): Promise<'granted'> { + return 'granted'; + } + + openScreenCapturePermissionSettings(): void { + // No-op + } +} diff --git a/src/vs/workbench/contrib/issue/browser/screenshotAnnotation.ts b/src/vs/workbench/contrib/issue/browser/screenshotAnnotation.ts new file mode 100644 index 00000000000..f7c4e0ce626 --- /dev/null +++ b/src/vs/workbench/contrib/issue/browser/screenshotAnnotation.ts @@ -0,0 +1,2427 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener, append, EventType, getWindow } from '../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IScreenshot } from './issueReporterOverlay.js'; + +const enum AnnotationTool { + Select = 'select', + Freehand = 'freehand', + Rectangle = 'rectangle', + Ellipse = 'ellipse', + Arrow = 'arrow', + Text = 'text', + Eraser = 'eraser', + Pan = 'pan', + Crop = 'crop', + Move = 'move', +} + +const COLORS = [ + '#ff3b30', // red + '#007aff', // blue + '#34c759', // green + '#ffcc00', // yellow + '#000000', // black + '#ffffff', // white +]; + +const LIGHT_SWATCH_COLORS = new Set(['#34c759', '#ffcc00', '#ffffff', 'transparent']); + +const FONT_FAMILIES = [ + { label: 'Sans-serif', value: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }, + { label: 'Monospace', value: '"Cascadia Code", "Fira Code", Consolas, monospace' }, + { label: 'Serif', value: 'Georgia, "Times New Roman", serif' }, +]; + +const DEFAULT_TEXT_BOX_WIDTH = 240; +const MIN_TEXT_BOX_WIDTH = 48; +const TEXT_DRAG_THRESHOLD = 4; +/** Padding on each side of the displayed image inside the canvas container at fit-to-window scale. */ +const CANVAS_BREATHING_ROOM = 64; +const FILL_COLORS = ['transparent', ...COLORS]; +const STROKE_WIDTHS = [2, 4, 8, 12]; +const TEXT_SIZES = [14, 18, 24, 32, 48]; + +export interface IAnnotationDrawAction { + readonly type: AnnotationTool; + strokeColor: string; + fillColor?: string; + opacity: number; + lineWidth: number; + fontSize?: number; + fontFamily?: string; + points?: { x: number; y: number }[]; + rect?: { x: number; y: number; width: number; height: number }; + ellipseRect?: { x: number; y: number; width: number; height: number }; + arrowStart?: { x: number; y: number }; + arrowEnd?: { x: number; y: number }; + text?: string; + textPos?: { x: number; y: number }; + textWidth?: number; + /** Only set for type === AnnotationTool.Eraser: the batch of actions removed in one stroke. */ + erasedActions?: IAnnotationDrawAction[]; + /** Only set for type === AnnotationTool.Eraser: the original index (in `actions[]`) of each erased action at the moment it was removed. */ + erasedIndices?: number[]; + /** Only set for type === AnnotationTool.Crop: the crop active before this action. null means no crop (full original image). */ + cropFrom?: { x: number; y: number; width: number; height: number } | null; + /** Only set for type === AnnotationTool.Crop: the crop active after this action. null means no crop (full original image). */ + cropTo?: { x: number; y: number; width: number; height: number } | null; + /** Only set for type === AnnotationTool.Move: the action that was moved or resized. */ + moveTarget?: IAnnotationDrawAction; + /** Only set for type === AnnotationTool.Move: snapshot of geometric fields before the change. */ + moveBefore?: IAnnotationMoveSnapshot; + /** Only set for type === AnnotationTool.Move: snapshot of geometric fields after the change. */ + moveAfter?: IAnnotationMoveSnapshot; +} + +interface IAnnotationMoveSnapshot { + points?: { x: number; y: number }[]; + rect?: { x: number; y: number; width: number; height: number }; + ellipseRect?: { x: number; y: number; width: number; height: number }; + arrowStart?: { x: number; y: number }; + arrowEnd?: { x: number; y: number }; + textPos?: { x: number; y: number }; + textWidth?: number; +} + +type DrawAction = IAnnotationDrawAction; + +export interface IAnnotationEditorState { + readonly actions: readonly IAnnotationDrawAction[]; + readonly undoneActions: readonly IAnnotationDrawAction[]; + readonly crop: { readonly x: number; readonly y: number; readonly width: number; readonly height: number } | null; +} + +export interface IAnnotationSaveResult { + readonly dataUrl: string; + readonly state: IAnnotationEditorState; +} + +function cloneDrawAction(action: IAnnotationDrawAction, identityMap: Map = new Map()): IAnnotationDrawAction { + const existing = identityMap.get(action); + if (existing) { + return existing; + } + const clone: IAnnotationDrawAction = { + type: action.type, + strokeColor: action.strokeColor, + fillColor: action.fillColor, + opacity: action.opacity, + lineWidth: action.lineWidth, + fontSize: action.fontSize, + fontFamily: action.fontFamily, + points: action.points ? action.points.map(p => ({ x: p.x, y: p.y })) : undefined, + rect: action.rect ? { ...action.rect } : undefined, + ellipseRect: action.ellipseRect ? { ...action.ellipseRect } : undefined, + arrowStart: action.arrowStart ? { ...action.arrowStart } : undefined, + arrowEnd: action.arrowEnd ? { ...action.arrowEnd } : undefined, + text: action.text, + textPos: action.textPos ? { ...action.textPos } : undefined, + textWidth: action.textWidth, + cropFrom: action.cropFrom === undefined ? undefined : action.cropFrom === null ? null : { ...action.cropFrom }, + cropTo: action.cropTo === undefined ? undefined : action.cropTo === null ? null : { ...action.cropTo }, + moveBefore: action.moveBefore ? cloneMoveSnapshot(action.moveBefore) : undefined, + moveAfter: action.moveAfter ? cloneMoveSnapshot(action.moveAfter) : undefined, + }; + identityMap.set(action, clone); + // Resolve references after registering self so cyclic structures don't recurse forever. + clone.erasedActions = action.erasedActions ? action.erasedActions.map(a => cloneDrawAction(a, identityMap)) : undefined; + clone.erasedIndices = action.erasedIndices ? action.erasedIndices.slice() : undefined; + clone.moveTarget = action.moveTarget ? cloneDrawAction(action.moveTarget, identityMap) : undefined; + return clone; +} + +function cloneMoveSnapshot(s: IAnnotationMoveSnapshot): IAnnotationMoveSnapshot { + return { + points: s.points ? s.points.map(p => ({ x: p.x, y: p.y })) : undefined, + rect: s.rect ? { ...s.rect } : undefined, + ellipseRect: s.ellipseRect ? { ...s.ellipseRect } : undefined, + arrowStart: s.arrowStart ? { ...s.arrowStart } : undefined, + arrowEnd: s.arrowEnd ? { ...s.arrowEnd } : undefined, + textPos: s.textPos ? { ...s.textPos } : undefined, + textWidth: s.textWidth, + }; +} + +function captureMoveSnapshot(action: IAnnotationDrawAction): IAnnotationMoveSnapshot { + return cloneMoveSnapshot({ + points: action.points, + rect: action.rect, + ellipseRect: action.ellipseRect, + arrowStart: action.arrowStart, + arrowEnd: action.arrowEnd, + textPos: action.textPos, + textWidth: action.textWidth, + }); +} + +function applyMoveSnapshot(action: IAnnotationDrawAction, snapshot: IAnnotationMoveSnapshot): void { + const fresh = cloneMoveSnapshot(snapshot); + action.points = fresh.points; + action.rect = fresh.rect; + action.ellipseRect = fresh.ellipseRect; + action.arrowStart = fresh.arrowStart; + action.arrowEnd = fresh.arrowEnd; + action.textPos = fresh.textPos; + action.textWidth = fresh.textWidth; +} + +function moveSnapshotsEqual(a: IAnnotationMoveSnapshot, b: IAnnotationMoveSnapshot): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export class ScreenshotAnnotationEditor { + + private readonly disposables = new DisposableStore(); + private readonly toolOptionsDisposables = new DisposableStore(); + private readonly _onDidSave = new Emitter(); + readonly onDidSave: Event = this._onDidSave.event; + private readonly _onDidCancel = new Emitter(); + readonly onDidCancel: Event = this._onDidCancel.event; + + private container!: HTMLElement; + private canvas!: HTMLCanvasElement; + private ctx!: CanvasRenderingContext2D; + + private activeTool: AnnotationTool = AnnotationTool.Freehand; + private activeStrokeColor = COLORS[0]; + private activeFillColor = 'transparent'; + private activeLineWidth = 4; + private activeOpacity = 1; + private readonly actions: DrawAction[] = []; + private readonly undoneActions: DrawAction[] = []; + private currentAction: DrawAction | null = null; + private isDrawing = false; + private isErasing = false; + /** Actions erased during the current pointer drag; committed to undo stack on pointer-up. */ + private pendingEraseActions: DrawAction[] = []; + /** Original index (in `actions[]`) of each entry in `pendingEraseActions`, captured at the moment it was removed. */ + private pendingEraseIndices: number[] = []; + + private imageElement: HTMLImageElement | null = null; + private imageWidth = 0; + private imageHeight = 0; + private scale = 1; + + // Pan & zoom + private panX = 0; + private panY = 0; + private isPanning = false; + private lastPanPoint = { x: 0, y: 0 }; + + // Crop with handles + private cropMode = false; + private cropRegion: { x: number; y: number; width: number; height: number } | null = null; + private cropDragHandle: 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'move' | null = null; + private cropDragStart = { x: 0, y: 0 }; + private cropRegionStart: { x: number; y: number; width: number; height: number } | null = null; + private hasUserZoomed = false; + /** Pending wheel-zoom delta accumulated across rapid wheel events; flushed on rAF. */ + private pendingZoom: { factor: number; cx: number; cy: number } | null = null; + private pendingZoomRaf = 0; + + // Original image preserved so crops can be expanded back + private originalImage: { element: HTMLImageElement; width: number; height: number } | null = null; + // Current crop region in original-image coords (null = no crop applied) + private currentCrop: { x: number; y: number; width: number; height: number } | null = null; + // Pre-crop state restored on Cancel + private preCropState: { element: HTMLImageElement; width: number; height: number; currentCrop: { x: number; y: number; width: number; height: number } | null } | null = null; + private mainToolbar: HTMLElement | null = null; + private cropToolbar: HTMLElement | null = null; + + /** Annotations are stored in original-image coords. While in crop mode the canvas already shows the original image, so the offset is 0. */ + private get cropOffsetX(): number { return this.cropMode ? 0 : (this.currentCrop?.x ?? 0); } + private get cropOffsetY(): number { return this.cropMode ? 0 : (this.currentCrop?.y ?? 0); } + + // Selection (Select tool) + private selectedActionIndex = -1; + private isDraggingSelected = false; + private isResizingSelectedText = false; + private dragStart = { x: 0, y: 0 }; + private selectedTextResizeStartWidth = DEFAULT_TEXT_BOX_WIDTH; + /** Captured at the start of a Select-tool drag/resize so a Move sentinel can be committed on pointer-up. */ + private pendingMove: { target: DrawAction; before: IAnnotationMoveSnapshot } | null = null; + + // Text configuration + private activeFontSize = 18; + private activeFontFamily = FONT_FAMILIES[0].value; + private textPlacementState: { + start: { x: number; y: number }; + current: { x: number; y: number }; + pointerId: number; + } | null = null; + private textEditState: { + pos: { x: number; y: number }; + text: string; + caretIndex: number; + strokeColor: string; + fillColor: string; + opacity: number; + fontSize: number; + fontFamily: string; + width: number; + showBoxOutline: boolean; + } | null = null; + private textEditor: HTMLTextAreaElement | null = null; + private textCaretVisible = true; + private textCaretInterval: number | null = null; + + // Tool buttons (for active state management) + private readonly toolButtons: { element: HTMLElement; tool: AnnotationTool }[] = []; + private undoBtn: HTMLButtonElement | null = null; + private redoBtn: HTMLButtonElement | null = null; + private toolOptionsPopover: HTMLElement | null = null; + + + constructor( + private readonly screenshot: IScreenshot, + private readonly parentElement: HTMLElement, + private readonly initialState?: IAnnotationEditorState, + ) { + this.createUI(); + this.loadImage(); + } + + private createUI(): void { + this.container = append(this.parentElement, $('div.issue-reporter-annotation-overlay')); + this.container.tabIndex = -1; + + // Main toolbar (hidden during crop mode) + const toolbar = append(this.container, $('div.annotation-toolbar')); + this.mainToolbar = toolbar; + + // 1. Drawing tools: Select, Pan, Crop, Draw, Rectangle, Ellipse, Arrow + const drawingTools: { tool: AnnotationTool; label: string; icon: HTMLSpanElement }[] = [ + { tool: AnnotationTool.Select, label: localize('select', "Select / Move"), icon: renderIcon(Codicon.inspect) }, + { tool: AnnotationTool.Pan, label: localize('pan', "Pan"), icon: renderIcon(Codicon.move) }, + ]; + for (const { tool, label, icon } of drawingTools) { + this.addToolButton(toolbar, tool, label, icon); + } + + // 2. Crop tool + const cropBtn = append(toolbar, $('button.tool-btn.crop-btn')); + cropBtn.appendChild(renderIcon(Codicon.screenCut)); + cropBtn.title = localize('crop', "Crop"); + cropBtn.setAttribute('aria-label', localize('crop', "Crop")); + this.toolButtons.push({ element: cropBtn, tool: AnnotationTool.Crop }); + this.disposables.add(addDisposableListener(cropBtn, EventType.CLICK, () => { + this.setActiveTool(AnnotationTool.Crop); + })); + + // 3. More drawing tools + const moreDrawingTools: { tool: AnnotationTool; label: string; icon: HTMLSpanElement }[] = [ + { tool: AnnotationTool.Freehand, label: localize('freehand', "Draw"), icon: renderIcon(Codicon.edit) }, + { tool: AnnotationTool.Rectangle, label: localize('rectangle', "Rectangle"), icon: renderIcon(Codicon.primitiveSquare) }, + { tool: AnnotationTool.Ellipse, label: localize('ellipse', "Ellipse"), icon: renderIcon(Codicon.circle) }, + { tool: AnnotationTool.Arrow, label: localize('arrow', "Arrow"), icon: renderIcon(Codicon.arrowRight) }, + { tool: AnnotationTool.Eraser, label: localize('eraser', "Eraser"), icon: renderIcon(Codicon.eraser) }, + ]; + for (const { tool, label, icon } of moreDrawingTools) { + this.addToolButton(toolbar, tool, label, icon); + } + + // 4. Text tool + this.addToolButton(toolbar, AnnotationTool.Text, localize('text', "Text"), renderIcon(Codicon.symbolString)); + + this.toolOptionsPopover = append(this.container, $('div.annotation-tool-options-popover')); + this.toolOptionsPopover.style.display = 'none'; + this.disposables.add(addDisposableListener(this.container, EventType.CLICK, e => { + if (!this.toolOptionsPopover || this.toolOptionsPopover.style.display === 'none') { + return; + } + const target = e.target as Node; + if (!this.toolOptionsPopover.contains(target) && !this.toolButtons.some(button => button.element.contains(target))) { + this.hideToolOptions(); + } + })); + this.renderToolOptions(); + + // 5. Separator + append(toolbar, $('div.toolbar-separator')); + + // 6. Undo button + const undoBtn = append(toolbar, $('button.tool-btn')) as HTMLButtonElement; + undoBtn.appendChild(renderIcon(Codicon.discard)); + undoBtn.title = localize('undo', "Undo"); + undoBtn.setAttribute('aria-label', localize('undo', "Undo")); + this.disposables.add(addDisposableListener(undoBtn, EventType.CLICK, () => this.undo())); + this.undoBtn = undoBtn; + + // 7. Redo button + const redoBtn = append(toolbar, $('button.tool-btn')) as HTMLButtonElement; + redoBtn.appendChild(renderIcon(Codicon.redo)); + redoBtn.title = localize('redo', "Redo"); + redoBtn.setAttribute('aria-label', localize('redo', "Redo")); + this.disposables.add(addDisposableListener(redoBtn, EventType.CLICK, () => this.redo())); + this.redoBtn = redoBtn; + this.updateUndoRedoState(); + + // 8. Separator + append(toolbar, $('div.toolbar-separator')); + + // 9. Discard button + const discardBtn = this.disposables.add(new Button(toolbar, { ...defaultButtonStyles, secondary: true })); + discardBtn.label = localize('discard', "Discard"); + this.disposables.add(discardBtn.onDidClick(() => { + this.cancelTextEdit(); + this._onDidCancel.fire(); + this.dispose(); + })); + + // 10. Save button + const saveBtn = this.disposables.add(new Button(toolbar, defaultButtonStyles)); + saveBtn.label = localize('save', "Save"); + this.disposables.add(saveBtn.onDidClick(() => { + this.commitTextEdit(); + const dataUrl = this.compositeToDataUrl(); + this._onDidSave.fire({ dataUrl, state: this.captureState() }); + this.dispose(); + })); + + // Crop toolbar (shown only during crop mode, hidden by default) + const cropToolbar = append(this.container, $('div.annotation-toolbar.annotation-crop-toolbar')); + cropToolbar.style.display = 'none'; + this.cropToolbar = cropToolbar; + + const cropCancelBtn = this.disposables.add(new Button(cropToolbar, { ...defaultButtonStyles, secondary: true })); + cropCancelBtn.label = localize('cancel', "Cancel"); + this.disposables.add(cropCancelBtn.onDidClick(() => { + this.cancelCrop(); + })); + + const cropApplyBtn = this.disposables.add(new Button(cropToolbar, defaultButtonStyles)); + cropApplyBtn.label = localize('apply', "Apply"); + this.disposables.add(cropApplyBtn.onDidClick(() => { + this.commitCrop(); + })); + + // Hint label + const hint = append(this.container, $('div.annotation-hint')); + hint.textContent = localize('annotationHint', "Edit screenshot to highlight the problem"); + + // Canvas container + const canvasContainer = append(this.container, $('div.annotation-canvas-container')); + this.canvas = append(canvasContainer, $('canvas')) as HTMLCanvasElement; + const ctx = this.canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get 2D canvas context'); + } + this.ctx = ctx; + + // Canvas pointer events + this.disposables.add(addDisposableListener(this.canvas, EventType.POINTER_DOWN, e => this.onPointerDown(e))); + this.disposables.add(addDisposableListener(this.canvas, EventType.POINTER_MOVE, e => this.onPointerMove(e))); + this.disposables.add(addDisposableListener(this.canvas, EventType.POINTER_UP, e => this.onPointerUp(e))); + + // Double-click to apply crop + this.disposables.add(addDisposableListener(this.canvas, EventType.DBLCLICK, () => { + this.commitCrop(); + })); + + // Wheel: touchpad two-finger scroll → pan; Ctrl+wheel or pinch → zoom around cursor + this.disposables.add(addDisposableListener(canvasContainer, EventType.WHEEL, (e: WheelEvent) => { + e.preventDefault(); + if (e.ctrlKey) { + // Pinch-to-zoom on touchpad (browser synthesises ctrlKey) or Ctrl+scroll. + // Wheel events can fire faster than we can redraw at high zoom levels, + // so we coalesce the deltas and flush once per animation frame. This keeps + // the canvas reallocation/redraw cost bounded and lets other input (like + // drawing) interleave responsively. + const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX; + const factor = delta < 0 ? 1.1 : 0.9; + const containerRect = canvasContainer.getBoundingClientRect(); + const cx = e.clientX - (containerRect.left + containerRect.width / 2); + const cy = e.clientY - (containerRect.top + containerRect.height / 2); + if (this.pendingZoom) { + this.pendingZoom.factor *= factor; + this.pendingZoom.cx = cx; + this.pendingZoom.cy = cy; + } else { + this.pendingZoom = { factor, cx, cy }; + } + if (!this.pendingZoomRaf) { + const targetWindow = getWindow(this.canvas); + this.pendingZoomRaf = targetWindow.requestAnimationFrame(() => { + this.pendingZoomRaf = 0; + this.flushPendingZoom(); + }); + } + } else { + // Two-finger scroll on touchpad (or plain scroll wheel) → pan + this.panX -= e.deltaX; + this.panY -= e.deltaY; + this.clampPan(); + this.canvas.style.transform = `translate(${this.panX}px, ${this.panY}px)`; + } + }, { passive: false })); + + // Keyboard shortcuts + this.disposables.add(addDisposableListener(this.container, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (this.textEditState) { + return; + } + if (this.textPlacementState && e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + this.cancelTextPlacement(); + return; + } + if (e.key === 'Escape') { + if (this.cropMode) { + e.preventDefault(); + e.stopPropagation(); + this.cancelCrop(); + return; + } + if (this.selectedActionIndex >= 0) { + this.selectedActionIndex = -1; + this.redraw(); + return; + } + e.preventDefault(); + e.stopPropagation(); + this._onDidCancel.fire(); + this.dispose(); + } else if (e.key === 'Enter' && this.cropMode) { + e.preventDefault(); + this.commitCrop(); + } else if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedActionIndex >= 0) { + e.preventDefault(); + const removedIndex = this.selectedActionIndex; + const [removed] = this.actions.splice(removedIndex, 1); + this.selectedActionIndex = -1; + // Record the deletion as an Eraser sentinel so undo/redo works just + // like the eraser tool. + this.actions.push({ + type: AnnotationTool.Eraser, + strokeColor: '', + opacity: 1, + lineWidth: 0, + erasedActions: [removed], + erasedIndices: [removedIndex], + }); + this.undoneActions.length = 0; + this.updateUndoRedoState(); + this.redraw(); + } + })); + + // Re-fit canvas when container resizes + const resizeObserver = new ResizeObserver(() => { + if (this.imageElement) { + // On resize, ensure the user's current zoom is still at least the new fit-to-window + // scale. Without this, growing the window after zooming out could leave the image + // orphaned in the centre with empty space around it that can't be filled. + if (this.hasUserZoomed) { + const minScale = this.getFitScale(); + if (this.scale < minScale) { + this.scale = minScale; + } + } + this.sizeCanvas(); + this.clampPan(); + this.canvas.style.transform = `translate(${this.panX}px, ${this.panY}px)`; + this.redraw(); + } + }); + resizeObserver.observe(canvasContainer); + this.disposables.add({ dispose: () => resizeObserver.disconnect() }); + } + + private addToolButton(toolbar: HTMLElement, tool: AnnotationTool, label: string, icon: HTMLSpanElement): void { + const btn = append(toolbar, $('button.tool-btn')); + btn.appendChild(icon); + btn.title = label; + btn.setAttribute('aria-label', label); + btn.setAttribute('aria-pressed', String(tool === this.activeTool)); + if (tool === this.activeTool) { + btn.classList.add('active'); + } + this.toolButtons.push({ element: btn, tool }); + this.disposables.add(addDisposableListener(btn, EventType.CLICK, e => { + e.stopPropagation(); + this.setActiveTool(tool); + })); + } + + private renderToolOptions(): void { + if (!this.toolOptionsPopover) { + return; + } + this.toolOptionsDisposables.clear(); + this.toolOptionsPopover.textContent = ''; + this.toolOptionsPopover.setAttribute('role', 'group'); + this.toolOptionsPopover.setAttribute('aria-label', localize('toolOptions', "Tool Options")); + + this.appendColorOptions( + this.toolOptionsPopover, + this.activeTool === AnnotationTool.Text ? localize('textColor', "Text Color") : localize('strokeColor', "Stroke Color"), + COLORS, + this.activeStrokeColor, + localize('setStrokeColor', "Set Stroke Color"), + color => { + this.activeStrokeColor = color; + this.applyToolOptionsToTextEdit(); + } + ); + + if (this.activeTool !== AnnotationTool.Freehand && this.activeTool !== AnnotationTool.Arrow) { + this.appendColorOptions( + this.toolOptionsPopover, + this.activeTool === AnnotationTool.Text ? localize('textBackgroundColor', "Background Color") : localize('fillColor', "Fill Color"), + FILL_COLORS, + this.activeFillColor, + localize('setFillColor', "Set Fill Color"), + color => { + this.activeFillColor = color; + this.applyToolOptionsToTextEdit(); + } + ); + } + + this.appendSizeOptions(this.toolOptionsPopover); + this.appendOpacityOptions(this.toolOptionsPopover); + } + + private appendColorOptions(container: HTMLElement, label: string, colors: string[], selectedColor: string, ariaLabelPrefix: string, onSelect: (color: string) => void): void { + const group = append(container, $('div.annotation-tool-options-group')); + append(group, $('span.annotation-tool-options-label')).textContent = label; + const swatches = append(group, $('div.annotation-color-swatches')); + for (const color of colors) { + const swatch = append(swatches, $('button.annotation-color-swatch')) as HTMLButtonElement; + const isTransparent = color === 'transparent'; + swatch.classList.toggle('transparent', isTransparent); + swatch.classList.toggle('light-swatch', LIGHT_SWATCH_COLORS.has(color)); + swatch.style.backgroundColor = isTransparent ? 'transparent' : color; + swatch.setAttribute('aria-label', isTransparent ? localize('transparentColor', "{0}: Transparent", ariaLabelPrefix) : localize('colorValue', "{0}: {1}", ariaLabelPrefix, color)); + swatch.setAttribute('aria-pressed', String(color === selectedColor)); + swatch.classList.toggle('active', color === selectedColor); + this.toolOptionsDisposables.add(addDisposableListener(swatch, EventType.CLICK, e => { + e.stopPropagation(); + onSelect(color); + this.renderToolOptions(); + this.redraw(); + })); + } + } + + private appendSizeOptions(container: HTMLElement): void { + const isText = this.activeTool === AnnotationTool.Text; + const values = isText ? TEXT_SIZES : STROKE_WIDTHS; + const selectedValue = isText ? this.activeFontSize : this.activeLineWidth; + const group = append(container, $('div.annotation-tool-options-group')); + append(group, $('span.annotation-tool-options-label')).textContent = isText ? localize('textSize', "Text Size") : localize('strokeWidth', "Stroke Width"); + const buttons = append(group, $('div.annotation-size-buttons')); + for (const value of values) { + const button = append(buttons, $('button.annotation-size-button')) as HTMLButtonElement; + button.textContent = `${value}`; + button.setAttribute('aria-label', isText ? localize('setTextSize', "Set Text Size to {0}px", value) : localize('setStrokeWidth', "Set Stroke Width to {0}px", value)); + button.setAttribute('aria-pressed', String(value === selectedValue)); + button.classList.toggle('active', value === selectedValue); + this.toolOptionsDisposables.add(addDisposableListener(button, EventType.CLICK, e => { + e.stopPropagation(); + if (isText) { + this.activeFontSize = value; + } else { + this.activeLineWidth = value; + } + this.applyToolOptionsToTextEdit(); + this.renderToolOptions(); + this.redraw(); + })); + } + } + + private appendOpacityOptions(container: HTMLElement): void { + const group = append(container, $('div.annotation-tool-options-group.annotation-opacity-options')); + const label = append(group, $('label.annotation-tool-options-label')); + label.textContent = localize('opacity', "Opacity"); + const input = append(group, $('input.annotation-opacity-slider')) as HTMLInputElement; + input.type = 'range'; + input.min = '20'; + input.max = '100'; + input.step = '10'; + input.value = `${Math.round(this.activeOpacity * 100)}`; + input.setAttribute('aria-label', localize('setOpacity', "Set Opacity")); + const value = append(group, $('span.annotation-opacity-value')); + value.textContent = `${input.value}%`; + this.toolOptionsDisposables.add(addDisposableListener(input, EventType.INPUT, e => { + e.stopPropagation(); + this.activeOpacity = Number(input.value) / 100; + value.textContent = `${input.value}%`; + this.applyToolOptionsToTextEdit(); + this.redraw(); + })); + } + + private applyToolOptionsToTextEdit(): void { + if (!this.textEditState) { + return; + } + this.textEditState.strokeColor = this.activeStrokeColor; + this.textEditState.fillColor = this.activeFillColor; + this.textEditState.opacity = this.activeOpacity; + this.textEditState.fontSize = this.activeFontSize; + } + + private showToolOptions(anchor: HTMLElement): void { + if (!this.toolOptionsPopover || !this.hasToolOptions(this.activeTool)) { + this.hideToolOptions(); + return; + } + this.renderToolOptions(); + const containerRect = this.container.getBoundingClientRect(); + const anchorRect = anchor.getBoundingClientRect(); + this.toolOptionsPopover.style.top = `${anchorRect.bottom - containerRect.top + 6}px`; + this.toolOptionsPopover.style.display = 'flex'; + const halfWidth = this.toolOptionsPopover.offsetWidth / 2; + const desiredLeft = anchorRect.left + anchorRect.width / 2 - containerRect.left; + const minLeft = halfWidth + 8; + const maxLeft = Math.max(minLeft, containerRect.width - halfWidth - 8); + this.toolOptionsPopover.style.left = `${Math.min(Math.max(desiredLeft, minLeft), maxLeft)}px`; + } + + private hideToolOptions(): void { + if (this.toolOptionsPopover) { + this.toolOptionsPopover.style.display = 'none'; + } + } + + private hasToolOptions(tool: AnnotationTool): boolean { + return tool === AnnotationTool.Freehand + || tool === AnnotationTool.Rectangle + || tool === AnnotationTool.Ellipse + || tool === AnnotationTool.Arrow + || tool === AnnotationTool.Text; + } + + private setActiveTool(tool: AnnotationTool): void { + if (this.textEditState && tool !== AnnotationTool.Text) { + this.commitTextEdit(); + } + if (this.textPlacementState && tool !== AnnotationTool.Text) { + this.cancelTextPlacement(); + } + + // Special handling for Crop: enter crop mode (don't change activeTool to Crop persistently) + if (tool === AnnotationTool.Crop) { + this.hideToolOptions(); + this.enterCropMode(); + return; + } + + this.activeTool = tool; + this.selectedActionIndex = -1; + for (const tb of this.toolButtons) { + tb.element.classList.toggle('active', tb.tool === tool); + tb.element.setAttribute('aria-pressed', String(tb.tool === tool)); + } + const activeToolButton = this.toolButtons.find(tb => tb.tool === tool)?.element; + if (activeToolButton && this.hasToolOptions(tool)) { + this.showToolOptions(activeToolButton); + } else { + this.hideToolOptions(); + } + this.canvas.style.cursor = tool === AnnotationTool.Select ? 'default' : + tool === AnnotationTool.Pan ? 'grab' : + tool === AnnotationTool.Eraser ? 'url("data:image/svg+xml,") 12 12, cell' : 'crosshair'; + this.redraw(); + } + + private enterCropMode(): void { + if (this.cropMode || !this.originalImage) { + return; + } + // Save current state for cancel + this.preCropState = { + element: this.imageElement!, + width: this.imageWidth, + height: this.imageHeight, + currentCrop: this.currentCrop, + }; + // Switch to original image so user can expand crop region + this.imageElement = this.originalImage.element; + this.imageWidth = this.originalImage.width; + this.imageHeight = this.originalImage.height; + // Initial crop region = current crop (or full original) + this.cropRegion = this.currentCrop + ? { ...this.currentCrop } + : { x: 0, y: 0, width: this.originalImage.width, height: this.originalImage.height }; + this.cropMode = true; + // Mark crop tool button active + for (const tb of this.toolButtons) { + tb.element.classList.toggle('active', tb.tool === AnnotationTool.Crop); + } + // Toggle toolbars + if (this.mainToolbar) { this.mainToolbar.style.display = 'none'; } + if (this.cropToolbar) { this.cropToolbar.style.display = ''; } + // Reset zoom/pan to fit original + this.hasUserZoomed = false; + this.panX = 0; + this.panY = 0; + this.canvas.style.transform = ''; + this.canvas.style.cursor = 'default'; + this.sizeCanvas(); + this.redraw(); + } + + private exitCropMode(): void { + this.cropMode = false; + this.cropRegion = null; + this.cropDragHandle = null; + this.cropRegionStart = null; + this.preCropState = null; + // Restore main toolbar + if (this.mainToolbar) { this.mainToolbar.style.display = ''; } + if (this.cropToolbar) { this.cropToolbar.style.display = 'none'; } + // Reactivate previous tool + this.setActiveTool(this.activeTool); + } + + private commitCrop(): void { + if (!this.cropMode || !this.cropRegion || !this.originalImage) { + return; + } + const cr = this.normalizeCropRect(this.cropRegion); + if (cr.width < 10 || cr.height < 10) { + return; + } + const cropFrom = this.preCropState?.currentCrop ?? null; + // Push a Crop sentinel into the linear undo stack so undo/redo treats it + // like any other action. + const cropAction: DrawAction = { + type: AnnotationTool.Crop, + strokeColor: '', + opacity: 1, + lineWidth: 0, + cropFrom, + cropTo: cr, + }; + this.actions.push(cropAction); + this.undoneActions.length = 0; + this.updateUndoRedoState(); + this.hasUserZoomed = false; + this.panX = 0; + this.panY = 0; + this.canvas.style.transform = ''; + this.exitCropMode(); + this.applyDisplayedCrop(cr); + } + + private cancelCrop(): void { + if (!this.cropMode || !this.preCropState) { + this.exitCropMode(); + return; + } + // Restore the pre-crop displayed state. Annotations live in original coords + // and don't need to be touched. + this.imageElement = this.preCropState.element; + this.imageWidth = this.preCropState.width; + this.imageHeight = this.preCropState.height; + this.currentCrop = this.preCropState.currentCrop; + this.hasUserZoomed = false; + this.panX = 0; + this.panY = 0; + this.canvas.style.transform = ''; + this.exitCropMode(); + this.sizeCanvas(); + this.redraw(); + } + + private loadImage(): void { + const img = mainWindow.document.createElement('img'); + img.onload = () => { + this.imageElement = img; + this.imageWidth = img.naturalWidth; + this.imageHeight = img.naturalHeight; + // Preserve the original image so crops can be re-expanded + this.originalImage = { element: img, width: img.naturalWidth, height: img.naturalHeight }; + this.currentCrop = null; + + // Restore prior actions (clone so undo/redo state survives reopens). + // Use a shared identity map so Move/Eraser sentinels keep pointing at + // the correct cloned action references, both in actions[] and + // undoneActions[]. + if (this.initialState && (this.initialState.actions.length || this.initialState.undoneActions.length)) { + const identityMap = new Map(); + this.actions.push(...this.initialState.actions.map(a => cloneDrawAction(a, identityMap))); + this.undoneActions.push(...this.initialState.undoneActions.map(a => cloneDrawAction(a, identityMap))); + this.updateUndoRedoState(); + } + + // Restore prior crop, if any. + this.applyDisplayedCrop(this.initialState?.crop ?? null); + }; + // Use original screenshot (not annotated) so we can re-crop from full original + img.src = this.screenshot.dataUrl; + } + + /** + * Update the displayed image to reflect the given crop (or the full original + * when null). Cropped images are re-rasterized from the preserved original so + * undo/redo of crop actions is fully reversible without keeping intermediate + * image elements around. + */ + private applyDisplayedCrop(crop: { x: number; y: number; width: number; height: number } | null): void { + if (!this.originalImage) { + return; + } + if (!crop) { + this.imageElement = this.originalImage.element; + this.imageWidth = this.originalImage.width; + this.imageHeight = this.originalImage.height; + this.currentCrop = null; + this.sizeCanvas(); + this.redraw(); + return; + } + const cr = { + x: Math.max(0, Math.min(this.originalImage.width, crop.x)), + y: Math.max(0, Math.min(this.originalImage.height, crop.y)), + width: Math.max(1, Math.min(this.originalImage.width - Math.max(0, crop.x), crop.width)), + height: Math.max(1, Math.min(this.originalImage.height - Math.max(0, crop.y), crop.height)), + }; + const cropCanvas = mainWindow.document.createElement('canvas'); + cropCanvas.width = cr.width; + cropCanvas.height = cr.height; + const cropCtx = cropCanvas.getContext('2d')!; + cropCtx.drawImage(this.originalImage.element, cr.x, cr.y, cr.width, cr.height, 0, 0, cr.width, cr.height); + + const croppedImg = mainWindow.document.createElement('img'); + croppedImg.onload = () => { + this.imageElement = croppedImg; + this.imageWidth = croppedImg.naturalWidth; + this.imageHeight = croppedImg.naturalHeight; + this.currentCrop = cr; + this.sizeCanvas(); + this.redraw(); + }; + croppedImg.src = cropCanvas.toDataURL('image/png'); + } + + private captureState(): IAnnotationEditorState { + const identityMap = new Map(); + return { + actions: this.actions.map(a => cloneDrawAction(a, identityMap)), + undoneActions: this.undoneActions.map(a => cloneDrawAction(a, identityMap)), + crop: this.currentCrop ? { ...this.currentCrop } : null, + }; + } + + private sizeCanvas(): void { + const container = this.canvas.parentElement; + if (!container) { + return; + } + + const targetWindow = getWindow(this.canvas); + const dpr = targetWindow.devicePixelRatio || 1; + const maxWidth = container.clientWidth - CANVAS_BREATHING_ROOM * 2; + const maxHeight = container.clientHeight - CANVAS_BREATHING_ROOM * 2; + + // Only auto-fit on initial load; respect user zoom after that + if (!this.hasUserZoomed) { + const scaleX = maxWidth / this.imageWidth; + const scaleY = maxHeight / this.imageHeight; + this.scale = Math.min(scaleX, scaleY, 1); + } + + const displayWidth = Math.floor(this.imageWidth * this.scale); + const displayHeight = Math.floor(this.imageHeight * this.scale); + + this.canvas.style.width = `${displayWidth}px`; + this.canvas.style.height = `${displayHeight}px`; + + // Cap the backing buffer so a 1920×1080 image at 8× zoom + dpr 2 doesn't try to + // allocate a 30k×17k canvas (~2GB GPU memory) per wheel tick. When the natural + // backing size exceeds the cap, the browser CSS-stretches the canvas (slight + // pixelation at extreme zoom) but allocation and drawing stay cheap. + const MAX_BACKING_DIM = 4096; + const naturalW = displayWidth * dpr; + const naturalH = displayHeight * dpr; + const overage = Math.max(1, naturalW / MAX_BACKING_DIM, naturalH / MAX_BACKING_DIM); + const effectiveDpr = dpr / overage; + this.canvas.width = Math.max(1, Math.floor(displayWidth * effectiveDpr)); + this.canvas.height = Math.max(1, Math.floor(displayHeight * effectiveDpr)); + + this.ctx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0); + } + + private canvasCoords(e: PointerEvent): { x: number; y: number } { + const rect = this.canvas.getBoundingClientRect(); + return { + x: (e.clientX - rect.left) / this.scale + this.cropOffsetX, + y: (e.clientY - rect.top) / this.scale + this.cropOffsetY, + }; + } + + private onPointerDown(e: PointerEvent): void { + const pos = this.canvasCoords(e); + + // Crop mode: hit test handles or interior + if (this.cropMode && this.cropRegion) { + const handle = this.cropHandleHitTest(pos); + if (handle) { + this.cropDragHandle = handle; + this.cropDragStart = pos; + this.cropRegionStart = { ...this.cropRegion }; + this.canvas.setPointerCapture(e.pointerId); + } + return; + } + + // Select tool: hit test and start drag + if (this.activeTool === AnnotationTool.Select) { + const hitIndex = this.hitTest(pos); + this.selectedActionIndex = hitIndex; + if (hitIndex >= 0) { + const hitAction = this.actions[hitIndex]; + this.pendingMove = { target: hitAction, before: captureMoveSnapshot(hitAction) }; + if (hitAction.type === AnnotationTool.Text && this.isNearTextResizeHandle(pos, hitAction)) { + this.isResizingSelectedText = true; + this.dragStart = { x: pos.x, y: pos.y }; + this.selectedTextResizeStartWidth = hitAction.textWidth ?? DEFAULT_TEXT_BOX_WIDTH; + this.canvas.setPointerCapture(e.pointerId); + this.canvas.style.cursor = 'ew-resize'; + } else { + this.isDraggingSelected = true; + this.dragStart = { x: pos.x, y: pos.y }; + this.canvas.setPointerCapture(e.pointerId); + this.canvas.style.cursor = 'move'; + } + } + this.redraw(); + return; + } + + // Deselect when using other tools + this.selectedActionIndex = -1; + + // Text tool: drag to define width, then enter text editing. + if (this.activeTool === AnnotationTool.Text) { + this.commitTextEdit(); + this.textPlacementState = { + start: pos, + current: pos, + pointerId: e.pointerId, + }; + this.canvas.setPointerCapture(e.pointerId); + this.redraw(); + return; + } + + // Eraser removes annotations that intersect the pointer path. + if (this.activeTool === AnnotationTool.Eraser) { + this.isErasing = true; + this.canvas.setPointerCapture(e.pointerId); + this.eraseAt(pos); + return; + } + + // Pan tool + if (this.activeTool === AnnotationTool.Pan) { + this.isPanning = true; + this.lastPanPoint = { x: e.clientX, y: e.clientY }; + this.canvas.setPointerCapture(e.pointerId); + this.canvas.style.cursor = 'grabbing'; + return; + } + + this.isDrawing = true; + this.canvas.setPointerCapture(e.pointerId); + + switch (this.activeTool) { + case AnnotationTool.Freehand: + this.currentAction = { + type: AnnotationTool.Freehand, + strokeColor: this.activeStrokeColor, + opacity: this.activeOpacity, + lineWidth: this.activeLineWidth, + points: [pos], + }; + break; + case AnnotationTool.Rectangle: + this.currentAction = { + type: AnnotationTool.Rectangle, + strokeColor: this.activeStrokeColor, + fillColor: this.activeFillColor, + opacity: this.activeOpacity, + lineWidth: this.activeLineWidth, + rect: { x: pos.x, y: pos.y, width: 0, height: 0 }, + }; + break; + case AnnotationTool.Ellipse: + this.currentAction = { + type: AnnotationTool.Ellipse, + strokeColor: this.activeStrokeColor, + fillColor: this.activeFillColor, + opacity: this.activeOpacity, + lineWidth: this.activeLineWidth, + ellipseRect: { x: pos.x, y: pos.y, width: 0, height: 0 }, + }; + break; + case AnnotationTool.Arrow: + this.currentAction = { + type: AnnotationTool.Arrow, + strokeColor: this.activeStrokeColor, + opacity: this.activeOpacity, + lineWidth: this.activeLineWidth, + arrowStart: pos, + arrowEnd: pos, + }; + break; + } + } + + private onPointerMove(e: PointerEvent): void { + // Crop mode: drag handle or move region; also update cursor + if (this.cropMode) { + const pos = this.canvasCoords(e); + if (this.cropDragHandle && this.cropRegionStart) { + this.updateCropRegion(pos); + this.redraw(); + return; + } + // Update cursor based on hover + const handle = this.cropHandleHitTest(pos); + this.canvas.style.cursor = this.cropCursorFor(handle); + return; + } + + // Select tool: resize selected text + if (this.isResizingSelectedText && this.selectedActionIndex >= 0) { + const pos = this.canvasCoords(e); + const action = this.actions[this.selectedActionIndex]; + if (action.type === AnnotationTool.Text) { + action.textWidth = Math.max(MIN_TEXT_BOX_WIDTH, this.selectedTextResizeStartWidth + (pos.x - this.dragStart.x)); + this.redraw(); + } + return; + } + + // Select tool: move selected element + if (this.isDraggingSelected && this.selectedActionIndex >= 0) { + const pos = this.canvasCoords(e); + const dx = pos.x - this.dragStart.x; + const dy = pos.y - this.dragStart.y; + this.moveAction(this.actions[this.selectedActionIndex], dx, dy); + this.dragStart = { x: pos.x, y: pos.y }; + this.redraw(); + return; + } + + // Pan + if (this.isPanning) { + const dx = e.clientX - this.lastPanPoint.x; + const dy = e.clientY - this.lastPanPoint.y; + this.panX += dx; + this.panY += dy; + this.lastPanPoint = { x: e.clientX, y: e.clientY }; + this.clampPan(); + this.canvas.style.transform = `translate(${this.panX}px, ${this.panY}px)`; + return; + } + + if (this.textPlacementState) { + const pos = this.canvasCoords(e); + this.textPlacementState.current = pos; + this.redraw(); + return; + } + + if (this.isErasing) { + const pos = this.canvasCoords(e); + this.eraseAt(pos); + return; + } + + if (this.activeTool === AnnotationTool.Select && this.selectedActionIndex >= 0) { + const pos = this.canvasCoords(e); + const action = this.actions[this.selectedActionIndex]; + if (action.type === AnnotationTool.Text && this.isNearTextResizeHandle(pos, action)) { + this.canvas.style.cursor = 'ew-resize'; + } else if (this.selectedActionIndex >= 0) { + this.canvas.style.cursor = 'default'; + } + } + + if (!this.isDrawing) { + return; + } + + const pos = this.canvasCoords(e); + + if (!this.currentAction) { + return; + } + + switch (this.currentAction.type) { + case AnnotationTool.Freehand: + this.currentAction.points!.push(pos); + break; + case AnnotationTool.Rectangle: { + const rect = this.currentAction.rect!; + // Mutate the rect on the current action (this is the in-progress drawing) + (this.currentAction as { rect: { x: number; y: number; width: number; height: number } }).rect = { + ...rect, + width: pos.x - rect.x, + height: pos.y - rect.y, + }; + break; + } + case AnnotationTool.Ellipse: { + const er = this.currentAction.ellipseRect!; + let w = pos.x - er.x; + let h = pos.y - er.y; + if (e.shiftKey) { + const size = Math.max(Math.abs(w), Math.abs(h)); + w = Math.sign(w) * size; + h = Math.sign(h) * size; + } + (this.currentAction as { ellipseRect: { x: number; y: number; width: number; height: number } }).ellipseRect = { ...er, width: w, height: h }; + break; + } + case AnnotationTool.Arrow: + (this.currentAction as { arrowEnd: { x: number; y: number } }).arrowEnd = pos; + break; + } + + this.redraw(); + } + + private onPointerUp(e: PointerEvent): void { + // Crop mode: end handle drag + if (this.cropMode && this.cropDragHandle) { + this.cropDragHandle = null; + this.cropRegionStart = null; + this.canvas.releasePointerCapture(e.pointerId); + return; + } + + // Select tool: end drag + if (this.isResizingSelectedText) { + this.isResizingSelectedText = false; + this.canvas.releasePointerCapture(e.pointerId); + this.canvas.style.cursor = 'default'; + this.commitPendingMove(); + return; + } + + // Select tool: end drag + if (this.isDraggingSelected) { + this.isDraggingSelected = false; + this.canvas.releasePointerCapture(e.pointerId); + this.canvas.style.cursor = 'default'; + this.commitPendingMove(); + return; + } + + // Pan + if (this.isPanning) { + this.isPanning = false; + this.canvas.releasePointerCapture(e.pointerId); + this.canvas.style.cursor = this.activeTool === AnnotationTool.Pan ? 'grab' : 'crosshair'; + return; + } + + if (this.isErasing) { + this.isErasing = false; + this.canvas.releasePointerCapture(e.pointerId); + if (this.pendingEraseActions.length > 0) { + this.actions.push({ + type: AnnotationTool.Eraser, + strokeColor: '', + opacity: 1, + lineWidth: 0, + erasedActions: this.pendingEraseActions.slice(), + erasedIndices: this.pendingEraseIndices.slice(), + }); + this.pendingEraseActions = []; + this.pendingEraseIndices = []; + this.undoneActions.length = 0; + this.updateUndoRedoState(); + } + return; + } + + if (this.textPlacementState) { + const { start, current, pointerId } = this.textPlacementState; + if (pointerId === e.pointerId) { + this.canvas.releasePointerCapture(e.pointerId); + } + const dx = current.x - start.x; + const didDrag = Math.abs(dx) >= TEXT_DRAG_THRESHOLD; + const x = didDrag ? Math.min(start.x, current.x) : start.x; + const rawWidth = didDrag ? Math.abs(dx) : this.getMaxTextWidthFrom(start.x); + const width = didDrag + ? Math.max(1, Math.min(rawWidth, this.getTextImageRight() - x)) + : rawWidth; + const y = start.y; + this.textPlacementState = null; + this.startTextEdit({ x, y }, width, didDrag); + return; + } + + if (!this.isDrawing) { + return; + } + this.canvas.releasePointerCapture(e.pointerId); + this.isDrawing = false; + + if (this.currentAction) { + this.actions.push(this.currentAction); + this.undoneActions.length = 0; + this.updateUndoRedoState(); + this.currentAction = null; + } + + this.redraw(); + } + + private eraseAt(pos: { x: number; y: number }): void { + const hitIndex = this.hitTest(pos); + if (hitIndex < 0) { + return; + } + const [erased] = this.actions.splice(hitIndex, 1); + this.pendingEraseActions.push(erased); + this.pendingEraseIndices.push(hitIndex); + this.selectedActionIndex = -1; + this.redraw(); + } + + private commitPendingMove(): void { + const pending = this.pendingMove; + this.pendingMove = null; + if (!pending) { + return; + } + const after = captureMoveSnapshot(pending.target); + if (moveSnapshotsEqual(pending.before, after)) { + return; + } + this.actions.push({ + type: AnnotationTool.Move, + strokeColor: '', + opacity: 1, + lineWidth: 0, + moveTarget: pending.target, + moveBefore: pending.before, + moveAfter: after, + }); + this.undoneActions.length = 0; + this.updateUndoRedoState(); + } + + private updateUndoRedoState(): void { + if (this.undoBtn) { + this.undoBtn.disabled = this.actions.length === 0; + } + if (this.redoBtn) { + this.redoBtn.disabled = this.undoneActions.length === 0; + } + } + + private undo(): void { + if (this.textPlacementState) { + this.cancelTextPlacement(); + return; + } + if (this.textEditState) { + this.cancelTextEdit(); + return; + } + const action = this.actions.pop(); + if (!action) { + return; + } + if (action.type === AnnotationTool.Eraser && action.erasedActions) { + // Re-insert each erased action at the index it occupied at the moment it was removed. + // Iterate in reverse because each erase splice was relative to the array state after + // the previous one, so unwinding must happen in reverse order to restore positions. + const erased = action.erasedActions; + const indices = action.erasedIndices ?? erased.map(() => this.actions.length); + for (let i = erased.length - 1; i >= 0; i--) { + const idx = Math.min(indices[i], this.actions.length); + this.actions.splice(idx, 0, erased[i]); + } + } + this.undoneActions.push(action); + this.updateUndoRedoState(); + this.selectedActionIndex = -1; + if (action.type === AnnotationTool.Crop) { + this.applyDisplayedCrop(action.cropFrom ?? null); + } else if (action.type === AnnotationTool.Move && action.moveTarget && action.moveBefore) { + applyMoveSnapshot(action.moveTarget, action.moveBefore); + this.redraw(); + } else { + this.redraw(); + } + } + + private redo(): void { + if (this.textPlacementState) { + return; + } + if (this.textEditState) { + return; + } + const action = this.undoneActions.pop(); + if (!action) { + return; + } + if (action.type === AnnotationTool.Eraser && action.erasedActions) { + // Re-apply the erase: remove the re-inserted actions by reference. + for (const erased of action.erasedActions) { + const idx = this.actions.indexOf(erased); + if (idx >= 0) { + this.actions.splice(idx, 1); + } + } + } + this.actions.push(action); + this.selectedActionIndex = -1; + this.updateUndoRedoState(); + if (action.type === AnnotationTool.Crop) { + this.applyDisplayedCrop(action.cropTo ?? null); + } else if (action.type === AnnotationTool.Move && action.moveTarget && action.moveAfter) { + applyMoveSnapshot(action.moveTarget, action.moveAfter); + this.redraw(); + } else { + this.redraw(); + } + } + + private cropHandleHitTest(pos: { x: number; y: number }): 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'move' | null { + if (!this.cropRegion) { + return null; + } + const r = this.normalizeCropRect(this.cropRegion); + // Convert handle pixel size to image coords + const handlePx = 12; + const tol = handlePx / this.scale; + const cx = r.x + r.width / 2; + const cy = r.y + r.height / 2; + const handles: { name: 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; x: number; y: number }[] = [ + { name: 'nw', x: r.x, y: r.y }, + { name: 'n', x: cx, y: r.y }, + { name: 'ne', x: r.x + r.width, y: r.y }, + { name: 'e', x: r.x + r.width, y: cy }, + { name: 'se', x: r.x + r.width, y: r.y + r.height }, + { name: 's', x: cx, y: r.y + r.height }, + { name: 'sw', x: r.x, y: r.y + r.height }, + { name: 'w', x: r.x, y: cy }, + ]; + for (const h of handles) { + if (Math.abs(pos.x - h.x) <= tol && Math.abs(pos.y - h.y) <= tol) { + return h.name; + } + } + // Inside region → move + if (pos.x >= r.x && pos.x <= r.x + r.width && pos.y >= r.y && pos.y <= r.y + r.height) { + return 'move'; + } + return null; + } + + private cropCursorFor(handle: 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'move' | null): string { + switch (handle) { + case 'nw': + case 'se': return 'nwse-resize'; + case 'ne': + case 'sw': return 'nesw-resize'; + case 'n': + case 's': return 'ns-resize'; + case 'e': + case 'w': return 'ew-resize'; + case 'move': return 'move'; + default: return 'default'; + } + } + + private updateCropRegion(pos: { x: number; y: number }): void { + if (!this.cropRegionStart || !this.cropDragHandle) { + return; + } + const dx = pos.x - this.cropDragStart.x; + const dy = pos.y - this.cropDragStart.y; + const start = this.cropRegionStart; + + // Translating the entire box: keep dimensions fixed and clamp only the position. + if (this.cropDragHandle === 'move') { + const x = Math.max(0, Math.min(this.imageWidth - start.width, start.x + dx)); + const y = Math.max(0, Math.min(this.imageHeight - start.height, start.y + dy)); + this.cropRegion = { x, y, width: start.width, height: start.height }; + return; + } + + let { x, y, width, height } = start; + switch (this.cropDragHandle) { + case 'nw': + x += dx; y += dy; width -= dx; height -= dy; + break; + case 'n': + y += dy; height -= dy; + break; + case 'ne': + y += dy; width += dx; height -= dy; + break; + case 'e': + width += dx; + break; + case 'se': + width += dx; height += dy; + break; + case 's': + height += dy; + break; + case 'sw': + x += dx; width -= dx; height += dy; + break; + case 'w': + x += dx; width -= dx; + break; + } + // Clamp to image bounds + x = Math.max(0, Math.min(this.imageWidth, x)); + y = Math.max(0, Math.min(this.imageHeight, y)); + width = Math.max(10, Math.min(this.imageWidth - x, width)); + height = Math.max(10, Math.min(this.imageHeight - y, height)); + this.cropRegion = { x, y, width, height }; + } + + private normalizeCropRect(r: { x: number; y: number; width: number; height: number }): { x: number; y: number; width: number; height: number } { + return { + x: r.width < 0 ? r.x + r.width : r.x, + y: r.height < 0 ? r.y + r.height : r.y, + width: Math.abs(r.width), + height: Math.abs(r.height), + }; + } + + private startTextEdit(pos: { x: number; y: number }, width: number, showBoxOutline: boolean): void { + this.commitTextEdit(); + + const editor = mainWindow.document.createElement('textarea'); + editor.setAttribute('aria-label', localize('typeText', "Type text")); + editor.setAttribute('wrap', 'off'); + editor.style.position = 'fixed'; + editor.style.left = '-10000px'; + editor.style.top = '0'; + editor.style.width = '1px'; + editor.style.height = '1px'; + editor.style.opacity = '0'; + editor.style.pointerEvents = 'none'; + editor.style.padding = '0'; + editor.style.border = '0'; + editor.style.margin = '0'; + editor.style.resize = 'none'; + editor.style.overflow = 'hidden'; + this.container.appendChild(editor); + + this.textEditState = { + pos, + text: '', + caretIndex: 0, + strokeColor: this.activeStrokeColor, + fillColor: this.activeFillColor, + opacity: this.activeOpacity, + fontSize: this.activeFontSize, + fontFamily: this.activeFontFamily, + width, + showBoxOutline, + }; + this.textEditor = editor; + this.startTextCaretBlink(); + + const sync = () => { + if (!this.textEditState || this.textEditor !== editor) { + return; + } + this.textEditState.text = editor.value; + this.textEditState.caretIndex = editor.selectionStart ?? editor.value.length; + this.textCaretVisible = true; + this.redraw(); + }; + + editor.addEventListener('input', sync); + editor.addEventListener('keyup', sync); + editor.addEventListener('click', sync); + editor.addEventListener('select', sync); + editor.addEventListener('keydown', e => { + e.stopPropagation(); + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.commitTextEdit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + this.cancelTextEdit(); + } + }); + editor.addEventListener('blur', () => { + if (this.textEditor === editor) { + this.commitTextEdit(); + } + }); + + setTimeout(() => { + if (this.textEditor === editor) { + editor.focus(); + editor.setSelectionRange(editor.value.length, editor.value.length); + } + }, 0); + + this.redraw(); + } + + private startTextCaretBlink(): void { + if (this.textCaretInterval !== null) { + getWindow(this.container).clearInterval(this.textCaretInterval); + } + this.textCaretVisible = true; + this.textCaretInterval = getWindow(this.container).setInterval(() => { + if (!this.textEditState) { + return; + } + this.textCaretVisible = !this.textCaretVisible; + this.redraw(); + }, 500); + } + + private stopTextCaretBlink(): void { + if (this.textCaretInterval !== null) { + getWindow(this.container).clearInterval(this.textCaretInterval); + this.textCaretInterval = null; + } + this.textCaretVisible = true; + } + + private commitTextEdit(): void { + if (!this.textEditState) { + return; + } + + const { text, pos, strokeColor, fillColor, opacity, fontFamily, fontSize, width } = this.textEditState; + this.cleanupTextEditor(); + if (text.trim()) { + this.actions.push({ + type: AnnotationTool.Text, + strokeColor, + fillColor, + opacity, + lineWidth: 1, + fontSize, + fontFamily, + text, + textPos: pos, + textWidth: width, + }); + this.undoneActions.length = 0; + this.updateUndoRedoState(); + } + this.redraw(); + } + + private cancelTextEdit(): void { + if (!this.textEditState) { + return; + } + this.cleanupTextEditor(); + this.redraw(); + } + + private cancelTextPlacement(): void { + if (!this.textPlacementState) { + return; + } + if (this.canvas.hasPointerCapture(this.textPlacementState.pointerId)) { + this.canvas.releasePointerCapture(this.textPlacementState.pointerId); + } + this.textPlacementState = null; + this.redraw(); + } + + private getTextImageRight(): number { + return this.cropOffsetX + this.imageWidth; + } + + private getMaxTextWidthFrom(startX: number): number { + return Math.max(1, this.getTextImageRight() - startX); + } + + private cleanupTextEditor(): void { + this.stopTextCaretBlink(); + this.textEditor?.remove(); + this.textEditor = null; + this.textEditState = null; + this.container.focus(); + } + + private redraw(): void { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + // Draw background image + if (this.imageElement) { + this.ctx.drawImage(this.imageElement, 0, 0, this.imageWidth * this.scale, this.imageHeight * this.scale); + } + + // Annotations are stored in original-image coords; translate so they appear correctly + // over the (possibly cropped) displayed image. + this.ctx.save(); + this.ctx.translate(-this.cropOffsetX * this.scale, -this.cropOffsetY * this.scale); + + // Draw all completed annotations + for (const action of this.actions) { + this.drawAction(action); + } + + // Draw selection highlight + if (this.selectedActionIndex >= 0 && this.selectedActionIndex < this.actions.length) { + this.drawSelectionHighlight(this.actions[this.selectedActionIndex]); + } + + // Draw current in-progress annotation + if (this.currentAction) { + this.drawAction(this.currentAction); + } + + if (this.textEditState) { + this.drawTextEditState(); + } + + if (this.textPlacementState) { + this.drawTextPlacementState(); + } + + this.ctx.restore(); + + // Draw crop overlay with handles + if (this.cropMode && this.cropRegion) { + const r = this.normalizeCropRect(this.cropRegion); + const dpr = getWindow(this.canvas).devicePixelRatio || 1; + const cw = this.canvas.width / dpr; + const ch = this.canvas.height / dpr; + const rx = r.x * this.scale; + const ry = r.y * this.scale; + const rw = r.width * this.scale; + const rh = r.height * this.scale; + + this.ctx.save(); + // Dim area outside crop + this.ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + this.ctx.fillRect(0, 0, cw, ry); // top + this.ctx.fillRect(0, ry + rh, cw, ch - (ry + rh)); // bottom + this.ctx.fillRect(0, ry, rx, rh); // left + this.ctx.fillRect(rx + rw, ry, cw - (rx + rw), rh); // right + + // Draw crop border + this.ctx.strokeStyle = '#ffffff'; + this.ctx.lineWidth = 1; + this.ctx.strokeRect(rx, ry, rw, rh); + + // Draw 8 handles (corner squares) + const handleSize = 10; + const half = handleSize / 2; + const handles: { x: number; y: number }[] = [ + { x: rx, y: ry }, // nw + { x: rx + rw / 2, y: ry }, // n + { x: rx + rw, y: ry }, // ne + { x: rx + rw, y: ry + rh / 2 }, // e + { x: rx + rw, y: ry + rh }, // se + { x: rx + rw / 2, y: ry + rh }, // s + { x: rx, y: ry + rh }, // sw + { x: rx, y: ry + rh / 2 }, // w + ]; + this.ctx.fillStyle = '#ffffff'; + this.ctx.strokeStyle = '#000000'; + this.ctx.lineWidth = 1; + for (const h of handles) { + this.ctx.fillRect(h.x - half, h.y - half, handleSize, handleSize); + this.ctx.strokeRect(h.x - half, h.y - half, handleSize, handleSize); + } + this.ctx.restore(); + } + } + + private drawAction(action: DrawAction): void { + // Erase, crop and move records are undo sentinels; nothing to draw. + if (action.type === AnnotationTool.Eraser || action.type === AnnotationTool.Crop || action.type === AnnotationTool.Move) { + return; + } + this.ctx.save(); + const fillColor = action.fillColor ?? 'transparent'; + this.ctx.globalAlpha = action.opacity; + this.ctx.strokeStyle = action.strokeColor; + this.ctx.fillStyle = this.isTransparent(fillColor) ? action.strokeColor : fillColor; + this.ctx.lineWidth = action.lineWidth * this.scale; + this.ctx.lineCap = 'round'; + this.ctx.lineJoin = 'round'; + + switch (action.type) { + case AnnotationTool.Freehand: + if (action.points && action.points.length > 0) { + this.ctx.beginPath(); + this.ctx.moveTo(action.points[0].x * this.scale, action.points[0].y * this.scale); + for (let i = 1; i < action.points.length; i++) { + this.ctx.lineTo(action.points[i].x * this.scale, action.points[i].y * this.scale); + } + this.ctx.stroke(); + } + break; + + case AnnotationTool.Rectangle: + if (action.rect) { + if (!this.isTransparent(fillColor)) { + this.ctx.fillRect( + action.rect.x * this.scale, + action.rect.y * this.scale, + action.rect.width * this.scale, + action.rect.height * this.scale, + ); + } + this.ctx.strokeRect( + action.rect.x * this.scale, + action.rect.y * this.scale, + action.rect.width * this.scale, + action.rect.height * this.scale, + ); + } + break; + + case AnnotationTool.Ellipse: + if (action.ellipseRect) { + const r = action.ellipseRect; + const cx = (r.x + r.width / 2) * this.scale; + const cy = (r.y + r.height / 2) * this.scale; + const rx = Math.abs(r.width / 2) * this.scale; + const ry = Math.abs(r.height / 2) * this.scale; + this.ctx.beginPath(); + this.ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); + if (!this.isTransparent(fillColor)) { + this.ctx.fill(); + } + this.ctx.stroke(); + } + break; + + case AnnotationTool.Arrow: + if (action.arrowStart && action.arrowEnd) { + this.drawArrow( + action.arrowStart.x * this.scale, + action.arrowStart.y * this.scale, + action.arrowEnd.x * this.scale, + action.arrowEnd.y * this.scale, + ); + } + break; + + case AnnotationTool.Text: + if (action.text && action.textPos) { + const fontSize = (action.fontSize || 16) * this.scale; + const fontFamily = action.fontFamily || 'sans-serif'; + const width = (action.textWidth ?? DEFAULT_TEXT_BOX_WIDTH) * this.scale; + this.ctx.font = `${fontSize}px ${fontFamily}`; + this.ctx.textBaseline = 'alphabetic'; + if (!this.isTransparent(fillColor)) { + const layout = this.measureWrappedText(action.text, width, fontSize, fontFamily); + this.ctx.fillRect( + action.textPos.x * this.scale, + action.textPos.y * this.scale - fontSize, + width, + Math.max(layout.height, fontSize * 1.2), + ); + } + this.ctx.fillStyle = action.strokeColor; + this.drawWrappedText(action.text, action.textPos.x * this.scale, action.textPos.y * this.scale, width, fontSize, fontFamily); + } + break; + } + + this.ctx.restore(); + } + + private drawTextEditState(): void { + if (!this.textEditState) { + return; + } + + const { pos, text, strokeColor, fillColor, opacity, fontFamily, fontSize, caretIndex, width, showBoxOutline } = this.textEditState; + const scaledFontSize = fontSize * this.scale; + const scaledWidth = width * this.scale; + this.ctx.save(); + this.ctx.globalAlpha = opacity; + this.ctx.fillStyle = strokeColor; + this.ctx.strokeStyle = strokeColor; + this.ctx.lineWidth = Math.max(1, this.scale); + this.ctx.font = `${scaledFontSize}px ${fontFamily}`; + this.ctx.textBaseline = 'alphabetic'; + if (!this.isTransparent(fillColor)) { + const layout = this.measureWrappedText(text, scaledWidth, scaledFontSize, fontFamily); + this.ctx.fillStyle = fillColor; + this.ctx.fillRect( + pos.x * this.scale, + pos.y * this.scale - scaledFontSize, + scaledWidth, + Math.max(layout.height, scaledFontSize * 1.2), + ); + this.ctx.fillStyle = strokeColor; + } + const layout = this.drawWrappedText(text, pos.x * this.scale, pos.y * this.scale, scaledWidth, scaledFontSize, fontFamily); + + if (showBoxOutline) { + this.ctx.setLineDash([4, 4]); + this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; + this.ctx.strokeRect( + pos.x * this.scale, + pos.y * this.scale - scaledFontSize, + scaledWidth, + Math.max(layout.height, scaledFontSize * 1.2), + ); + this.ctx.setLineDash([]); + } + + if (this.textCaretVisible) { + const caret = this.getTextCaretMetrics(text, caretIndex, scaledWidth, scaledFontSize, fontFamily); + const caretX = pos.x * this.scale + caret.x; + const baselineY = pos.y * this.scale + caret.baselineOffsetY; + this.ctx.beginPath(); + this.ctx.moveTo(caretX, baselineY - scaledFontSize); + this.ctx.lineTo(caretX, baselineY + Math.max(2, this.scale)); + this.ctx.stroke(); + } + this.ctx.restore(); + } + + private isTransparent(color: string): boolean { + return color === 'transparent'; + } + + private drawTextPlacementState(): void { + if (!this.textPlacementState) { + return; + } + const { start, current } = this.textPlacementState; + const dx = current.x - start.x; + const didDrag = Math.abs(dx) >= TEXT_DRAG_THRESHOLD; + if (!didDrag) { + return; + } + const x = Math.min(start.x, current.x); + const width = Math.max(1, Math.min(Math.abs(dx), this.getTextImageRight() - x)); + const y = (start.y - this.activeFontSize) * this.scale; + const height = this.activeFontSize * this.scale * 1.2; + this.ctx.save(); + this.ctx.setLineDash([4, 4]); + this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; + this.ctx.lineWidth = Math.max(1, this.scale); + this.ctx.strokeRect(x * this.scale, y, width * this.scale, height); + this.ctx.setLineDash([]); + this.ctx.restore(); + } + + private drawWrappedText(text: string, x: number, baselineY: number, maxWidth: number, fontSize: number, fontFamily: string): { width: number; height: number; lineHeight: number } { + const layout = this.measureWrappedText(text, maxWidth, fontSize, fontFamily); + const lineHeight = layout.lineHeight; + for (let i = 0; i < layout.lines.length; i++) { + const line = layout.lines[i]; + this.ctx.fillText(line.text, x, baselineY + i * lineHeight); + } + return { + width: layout.width, + height: layout.height, + lineHeight, + }; + } + + private getTextCaretMetrics(text: string, caretIndex: number, maxWidth: number, fontSize: number, fontFamily: string): { x: number; baselineOffsetY: number } { + const layout = this.measureWrappedText(text, maxWidth, fontSize, fontFamily); + const line = [...layout.lines].reverse().find(candidate => candidate.startIndex <= caretIndex) ?? layout.lines[0]; + const safeCaretIndex = Math.min(Math.max(caretIndex, line.startIndex), line.endIndex); + const beforeCaret = line.text.slice(0, safeCaretIndex - line.startIndex); + this.ctx.save(); + this.ctx.font = `${fontSize}px ${fontFamily}`; + const x = this.ctx.measureText(beforeCaret).width; + this.ctx.restore(); + return { + x, + baselineOffsetY: line.lineIndex * layout.lineHeight, + }; + } + + private measureWrappedText(text: string, maxWidth: number, fontSize: number, fontFamily: string): { lines: { text: string; startIndex: number; endIndex: number; lineIndex: number }[]; width: number; height: number; lineHeight: number } { + this.ctx.save(); + this.ctx.font = `${fontSize}px ${fontFamily}`; + const lineHeight = fontSize * 1.2; + const lines: { text: string; startIndex: number; endIndex: number; lineIndex: number }[] = []; + const paragraphs = text.split('\n'); + let globalIndex = 0; + let lineIndex = 0; + let maxLineWidth = 0; + + for (let p = 0; p < paragraphs.length; p++) { + const paragraph = paragraphs[p]; + const paragraphStart = globalIndex; + const paragraphEnd = paragraphStart + paragraph.length; + + if (paragraph.length === 0) { + lines.push({ text: '', startIndex: paragraphStart, endIndex: paragraphStart, lineIndex }); + lineIndex++; + } else { + let lineStart = paragraphStart; + while (lineStart < paragraphEnd) { + let bestEnd = lineStart + 1; + let lastWhitespaceBreak = -1; + for (let i = lineStart + 1; i <= paragraphEnd; i++) { + const candidate = text.slice(lineStart, i); + if (this.ctx.measureText(candidate).width <= maxWidth) { + bestEnd = i; + if (/\s/.test(text[i - 1])) { + lastWhitespaceBreak = i; + } + } else { + break; + } + } + + let lineEnd = bestEnd; + if (bestEnd < paragraphEnd && lastWhitespaceBreak > lineStart) { + lineEnd = lastWhitespaceBreak; + } + if (lineEnd <= lineStart) { + lineEnd = lineStart + 1; + } + + const rawLineText = text.slice(lineStart, lineEnd); + const lineText = rawLineText.replace(/\s+$/u, ''); + lines.push({ text: lineText, startIndex: lineStart, endIndex: lineEnd, lineIndex }); + maxLineWidth = Math.max(maxLineWidth, this.ctx.measureText(lineText).width); + lineIndex++; + + lineStart = lineEnd; + while (lineStart < paragraphEnd && /\s/u.test(text[lineStart])) { + lineStart++; + } + } + } + + globalIndex = paragraphEnd + 1; + } + + if (lines.length === 0) { + lines.push({ text: '', startIndex: 0, endIndex: 0, lineIndex: 0 }); + } + + if (maxLineWidth === 0) { + for (const line of lines) { + maxLineWidth = Math.max(maxLineWidth, this.ctx.measureText(line.text).width); + } + } + this.ctx.restore(); + return { + lines, + width: Math.max(maxLineWidth, maxWidth), + height: lines.length * lineHeight, + lineHeight, + }; + } + + private hitTest(pos: { x: number; y: number }): number { + for (let i = this.actions.length - 1; i >= 0; i--) { + if (this.isPointOnAction(pos, this.actions[i])) { + return i; + } + } + return -1; + } + + private isPointOnAction(pos: { x: number; y: number }, action: DrawAction): boolean { + const threshold = 8; + switch (action.type) { + case AnnotationTool.Freehand: + if (action.points) { + for (let i = 1; i < action.points.length; i++) { + if (this.pointToSegmentDist(pos, action.points[i - 1], action.points[i]) < threshold) { + return true; + } + } + } + return false; + case AnnotationTool.Rectangle: + if (action.rect) { + const r = action.rect; + const nx = Math.min(r.x, r.x + r.width); + const ny = Math.min(r.y, r.y + r.height); + const nw = Math.abs(r.width); + const nh = Math.abs(r.height); + return pos.x >= nx - threshold && pos.x <= nx + nw + threshold && + pos.y >= ny - threshold && pos.y <= ny + nh + threshold; + } + return false; + case AnnotationTool.Ellipse: + if (action.ellipseRect) { + const er = action.ellipseRect; + const cx = er.x + er.width / 2; + const cy = er.y + er.height / 2; + const rx = Math.abs(er.width / 2); + const ry = Math.abs(er.height / 2); + if (rx < 1 || ry < 1) { + return false; + } + // Normalized distance from center + const dx = (pos.x - cx) / rx; + const dy = (pos.y - cy) / ry; + const dist = Math.sqrt(dx * dx + dy * dy); + if (!this.isTransparent(action.fillColor ?? 'transparent')) { + return dist <= 1 + threshold / Math.min(rx, ry); + } + // Check if point is near the ellipse border (dist around 1) + const normalizedThreshold = threshold / Math.min(rx, ry); + return Math.abs(dist - 1) < normalizedThreshold; + } + return false; + case AnnotationTool.Arrow: + if (action.arrowStart && action.arrowEnd) { + return this.pointToSegmentDist(pos, action.arrowStart, action.arrowEnd) < threshold; + } + return false; + case AnnotationTool.Text: + if (action.text && action.textPos) { + const bounds = this.getActionBounds(action); + if (!bounds) { + return false; + } + return pos.x >= action.textPos.x - threshold && + pos.x <= bounds.x + bounds.width + threshold && + pos.y >= bounds.y - threshold && + pos.y <= bounds.y + bounds.height + threshold; + } + return false; + } + return false; + } + + private pointToSegmentDist(p: { x: number; y: number }, a: { x: number; y: number }, b: { x: number; y: number }): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSq = dx * dx + dy * dy; + if (lengthSq === 0) { + return Math.hypot(p.x - a.x, p.y - a.y); + } + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq; + t = Math.max(0, Math.min(1, t)); + const projX = a.x + t * dx; + const projY = a.y + t * dy; + return Math.hypot(p.x - projX, p.y - projY); + } + + private moveAction(action: DrawAction, dx: number, dy: number): void { + switch (action.type) { + case AnnotationTool.Freehand: + if (action.points) { + for (const pt of action.points) { + pt.x += dx; + pt.y += dy; + } + } + break; + case AnnotationTool.Rectangle: + if (action.rect) { + action.rect.x += dx; + action.rect.y += dy; + } + break; + case AnnotationTool.Ellipse: + if (action.ellipseRect) { + action.ellipseRect.x += dx; + action.ellipseRect.y += dy; + } + break; + case AnnotationTool.Arrow: + if (action.arrowStart) { + action.arrowStart.x += dx; + action.arrowStart.y += dy; + } + if (action.arrowEnd) { + action.arrowEnd.x += dx; + action.arrowEnd.y += dy; + } + break; + case AnnotationTool.Text: + if (action.textPos) { + action.textPos.x += dx; + action.textPos.y += dy; + } + break; + } + } + + private drawSelectionHighlight(action: DrawAction): void { + this.ctx.save(); + this.ctx.strokeStyle = '#007acc'; + this.ctx.lineWidth = 1; + this.ctx.setLineDash([4, 4]); + const pad = 6; + const bounds = this.getActionBounds(action); + if (bounds) { + this.ctx.strokeRect( + (bounds.x - pad) * this.scale, + (bounds.y - pad) * this.scale, + (bounds.width + pad * 2) * this.scale, + (bounds.height + pad * 2) * this.scale, + ); + if (action.type === AnnotationTool.Text) { + const handleSize = 8; + const handleX = (bounds.x + bounds.width + pad) * this.scale; + const handleY = (bounds.y + bounds.height / 2) * this.scale; + this.ctx.fillStyle = '#007acc'; + this.ctx.fillRect(handleX - handleSize / 2, handleY - handleSize / 2, handleSize, handleSize); + } + } + this.ctx.setLineDash([]); + this.ctx.restore(); + } + + private isNearTextResizeHandle(pos: { x: number; y: number }, action: DrawAction): boolean { + if (action.type !== AnnotationTool.Text) { + return false; + } + const bounds = this.getActionBounds(action); + if (!bounds) { + return false; + } + const threshold = 8; + const handleX = bounds.x + bounds.width; + const handleY = bounds.y + bounds.height / 2; + return Math.abs(pos.x - handleX) <= threshold && Math.abs(pos.y - handleY) <= threshold * 2; + } + + private getActionBounds(action: DrawAction): { x: number; y: number; width: number; height: number } | null { + switch (action.type) { + case AnnotationTool.Freehand: + if (action.points && action.points.length > 0) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const pt of action.points) { + minX = Math.min(minX, pt.x); + minY = Math.min(minY, pt.y); + maxX = Math.max(maxX, pt.x); + maxY = Math.max(maxY, pt.y); + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + return null; + case AnnotationTool.Rectangle: + if (action.rect) { + const r = action.rect; + return { + x: Math.min(r.x, r.x + r.width), + y: Math.min(r.y, r.y + r.height), + width: Math.abs(r.width), + height: Math.abs(r.height), + }; + } + return null; + case AnnotationTool.Ellipse: + if (action.ellipseRect) { + const er = action.ellipseRect; + return { + x: Math.min(er.x, er.x + er.width), + y: Math.min(er.y, er.y + er.height), + width: Math.abs(er.width), + height: Math.abs(er.height), + }; + } + return null; + case AnnotationTool.Arrow: + if (action.arrowStart && action.arrowEnd) { + const minX = Math.min(action.arrowStart.x, action.arrowEnd.x); + const minY = Math.min(action.arrowStart.y, action.arrowEnd.y); + const maxX = Math.max(action.arrowStart.x, action.arrowEnd.x); + const maxY = Math.max(action.arrowStart.y, action.arrowEnd.y); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + return null; + case AnnotationTool.Text: + if (action.text && action.textPos) { + const fontSize = action.fontSize || 16; + const fontFamily = action.fontFamily || 'sans-serif'; + const textWidth = action.textWidth ?? DEFAULT_TEXT_BOX_WIDTH; + const layout = this.measureWrappedText(action.text, textWidth, fontSize, fontFamily); + return { + x: action.textPos.x, + y: action.textPos.y - fontSize, + width: textWidth, + height: layout.height, + }; + } + return null; + } + return null; + } + + private drawArrow(fromX: number, fromY: number, toX: number, toY: number): void { + const dx = toX - fromX; + const dy = toY - fromY; + const length = Math.hypot(dx, dy); + if (length === 0) { + return; + } + + const unitX = dx / length; + const unitY = dy / length; + const normalX = -unitY; + const normalY = unitX; + const lineWidth = this.ctx.lineWidth; + const headLength = Math.min(Math.max(12 * this.scale, lineWidth * 3), length); + const headWidth = Math.max(10 * this.scale, lineWidth * 2.5); + const baseX = toX - unitX * headLength; + const baseY = toY - unitY * headLength; + + this.ctx.beginPath(); + this.ctx.moveTo(fromX, fromY); + this.ctx.lineTo(baseX, baseY); + this.ctx.stroke(); + + this.ctx.beginPath(); + this.ctx.moveTo(toX, toY); + this.ctx.lineTo(baseX + normalX * headWidth / 2, baseY + normalY * headWidth / 2); + this.ctx.lineTo(baseX - normalX * headWidth / 2, baseY - normalY * headWidth / 2); + this.ctx.closePath(); + this.ctx.fillStyle = this.ctx.strokeStyle; + this.ctx.fill(); + } + + private flushPendingZoom(): void { + const pending = this.pendingZoom; + this.pendingZoom = null; + if (!pending) { + return; + } + const minScale = this.getFitScale(); + const maxScale = 8; + const desiredScale = this.scale * pending.factor; + const newScale = Math.max(minScale, Math.min(maxScale, desiredScale)); + if (newScale === this.scale) { + return; + } + // Cursor-anchored zoom: keep the image pixel under the cursor under the + // cursor after zoom. Clamp the cursor's image-space coord to the actual + // image extent so an off-image cursor (in breathing-room padding) still + // pivots on the nearest real image pixel. + const halfImgW = (this.imageWidth * this.scale) / 2; + const halfImgH = (this.imageHeight * this.scale) / 2; + const anchorCx = this.panX + Math.max(-halfImgW, Math.min(halfImgW, pending.cx - this.panX)); + const anchorCy = this.panY + Math.max(-halfImgH, Math.min(halfImgH, pending.cy - this.panY)); + const r = newScale / this.scale; + this.panX = anchorCx * (1 - r) + this.panX * r; + this.panY = anchorCy * (1 - r) + this.panY * r; + this.scale = newScale; + this.hasUserZoomed = true; + // Deliberately do NOT call clampPan() here. With rAF-coalesced wheel events + // a single flush can produce a large zoom factor (e.g. trackpad pinch firing + // 10+ events in one frame -> r ~= 2-3); the cursor-anchored pan that needs to + // be applied at large r can exceed the strict clamp, and clamping then + // drifts the cursor away from the anchor pixel. The cursor anchor itself + // ensures at least one image pixel stays visible (the one under the cursor), + // so unbounded zoom pan is safe. + // When zooming back out to fit, snap pan to centered so the breathing-room + // layout looks symmetric instead of carrying over any accumulated offset. + if (newScale === minScale) { + this.panX = 0; + this.panY = 0; + } + this.sizeCanvas(); + this.canvas.style.transform = `translate(${this.panX}px, ${this.panY}px)`; + this.redraw(); + } + + private getFitScale(): number { + const container = this.canvas.parentElement; + if (!container || !this.imageWidth || !this.imageHeight) { + return 1; + } + const maxWidth = Math.max(1, container.clientWidth - CANVAS_BREATHING_ROOM * 2); + const maxHeight = Math.max(1, container.clientHeight - CANVAS_BREATHING_ROOM * 2); + return Math.min(maxWidth / this.imageWidth, maxHeight / this.imageHeight, 1); + } + + private clampPan(): void { + const container = this.canvas.parentElement; + if (!container) { + return; + } + const imgW = this.imageWidth * this.scale; + const imgH = this.imageHeight * this.scale; + const cW = container.clientWidth; + const cH = container.clientHeight; + // Manual-pan clamp: image edge can't travel past container edge in either + // direction. When image is smaller than container (fit / zoomed-out), the + // bound shrinks symmetrically toward 0 so pan can shift the image around + // inside the container without sliding off either edge. When zoomed in, + // allows full pan within the zoomed content. + const maxPanX = Math.abs(cW - imgW) / 2; + const maxPanY = Math.abs(cH - imgH) / 2; + this.panX = Math.max(-maxPanX, Math.min(maxPanX, this.panX)); + this.panY = Math.max(-maxPanY, Math.min(maxPanY, this.panY)); + } + + private compositeToDataUrl(): string { + // Create a final canvas at full resolution + const finalCanvas = mainWindow.document.createElement('canvas'); + finalCanvas.width = this.imageWidth; + finalCanvas.height = this.imageHeight; + const ctx = finalCanvas.getContext('2d')!; + + // Draw background image + if (this.imageElement) { + ctx.drawImage(this.imageElement, 0, 0, this.imageWidth, this.imageHeight); + } + + // Replay annotations at full resolution. Actions are in original-image coords; + // translate by -currentCrop offset so they land correctly on the cropped output. + const savedScale = this.scale; + this.scale = 1; + const savedCtx = this.ctx; + this.ctx = ctx; + + const offX = this.currentCrop?.x ?? 0; + const offY = this.currentCrop?.y ?? 0; + ctx.save(); + ctx.translate(-offX, -offY); + for (const action of this.actions) { + this.drawAction(action); + } + ctx.restore(); + + this.ctx = savedCtx; + this.scale = savedScale; + + return finalCanvas.toDataURL('image/png'); + } + + dispose(): void { + if (this.pendingZoomRaf) { + getWindow(this.canvas).cancelAnimationFrame(this.pendingZoomRaf); + this.pendingZoomRaf = 0; + this.pendingZoom = null; + } + this.cancelTextPlacement(); + this.cleanupTextEditor(); + this.container.remove(); + this.toolOptionsDisposables.dispose(); + this.disposables.dispose(); + this._onDidSave.dispose(); + this._onDidCancel.dispose(); + } +} diff --git a/src/vs/workbench/contrib/issue/browser/screenshotService.ts b/src/vs/workbench/contrib/issue/browser/screenshotService.ts new file mode 100644 index 00000000000..d4fa4b44108 --- /dev/null +++ b/src/vs/workbench/contrib/issue/browser/screenshotService.ts @@ -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('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; +} + +/** + * Browser fallback — screenshot not available in web. + */ +export class BrowserScreenshotService implements IScreenshotService { + readonly _serviceBrand: undefined; + + async captureScreenshot(_rect?: IRectangle): Promise { + // Screen capture is not available in web browsers without permission APIs. + return undefined; + } +} diff --git a/src/vs/workbench/contrib/issue/common/issue.ts b/src/vs/workbench/contrib/issue/common/issue.ts index 6492620eab4..c383c91e751 100644 --- a/src/vs/workbench/contrib/issue/common/issue.ts +++ b/src/vs/workbench/contrib/issue/common/issue.ts @@ -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; 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('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; sendReporterMenu(extensionId: string): Promise; closeReporter(): Promise; + submitIssue(host: IIssueSubmissionHost, data: IssueReporterData, title: string, body: string): Promise; } export const IWorkbenchIssueService = createDecorator('workbenchIssueService'); diff --git a/src/vs/workbench/contrib/issue/electron-browser/issue.contribution.ts b/src/vs/workbench/contrib/issue/electron-browser/issue.contribution.ts index 391b9333b2c..fa4e2d482ab 100644 --- a/src/vs/workbench/contrib/issue/electron-browser/issue.contribution.ts +++ b/src/vs/workbench/contrib/issue/electron-browser/issue.contribution.ts @@ -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(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(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + IssueReporterEditorPane, + IssueReporterEditorPane.ID, + localize('issueReporterEditorPaneTitle', "Issue Reporter") + ), + [new SyncDescriptor(IssueReporterEditorInput)] +); class NativeIssueContribution extends BaseIssueContribution { diff --git a/src/vs/workbench/contrib/issue/electron-browser/issueReporterService.ts b/src/vs/workbench/contrib/issue/electron-browser/issueReporterService.ts index 94230a89c7f..692075b4e32 100644 --- a/src/vs/workbench/contrib/issue/electron-browser/issueReporterService.ts +++ b/src/vs/workbench/contrib/issue/electron-browser/issueReporterService.ts @@ -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 { @@ -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) { diff --git a/src/vs/workbench/contrib/issue/electron-browser/issueService.ts b/src/vs/workbench/contrib/issue/electron-browser/issueService.ts index 2fd60d6b856..16183b07294 100644 --- a/src/vs/workbench/contrib/issue/electron-browser/issueService.ts +++ b/src/vs/workbench/contrib/issue/electron-browser/issueService.ts @@ -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 = {}): Promise { + const useWizard = this.configurationService.getValue('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): Promise { + const theme = this.themeService.getColorTheme(); + const extensionsLoaded = new DeferredPromise(); + 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): Promise { 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, extensionsLoaded?: DeferredPromise): Promise { + // 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 { diff --git a/src/vs/workbench/contrib/issue/electron-browser/nativeGitHubUploadService.ts b/src/vs/workbench/contrib/issue/electron-browser/nativeGitHubUploadService.ts new file mode 100644 index 00000000000..06f9cff8643 --- /dev/null +++ b/src/vs/workbench/contrib/issue/electron-browser/nativeGitHubUploadService.ts @@ -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 { + const headers: Record = { '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 { + 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; + } +} diff --git a/src/vs/workbench/contrib/issue/electron-browser/nativeIssueFormService.ts b/src/vs/workbench/contrib/issue/electron-browser/nativeIssueFormService.ts index 831ec00e7c4..fdf0c616c82 100644 --- a/src/vs/workbench/contrib/issue/electron-browser/nativeIssueFormService.ts +++ b/src/vs/workbench/contrib/issue/electron-browser/nativeIssueFormService.ts @@ -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()); 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 { if (this.hasToReload(data)) { return; } - const bounds = await this.nativeHostService.getActiveWindowPosition(); - if (!bounds) { - return; + const useWizard = this.configurationService.getValue('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 { + 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(); } } } diff --git a/src/vs/workbench/contrib/issue/electron-browser/nativeRecordingService.ts b/src/vs/workbench/contrib/issue/electron-browser/nativeRecordingService.ts new file mode 100644 index 00000000000..a6200d1c134 --- /dev/null +++ b/src/vs/workbench/contrib/issue/electron-browser/nativeRecordingService.ts @@ -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()); + readonly onDidChangeState: Event = 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 { + 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 { + 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(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; + } +} diff --git a/src/vs/workbench/contrib/issue/electron-browser/nativeScreenshotService.ts b/src/vs/workbench/contrib/issue/electron-browser/nativeScreenshotService.ts new file mode 100644 index 00000000000..88e211129ea --- /dev/null +++ b/src/vs/workbench/contrib/issue/electron-browser/nativeScreenshotService.ts @@ -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 { + const buffer = await this.nativeHostService.getScreenshot(rect); + if (!buffer) { + return undefined; + } + + return `data:image/jpeg;base64,${encodeBase64(buffer)}`; + } +} diff --git a/src/vs/workbench/contrib/issue/test/browser/testReporterModel.test.ts b/src/vs/workbench/contrib/issue/test/browser/testReporterModel.test.ts index 14fd79ddda3..dab39c574e3 100644 --- a/src/vs/workbench/contrib/issue/test/browser/testReporterModel.test.ts +++ b/src/vs/workbench/contrib/issue/test/browser/testReporterModel.test.ts @@ -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(), diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index c56e45f72c8..fbbadf65a29 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -146,6 +146,7 @@ export class TestNativeHostService implements INativeHostService { async openExternal(url: string, defaultApplication?: string): Promise { return false; } async updateTouchBar(): Promise { } async moveItemToTrash(): Promise { } + async getMediaAccessStatus(_mediaType: 'microphone' | 'camera' | 'screen'): Promise<'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'> { return 'granted'; } async newWindowTab(): Promise { } async showPreviousWindowTab(): Promise { } async showNextWindowTab(): Promise { } @@ -186,6 +187,7 @@ export class TestNativeHostService implements INativeHostService { async profileRenderer(): Promise { throw new Error(); } async startTracing(): Promise { throw new Error(); } async getScreenshot(rect?: IRectangle): Promise { 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 { return { supported: false, clicked: false }; } async clearToast(id: string): Promise { } async clearToasts(): Promise { }