diff --git a/.github/CODENOTIFY b/.github/CODENOTIFY index dc4ef34cf2184..9b07c27526ff2 100644 --- a/.github/CODENOTIFY +++ b/.github/CODENOTIFY @@ -117,6 +117,7 @@ src/vs/workbench/contrib/preferences/** @rzhao271 # Build build/azure-pipelines/** @lszomoru +build/azure-pipelines/common/sanity-tests.yml @dmitrivMS build/lib/i18n.ts @TylerLeonhardt resources/linux/debian/** @rzhao271 resources/linux/rpm/** @rzhao271 @@ -144,3 +145,6 @@ extensions/github/** @lszomoru # Chat Editing, Inline Chat src/vs/workbench/contrib/chat/browser/chatEditing/** @jrieken src/vs/workbench/contrib/inlineChat/** @jrieken + +# Testing +test/sanity/** @dmitrivMS diff --git a/.github/instructions/modal-editor-part.instructions.md b/.github/instructions/modal-editor-part.instructions.md deleted file mode 100644 index f81d2922ff75a..0000000000000 --- a/.github/instructions/modal-editor-part.instructions.md +++ /dev/null @@ -1,213 +0,0 @@ ---- -description: Architecture documentation for VS Code modal editor part. Use when working with modal editor functionality in `src/vs/workbench/browser/parts/editor/modalEditorPart.ts` -applyTo: src/vs/workbench/**/modal*.ts ---- - -# Modal Editor Part Design Document - -This document describes the conceptual design of the Modal Editor Part feature in VS Code. Use this as a reference when working with modal editor functionality. - -## Overview - -The Modal Editor Part is a new editor part concept that displays editors in a modal overlay on top of the workbench. It follows the same architectural pattern as `AUX_WINDOW_GROUP` (auxiliary window editor parts) but renders within the main window as an overlay instead of a separate window. - -## Architecture - -### Constants and Types - -Location: `src/vs/workbench/services/editor/common/editorService.ts` - -```typescript -export const MODAL_GROUP = -4; -export type MODAL_GROUP_TYPE = typeof MODAL_GROUP; -``` - -The `MODAL_GROUP` constant follows the pattern of other special group identifiers: -- `ACTIVE_GROUP = -1` -- `SIDE_GROUP = -2` -- `AUX_WINDOW_GROUP = -3` -- `MODAL_GROUP = -4` - -### Interfaces - -Location: `src/vs/workbench/services/editor/common/editorGroupsService.ts` - -```typescript -export interface IModalEditorPart extends IEditorPart { - readonly onWillClose: Event; - close(): boolean; -} -``` - -The `IModalEditorPart` interface extends `IEditorPart` and adds: -- `onWillClose`: Event fired before the modal closes -- `close()`: Closes the modal, merging confirming editors back to the main part - -### Service Method - -The `IEditorGroupsService` interface includes: - -```typescript -createModalEditorPart(): Promise; -``` - -## Implementation - -### ModalEditorPart Class - -Location: `src/vs/workbench/browser/parts/editor/modalEditorPart.ts` - -The implementation consists of two classes: - -1. **`ModalEditorPart`**: Factory class that creates the modal UI - - Creates modal backdrop with dimmed overlay - - Creates shadow container for the modal window - - Handles layout relative to main container dimensions - - Registers escape key and click-outside handlers for closing - -2. **`ModalEditorPartImpl`**: The actual editor part extending `EditorPart` - - Enforces `showTabs: 'single'` and `closeEmptyGroups: true` - - Overrides `removeGroup` to close modal when last group is removed - - Does not persist state (modal is transient) - - Merges editors back to main part on close - -### Key Behaviors - -1. **Single Tab Mode**: Modal enforces `showTabs: 'single'` for a focused experience -2. **Auto-close on Empty**: When all editors are closed, the modal closes automatically -3. **Merge on Close**: Confirming editors (dirty, etc.) are merged back to main part -4. **Escape to Close**: Pressing Escape closes the modal -5. **Click Outside to Close**: Clicking the dimmed backdrop closes the modal - -### CSS Styling - -Location: `src/vs/workbench/browser/parts/editor/media/modalEditorPart.css` - -```css -.monaco-modal-editor-block { - /* Full-screen overlay with flexbox centering */ -} - -.monaco-modal-editor-block.dimmed { - /* Semi-transparent dark background */ -} - -.modal-editor-shadow { - /* Shadow and border-radius for the modal window */ -} -``` - -## Integration Points - -### EditorParts Service - -Location: `src/vs/workbench/browser/parts/editor/editorParts.ts` - -The `EditorParts` class implements `createModalEditorPart()`: - -```typescript -async createModalEditorPart(): Promise { - const { part, disposables } = await this.instantiationService - .createInstance(ModalEditorPart, this).create(); - - this._onDidAddGroup.fire(part.activeGroup); - - disposables.add(toDisposable(() => { - this._onDidRemoveGroup.fire(part.activeGroup); - })); - - return part; -} -``` - -### Active Part Detection - -Location: `src/vs/workbench/browser/parts/editor/editorParts.ts` - -Override of `getPartByDocument` to detect when focus is in a modal: - -```typescript -protected override getPartByDocument(document: Document): EditorPart { - if (this._parts.size > 1) { - const activeElement = getActiveElement(); - - for (const part of this._parts) { - if (part !== this.mainPart && part.element?.ownerDocument === document) { - const container = part.getContainer(); - if (container && isAncestor(activeElement, container)) { - return part; - } - } - } - } - return super.getPartByDocument(document); -} -``` - -This ensures that when focus is in the modal, it is considered the active part for editor opening via quick open, etc. - -### Editor Group Finder - -Location: `src/vs/workbench/services/editor/common/editorGroupFinder.ts` - -The `findGroup` function handles `MODAL_GROUP`: - -```typescript -else if (preferredGroup === MODAL_GROUP) { - group = editorGroupService.createModalEditorPart() - .then(part => part.activeGroup); -} -``` - -## Usage Examples - -### Opening an Editor in Modal - -```typescript -// Using the editor service -await editorService.openEditor(input, options, MODAL_GROUP); - -// Using a flag pattern (e.g., settings) -interface IOpenSettingsOptions { - openInModal?: boolean; -} - -// Implementation checks the flag -if (options.openInModal) { - group = await findGroup(accessor, {}, MODAL_GROUP); -} -``` - -### Current Integrations - -1. **Settings Editor**: Opens in modal via `openInModal: true` option -2. **Keyboard Shortcuts Editor**: Opens in modal via `openInModal: true` option -3. **Extensions Editor**: Uses `openInModal: true` in `IExtensionEditorOptions` -4. **Profiles Editor**: Opens directly with `MODAL_GROUP` - -## Testing - -Location: `src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts` - -Test categories: -- Constants and types verification -- Creation and initial state -- Editor operations (open, split) -- Closing behavior and events -- Options enforcement -- Integration with EditorParts service - -## Design Decisions - -1. **Why extend EditorPart?**: Reuses all editor group functionality without duplication -2. **Why single tab mode?**: Modal is for focused, single-editor experiences -3. **Why merge on close?**: Prevents data loss for dirty editors -4. **Why same window?**: Avoids complexity of auxiliary windows while providing overlay UX -5. **Why transient state?**: Modal is meant for temporary focused editing, not persistence - -## Future Considerations - -- Consider adding animation for open/close transitions -- Consider size/position customization -- Consider multiple modal stacking (though likely not needed) -- Consider keyboard navigation between modal and main editor areas diff --git a/build/azure-pipelines/common/checkCopilotChatCompatibility.ts b/build/azure-pipelines/common/checkCopilotChatCompatibility.ts new file mode 100644 index 0000000000000..9c4c12943b971 --- /dev/null +++ b/build/azure-pipelines/common/checkCopilotChatCompatibility.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import path from 'path'; +import fs from 'fs'; +import { retry } from './retry.ts'; +import { type IExtensionManifest, parseApiProposalsFromSource, checkExtensionCompatibility, areAllowlistedApiProposalsMatching } from './versionCompatibility.ts'; + +const root = path.dirname(path.dirname(path.dirname(import.meta.dirname))); + +async function fetchLatestExtensionManifest(extensionId: string): Promise { + // Use the vscode-unpkg service to get the latest extension package.json + const [publisher, name] = extensionId.split('.'); + + // First, get the latest version from the gallery endpoint + const galleryUrl = `https://main.vscode-unpkg.net/_gallery/${publisher}/${name}/latest`; + const galleryResponse = await fetch(galleryUrl, { + headers: { 'User-Agent': 'VSCode Build' } + }); + + if (!galleryResponse.ok) { + throw new Error(`Failed to fetch latest version for ${extensionId}: ${galleryResponse.status} ${galleryResponse.statusText}`); + } + + const galleryData = await galleryResponse.json() as { versions: { version: string }[] }; + const version = galleryData.versions[0].version; + + // Now fetch the package.json using the actual version + const url = `https://${publisher}.vscode-unpkg.net/${publisher}/${name}/${version}/extension/package.json`; + + const response = await fetch(url, { + headers: { 'User-Agent': 'VSCode Build' } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch extension ${extensionId} from unpkg: ${response.status} ${response.statusText}`); + } + + return await response.json() as IExtensionManifest; +} + +export async function checkCopilotChatCompatibility(): Promise { + const extensionId = 'github.copilot-chat'; + + console.log(`Checking compatibility of ${extensionId}...`); + + // Get product version from package.json + const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const productVersion = packageJson.version; + + console.log(`Product version: ${productVersion}`); + + // Get API proposals from the generated file + const apiProposalsPath = path.join(root, 'src/vs/platform/extensions/common/extensionsApiProposals.ts'); + const apiProposalsContent = fs.readFileSync(apiProposalsPath, 'utf8'); + const allApiProposals = parseApiProposalsFromSource(apiProposalsContent); + + const proposalCount = Object.keys(allApiProposals).length; + if (proposalCount === 0) { + throw new Error('Failed to load API proposals from source'); + } + + console.log(`Loaded ${proposalCount} API proposals from source`); + + // Load product.json to check allowlisted API proposals + const productJsonPath = path.join(root, 'product.json'); + let productJson; + try { + productJson = JSON.parse(fs.readFileSync(productJsonPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to load or parse product.json: ${error}`); + } + const extensionEnabledApiProposals = productJson?.extensionEnabledApiProposals; + const extensionIdKey = extensionEnabledApiProposals ? Object.keys(extensionEnabledApiProposals).find(key => key.toLowerCase() === extensionId.toLowerCase()) : undefined; + const productAllowlistedProposals = extensionIdKey ? extensionEnabledApiProposals[extensionIdKey] : undefined; + + if (productAllowlistedProposals) { + console.log(`Product.json allowlisted proposals for ${extensionId}:`); + for (const proposal of productAllowlistedProposals) { + console.log(` ${proposal}`); + } + } else { + console.log(`Product.json allowlisted proposals for ${extensionId}: none`); + } + + // Fetch the latest extension manifest + const manifest = await retry(() => fetchLatestExtensionManifest(extensionId)); + + console.log(`Extension ${extensionId}@${manifest.version}:`); + console.log(` engines.vscode: ${manifest.engines.vscode}`); + console.log(` enabledApiProposals:\n ${manifest.enabledApiProposals?.join('\n ') || 'none'}`); + + // Check compatibility + const result = checkExtensionCompatibility(productVersion, allApiProposals, manifest); + if (!result.compatible) { + throw new Error(`Compatibility check failed:\n ${result.errors.join('\n ')}`); + } + + console.log(` ✓ Engine version compatible`); + if (manifest.enabledApiProposals?.length) { + console.log(` ✓ API proposals compatible`); + } + + // Check that product.json allowlist matches package.json declarations + const allowlistResult = areAllowlistedApiProposalsMatching(extensionId, productAllowlistedProposals, manifest.enabledApiProposals); + if (!allowlistResult.compatible) { + throw new Error(`Allowlist check failed:\n ${allowlistResult.errors.join('\n ')}`); + } + + console.log(` ✓ Product.json allowlist matches package.json`); + console.log(`✓ ${extensionId} is compatible with this build`); +} + +if (import.meta.main) { + const warnOnly = process.argv.includes('--warn-only'); + + checkCopilotChatCompatibility().then(() => { + console.log('Copilot Chat compatibility check passed'); + process.exit(0); + }, err => { + if (warnOnly) { + // Issue a warning using Azure DevOps logging commands but don't fail the build + console.log(`##vso[task.logissue type=warning]Copilot Chat compatibility check failed: ${err.message}`); + console.log(`##vso[task.complete result=SucceededWithIssues;]Copilot Chat compatibility check failed`); + console.log(''); + console.log(`⚠️ WARNING: ${err.message}`); + console.log(''); + console.log('The build will continue, but the release step will fail if this is not resolved.'); + process.exit(0); + } else { + console.error(err); + process.exit(1); + } + }); +} diff --git a/build/azure-pipelines/common/checkDistroCommit.ts b/build/azure-pipelines/common/checkDistroCommit.ts new file mode 100644 index 0000000000000..4003a10df317c --- /dev/null +++ b/build/azure-pipelines/common/checkDistroCommit.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 path from 'path'; +import fs from 'fs'; +import { retry } from './retry.ts'; + +const root = path.dirname(path.dirname(path.dirname(import.meta.dirname))); + +function getEnv(name: string): string { + const result = process.env[name]; + + if (typeof result === 'undefined') { + throw new Error('Missing env: ' + name); + } + + return result; +} + +interface GitHubBranchResponse { + commit: { + sha: string; + }; +} + +async function getDistroBranchHead(branch: string, token: string): Promise { + const url = `https://api.github.com/repos/microsoft/vscode-distro/branches/${encodeURIComponent(branch)}`; + + const response = await fetch(url, { + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'VSCode Build' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch branch ${branch} from vscode-distro: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as GitHubBranchResponse; + return data.commit.sha; +} + +async function checkDistroCommit(): Promise { + // Get the distro commit from package.json + const packageJsonPath = path.join(root, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const distroCommit: string = packageJson.distro; + + if (!distroCommit) { + console.log('No distro commit found in package.json, skipping check'); + return; + } + + console.log(`Distro commit in package.json: ${distroCommit}`); + + // Get the current branch from Azure DevOps + // BUILD_SOURCEBRANCH is in format refs/heads/main or refs/heads/release/1.90 + const sourceBranch = getEnv('BUILD_SOURCEBRANCH'); + const branchMatch = sourceBranch.match(/^refs\/heads\/(.+)$/); + + if (!branchMatch) { + console.log(`Cannot determine branch from BUILD_SOURCEBRANCH: ${sourceBranch}, skipping check`); + return; + } + + const branch = branchMatch[1]; + console.log(`Current branch: ${branch}`); + + // Get the GitHub token + const token = getEnv('GITHUB_TOKEN'); + + // Fetch the HEAD of the matching branch in vscode-distro + let distroBranchHead: string; + try { + distroBranchHead = await retry(() => getDistroBranchHead(branch, token)); + } catch (error) { + // If the branch doesn't exist in distro, that's expected for feature branches + console.log(`Could not fetch branch '${branch}' from vscode-distro: ${error}`); + console.log('This is expected for feature branches that have not been merged to distro'); + return; + } + + console.log(`Distro branch '${branch}' HEAD: ${distroBranchHead}`); + + // Compare the commits + if (distroCommit === distroBranchHead) { + console.log(`✓ Distro commit matches branch HEAD`); + } else { + // Issue a warning using Azure DevOps logging commands + console.log(`##vso[task.logissue type=warning]Distro commit mismatch: package.json has ${distroCommit.substring(0, 8)} but ${branch} HEAD is ${distroBranchHead.substring(0, 8)}`); + console.log(`##vso[task.complete result=SucceededWithIssues;]Distro commit does not match branch HEAD`); + console.log(''); + console.log(`⚠️ WARNING: Distro commit in package.json does not match the HEAD of branch '${branch}' in vscode-distro`); + console.log(` package.json distro: ${distroCommit}`); + console.log(` ${branch} HEAD: ${distroBranchHead}`); + console.log(''); + console.log(' To update, run: npm run update-distro'); + } +} + +checkDistroCommit().then(() => { + console.log('Distro commit check completed'); + process.exit(0); +}, err => { + console.error(err); + process.exit(1); +}); diff --git a/build/azure-pipelines/common/releaseBuild.ts b/build/azure-pipelines/common/releaseBuild.ts index 37e869a42bcc3..3cd8082308e28 100644 --- a/build/azure-pipelines/common/releaseBuild.ts +++ b/build/azure-pipelines/common/releaseBuild.ts @@ -4,12 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { CosmosClient } from '@azure/cosmos'; -import path from 'path'; -import fs from 'fs'; import { retry } from './retry.ts'; -import { type IExtensionManifest, parseApiProposalsFromSource, checkExtensionCompatibility, areAllowlistedApiProposalsMatching } from './versionCompatibility.ts'; - -const root = path.dirname(path.dirname(path.dirname(import.meta.dirname))); +import { checkCopilotChatCompatibility } from './checkCopilotChatCompatibility.ts'; function getEnv(name: string): string { const result = process.env[name]; @@ -21,109 +17,6 @@ function getEnv(name: string): string { return result; } -async function fetchLatestExtensionManifest(extensionId: string): Promise { - // Use the vscode-unpkg service to get the latest extension package.json - const [publisher, name] = extensionId.split('.'); - - // First, get the latest version from the gallery endpoint - const galleryUrl = `https://main.vscode-unpkg.net/_gallery/${publisher}/${name}/latest`; - const galleryResponse = await fetch(galleryUrl, { - headers: { 'User-Agent': 'VSCode Build' } - }); - - if (!galleryResponse.ok) { - throw new Error(`Failed to fetch latest version for ${extensionId}: ${galleryResponse.status} ${galleryResponse.statusText}`); - } - - const galleryData = await galleryResponse.json() as { versions: { version: string }[] }; - const version = galleryData.versions[0].version; - - // Now fetch the package.json using the actual version - const url = `https://${publisher}.vscode-unpkg.net/${publisher}/${name}/${version}/extension/package.json`; - - const response = await fetch(url, { - headers: { 'User-Agent': 'VSCode Build' } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch extension ${extensionId} from unpkg: ${response.status} ${response.statusText}`); - } - - return await response.json() as IExtensionManifest; -} - -async function checkCopilotChatCompatibility(): Promise { - const extensionId = 'github.copilot-chat'; - - console.log(`Checking compatibility of ${extensionId}...`); - - // Get product version from package.json - const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); - const productVersion = packageJson.version; - - console.log(`Product version: ${productVersion}`); - - // Get API proposals from the generated file - const apiProposalsPath = path.join(root, 'src/vs/platform/extensions/common/extensionsApiProposals.ts'); - const apiProposalsContent = fs.readFileSync(apiProposalsPath, 'utf8'); - const allApiProposals = parseApiProposalsFromSource(apiProposalsContent); - - const proposalCount = Object.keys(allApiProposals).length; - if (proposalCount === 0) { - throw new Error('Failed to load API proposals from source'); - } - - console.log(`Loaded ${proposalCount} API proposals from source`); - - // Load product.json to check allowlisted API proposals - const productJsonPath = path.join(root, 'product.json'); - let productJson; - try { - productJson = JSON.parse(fs.readFileSync(productJsonPath, 'utf8')); - } catch (error) { - throw new Error(`Failed to load or parse product.json: ${error}`); - } - const extensionEnabledApiProposals = productJson?.extensionEnabledApiProposals; - const extensionIdKey = extensionEnabledApiProposals ? Object.keys(extensionEnabledApiProposals).find(key => key.toLowerCase() === extensionId.toLowerCase()) : undefined; - const productAllowlistedProposals = extensionIdKey ? extensionEnabledApiProposals[extensionIdKey] : undefined; - - if (productAllowlistedProposals) { - console.log(`Product.json allowlisted proposals for ${extensionId}:`); - for (const proposal of productAllowlistedProposals) { - console.log(` ${proposal}`); - } - } else { - console.log(`Product.json allowlisted proposals for ${extensionId}: none`); - } - - // Fetch the latest extension manifest - const manifest = await retry(() => fetchLatestExtensionManifest(extensionId)); - - console.log(`Extension ${extensionId}@${manifest.version}:`); - console.log(` engines.vscode: ${manifest.engines.vscode}`); - console.log(` enabledApiProposals:\n ${manifest.enabledApiProposals?.join('\n ') || 'none'}`); - - // Check compatibility - const result = checkExtensionCompatibility(productVersion, allApiProposals, manifest); - if (!result.compatible) { - throw new Error(`Compatibility check failed:\n ${result.errors.join('\n ')}`); - } - - console.log(` ✓ Engine version compatible`); - if (manifest.enabledApiProposals?.length) { - console.log(` ✓ API proposals compatible`); - } - - // Check that product.json allowlist matches package.json declarations - const allowlistResult = areAllowlistedApiProposalsMatching(extensionId, productAllowlistedProposals, manifest.enabledApiProposals); - if (!allowlistResult.compatible) { - throw new Error(`Allowlist check failed:\n ${allowlistResult.errors.join('\n ')}`); - } - - console.log(` ✓ Product.json allowlist matches package.json`); - console.log(`✓ ${extensionId} is compatible with this build`); -} - interface Config { id: string; frozen: boolean; diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 3ff40d1d9418a..897c27e680dbb 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -203,6 +203,15 @@ extends: jobs: - template: build/azure-pipelines/product-compile.yml@self + - ${{ if eq(variables['VSCODE_PUBLISH'], 'true') }}: + - stage: ValidationChecks + dependsOn: [] + pool: + name: 1es-ubuntu-22.04-x64 + os: linux + jobs: + - template: build/azure-pipelines/product-validation-checks.yml@self + - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_ALPINE, true),eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - stage: CompileCLI dependsOn: [] diff --git a/build/azure-pipelines/product-validation-checks.yml b/build/azure-pipelines/product-validation-checks.yml new file mode 100644 index 0000000000000..adf61f33c428c --- /dev/null +++ b/build/azure-pipelines/product-validation-checks.yml @@ -0,0 +1,40 @@ +jobs: + - job: ValidationChecks + displayName: Distro and Extension Validation + timeoutInMinutes: 15 + steps: + - template: ./common/checkout.yml@self + + - task: NodeTool@0 + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + + - template: ./distro/download-distro.yml@self + + - script: node build/azure-pipelines/distro/mixin-quality.ts + displayName: Mixin distro quality + + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get Secrets" + inputs: + azureSubscription: vscode + KeyVaultName: vscode-build-secrets + SecretsFilter: "github-distro-mixin-password" + + - script: npm ci + workingDirectory: build + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Install build dependencies + + - script: node build/azure-pipelines/common/checkDistroCommit.ts + displayName: Check distro commit + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + BUILD_SOURCEBRANCH: "$(Build.SourceBranch)" + continueOnError: true + + - script: node build/azure-pipelines/common/checkCopilotChatCompatibility.ts --warn-only + displayName: Check Copilot Chat compatibility + continueOnError: true diff --git a/build/darwin/sign.ts b/build/darwin/sign.ts index fcdcb2b2d4560..e1adf88c8bd76 100644 --- a/build/darwin/sign.ts +++ b/build/darwin/sign.ts @@ -117,6 +117,13 @@ async function main(buildDir?: string): Promise { 'An application in Visual Studio Code wants to use the Camera.', `${infoPlistPath}` ]); + await spawn('plutil', [ + '-replace', + 'NSAudioCaptureUsageDescription', + '-string', + 'An application in Visual Studio Code wants to use Audio Capture.', + `${infoPlistPath}` + ]); } await retrySignOnKeychainError(() => sign(appOpts)); diff --git a/build/lib/typings/vscode-gulp-watch.d.ts b/build/lib/typings/vscode-gulp-watch.d.ts new file mode 100644 index 0000000000000..24316c07f16ca --- /dev/null +++ b/build/lib/typings/vscode-gulp-watch.d.ts @@ -0,0 +1,3 @@ +declare module 'vscode-gulp-watch' { + export default function watch(...args: any[]): any; +} diff --git a/build/lib/watch/index.ts b/build/lib/watch/index.ts index 6276744e84fd3..763cacc6d893d 100644 --- a/build/lib/watch/index.ts +++ b/build/lib/watch/index.ts @@ -2,121 +2,11 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { createRequire } from 'node:module'; -import * as watcher from '@parcel/watcher'; -import es from 'event-stream'; -import fs from 'fs'; -import filter from 'gulp-filter'; -import path from 'path'; -import { Stream } from 'stream'; -import File from 'vinyl'; +const require = createRequire(import.meta.url); +const watch = process.platform === 'win32' ? require('./watch-win32.ts').default : require('vscode-gulp-watch'); -interface WatchOptions { - cwd?: string; - base?: string; - dot?: boolean; - readDelay?: number; - read?: boolean; -} - -type EventType = 'change' | 'add' | 'unlink'; - -function toEventType(type: watcher.EventType): EventType { - switch (type) { - case 'create': return 'add'; - case 'update': return 'change'; - case 'delete': return 'unlink'; - } -} - -const subscriptionCache: Map }> = new Map(); - -function createWatcher(root: string): Stream { - const result = es.through(); - - const subscription = watcher.subscribe(root, (_err, events) => { - for (const event of events) { - const relativePath = path.relative(root, event.path); - - // Filter out .git and out directories early - if (/^\.git/.test(relativePath) || /(^|[\\/])out($|[\\/])/.test(relativePath)) { - continue; - } - - const file = new File({ - path: event.path, - base: root - }); - (file as File & { event: EventType }).event = toEventType(event.type); - result.emit('data', file); - } - }, { - ignore: [ - '**/.git/**', - '**/out/**' - ] - }); - - // Cleanup on process exit - const cleanup = () => { - subscription.then(sub => sub.unsubscribe()).catch(() => { }); - }; - process.once('SIGTERM', cleanup); - process.once('exit', cleanup); - - return result; -} - -export default function watch(pattern: string | string[] | filter.FileFunction, options?: WatchOptions): Stream { - options = options || {}; - - const cwd = path.normalize(options.cwd || process.cwd()); - let cached = subscriptionCache.get(cwd); - - if (!cached) { - const stream = createWatcher(cwd); - cached = { stream, subscription: Promise.resolve(null as unknown as watcher.AsyncSubscription) }; - subscriptionCache.set(cwd, cached); - } - - const rebase = !options.base ? es.through() : es.mapSync((f: File) => { - f.base = options!.base!; - return f; - }); - - const readDelay = options.readDelay ?? 0; - const shouldRead = options.read !== false; - - return cached.stream - .pipe(filter(['**', '!.git{,/**}'], { dot: options.dot })) // ignore all things git - .pipe(filter(pattern, { dot: options.dot })) - .pipe(es.map(function (file: File & { event: EventType }, cb) { - const processFile = () => { - if (!shouldRead) { - return cb(undefined, file); - } - - fs.stat(file.path, (err, stat) => { - if (err && err.code === 'ENOENT') { return cb(undefined, file); } - if (err) { return cb(); } - if (!stat.isFile()) { return cb(); } - - fs.readFile(file.path, (err, contents) => { - if (err && err.code === 'ENOENT') { return cb(undefined, file); } - if (err) { return cb(); } - - file.contents = contents; - file.stat = stat; - cb(undefined, file); - }); - }); - }; - - if (readDelay > 0) { - setTimeout(processFile, readDelay); - } else { - processFile(); - } - })) - .pipe(rebase); +export default function (...args: any[]): ReturnType { + return watch.apply(null, args); } diff --git a/build/lib/watch/watch-win32.ts b/build/lib/watch/watch-win32.ts new file mode 100644 index 0000000000000..12b8ffc0ac3ea --- /dev/null +++ b/build/lib/watch/watch-win32.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import path from 'path'; +import cp from 'child_process'; +import fs from 'fs'; +import File from 'vinyl'; +import es from 'event-stream'; +import filter from 'gulp-filter'; +import { Stream } from 'stream'; + +const watcherPath = path.join(import.meta.dirname, 'watcher.exe'); + +function toChangeType(type: '0' | '1' | '2'): 'change' | 'add' | 'unlink' { + switch (type) { + case '0': return 'change'; + case '1': return 'add'; + default: return 'unlink'; + } +} + +function watch(root: string): Stream { + const result = es.through(); + let child: cp.ChildProcess | null = cp.spawn(watcherPath, [root]); + + child.stdout!.on('data', function (data) { + const lines: string[] = data.toString('utf8').split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.length === 0) { + continue; + } + + const changeType = line[0] as '0' | '1' | '2'; + const changePath = line.substr(2); + + // filter as early as possible + if (/^\.git/.test(changePath) || /(^|\\)out($|\\)/.test(changePath)) { + continue; + } + + const changePathFull = path.join(root, changePath); + + const file = new File({ + path: changePathFull, + base: root + }); + file.event = toChangeType(changeType); + result.emit('data', file); + } + }); + + child.stderr!.on('data', function (data) { + result.emit('error', data); + }); + + child.on('exit', function (code) { + result.emit('error', 'Watcher died with code ' + code); + child = null; + }); + + process.once('SIGTERM', function () { process.exit(0); }); + process.once('SIGTERM', function () { process.exit(0); }); + process.once('exit', function () { if (child) { child.kill(); } }); + + return result; +} + +const cache: { [cwd: string]: Stream } = Object.create(null); + +export default function (pattern: string | string[] | filter.FileFunction, options?: { cwd?: string; base?: string; dot?: boolean }) { + options = options || {}; + + const cwd = path.normalize(options.cwd || process.cwd()); + let watcher = cache[cwd]; + + if (!watcher) { + watcher = cache[cwd] = watch(cwd); + } + + const rebase = !options.base ? es.through() : es.mapSync(function (f: File) { + f.base = options!.base!; + return f; + }); + + return watcher + .pipe(filter(['**', '!.git{,/**}'], { dot: options.dot })) // ignore all things git + .pipe(filter(pattern, { dot: options.dot })) + .pipe(es.map(function (file: File, cb) { + fs.stat(file.path, function (err, stat) { + if (err && err.code === 'ENOENT') { return cb(undefined, file); } + if (err) { return cb(); } + if (!stat.isFile()) { return cb(); } + + fs.readFile(file.path, function (err, contents) { + if (err && err.code === 'ENOENT') { return cb(undefined, file); } + if (err) { return cb(); } + + file.contents = contents; + file.stat = stat; + cb(undefined, file); + }); + }); + })) + .pipe(rebase); +} diff --git a/build/lib/watch/watcher.exe b/build/lib/watch/watcher.exe new file mode 100644 index 0000000000000..d4101748e4db5 Binary files /dev/null and b/build/lib/watch/watcher.exe differ diff --git a/build/package-lock.json b/build/package-lock.json index af29d59be6932..255effe830ecf 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -16,7 +16,6 @@ "@azure/storage-blob": "^12.25.0", "@electron/get": "^2.0.0", "@electron/osx-sign": "^2.0.0", - "@parcel/watcher": "^2.5.6", "@types/ansi-colors": "^3.2.0", "@types/byline": "^4.2.32", "@types/debounce": "^1.0.0", @@ -72,7 +71,8 @@ "zx": "^8.8.5" }, "optionalDependencies": { - "tree-sitter-typescript": "^0.23.2" + "tree-sitter-typescript": "^0.23.2", + "vscode-gulp-watch": "^5.0.3" } }, "node_modules/@azu/format-text": { @@ -1804,335 +1804,6 @@ "node": ">=10" } }, - "node_modules/@parcel/watcher": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", - "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.6", - "@parcel/watcher-darwin-arm64": "2.5.6", - "@parcel/watcher-darwin-x64": "2.5.6", - "@parcel/watcher-freebsd-x64": "2.5.6", - "@parcel/watcher-linux-arm-glibc": "2.5.6", - "@parcel/watcher-linux-arm-musl": "2.5.6", - "@parcel/watcher-linux-arm64-glibc": "2.5.6", - "@parcel/watcher-linux-arm64-musl": "2.5.6", - "@parcel/watcher-linux-x64-glibc": "2.5.6", - "@parcel/watcher-linux-x64-musl": "2.5.6", - "@parcel/watcher-win32-arm64": "2.5.6", - "@parcel/watcher-win32-ia32": "2.5.6", - "@parcel/watcher-win32-x64": "2.5.6" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", - "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", - "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", - "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", - "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", - "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", - "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", - "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", - "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", - "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", - "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", - "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", - "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@parcel/watcher/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3398,6 +3069,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ansi-gray": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", + "integrity": "sha1-KWLPVOyXksSFEKPetSRDaGHvclE= sha512-HrgGIZUl8h2EHuZaU9hTR/cU5nhKxpVE1V6kdGsQ8e4zirElJ5fvtfc8N7Q1oq1aatO275i8pUFUCpNWCAnVWw==", + "optional": true, + "dependencies": { + "ansi-wrap": "0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -3427,7 +3110,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", "integrity": "sha1-qCJQ3bABXponyoLoLqYDu/pF768= sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -3436,7 +3119,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, + "devOptional": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -3671,7 +3354,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -3680,7 +3363,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -3700,7 +3383,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -3798,7 +3481,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -3865,7 +3548,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, + "devOptional": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -4336,7 +4019,7 @@ "version": "3.5.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", - "dev": true, + "devOptional": true, "dependencies": { "anymatch": "~3.1.1", "braces": "~3.0.2", @@ -4578,7 +4261,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.8" } @@ -4587,7 +4270,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz", "integrity": "sha1-4+JbIHrE5wGvch4staFnksrD3Fg= sha512-KLLTJWrvwIP+OPfMn0x2PheDEP20RPUcGXj/ERegTgdmPEZylALQldygiqrPPu8P45uNuPs7ckmReLY6v/iA5g==", - "dev": true, + "devOptional": true, "engines": { "node": ">= 0.10" } @@ -4605,13 +4288,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz", "integrity": "sha1-s3gt/4u1R04Yuba/D9/ngvh3doA= sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==", - "dev": true + "devOptional": true }, "node_modules/cloneable-readable": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.1.3.tgz", "integrity": "sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==", - "dev": true, + "devOptional": true, "dependencies": { "inherits": "^2.0.1", "process-nextick-args": "^2.0.0", @@ -4622,7 +4305,7 @@ "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, + "devOptional": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -4658,6 +4341,15 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4701,7 +4393,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true + "devOptional": true }, "node_modules/crc": { "version": "3.8.0", @@ -4877,11 +4569,10 @@ } }, "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", + "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=8" } @@ -5564,7 +5255,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, + "devOptional": true, "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" @@ -5604,6 +5295,21 @@ "license": "MIT", "optional": true }, + "node_modules/fancy-log": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", + "integrity": "sha512-k9oEhlyc0FrVh25qYuSELjr8oxsCoc4/LEZfg2iJJrfEk/tZL9bCoJE47gqAvI2m/AUjluCS4+3I0eTx8n3AEw==", + "optional": true, + "dependencies": { + "ansi-gray": "^0.1.1", + "color-support": "^1.1.3", + "parse-node-version": "^1.0.0", + "time-stamp": "^1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5748,7 +5454,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, + "devOptional": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -5756,6 +5462,33 @@ "node": ">=8" } }, + "node_modules/first-chunk-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz", + "integrity": "sha1-G97NuOCDwGZLkZRVgVd6Q6nzHXA= sha512-X8Z+b/0L4lToKYq+lwnKqi9X/Zek0NibLpsJgVsSxpoYq7JtiCtRb5HqKVEjEw/qAb/4AKKRLOwwKHlWNpm2Eg==", + "optional": true, + "dependencies": { + "readable-stream": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/first-chunk-stream/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "optional": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5840,7 +5573,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -5956,7 +5688,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, + "devOptional": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -6077,7 +5809,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/gulp-merge-json": { @@ -6363,7 +6095,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "devOptional": true }, "node_modules/ini": { "version": "1.3.8", @@ -6386,7 +6118,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, + "devOptional": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -6413,7 +6145,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, + "devOptional": true, "dependencies": { "is-plain-object": "^2.0.4" }, @@ -6425,7 +6157,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -6444,7 +6176,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -6466,7 +6198,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.12.0" } @@ -6475,7 +6207,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, + "devOptional": true, "dependencies": { "isobject": "^3.0.1" }, @@ -6496,6 +6228,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", + "optional": true + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -6512,7 +6250,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "devOptional": true }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -6537,7 +6275,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8= sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -7584,7 +7322,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -7613,6 +7351,15 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -7876,6 +7623,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "optional": true, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/parse-semver": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", @@ -8009,7 +7765,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -8018,6 +7774,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -8036,7 +7801,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", "integrity": "sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA==", - "dev": true, + "devOptional": true, "dependencies": { "ansi-colors": "^1.0.1", "arr-diff": "^4.0.0", @@ -8051,7 +7816,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-wrap": "^0.1.0" @@ -8117,7 +7882,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "devOptional": true }, "node_modules/progress": { "version": "2.0.3", @@ -8352,7 +8117,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, + "devOptional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -8366,7 +8131,7 @@ "version": "3.5.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", - "dev": true, + "devOptional": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -8378,13 +8143,13 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8= sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", - "dev": true + "devOptional": true }, "node_modules/replace-ext": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.1.tgz", "integrity": "sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==", - "dev": true, + "devOptional": true, "engines": { "node": ">= 0.10" } @@ -8533,7 +8298,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "devOptional": true }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -8998,7 +8763,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, + "devOptional": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -9120,6 +8885,43 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4= sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", + "optional": true, + "dependencies": { + "is-utf8": "^0.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-bom-buf": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-buf/-/strip-bom-buf-1.0.0.tgz", + "integrity": "sha1-HLRar1dTD0yvhsf3UXnSyaUd1XI= sha512-1sUIL1jck0T1mhOLP2c696BIznzT525Lkub+n4jjMHjhjhoAQA6Ye659DxdlZBr0aLDMQoTxKIpnlqxgtwjsuQ==", + "optional": true, + "dependencies": { + "is-utf8": "^0.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-bom-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz", + "integrity": "sha1-+H217yYT9paKpUWr/h7HKLaoKco= sha512-yH0+mD8oahBZWnY43vxs4pSinn8SMKAdml/EOGBewoe1Y0Eitd0h2Mg3ZRiXruUW6L4P+lvZiEgbh0NgUGia1w==", + "optional": true, + "dependencies": { + "first-chunk-stream": "^2.0.0", + "strip-bom": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -9446,6 +9248,15 @@ "readable-stream": "3" } }, + "node_modules/time-stamp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz", + "integrity": "sha1-dkpaEa9QVhkhsTPztE5hhofg9cM= sha512-gLCeArryy2yNTRzTGKbZbloctj64jkZ57hj5zdraXue6aFgd6PmvVtEyiUU+hvU0v7q08oVv8r8ev0tRo6bvgw==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -9520,7 +9331,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "dependencies": { "is-number": "^7.0.0" }, @@ -9777,7 +9588,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "devOptional": true }, "node_modules/uuid": { "version": "8.3.1", @@ -9832,7 +9643,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", "integrity": "sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==", - "dev": true, + "devOptional": true, "dependencies": { "clone": "^2.1.1", "clone-buffer": "^1.0.0", @@ -9845,6 +9656,50 @@ "node": ">= 0.10" } }, + "node_modules/vinyl-file": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vinyl-file/-/vinyl-file-3.0.0.tgz", + "integrity": "sha1-sQTZ5ECf+jJfqt1SBkLQo7SIs2U= sha512-BoJDj+ca3D9xOuPEM6RWVtWQtvEPQiQYn82LvdxhLWplfQsBzBqtgK0yhCP0s1BNTi6dH9BO+dzybvyQIacifg==", + "optional": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "pify": "^2.3.0", + "strip-bom-buf": "^1.0.0", + "strip-bom-stream": "^2.0.0", + "vinyl": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/vscode-gulp-watch": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vscode-gulp-watch/-/vscode-gulp-watch-5.0.3.tgz", + "integrity": "sha512-MTUp2yLE9CshhkNSNV58EQNxQSeF8lIj3mkXZX9a1vAk+EQNM2PAYdPUDSd/P/08W3PMHGznEiZyfK7JAjLosg==", + "optional": true, + "dependencies": { + "ansi-colors": "4.1.1", + "anymatch": "^3.1.1", + "chokidar": "3.5.1", + "fancy-log": "^1.3.3", + "glob-parent": "^5.1.1", + "normalize-path": "^3.0.0", + "object-assign": "^4.1.1", + "plugin-error": "1.0.1", + "readable-stream": "^3.6.0", + "vinyl": "^2.2.0", + "vinyl-file": "^3.0.0" + } + }, + "node_modules/vscode-gulp-watch/node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/vscode-universal-bundler": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/vscode-universal-bundler/-/vscode-universal-bundler-0.1.3.tgz", diff --git a/build/package.json b/build/package.json index eb5c60ff0e974..e45161dc2c328 100644 --- a/build/package.json +++ b/build/package.json @@ -4,7 +4,6 @@ "license": "MIT", "devDependencies": { "@azure/core-auth": "^1.9.0", - "@parcel/watcher": "^2.5.6", "@azure/cosmos": "^3", "@azure/identity": "^4.2.1", "@azure/msal-node": "^2.16.1", @@ -74,7 +73,8 @@ "test": "mocha --ui tdd 'lib/**/*.test.ts'" }, "optionalDependencies": { - "tree-sitter-typescript": "^0.23.2" + "tree-sitter-typescript": "^0.23.2", + "vscode-gulp-watch": "^5.0.3" }, "overrides": { "path-scurry": { diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 0b85d213056e3..ff4be15a69482 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -34,15 +34,15 @@ "inputOption.activeBackground": "#3994BC33", "inputOption.activeForeground": "#bfbfbf", "inputOption.activeBorder": "#2A2B2CFF", - "inputValidation.errorBackground": "#191A1B", - "inputValidation.errorBorder": "#2A2B2CFF", - "inputValidation.errorForeground": "#bfbfbf", - "inputValidation.infoBackground": "#191A1B", - "inputValidation.infoBorder": "#2A2B2CFF", + "inputValidation.infoBackground": "#1E3A47", + "inputValidation.infoBorder": "#3994BC", "inputValidation.infoForeground": "#bfbfbf", - "inputValidation.warningBackground": "#191A1B", - "inputValidation.warningBorder": "#2A2B2CFF", + "inputValidation.warningBackground": "#352A05", + "inputValidation.warningBorder": "#B89500", "inputValidation.warningForeground": "#bfbfbf", + "inputValidation.errorBackground": "#3A1D1D", + "inputValidation.errorBorder": "#BE1100", + "inputValidation.errorForeground": "#bfbfbf", "scrollbar.shadow": "#191B1D4D", "scrollbarSlider.background": "#83848533", "scrollbarSlider.hoverBackground": "#83848566", diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 246e8d7e2e91e..c4f66b51a9053 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -37,15 +37,15 @@ "inputOption.activeBackground": "#0069CC26", "inputOption.activeForeground": "#202020", "inputOption.activeBorder": "#F2F3F4FF", - "inputValidation.errorBackground": "#FFFFFF", - "inputValidation.errorBorder": "#F2F3F4FF", - "inputValidation.errorForeground": "#202020", - "inputValidation.infoBackground": "#FFFFFF", - "inputValidation.infoBorder": "#F2F3F4FF", + "inputValidation.infoBackground": "#E6F2FA", + "inputValidation.infoBorder": "#0069CC", "inputValidation.infoForeground": "#202020", - "inputValidation.warningBackground": "#FFFFFF", - "inputValidation.warningBorder": "#F2F3F4FF", + "inputValidation.warningBackground": "#FDF6E3", + "inputValidation.warningBorder": "#B69500", "inputValidation.warningForeground": "#202020", + "inputValidation.errorBackground": "#FDEDED", + "inputValidation.errorBorder": "#ad0707", + "inputValidation.errorForeground": "#202020", "scrollbar.shadow": "#00000000", "widget.shadow": "#00000000", "widget.border": "#F2F3F4FF", diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index a3efce99744b6..afd3b1fcf456a 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -121,6 +121,9 @@ import { normalizeNFC } from '../../base/common/normalization.js'; import { ICSSDevelopmentService, CSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } from '../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeMcpDiscoveryHelperService.js'; +import { IMcpGatewayService, McpGatewayChannelName } from '../../platform/mcp/common/mcpGateway.js'; +import { McpGatewayService } from '../../platform/mcp/node/mcpGatewayService.js'; +import { McpGatewayChannel } from '../../platform/mcp/node/mcpGatewayChannel.js'; import { IWebContentExtractorService } from '../../platform/webContentExtractor/common/webContentExtractor.js'; import { NativeWebContentExtractorService } from '../../platform/webContentExtractor/electron-main/webContentExtractorService.js'; import ErrorTelemetry from '../../platform/telemetry/electron-main/errorTelemetry.js'; @@ -1114,6 +1117,7 @@ export class CodeApplication extends Disposable { // MCP services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService)); + services.set(IMcpGatewayService, new SyncDescriptor(McpGatewayService)); // Dev Only: CSS service (for ESM) @@ -1235,6 +1239,8 @@ export class CodeApplication extends Disposable { // MCP const mcpDiscoveryChannel = ProxyChannel.fromService(accessor.get(INativeMcpDiscoveryHelperService), disposables); mainProcessElectronServer.registerChannel(NativeMcpDiscoveryHelperChannelName, mcpDiscoveryChannel); + const mcpGatewayChannel = this._register(new McpGatewayChannel(mainProcessElectronServer, accessor.get(IMcpGatewayService))); + mainProcessElectronServer.registerChannel(McpGatewayChannelName, mcpGatewayChannel); // Logger const loggerChannel = new LoggerChannel(accessor.get(ILoggerMainService),); diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index e7fbab4322857..c7d731eaa3d1b 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -220,6 +220,22 @@ export class CommonFindController extends Disposable implements IEditorContribut return !!CONTEXT_FIND_INPUT_FOCUSED.getValue(this._contextKeyService); } + /** + * Returns whether the Replace input was the last focused input in the find widget. + * Returns false by default; overridden in FindController. + */ + public wasReplaceInputLastFocused(): boolean { + return false; + } + + /** + * Focuses the last focused element in the find widget. + * Implemented by FindController; base implementation does nothing. + */ + public focusLastElement(): void { + // Base implementation - overridden in FindController + } + public getState(): FindReplaceState { return this._state; } @@ -517,6 +533,22 @@ export class FindController extends CommonFindController implements IFindControl this._findOptionsWidget = this._register(new FindOptionsWidget(this._editor, this._state, this._keybindingService)); } + /** + * Returns whether the Replace input was the last focused input in the find widget. + */ + public override wasReplaceInputLastFocused(): boolean { + return this._widget?.lastFocusedInputWasReplace ?? false; + } + + /** + * Focuses the last focused element in the find widget. + * This is more precise than just focusing the Find or Replace input, + * as it can restore focus to checkboxes, buttons, etc. + */ + public override focusLastElement(): void { + this._widget?.focusLastElement(); + } + saveViewState(): any { return this._widget?.getViewState(); } diff --git a/src/vs/editor/contrib/find/browser/findModel.ts b/src/vs/editor/contrib/find/browser/findModel.ts index 1862cd2d3918d..5ec8c791d2583 100644 --- a/src/vs/editor/contrib/find/browser/findModel.ts +++ b/src/vs/editor/contrib/find/browser/findModel.ts @@ -30,6 +30,11 @@ export const CONTEXT_FIND_WIDGET_NOT_VISIBLE = CONTEXT_FIND_WIDGET_VISIBLE.toNeg // Keep ContextKey use of 'Focussed' to not break when clauses export const CONTEXT_FIND_INPUT_FOCUSED = new RawContextKey('findInputFocussed', false); export const CONTEXT_REPLACE_INPUT_FOCUSED = new RawContextKey('replaceInputFocussed', false); +/** + * Context key that is true when any element within the Find widget has focus. + * This includes the Find input, Replace input, checkboxes, buttons, etc. + */ +export const CONTEXT_FIND_WIDGET_FOCUSED = new RawContextKey('findWidgetFocused', false); export const ToggleCaseSensitiveKeybinding: IKeybindings = { primary: KeyMod.Alt | KeyCode.KeyC, diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index 25efe745fe0f4..2891f84836c14 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -25,7 +25,7 @@ import './findWidget.css'; import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, IViewZone, OverlayWidgetPositionPreference } from '../../../browser/editorBrowser.js'; import { ConfigurationChangedEvent, EditorOption } from '../../../common/config/editorOptions.js'; import { Range } from '../../../common/core/range.js'; -import { CONTEXT_FIND_INPUT_FOCUSED, CONTEXT_REPLACE_INPUT_FOCUSED, FIND_IDS, MATCHES_LIMIT } from './findModel.js'; +import { CONTEXT_FIND_INPUT_FOCUSED, CONTEXT_FIND_WIDGET_FOCUSED, CONTEXT_REPLACE_INPUT_FOCUSED, FIND_IDS, MATCHES_LIMIT } from './findModel.js'; import { FindReplaceState, FindReplaceStateChangedEvent } from './findState.js'; import * as nls from '../../../../nls.js'; import { AccessibilitySupport } from '../../../../platform/accessibility/common/accessibility.js'; @@ -144,11 +144,15 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private _isVisible: boolean; private _isReplaceVisible: boolean; private _ignoreChangeEvent: boolean; + private _lastFocusedInputWasReplace: boolean = false; private readonly _findFocusTracker: dom.IFocusTracker; private readonly _findInputFocused: IContextKey; private readonly _replaceFocusTracker: dom.IFocusTracker; private readonly _replaceInputFocused: IContextKey; + private _widgetFocusTracker: dom.IFocusTracker | undefined; + private readonly _findWidgetFocused: IContextKey; + private _lastFocusedElement: HTMLElement | null = null; private _viewZone?: FindWidgetViewZone; private _viewZoneId?: string; @@ -235,6 +239,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._findFocusTracker = this._register(dom.trackFocus(this._findInput.inputBox.inputElement)); this._register(this._findFocusTracker.onDidFocus(() => { this._findInputFocused.set(true); + this._lastFocusedInputWasReplace = false; this._updateSearchScope(); })); this._register(this._findFocusTracker.onDidBlur(() => { @@ -245,12 +250,30 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._replaceFocusTracker = this._register(dom.trackFocus(this._replaceInput.inputBox.inputElement)); this._register(this._replaceFocusTracker.onDidFocus(() => { this._replaceInputFocused.set(true); + this._lastFocusedInputWasReplace = true; this._updateSearchScope(); })); this._register(this._replaceFocusTracker.onDidBlur(() => { this._replaceInputFocused.set(false); })); + // Track focus on the entire Find widget for accessibility help + this._findWidgetFocused = CONTEXT_FIND_WIDGET_FOCUSED.bindTo(contextKeyService); + this._widgetFocusTracker = this._register(dom.trackFocus(this._domNode)); + this._register(this._widgetFocusTracker.onDidFocus(() => { + this._findWidgetFocused.set(true); + })); + this._register(this._widgetFocusTracker.onDidBlur(() => { + this._findWidgetFocused.set(false); + })); + + // Track which element was last focused within the widget using focusin (which bubbles) + this._register(dom.addDisposableListener(this._domNode, 'focusin', (e: FocusEvent) => { + if (dom.isHTMLElement(e.target)) { + this._lastFocusedElement = e.target; + } + })); + this._codeEditor.addOverlayWidget(this); if (this._codeEditor.getOption(EditorOption.find).addExtraSpaceOnTop) { this._viewZone = new FindWidgetViewZone(0); // Put it before the first line then users can scroll beyond the first line. @@ -287,6 +310,41 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL return this._domNode; } + /** + * Returns whether the Replace input was the last focused input in the find widget. + * This persists even after focus leaves the widget, allowing external code to know + * which input to restore focus to. + */ + public get lastFocusedInputWasReplace(): boolean { + return this._lastFocusedInputWasReplace; + } + + /** + * Returns the last focused element within the Find widget. + * This is useful for restoring focus to the exact element after + * accessibility help or other overlays are dismissed. + */ + public get lastFocusedElement(): HTMLElement | null { + return this._lastFocusedElement; + } + + /** + * Focuses the last focused element in the Find widget. + * Falls back to the Find or Replace input based on lastFocusedInputWasReplace. + */ + public focusLastElement(): void { + if (!this._isVisible) { + return; + } + if (this._lastFocusedElement && this._domNode.contains(this._lastFocusedElement) && dom.getWindow(this._lastFocusedElement).document.body.contains(this._lastFocusedElement)) { + this._lastFocusedElement.focus(); + } else if (this._lastFocusedInputWasReplace) { + this.focusReplaceInput(); + } else { + this.focusFindInput(); + } + } + public getPosition(): IOverlayWidgetPosition | null { if (this._isVisible) { return { diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 764c4ff0a6ca9..141199be16394 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -39,7 +39,13 @@ export const enum AccessibleViewProviderId { ReplHelp = 'replHelp', RunAndDebug = 'runAndDebug', Walkthrough = 'walkthrough', - SourceControl = 'scm' + SourceControl = 'scm', + EditorFindHelp = 'editorFindHelp', + SearchHelp = 'searchHelp', + TerminalFindHelp = 'terminalFindHelp', + WebviewFindHelp = 'webviewFindHelp', + OutputFindHelp = 'outputFindHelp', + ProblemsFilterHelp = 'problemsFilterHelp', } export const enum AccessibleViewType { diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index b81e7b5e87849..7c470fb6ccab8 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -88,6 +88,7 @@ export class MenuId { static readonly EditorContextPeek = new MenuId('EditorContextPeek'); static readonly EditorContextShare = new MenuId('EditorContextShare'); static readonly EditorTitle = new MenuId('EditorTitle'); + static readonly ModalEditorTitle = new MenuId('ModalEditorTitle'); static readonly CompactWindowEditorTitle = new MenuId('CompactWindowEditorTitle'); static readonly EditorTitleRun = new MenuId('EditorTitleRun'); static readonly EditorTitleContext = new MenuId('EditorTitleContext'); diff --git a/src/vs/platform/extensions/electron-main/extensionHostStarter.ts b/src/vs/platform/extensions/electron-main/extensionHostStarter.ts index 96cdf66125cc6..97a0519a493cb 100644 --- a/src/vs/platform/extensions/electron-main/extensionHostStarter.ts +++ b/src/vs/platform/extensions/electron-main/extensionHostStarter.ts @@ -120,6 +120,8 @@ export class ExtensionHostStarter extends Disposable implements IDisposable, IEx execArgv: opts.execArgv, allowLoadingUnsignedLibraries: true, respondToAuthRequestsFromMainProcess: true, + windowLifecycleBound: true, + windowLifecycleGraceTime: 6000, correlationId: id }); const pid = await Event.toPromise(extHost.onSpawn); diff --git a/src/vs/platform/mcp/common/mcpGateway.ts b/src/vs/platform/mcp/common/mcpGateway.ts new file mode 100644 index 0000000000000..816824dcd4333 --- /dev/null +++ b/src/vs/platform/mcp/common/mcpGateway.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const IMcpGatewayService = createDecorator('IMcpGatewayService'); + +export const McpGatewayChannelName = 'mcpGateway'; + +/** + * Result of creating an MCP gateway. + */ +export interface IMcpGatewayInfo { + /** + * The address of the HTTP endpoint for this gateway. + */ + readonly address: URI; + + /** + * The unique identifier for this gateway, used for disposal. + */ + readonly gatewayId: string; +} + +/** + * Service that manages MCP gateway HTTP endpoints in the main process (or remote server). + * + * The gateway provides an HTTP server that external processes can connect + * to in order to interact with MCP servers known to the editor. The server + * is shared among all gateways and is automatically torn down when the + * last gateway is disposed. + */ +export interface IMcpGatewayService { + readonly _serviceBrand: undefined; + + /** + * Disposes all gateways associated with a given client context (e.g., client ID or connection). + * @param context The client context whose gateways should be disposed. + * @return A disposable that can be used to unregister the client context from future cleanup (e.g., if the context is reused). + */ + disposeGatewaysForClient(context: TContext): void; + + /** + * Creates a new MCP gateway endpoint. + * + * The gateway is assigned a secure random route ID to make the endpoint + * URL unguessable without authentication. + * + * @param context Optional context (e.g., client ID) to associate with the gateway for cleanup purposes. + * @returns A promise that resolves to the gateway info if successful. + */ + createGateway(context: TContext): Promise; + + /** + * Disposes a previously created gateway. + * + * When the last gateway is disposed, the underlying HTTP server is shut down. + * + * @param gatewayId The unique identifier of the gateway to dispose. + */ + disposeGateway(gatewayId: string): Promise; +} diff --git a/src/vs/platform/mcp/electron-main/mcpGatewayMainChannel.ts b/src/vs/platform/mcp/electron-main/mcpGatewayMainChannel.ts new file mode 100644 index 0000000000000..8c6c30ed144d7 --- /dev/null +++ b/src/vs/platform/mcp/electron-main/mcpGatewayMainChannel.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable } from '../../../base/common/lifecycle.js'; +import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IMcpGatewayService } from '../common/mcpGateway.js'; + +/** + * IPC channel for the MCP Gateway service in the electron-main process. + * + * This channel tracks which client (identified by ctx) creates gateways, + * enabling cleanup when a client disconnects (e.g., window crash). + */ +export class McpGatewayMainChannel extends Disposable implements IServerChannel { + + constructor( + ipcServer: IPCServer, + @IMcpGatewayService private readonly mcpGatewayService: IMcpGatewayService + ) { + super(); + this._register(ipcServer.onDidRemoveConnection(c => mcpGatewayService.disposeGatewaysForClient(c.ctx))); + } + + listen(_ctx: string, _event: string): Event { + throw new Error('Invalid listen'); + } + + async call(ctx: string, command: string, args?: unknown): Promise { + switch (command) { + case 'createGateway': { + // Use the context (client ID) to track gateway ownership + const result = await this.mcpGatewayService.createGateway(ctx); + return result as T; + } + case 'disposeGateway': { + await this.mcpGatewayService.disposeGateway(args as string); + return undefined as T; + } + } + throw new Error(`Invalid call: ${command}`); + } +} diff --git a/src/vs/platform/mcp/node/mcpGatewayChannel.ts b/src/vs/platform/mcp/node/mcpGatewayChannel.ts new file mode 100644 index 0000000000000..5c0fefefe33a8 --- /dev/null +++ b/src/vs/platform/mcp/node/mcpGatewayChannel.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable } from '../../../base/common/lifecycle.js'; +import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IMcpGatewayService } from '../common/mcpGateway.js'; + +/** + * IPC channel for the MCP Gateway service, used by the remote server. + * + * This channel tracks which client (identified by reconnectionToken) creates gateways, + * enabling cleanup when a client disconnects. + */ +export class McpGatewayChannel extends Disposable implements IServerChannel { + + constructor( + ipcServer: IPCServer, + @IMcpGatewayService private readonly mcpGatewayService: IMcpGatewayService + ) { + super(); + this._register(ipcServer.onDidRemoveConnection(c => mcpGatewayService.disposeGatewaysForClient(c.ctx))); + } + + listen(_ctx: TContext, _event: string): Event { + throw new Error('Invalid listen'); + } + + async call(ctx: TContext, command: string, args?: unknown): Promise { + switch (command) { + case 'createGateway': { + const result = await this.mcpGatewayService.createGateway(ctx); + return result as T; + } + case 'disposeGateway': { + await this.mcpGatewayService.disposeGateway(args as string); + return undefined as T; + } + } + + throw new Error(`Invalid call: ${command}`); + } +} diff --git a/src/vs/platform/mcp/node/mcpGatewayService.ts b/src/vs/platform/mcp/node/mcpGatewayService.ts new file mode 100644 index 0000000000000..e4ee1fe42bbad --- /dev/null +++ b/src/vs/platform/mcp/node/mcpGatewayService.ts @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as http from 'http'; +import { DeferredPromise } from '../../../base/common/async.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { ILogService } from '../../log/common/log.js'; +import { IMcpGatewayInfo, IMcpGatewayService } from '../common/mcpGateway.js'; + +/** + * Node.js implementation of the MCP Gateway Service. + * + * Creates and manages an HTTP server on localhost that provides MCP gateway endpoints. + * The server is shared among all gateways and uses ref-counting for lifecycle management. + */ +export class McpGatewayService extends Disposable implements IMcpGatewayService { + declare readonly _serviceBrand: undefined; + + private _server: http.Server | undefined; + private _port: number | undefined; + private readonly _gateways = new Map(); + /** Maps gatewayId to clientId for tracking ownership */ + private readonly _gatewayToClient = new Map(); + private _serverStartPromise: Promise | undefined; + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + async createGateway(clientId: unknown): Promise { + // Ensure server is running + await this._ensureServer(); + + if (this._port === undefined) { + throw new Error('[McpGatewayService] Server failed to start, port is undefined'); + } + + // Generate a secure random ID for the gateway route + const gatewayId = generateUuid(); + + // Create the gateway route + const gateway = new McpGatewayRoute(gatewayId); + this._gateways.set(gatewayId, gateway); + + // Track client ownership if clientId provided (for cleanup on disconnect) + if (clientId) { + this._gatewayToClient.set(gatewayId, clientId); + this._logService.info(`[McpGatewayService] Created gateway at http://127.0.0.1:${this._port}/gateway/${gatewayId} for client ${clientId}`); + } else { + this._logService.warn(`[McpGatewayService] Created gateway without client tracking at http://127.0.0.1:${this._port}/gateway/${gatewayId}`); + } + + const address = URI.parse(`http://127.0.0.1:${this._port}/gateway/${gatewayId}`); + + return { + address, + gatewayId, + }; + } + + async disposeGateway(gatewayId: string): Promise { + const gateway = this._gateways.get(gatewayId); + if (!gateway) { + this._logService.warn(`[McpGatewayService] Attempted to dispose unknown gateway: ${gatewayId}`); + return; + } + + this._gateways.delete(gatewayId); + this._gatewayToClient.delete(gatewayId); + this._logService.info(`[McpGatewayService] Disposed gateway: ${gatewayId}`); + + // If no more gateways, shut down the server + if (this._gateways.size === 0) { + this._stopServer(); + } + } + + disposeGatewaysForClient(clientId: unknown): void { + const gatewaysToDispose: string[] = []; + + for (const [gatewayId, ownerClientId] of this._gatewayToClient) { + if (ownerClientId === clientId) { + gatewaysToDispose.push(gatewayId); + } + } + + if (gatewaysToDispose.length > 0) { + this._logService.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}`); + + for (const gatewayId of gatewaysToDispose) { + this._gateways.delete(gatewayId); + this._gatewayToClient.delete(gatewayId); + } + + // If no more gateways, shut down the server + if (this._gateways.size === 0) { + this._stopServer(); + } + } + } + + private async _ensureServer(): Promise { + if (this._server?.listening) { + return; + } + + // If server is already starting, wait for it + if (this._serverStartPromise) { + return this._serverStartPromise; + } + + this._serverStartPromise = this._startServer(); + try { + await this._serverStartPromise; + } finally { + this._serverStartPromise = undefined; + } + } + + private async _startServer(): Promise { + const deferredPromise = new DeferredPromise(); + + this._server = http.createServer((req, res) => { + this._handleRequest(req, res); + }); + + const portTimeout = setTimeout(() => { + deferredPromise.error(new Error('[McpGatewayService] Timeout waiting for server to start')); + }, 5000); + + this._server.on('listening', () => { + const address = this._server!.address(); + if (typeof address === 'string') { + this._port = parseInt(address); + } else if (address instanceof Object) { + this._port = address.port; + } else { + clearTimeout(portTimeout); + deferredPromise.error(new Error('[McpGatewayService] Unable to determine port')); + return; + } + + clearTimeout(portTimeout); + this._logService.info(`[McpGatewayService] Server started on port ${this._port}`); + deferredPromise.complete(); + }); + + this._server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + this._logService.warn('[McpGatewayService] Port in use, retrying with random port...'); + // Try with a random port + this._server!.listen(0, '127.0.0.1'); + return; + } + clearTimeout(portTimeout); + this._logService.error(`[McpGatewayService] Server error: ${err}`); + deferredPromise.error(err); + }); + + // Use dynamic port assignment (port 0) + this._server.listen(0, '127.0.0.1'); + + return deferredPromise.p; + } + + private _stopServer(): void { + if (!this._server) { + return; + } + + this._logService.info('[McpGatewayService] Stopping server (no more gateways)'); + + this._server.close(err => { + if (err) { + this._logService.error(`[McpGatewayService] Error closing server: ${err}`); + } else { + this._logService.info('[McpGatewayService] Server stopped'); + } + }); + + this._server = undefined; + this._port = undefined; + } + + private _handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const url = new URL(req.url!, `http://${req.headers.host}`); + const pathParts = url.pathname.split('/').filter(Boolean); + + // Expected path: /gateway/{gatewayId} + if (pathParts.length >= 2 && pathParts[0] === 'gateway') { + const gatewayId = pathParts[1]; + const gateway = this._gateways.get(gatewayId); + + if (gateway) { + gateway.handleRequest(req, res); + return; + } + } + + // Not found + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Gateway not found' })); + } + + override dispose(): void { + this._stopServer(); + this._gateways.clear(); + super.dispose(); + } +} + +/** + * Represents a single MCP gateway route. + * This is a stub implementation that will be expanded later. + */ +class McpGatewayRoute { + constructor( + public readonly gatewayId: string, + ) { } + + handleRequest(_req: http.IncomingMessage, res: http.ServerResponse): void { + // Stub implementation - return 501 Not Implemented + res.writeHead(501, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32601, + message: 'MCP Gateway not yet implemented', + }, + })); + } +} diff --git a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts index 05d83649c013e..fccddba015651 100644 --- a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts +++ b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts @@ -99,6 +99,14 @@ export interface IWindowUtilityProcessConfiguration extends IUtilityProcessConfi * when the associated browser window closes or reloads. */ readonly windowLifecycleBound?: boolean; + + /** + * Optional period in milliseconds to allow for graceful shutdown + * before forcefully killing the process when the window lifecycle ends. + * If not set or 0, the process will be killed immediately. + * This is useful for extension hosts that need time to deactivate extensions. + */ + readonly windowLifecycleGraceTime?: number; } function isWindowUtilityProcessConfiguration(config: IUtilityProcessConfiguration): config is IWindowUtilityProcessConfiguration { @@ -488,11 +496,17 @@ export class WindowUtilityProcess extends UtilityProcess { private registerWindowListeners(window: BrowserWindow, configuration: IWindowUtilityProcessConfiguration): void { // If the lifecycle of the utility process is bound to the window, - // we kill the process if the window closes or changes + // we terminate the process if the window closes or changes. + // If a grace period is configured, we wait for the process to exit + // before terminating (e.g. extensions need time to deactivate). if (configuration.windowLifecycleBound) { - this._register(Event.filter(this.lifecycleMainService.onWillLoadWindow, e => e.window.win === window)(() => this.kill())); - this._register(Event.fromNodeEventEmitter(window, 'closed')(() => this.kill())); + const graceTime = configuration.windowLifecycleGraceTime; + const terminate = graceTime && graceTime > 0 + ? () => this.waitForExit(graceTime) + : () => this.kill(); + this._register(Event.filter(this.lifecycleMainService.onWillLoadWindow, e => e.window.win === window)(terminate)); + this._register(Event.fromNodeEventEmitter(window, 'closed')(terminate)); } } } diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index 34b41d8d00b5f..053aa4395a640 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -83,6 +83,9 @@ import { TelemetryLogAppender } from '../../platform/telemetry/common/telemetryL import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } from '../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; import { NativeMcpDiscoveryHelperChannel } from '../../platform/mcp/node/nativeMcpDiscoveryHelperChannel.js'; import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeMcpDiscoveryHelperService.js'; +import { IMcpGatewayService, McpGatewayChannelName } from '../../platform/mcp/common/mcpGateway.js'; +import { McpGatewayService } from '../../platform/mcp/node/mcpGatewayService.js'; +import { McpGatewayChannel } from '../../platform/mcp/node/mcpGatewayChannel.js'; import { IExtensionGalleryManifestService } from '../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { ExtensionGalleryManifestIPCService } from '../../platform/extensionManagement/common/extensionGalleryManifestServiceIpc.js'; import { IAllowedMcpServersService, IMcpGalleryService, IMcpManagementService } from '../../platform/mcp/common/mcpManagement.js'; @@ -209,6 +212,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken services.set(IAllowedExtensionsService, new SyncDescriptor(AllowedExtensionsService)); services.set(INativeServerExtensionManagementService, new SyncDescriptor(ExtensionManagementService)); services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService)); + services.set(IMcpGatewayService, new SyncDescriptor(McpGatewayService)); const instantiationService: IInstantiationService = new InstantiationService(services); services.set(ILanguagePackService, instantiationService.createInstance(NativeLanguagePackService)); @@ -247,6 +251,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken socketServer.registerChannel(RemoteExtensionsScannerChannelName, new RemoteExtensionsScannerChannel(remoteExtensionsScanner, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); socketServer.registerChannel(NativeMcpDiscoveryHelperChannelName, instantiationService.createInstance(NativeMcpDiscoveryHelperChannel, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); + socketServer.registerChannel(McpGatewayChannelName, instantiationService.createInstance(McpGatewayChannel, socketServer)); const remoteFileSystemChannel = disposables.add(new RemoteAgentFileSystemProviderChannel(logService, environmentService, configurationService)); socketServer.registerChannel(REMOTE_FILE_SYSTEM_CHANNEL_NAME, remoteFileSystemChannel); diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index f09b6b867fe31..f71ccdba8fef1 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -11,12 +11,14 @@ import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '. import { autorun, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import Severity from '../../../base/common/severity.js'; import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; import * as nls from '../../../nls.js'; import { ContextKeyExpr, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { IDialogService, IPromptButton } from '../../../platform/dialogs/common/dialogs.js'; import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { LogLevel } from '../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; +import { IMcpGatewayResult, IWorkbenchMcpGatewayService } from '../../contrib/mcp/common/mcpGatewayService.js'; import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js'; import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js'; import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js'; @@ -44,6 +46,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { servers: ISettableObservable; dispose(): void; }>()); + private readonly _gateways = this._register(new DisposableMap()); constructor( private readonly _extHostContext: IExtHostContext, @@ -57,6 +60,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { @IExtensionService private readonly _extensionService: IExtensionService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IWorkbenchMcpGatewayService private readonly _mcpGatewayService: IWorkbenchMcpGatewayService, ) { super(); this._register(_authenticationService.onDidChangeSessions(e => this._onDidChangeAuthSessions(e.providerId, e.label))); @@ -397,6 +401,30 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { this._telemetryService.publicLog2('mcp/authSetup', data); } + async $startMcpGateway(): Promise<{ address: URI; gatewayId: string } | undefined> { + const result = await this._mcpGatewayService.createGateway(this._extHostContext.extensionHostKind === ExtensionHostKind.Remote); + if (!result) { + return undefined; + } + + if (this._store.isDisposed) { + result.dispose(); + return undefined; + } + + const gatewayId = generateUuid(); + this._gateways.set(gatewayId, result); + + return { + address: result.address, + gatewayId, + }; + } + + $disposeMcpGateway(gatewayId: string): void { + this._gateways.deleteAndDispose(gatewayId); + } + private async loginPrompt(mcpLabel: string, providerLabel: string, recreatingSession: boolean): Promise { const message = recreatingSession ? nls.localize('confirmRelogin', "The MCP Server Definition '{0}' wants you to authenticate to {1}.", mcpLabel, providerLabel) diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index fccab633ec54d..0d04a8f0382c3 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1736,6 +1736,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'mcpServerDefinitions'); return extHostMcp.mcpServerDefinitions; }, + startMcpGateway() { + checkProposedApiEnabled(extension, 'mcpServerDefinitions'); + return extHostMcp.startMcpGateway(); + }, onDidChangeChatRequestTools(...args) { checkProposedApiEnabled(extension, 'chatParticipantAdditions'); return _asExtensionEvent(extHostChatAgents2.onDidChangeChatRequestTools)(...args); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index e233de77dd875..268fb1c15714d 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3280,6 +3280,8 @@ export interface MainThreadMcpShape { $getTokenFromServerMetadata(id: number, authDetails: IMcpAuthenticationDetails, options?: IMcpAuthenticationOptions): Promise; $getTokenForProviderId(id: number, providerId: string, scopes: string[], options?: IMcpAuthenticationOptions): Promise; $logMcpAuthSetup(data: IAuthMetadataSource): void; + $startMcpGateway(): Promise<{ address: UriComponents; gatewayId: string } | undefined>; + $disposeMcpGateway(gatewayId: string): void; } export interface MainThreadDataChannelsShape extends IDisposable { diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index e148c5d062853..27e6c334d6b0b 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -40,6 +40,9 @@ export interface IExtHostMpcService extends ExtHostMcpShape { /** Returns all MCP server definitions known to the editor. */ readonly mcpServerDefinitions: readonly vscode.McpServerDefinition[]; + + /** Starts an MCP gateway that exposes MCP servers via an HTTP endpoint. */ + startMcpGateway(): Promise; } const serverDataValidation = vObj({ @@ -253,6 +256,24 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService return store; } + + /** {@link vscode.lm.startMcpGateway} */ + public async startMcpGateway(): Promise { + const result = await this._proxy.$startMcpGateway(); + if (!result) { + return undefined; + } + + const address = URI.revive(result.address); + const gatewayId = result.gatewayId; + + return { + address, + dispose: () => { + this._proxy.$disposeMcpGateway(gatewayId); + } + }; + } } const enum HttpMode { diff --git a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts index 3b9082b1a0ae2..ec1cf50a7be5a 100644 --- a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts @@ -360,6 +360,13 @@ class AuxiliaryEditorPartImpl extends EditorPart implements IAuxiliaryEditorPart super(editorPartsView, `workbench.parts.auxiliaryEditor.${id}`, groupsLabel, windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); } + protected override handleContextKeys(): void { + const isAuxiliaryWindowContext = IsAuxiliaryWindowContext.bindTo(this.scopedContextKeyService); + isAuxiliaryWindowContext.set(true); + + super.handleContextKeys(); + } + updateOptions(options: { compact: boolean }): void { this.isCompact = options.compact; diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 7dd71ed7f4c70..3a2faf87d04e2 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -9,10 +9,11 @@ import { Schemas, matchesScheme } from '../../../../base/common/network.js'; import { extname, isEqual } from '../../../../base/common/resources.js'; import { isNumber, isObject, isString, isUndefined } from '../../../../base/common/types.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; -import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { CommandsRegistry, ICommandHandler, ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; @@ -26,12 +27,13 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { ActiveGroupEditorsByMostRecentlyUsedQuickAccess } from './editorQuickAccess.js'; import { SideBySideEditor } from './sideBySideEditor.js'; import { TextDiffEditor } from './textDiffEditor.js'; -import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; +import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; import { CloseDirection, EditorInputCapabilities, EditorsOrder, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, isEditorInputWithOptionsAndGroup } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { EditorGroupColumn, columnToEditorGroup } from '../../../services/editor/common/editorGroupColumn.js'; -import { EditorGroupLayout, GroupDirection, GroupLocation, GroupsOrder, IEditorGroup, IEditorGroupsService, IEditorReplacement, preferredSideBySideGroupDirection } from '../../../services/editor/common/editorGroupsService.js'; +import { EditorGroupLayout, GroupDirection, GroupLocation, GroupsOrder, IEditorGroup, IEditorGroupsService, IEditorReplacement, IModalEditorPart, preferredSideBySideGroupDirection } from '../../../services/editor/common/editorGroupsService.js'; +import { mainWindow } from '../../../../base/browser/window.js'; import { IEditorResolverService } from '../../../services/editor/common/editorResolverService.js'; import { IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { IPathService } from '../../../services/path/common/pathService.js'; @@ -103,6 +105,9 @@ export const COPY_EDITOR_GROUP_INTO_NEW_WINDOW_COMMAND_ID = 'workbench.action.co export const NEW_EMPTY_EDITOR_WINDOW_COMMAND_ID = 'workbench.action.newEmptyEditorWindow'; +export const CLOSE_MODAL_EDITOR_COMMAND_ID = 'workbench.action.closeModalEditor'; +export const MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID = 'workbench.action.moveModalEditorToMain'; + export const API_OPEN_EDITOR_COMMAND_ID = '_workbench.open'; export const API_OPEN_DIFF_EDITOR_COMMAND_ID = '_workbench.diff'; export const API_OPEN_WITH_EDITOR_COMMAND_ID = '_workbench.openWith'; @@ -1400,6 +1405,75 @@ function registerOtherEditorCommands(): void { }); } +function registerModalEditorCommands(): void { + + registerAction2(class extends Action2 { + constructor() { + super({ + id: MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, + title: localize2('moveToMainWindow', 'Open as Editor'), + icon: Codicon.openInProduct, + precondition: EditorPartModalContext, + menu: { + id: MenuId.ModalEditorTitle, + group: 'navigation', + order: 1 + } + }); + } + run(accessor: ServicesAccessor): void { + const editorGroupsService = accessor.get(IEditorGroupsService); + + for (const part of editorGroupsService.parts) { + if (isModalEditorPart(part)) { + part.close({ mergeAllEditorsToMainPart: true }); + break; + } + } + } + }); + + registerAction2(class extends Action2 { + constructor() { + super({ + id: CLOSE_MODAL_EDITOR_COMMAND_ID, + title: localize2('closeModalEditor', 'Close Modal Editor'), + icon: Codicon.close, + precondition: EditorPartModalContext, + keybinding: { + primary: KeyCode.Escape, + weight: KeybindingWeight.WorkbenchContrib + 10, + when: EditorPartModalContext + }, + menu: { + id: MenuId.ModalEditorTitle, + group: 'navigation', + order: 2 + } + }); + } + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + + for (const part of editorGroupsService.parts) { + if (isModalEditorPart(part)) { + part.close(); + break; + } + } + } + }); +} + +function isModalEditorPart(obj: unknown): obj is IModalEditorPart { + const part = obj as IModalEditorPart | undefined; + + return !!part + && typeof part.close === 'function' + && typeof part.onWillClose === 'function' + && part.windowId === mainWindow.vscodeWindowId; +} + export function setup(): void { registerEditorMoveCopyCommand(); registerEditorGroupsLayoutCommands(); @@ -1413,4 +1487,5 @@ export function setup(): void { registerFocusEditorGroupAtIndexCommands(); registerSplitEditorCommands(); registerFocusEditorGroupWihoutWrapCommands(); + registerModalEditorCommands(); } diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index cb830aecd0e54..56ca88b06d6b0 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -34,7 +34,7 @@ import { IBoundarySashes } from '../../../../base/browser/ui/sash/sash.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { EditorPartMaximizedEditorGroupContext, EditorPartMultipleEditorGroupsContext, IsAuxiliaryWindowContext } from '../../../common/contextkeys.js'; +import { EditorPartMaximizedEditorGroupContext, EditorPartMultipleEditorGroupsContext } from '../../../common/contextkeys.js'; import { mainWindow } from '../../../../base/browser/window.js'; export interface IEditorPartUIState { @@ -153,7 +153,7 @@ export class EditorPart extends Part implements IEditorPart, protected readonly container = $('.content'); readonly scopedInstantiationService: IInstantiationService; - private readonly scopedContextKeyService: IContextKeyService; + protected readonly scopedContextKeyService: IContextKeyService; private centeredLayoutWidget!: CenteredViewLayout; @@ -1036,10 +1036,7 @@ export class EditorPart extends Part implements IEditorPart, return this.container; } - private handleContextKeys(): void { - const isAuxiliaryWindowContext = IsAuxiliaryWindowContext.bindTo(this.scopedContextKeyService); - isAuxiliaryWindowContext.set(this.windowId !== mainWindow.vscodeWindowId); - + protected handleContextKeys(): void { const multipleEditorGroupsContext = EditorPartMultipleEditorGroupsContext.bindTo(this.scopedContextKeyService); const maximizedEditorGroupContext = EditorPartMaximizedEditorGroupContext.bindTo(this.scopedContextKeyService); diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index 863dd8acd6a32..41f2b76b8d7f5 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -49,18 +49,17 @@ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; - height: 32px; - min-height: 32px; + height: 24px; + min-height: 24px; padding: 0 8px; - background-color: var(--vscode-editorGroupHeader-tabsBackground); - border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, transparent); + background-color: var(--vscode-titleBar-activeBackground); + border-bottom: 1px solid var(--vscode-titleBar-border, transparent); } .monaco-modal-editor-block .modal-editor-title { grid-column: 2; font-size: 12px; - font-weight: 500; - color: var(--vscode-foreground); + color: var(--vscode-titleBar-activeForeground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 54542653a9eed..72ca9e19c5904 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -5,13 +5,10 @@ import './media/modalEditorPart.css'; import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; -import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; -import { Action } from '../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { widgetClose } from '../../../../platform/theme/common/iconRegistry.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -22,12 +19,11 @@ import { IEditorGroupView, IEditorPartsView } from './editor.js'; import { EditorPart } from './editorPart.js'; import { GroupDirection, GroupsOrder, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { EditorPartModalContext } from '../../../common/contextkeys.js'; import { Verbosity } from '../../../common/editor.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; -import { KeyCode } from '../../../../base/common/keyCodes.js'; import { localize } from '../../../../nls.js'; export interface ICreateModalEditorPartResult { @@ -74,35 +70,8 @@ export class ModalEditorPart { titleElement.id = titleId; titleElement.textContent = ''; - // Action buttons using ActionBar for proper accessibility + // Action buttons const actionBarContainer = append(headerElement, $('div.modal-editor-action-container')); - const actionBar = disposables.add(new ActionBar(actionBarContainer)); - - // Open as Editor - const openAsEditorAction = disposables.add(new Action( - 'modalEditorPart.openAsEditor', - localize('openAsEditor', "Open as Editor"), - ThemeIcon.asClassName(Codicon.openInProduct), - true, - async () => { - const activeEditor = editorPart.activeGroup.activeEditor; - if (activeEditor) { - await this.editorService.openEditor(activeEditor, { pinned: true, preserveFocus: false }, this.editorPartsView.mainPart.activeGroup.id); - editorPart.close(); - } - } - )); - actionBar.push(openAsEditorAction, { icon: true, label: false }); - - // Close action - const closeAction = disposables.add(new Action( - 'modalEditorPart.close', - localize('close', "Close"), - ThemeIcon.asClassName(widgetClose), - true, - async () => editorPart.close() - )); - actionBar.push(closeAction, { icon: true, label: false, keybinding: localize('escape', "Escape") }); // Create the editor part const editorPart = disposables.add(this.instantiationService.createInstance( @@ -120,6 +89,12 @@ export class ModalEditorPart { [IEditorService, modalEditorService] ))); + // Create toolbar driven by MenuId.ModalEditorTitle + disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.ModalEditorTitle, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + menuOptions: { shouldForwardArgs: true } + })); + // Update title when active editor changes disposables.add(Event.runAndSubscribe(modalEditorService.onDidActiveEditorChange, (() => { const activeEditor = editorPart.activeGroup.activeEditor; @@ -133,14 +108,6 @@ export class ModalEditorPart { } })); - // Handle escape key to close - disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => { - const event = new StandardKeyboardEvent(e); - if (event.keyCode === KeyCode.Escape) { - editorPart.close(); - } - })); - // Handle close event from editor part disposables.add(Event.once(editorPart.onWillClose)(() => { disposables.dispose(); @@ -156,7 +123,7 @@ export class ModalEditorPart { editorPartContainer.style.height = `${height}px`; const borderSize = 2; // Account for 1px border on all sides and modal header height - const headerHeight = 35; + const headerHeight = 24 + 1 /* border bottom */; editorPart.layout(width - borderSize, height - borderSize - headerHeight, 0, 0); })); @@ -206,6 +173,13 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { }); } + protected override handleContextKeys(): void { + const isModalEditorPartContext = EditorPartModalContext.bindTo(this.scopedContextKeyService); + isModalEditorPartContext.set(true); + + super.handleContextKeys(); + } + override removeGroup(group: number | IEditorGroupView, preserveFocus?: boolean): void { // Close modal when last group removed @@ -234,24 +208,26 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { } } - this.doClose(false /* do not merge any confirming editors to main part */); + this.doClose({ mergeConfirmingEditorsToMainPart: false }); } protected override saveState(): void { return; // disabled, modal editor part state is not persisted } - close(): boolean { - return this.doClose(true /* merge all confirming editors to main part */); + close(options?: { mergeAllEditorsToMainPart?: boolean }): boolean { + return this.doClose({ ...options, mergeConfirmingEditorsToMainPart: true }); } - private doClose(mergeConfirmingEditorsToMainPart: boolean): boolean { + private doClose(options?: { mergeAllEditorsToMainPart?: boolean; mergeConfirmingEditorsToMainPart?: boolean }): boolean { let result = true; - if (mergeConfirmingEditorsToMainPart) { + if (options?.mergeConfirmingEditorsToMainPart) { - // First close all editors that are non-confirming - for (const group of this.groups) { - group.closeAllEditors({ excludeConfirming: true }); + // First close all editors that are non-confirming (unless we merge all) + if (!options.mergeAllEditorsToMainPart) { + for (const group of this.groups) { + group.closeAllEditors({ excludeConfirming: true }); + } } // Then merge remaining to main part diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index 3a27ea16cac68..9d00d6827309c 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -95,6 +95,7 @@ export const SelectedEditorsInGroupFileOrUntitledResourceContextKey = new RawCon export const EditorPartMultipleEditorGroupsContext = new RawContextKey('editorPartMultipleEditorGroups', false, localize('editorPartMultipleEditorGroups', "Whether there are multiple editor groups opened in an editor part")); export const EditorPartSingleEditorGroupsContext = EditorPartMultipleEditorGroupsContext.toNegated(); export const EditorPartMaximizedEditorGroupContext = new RawContextKey('editorPartMaximizedEditorGroup', false, localize('editorPartEditorGroupMaximized', "Editor Part has a maximized group")); +export const EditorPartModalContext = new RawContextKey('editorPartModal', false, localize('editorPartModal', "Whether focus is in a modal editor part")); // Editor Layout Context Keys export const EditorsVisibleContext = new RawContextKey('editorIsOpen', false, localize('editorIsOpen', "Whether an editor is open")); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 068e2f34d3750..23c4f8c742c22 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -65,7 +65,8 @@ export const enum AccessibilityVerbositySettingId { DiffEditorActive = 'accessibility.verbosity.diffEditorActive', Debug = 'accessibility.verbosity.debug', Walkthrough = 'accessibility.verbosity.walkthrough', - SourceControl = 'accessibility.verbosity.sourceControl' + SourceControl = 'accessibility.verbosity.sourceControl', + Find = 'accessibility.verbosity.find' } const baseVerbosityProperty: IConfigurationPropertySchema = { @@ -199,6 +200,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.scm', 'Provide information about how to access the source control accessibility help menu when the input is focused.'), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.Find]: { + description: localize('verbosity.find', 'Provide information about how to access the find accessibility help menu when the find input is focused.'), + ...baseVerbosityProperty + }, 'accessibility.signalOptions.volume': { 'description': localize('accessibility.signalOptions.volume', "The volume of the sounds in percent (0-100)."), 'type': 'number', diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f57c122ce4b8e..2e05c9c728943 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -593,6 +593,7 @@ configurationRegistry.registerConfiguration({ type: 'boolean', description: nls.localize('chat.agent.enabled.description', "When enabled, agent mode can be activated from chat and tools in agentic contexts with side effects can be used."), default: true, + order: 1, policy: { name: 'ChatAgentMode', category: PolicyCategory.InteractiveSession, @@ -1191,6 +1192,7 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr type: 'number', markdownDescription: nls.localize('chat.agent.maxRequests', "The maximum number of requests to allow per-turn when using an agent. When the limit is reached, will ask to confirm to continue."), default: defaultValue, + order: 2, }, } }; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts index 1afa3ecf9a2ba..442e73d33f4a2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts @@ -11,6 +11,7 @@ import { DetailedLineRangeMapping } from '../../../../../editor/common/diff/rang import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { localize, localize2 } from '../../../../../nls.js'; +import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../../platform/accessibility/common/accessibility.js'; import { Action2, IAction2Options, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -348,7 +349,7 @@ class ToggleAccessibleDiffViewAction extends ChatEditingEditorAction { f1: true, precondition: ContextKeyExpr.and(ctxHasEditorModification, ctxIsCurrentlyBeingModified.negate()), keybinding: { - when: EditorContextKeys.focus, + when: ContextKeyExpr.and(EditorContextKeys.focus, CONTEXT_ACCESSIBILITY_MODE_ENABLED), weight: KeybindingWeight.WorkbenchContrib, primary: KeyCode.F7, } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 4bf4872c009f9..d5e8cb4410267 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -5,8 +5,6 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; -import { getBaseLayerHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegate2.js'; -import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -21,6 +19,8 @@ import { IChatContentPart, IChatContentPartRenderContext } from './chatContentPa import { IChatRendererContent, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatTreeItem } from '../../chat.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidget.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import './media/chatQuestionCarousel.css'; export interface IChatQuestionCarouselOptions { @@ -44,6 +44,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _navigationButtons: HTMLElement | undefined; private _prevButton: Button | undefined; private _nextButton: Button | undefined; + private readonly _nextButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable()); private _skipAllButton: Button | undefined; private _isSkipped = false; @@ -61,9 +62,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private readonly _interactiveUIStore: MutableDisposable = this._register(new MutableDisposable()); constructor( - private readonly carousel: IChatQuestionCarousel, + public readonly carousel: IChatQuestionCarousel, context: IChatContentPartRenderContext, private readonly _options: IChatQuestionCarouselOptions, + @IHoverService private readonly _hoverService: IHoverService, ) { super(); @@ -98,10 +100,11 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (carousel.allowSkip) { this._closeButtonContainer = dom.$('.chat-question-close-container'); const skipAllTitle = localize('chat.questionCarousel.skipAllTitle', 'Skip all questions'); - const skipAllButton = interactiveStore.add(new Button(this._closeButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: skipAllTitle })); + const skipAllButton = interactiveStore.add(new Button(this._closeButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); skipAllButton.label = `$(${Codicon.close.id})`; skipAllButton.element.classList.add('chat-question-nav-arrow', 'chat-question-close'); skipAllButton.element.setAttribute('aria-label', skipAllTitle); + interactiveStore.add(this._hoverService.setupDelayedHover(skipAllButton.element, { content: skipAllTitle })); this._skipAllButton = skipAllButton; } @@ -121,14 +124,14 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const arrowsContainer = dom.$('.chat-question-nav-arrows'); const previousLabel = localize('previous', 'Previous'); - const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: previousLabel })); + const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); prevButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-prev'); prevButton.label = `$(${Codicon.chevronLeft.id})`; prevButton.element.setAttribute('aria-label', previousLabel); + interactiveStore.add(this._hoverService.setupDelayedHover(prevButton.element, { content: previousLabel })); this._prevButton = prevButton; - const nextLabel = localize('next', 'Next'); - const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: nextLabel })); + const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); nextButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-next'); nextButton.label = `$(${Codicon.chevronRight.id})`; this._nextButton = nextButton; @@ -430,16 +433,16 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const nextLabel = localize('next', 'Next'); if (isLastQuestion) { this._nextButton!.label = submitLabel; - this._nextButton!.element.title = submitLabel; this._nextButton!.element.setAttribute('aria-label', submitLabel); // Switch to primary style for submit this._nextButton!.element.classList.add('chat-question-nav-submit'); + this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: submitLabel }); } else { this._nextButton!.label = `$(${Codicon.chevronRight.id})`; - this._nextButton!.element.title = nextLabel; this._nextButton!.element.setAttribute('aria-label', nextLabel); // Keep secondary style for next this._nextButton!.element.classList.remove('chat-question-nav-submit'); + this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: nextLabel }); } this._onDidChangeHeight.fire(); @@ -520,7 +523,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent options.forEach((option, index) => { if (previousSelectedValue !== undefined && option.value === previousSelectedValue) { selectedIndex = index; - } else if (selectedIndex === -1 && defaultOptionId !== undefined && option.id === defaultOptionId) { + } else if (selectedIndex === -1 && !previousFreeform && defaultOptionId !== undefined && option.id === defaultOptionId) { selectedIndex = index; } }); @@ -589,13 +592,22 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent listItem.classList.add('selected'); } - this._inputBoxes.add(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), listItem, option.label)); - - // Click handler + // if we select an option, clear text and go to next question this._inputBoxes.add(dom.addDisposableListener(listItem, dom.EventType.CLICK, (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); updateSelection(index); + const freeform = this._freeformTextareas.get(question.id); + if (freeform) { + freeform.value = ''; + } + this.handleNext(); + })); + + this._inputBoxes.add(this._hoverService.setupDelayedHover(listItem, { + content: option.label, + position: { hoverPosition: HoverPosition.BELOW }, + appearance: { showPointer: true } })); selectContainer.appendChild(listItem); @@ -683,16 +695,22 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); } - // focus on the row when first rendered - if (this._options.shouldAutoFocus !== false && listItems.length > 0) { - const focusIndex = selectedIndex >= 0 ? selectedIndex : 0; - // if no default, select the first answer - if (selectedIndex < 0) { - updateSelection(0); + // focus on the row when first rendered or textarea if it has content + if (this._options.shouldAutoFocus !== false) { + if (previousFreeform) { + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => { + freeformTextarea.focus(); + })); + } else if (listItems.length > 0) { + const focusIndex = selectedIndex >= 0 ? selectedIndex : 0; + // if no default and no freeform text, select the first answer + if (selectedIndex < 0) { + updateSelection(0); + } + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => { + listItems[focusIndex]?.focus(); + })); } - this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => { - listItems[focusIndex]?.focus(); - })); } } @@ -729,7 +747,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent let isChecked = false; if (previousSelectedValues && previousSelectedValues.length > 0) { isChecked = previousSelectedValues.includes(option.value); - } else if (defaultOptionIds.includes(option.id)) { + } else if (!previousFreeform && defaultOptionIds.includes(option.id)) { isChecked = true; } @@ -791,7 +809,11 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } })); - this._inputBoxes.add(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), listItem, option.label)); + this._inputBoxes.add(this._hoverService.setupDelayedHover(listItem, { + content: option.label, + position: { hoverPosition: HoverPosition.BELOW }, + appearance: { showPointer: true } + })); selectContainer.appendChild(listItem); checkboxes.push(checkbox); @@ -868,13 +890,19 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); } - // Focus on the appropriate row when rendered (first checked row, or first row if none) - if (this._options.shouldAutoFocus !== false && listItems.length > 0) { - const initialFocusIndex = firstCheckedIndex >= 0 ? firstCheckedIndex : 0; - focusedIndex = initialFocusIndex; - this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => { - listItems[initialFocusIndex]?.focus(); - })); + // Focus on the appropriate row when rendered or textarea if it has content + if (this._options.shouldAutoFocus !== false) { + if (previousFreeform) { + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => { + freeformTextarea.focus(); + })); + } else if (listItems.length > 0) { + const initialFocusIndex = firstCheckedIndex >= 0 ? firstCheckedIndex : 0; + focusedIndex = initialFocusIndex; + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => { + listItems[initialFocusIndex]?.focus(); + })); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 8ba0e1da64613..98c05a0bc8cfa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -62,10 +62,14 @@ .chat-question-header-row { display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; gap: 8px; min-width: 0; - padding-bottom: 12px; + padding-bottom: 5px; + margin-left: -16px; + margin-right: -16px; + padding-left: 16px; + padding-right: 16px; border-bottom: 1px solid var(--vscode-chat-requestBorder); } @@ -100,7 +104,7 @@ display: flex; justify-content: space-between; align-items: center; - padding: 8px 16px; + padding: 4px 16px; border-top: 1px solid var(--vscode-chat-requestBorder); background: var(--vscode-chat-requestBackground); } @@ -172,7 +176,7 @@ display: flex; flex-direction: column; background: var(--vscode-chat-requestBackground); - padding: 12px 16px; + padding: 8px 16px 10px 16px; overflow: hidden; } @@ -214,7 +218,7 @@ .chat-question-list { display: flex; flex-direction: column; - gap: 0; + gap: 3px; outline: none; padding: 4px 0; } @@ -228,7 +232,7 @@ display: flex; align-items: center; gap: 8px; - padding: 6px 8px; + padding: 3px 8px; cursor: pointer; border-radius: 3px; user-select: none; @@ -238,7 +242,7 @@ background-color: var(--vscode-list-hoverBackground); } -.interactive-session .interactive-response .value { +.interactive-input-part .chat-question-carousel-widget-container .chat-question-input-container { .chat-question-list-item:focus:not(.selected), .chat-question-list:focus { outline: none; @@ -270,7 +274,7 @@ align-items: center; justify-content: center; min-width: 14px; - padding: 2px 4px; + padding: 0px 4px; border-style: solid; border-width: 1px; border-radius: 3px; @@ -285,7 +289,6 @@ } .chat-question-freeform-number { - margin-top: 4px; height: fit-content; } @@ -325,16 +328,6 @@ margin-right: 0; } -.chat-question-list-checkbox.monaco-custom-toggle.checked { - background-color: var(--vscode-button-background) !important; - border-color: var(--vscode-button-background) !important; - color: var(--vscode-button-foreground) !important; - align-content: center; -} - -.chat-question-list-checkbox.monaco-custom-toggle.checked .codicon { - color: var(--vscode-button-foreground) !important; -} /* Label in list item */ .chat-question-list-label { @@ -457,7 +450,7 @@ margin-left: 8px; display: flex; flex-direction: row; - align-items: flex-start; + align-items: center; gap: 8px; } @@ -468,9 +461,9 @@ .chat-question-freeform-textarea { width: 100%; - min-height: 32px; + min-height: 24px; max-height: 200px; - padding: 6px 8px; + padding: 3px 8px; border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index d442181f2235e..1b15146851d92 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1967,46 +1967,101 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - // Mark the carousel as used and store the answers - const answersRecord = answers ? Object.fromEntries(answers) : undefined; - if (answersRecord) { - carousel.data = answersRecord; - } - carousel.isUsed = true; + const handleSubmit = async (answers: Map | undefined, part: ChatQuestionCarouselPart) => { + // Mark the carousel as used and store the answers + const answersRecord = answers ? Object.fromEntries(answers) : undefined; + if (answersRecord) { + carousel.data = answersRecord; + } + carousel.isUsed = true; - // Notify the extension about the carousel answers to resolve the deferred promise - if (isResponseVM(context.element) && carousel.resolveId) { - this.chatService.notifyQuestionCarouselAnswer(context.element.requestId, carousel.resolveId, answersRecord); - } + // Notify the extension about the carousel answers to resolve the deferred promise + if (isResponseVM(context.element) && carousel.resolveId) { + this.chatService.notifyQuestionCarouselAnswer(context.element.requestId, carousel.resolveId, answersRecord); + } + + // Remove from pending carousels + this.removeCarouselFromTracking(context, part); - // Remove from pending carousels - this.removeCarouselFromTracking(context, part); + // Clear from input part (always clear on submit, no response check needed) + widget?.input.clearQuestionCarousel(); + }; + + // If carousel is already used or response is complete/canceled, render summary inline in the list + const responseIsComplete = isResponseVM(context.element) && context.element.isComplete; + const inputPartHasCarousel = widget?.input.questionCarousel !== undefined; + + if (carousel.isUsed || responseIsComplete) { + // Clear the carousel from input part when response completes (stopped/canceled) + // Only clear if this response's carousel is currently displayed (pass responseId) + if (responseIsComplete && inputPartHasCarousel && responseId) { + widget?.input.clearQuestionCarousel(responseId); } + + const part = this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, { + shouldAutoFocus: false, + onSubmit: async (answers) => handleSubmit(answers, part) + }); + return part; + } + + // Render the active carousel in the input part (above the input box) + const part = widget?.input.renderQuestionCarousel(carousel, context, { + shouldAutoFocus, + onSubmit: async (answers) => handleSubmit(answers, part!) }); + // If we couldn't render in the input part, fall back to inline rendering + if (!part) { + const fallbackPart = this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, { + shouldAutoFocus, + onSubmit: async (answers) => handleSubmit(answers, fallbackPart) + }); + return fallbackPart; + } + // If global auto-approve (yolo mode) is enabled, skip with defaults immediately if (!carousel.isUsed && this.configService.getValue(ChatConfiguration.GlobalAutoApprove)) { part.skip(); } // Track the carousel for auto-skip when user submits a new message + // Only add tracking if not already tracked (prevents duplicate tracking on re-render) if (isResponseVM(context.element) && carousel.allowSkip && !carousel.isUsed) { let carousels = this.pendingQuestionCarousels.get(context.element.sessionResource); if (!carousels) { carousels = new Set(); this.pendingQuestionCarousels.set(context.element.sessionResource, carousels); } - carousels.add(part); + if (!carousels.has(part)) { + carousels.add(part); - // Clean up when the part is disposed - part.addDisposable({ dispose: () => this.removeCarouselFromTracking(context, part) }); + // Clean up when the part is disposed + part.addDisposable({ dispose: () => this.removeCarouselFromTracking(context, part) }); + } } - return part; + // Return a placeholder that will re-render as a summary when the carousel is used or response is complete/stopped + return this.renderNoContent((other, _followingContent, element) => { + // Re-render (return false) if: + // - carousel was used/submitted + // - response is complete (stopped) + if (carousel.isUsed || (isResponseVM(element) && element.isComplete)) { + return false; + } + // Use resolveId for comparison instead of object identity to handle re-rendering during scrolling + if (other.kind === 'questionCarousel') { + const otherCarousel = other as IChatQuestionCarousel; + // Compare by resolveId if available, otherwise fall back to object identity + if (carousel.resolveId && otherCarousel.resolveId) { + return carousel.resolveId === otherCarousel.resolveId; + } + return other === carousel; + } + return false; + }); } private _notifyOnQuestionCarousel(context: IChatContentPartRenderContext, carousel: IChatQuestionCarousel): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 137ade79af9fe..dd00cc4c58988 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -85,14 +85,14 @@ import { IChatViewTitleActionContext } from '../../../common/actions/chatActions import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; -import { IChatFollowup, IChatService, IChatSessionContext } from '../../../common/chatService/chatService.js'; +import { IChatFollowup, IChatQuestionCarousel, IChatService, IChatSessionContext } from '../../../common/chatService/chatService.js'; import { agentOptionId, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, validateChatMode } from '../../../common/constants.js'; import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; -import { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; +import { IChatResponseViewModel, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; @@ -110,6 +110,8 @@ import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../c import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; import { IChatContextService } from '../../contextContrib/chatContextService.js'; import { IDisposableReference } from '../chatContentParts/chatCollections.js'; +import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../chatContentParts/chatQuestionCarouselPart.js'; +import { IChatContentPartRenderContext } from '../chatContentParts/chatContentParts.js'; import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; import { ChatTodoListWidget } from '../chatContentParts/chatTodoListWidget.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; @@ -203,6 +205,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _workingSetCollapsed = observableValue('chatInputPart.workingSetCollapsed', true); private readonly _chatInputTodoListWidget = this._register(new MutableDisposable()); + private readonly _chatQuestionCarouselWidget = this._register(new MutableDisposable()); + private readonly _chatQuestionCarouselDisposables = this._register(new DisposableStore()); + private _currentQuestionCarouselResponseId: string | undefined; private readonly _chatEditingTodosDisposables = this._register(new DisposableStore()); private _lastEditingSessionResource: URI | undefined; @@ -283,6 +288,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatEditingSessionWidgetContainer!: HTMLElement; private chatInputTodoListWidgetContainer!: HTMLElement; + private chatQuestionCarouselContainer!: HTMLElement; private chatInputWidgetsContainer!: HTMLElement; private readonly _widgetController = this._register(new MutableDisposable()); @@ -1733,12 +1739,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.refreshChatSessionPickers(); this.tryUpdateWidgetController(); this.updateContextUsageWidget(); + this.clearQuestionCarousel(); })); let elements; if (this.options.renderStyle === 'compact') { elements = dom.h('.interactive-input-part', [ dom.h('.interactive-input-and-edit-session', [ + dom.h('.chat-question-carousel-widget-container@chatQuestionCarouselContainer'), dom.h('.chat-input-widgets-container@chatInputWidgetsContainer'), dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'), dom.h('.chat-editing-session@chatEditingSessionWidgetContainer'), @@ -1758,6 +1766,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ]); } else { elements = dom.h('.interactive-input-part', [ + dom.h('.chat-question-carousel-widget-container@chatQuestionCarouselContainer'), dom.h('.interactive-input-followups@followupsContainer'), dom.h('.chat-input-widgets-container@chatInputWidgetsContainer'), dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'), @@ -1795,6 +1804,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const attachmentToolbarContainer = elements.attachmentToolbar; this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer; this.chatInputTodoListWidgetContainer = elements.chatInputTodoListWidgetContainer; + this.chatQuestionCarouselContainer = elements.chatQuestionCarouselContainer; this.chatInputWidgetsContainer = elements.chatInputWidgetsContainer; this.contextUsageWidgetContainer = elements.contextUsageWidgetContainer; @@ -2400,6 +2410,48 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._chatInputTodoListWidget.value?.clear(sessionResource, force); } + renderQuestionCarousel(carousel: IChatQuestionCarousel, context: IChatContentPartRenderContext, options: IChatQuestionCarouselOptions): ChatQuestionCarouselPart { + + if (this._chatQuestionCarouselWidget.value) { + const existingCarousel = this._chatQuestionCarouselWidget.value; + const existingResolveId = existingCarousel.carousel.resolveId; + if (existingResolveId && carousel.resolveId && existingResolveId === carousel.resolveId) { + return existingCarousel; + } + this.clearQuestionCarousel(); + } + + // track the response id and session + this._currentQuestionCarouselResponseId = isResponseVM(context.element) ? context.element.requestId : undefined; + + const part = this._chatQuestionCarouselDisposables.add( + this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, options) + ); + this._chatQuestionCarouselWidget.value = part; + + dom.clearNode(this.chatQuestionCarouselContainer); + dom.append(this.chatQuestionCarouselContainer, part.domNode); + + return part; + } + + clearQuestionCarousel(responseId?: string): void { + if (responseId && this._currentQuestionCarouselResponseId !== responseId) { + return; + } + this._chatQuestionCarouselDisposables.clear(); + this._chatQuestionCarouselWidget.clear(); + this._currentQuestionCarouselResponseId = undefined; + dom.clearNode(this.chatQuestionCarouselContainer); + } + get questionCarouselResponseId(): string | undefined { + return this._currentQuestionCarouselResponseId; + } + + get questionCarousel(): ChatQuestionCarouselPart | undefined { + return this._chatQuestionCarouselWidget.value; + } + setWorkingSetCollapsed(collapsed: boolean): void { this._workingSetCollapsed.set(collapsed, undefined); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index d94ccd6dbacf7..b8f69ea3084f1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1053,6 +1053,23 @@ have to be updated for changes to the rules above, or to support more deeply nes position: relative; } +/* question carousel - this is above edits and todos */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container { + width: 100%; + position: relative; +} + +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty { + display: none; +} + +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container { + margin: 0px; + border: 1px solid var(--vscode-input-border, transparent); + background-color: var(--vscode-editor-background); + border-radius: 4px; +} + /* Chat Todo List Widget Container - mirrors chat-editing-session styling */ .interactive-session .interactive-input-part > .chat-todo-list-widget-container { margin-bottom: -4px; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 056abf3a60f02..a1dc066456011 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1354,7 +1354,7 @@ interface ISerializableChatResponseData { timeSpentWaiting?: number; } -export type SerializedChatResponsePart = IMarkdownString | IChatResponseProgressFileTreeData | IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatThinkingPart | IChatProgressResponseContentSerialized; +export type SerializedChatResponsePart = IMarkdownString | IChatResponseProgressFileTreeData | IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatThinkingPart | IChatProgressResponseContentSerialized | IChatQuestionCarousel; export interface ISerializableChatRequestData extends ISerializableChatResponseData { requestId: string; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 1850b900f6318..4cffb9f84e7df 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -253,7 +253,7 @@ suite('ChatQuestionCarouselPart', () => { // Use dedicated class selector for stability const nextButton = widget.domNode.querySelector('.chat-question-nav-next') as HTMLElement; assert.ok(nextButton, 'Next button should exist'); - assert.strictEqual(nextButton.title, 'Submit', 'Next button should have Submit title on last question'); + assert.strictEqual(nextButton.getAttribute('aria-label'), 'Submit', 'Next button should have Submit aria-label on last question'); }); }); diff --git a/src/vs/workbench/contrib/codeEditor/browser/codeEditor.contribution.ts b/src/vs/workbench/contrib/codeEditor/browser/codeEditor.contribution.ts index 4e42d6cd7c45e..4933cebe4df75 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/codeEditor.contribution.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/codeEditor.contribution.ts @@ -7,6 +7,7 @@ import './menuPreventer.js'; import './accessibility/accessibility.js'; import './diffEditorHelper.js'; import './editorFeatures.js'; +import './editorFindAccessibilityHelp.js'; import './editorSettingsMigration.js'; import './inspectKeybindings.js'; import './largeFileOptimizations.js'; diff --git a/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts b/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts new file mode 100644 index 0000000000000..32b0152643dbf --- /dev/null +++ b/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isMacintosh } from '../../../../base/common/platform.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { CommonFindController } from '../../../../editor/contrib/find/browser/findController.js'; +import { CONTEXT_FIND_WIDGET_FOCUSED } from '../../../../editor/contrib/find/browser/findModel.js'; +import { localize } from '../../../../nls.js'; +import { AccessibleViewProviderId, AccessibleViewType, IAccessibleViewContentProvider, IAccessibleViewOptions } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { AccessibleViewRegistry, IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; + +/** + * Accessible view implementation for Find and Replace help in the code editor. + * Provides comprehensive accessibility support for the Find dialog, including: + * - Search status information (current term, match count, position) + * - Navigation instructions for keyboard control + * - Focus behavior explanation + * - Available settings and options + * - Platform-specific guidance + * + * Activated via Alt+F1 when any element in the Find widget is focused. + */ +export class EditorFindAccessibilityHelp implements IAccessibleViewImplementation { + readonly priority = 105; + readonly name = 'editor-find'; + readonly when = CONTEXT_FIND_WIDGET_FOCUSED; + readonly type = AccessibleViewType.Help; + + /** + * Creates an accessible view content provider for the active code editor's Find/Replace dialog. + * @param accessor Service accessor for retrieving the code editor service + * @returns The provider instance, or undefined if no active editor or find controller is found + */ + getProvider(accessor: ServicesAccessor) { + const codeEditorService = accessor.get(ICodeEditorService); + const codeEditor = codeEditorService.getActiveCodeEditor() || codeEditorService.getFocusedCodeEditor(); + + if (!codeEditor) { + return; + } + + const findController = CommonFindController.get(codeEditor); + if (!findController) { + return; + } + + return new EditorFindAccessibilityHelpProvider(findController); + } +} + +/** + * Content provider for the Find and Replace accessibility help. + * Generates localized, context-aware help information based on the current Find state. + * + * The implementation: + * - Adapts content based on whether Replace mode is active + * - Provides current search status (term, match count, position) + * - Explains focus behavior (how focus moves between Find input, Replace input, and editor) + * - Lists keyboard navigation shortcuts for different contexts + * - Documents available Find and Replace options + * - References relevant settings that affect Find behavior + * - Includes platform-specific guidance where applicable + */ +class EditorFindAccessibilityHelpProvider extends Disposable implements IAccessibleViewContentProvider { + readonly id = AccessibleViewProviderId.EditorFindHelp; + readonly verbositySettingKey = AccessibilityVerbositySettingId.Find; + readonly options: IAccessibleViewOptions = { type: AccessibleViewType.Help }; + + constructor( + private readonly _findController: CommonFindController + ) { + super(); + } + + /** + * Returns focus to the last focused element in the Find widget when the accessibility help is closed. + * This handles focus restoration for any element (inputs, checkboxes, buttons) not just the text inputs. + */ + onClose(): void { + this._findController.focusLastElement(); + } + + /** + * Generates the complete accessibility help content for Find and Replace. + * The content structure varies based on whether Replace mode is visible: + * + * Replace Mode Content: + * - Header identifying the dialog + * - Context explaining what the dialog does + * - Current search and replace status + * - Focus behavior explanation + * - Keyboard shortcuts for Find, Replace, and Editor contexts + * - Find and Replace options explanation + * - Configurable settings documentation + * - Platform-specific settings (macOS) + * + * Find-Only Mode Content: + * - Similar structure but without Replace-specific sections + * + * @returns The complete help text as a newline-joined string for audio announcement + */ + provideContent(): string { + const state = this._findController.getState(); + const isReplaceVisible = state.isReplaceRevealed; + const searchString = state.searchString; + const matchCount = state.matchesCount; + const matchPosition = state.matchesPosition; + + const content: string[] = []; + + if (isReplaceVisible) { + // ========== REPLACE MODE CONTENT ========== + content.push(localize('replace.header', "Accessibility Help: Editor Find and Replace")); + content.push(localize('replace.context', "You are in the Find and Replace dialog for the active editor. This dialog lets you locate text and replace it. The editor is a separate surface that shows each match and the surrounding context.")); + content.push(''); + + // Current Search Status + content.push(localize('replace.statusHeader', "Current Search Status:")); + if (searchString) { + content.push(localize('replace.searchTerm', "You are searching for: \"{0}\".", searchString)); + if (matchCount !== undefined && matchPosition !== undefined) { + if (matchCount === 0) { + content.push(localize('replace.noMatches', "No matches found in the current file. Try adjusting your search text or the options below.")); + } else { + content.push(localize('replace.matchStatus', "Match {0} of {1}.", matchPosition, matchCount)); + } + } + } else { + content.push(localize('replace.noSearchTerm', "No search text entered yet. Start typing to find matches in the editor.")); + } + + const replaceString = state.replaceString; + if (replaceString) { + content.push(localize('replace.replaceText', "Replacement text: \"{0}\".", replaceString)); + } else { + content.push(localize('replace.noReplaceText', "No replacement text entered yet. Press Tab to move to the Replace input and type your replacement.")); + } + content.push(''); + + // Inside the Find and Replace Dialog + content.push(localize('replace.dialogHeader', "Inside the Find and Replace Dialog (What It Does):")); + content.push(localize('replace.dialogDesc', "While you are in either input, your focus stays in that input. You can type, edit, or navigate matches without leaving. When you navigate to a match from the Find input, the editor updates in the background, but your focus remains in the dialog. Tab moves you from Find to Replace and back.")); + content.push(''); + + // What You Hear + content.push(localize('replace.hearHeader', "What You Hear Each Time You Move to a Match:")); + content.push(localize('replace.hearDesc', "Each navigation step gives you a complete spoken update:")); + content.push(localize('replace.hear1', "1) The full line that contains the match is read first, so you get immediate context.")); + content.push(localize('replace.hear2', "2) Your position among the matches is announced, so you know how far you are through the results.")); + content.push(localize('replace.hear3', "3) The exact line and column are announced, so you know precisely where the match is in the file.")); + content.push(''); + + // Focus Behavior + content.push(localize('replace.focusHeader', "Focus Behavior (Important):")); + content.push(localize('replace.focusDesc1', "When you navigate from inside the Find dialog, the editor updates while your focus stays in the input. This is intentional, so you can keep adjusting your search without losing your place.")); + content.push(localize('replace.focusDesc2', "When you replace from the Replace input, the match is replaced and focus moves to the next match. If you have replaced all matches, the dialog remains open.")); + content.push(localize('replace.focusDesc3', "If you want to move focus into the editor to edit text, press Escape to close the dialog. Focus returns to the editor at the last replacement location.")); + content.push(''); + + // Keyboard Navigation Summary + content.push(localize('replace.keyboardHeader', "Keyboard Navigation Summary:")); + content.push(''); + content.push(localize('replace.keyNavFindHeader', "While focused IN the Find input:")); + content.push(localize('replace.keyEnter', "- Enter: Move to the next match while staying in Find.")); + content.push(localize('replace.keyShiftEnter', "- Shift+Enter: Move to the previous match while staying in Find.")); + content.push(localize('replace.keyTab', "- Tab: Move between Find and Replace inputs.")); + content.push(''); + content.push(localize('replace.keyNavReplaceHeader', "While focused IN the Replace input:")); + content.push(localize('replace.keyReplaceEnter', "- Enter: Replace the current match and move to the next.")); + content.push(localize('replace.keyReplaceOne', "- {0}: Replace only the current match.", '')); + content.push(localize('replace.keyReplaceAll', "- {0}: Replace all matches at once.", '')); + content.push(''); + content.push(localize('replace.keyNavEditorHeader', "While focused IN the editor (not the Find input):")); + content.push(localize('replace.keyF3', "- {0}: Move to the next match.", '')); + content.push(localize('replace.keyShiftF3', "- {0}: Move to the previous match.", '')); + content.push(''); + content.push(localize('replace.keyNavNote', "Note: Don't press Enter or Shift+Enter when focused in the editor - these will insert line breaks instead of navigating.")); + content.push(''); + + // Find and Replace Options + content.push(localize('replace.optionsHeader', "Find and Replace Options in the Dialog:")); + content.push(localize('replace.optionCase', "- Match Case: Only exact case matches are included.")); + content.push(localize('replace.optionWord', "- Whole Word: Only full words are matched.")); + content.push(localize('replace.optionRegex', "- Regular Expression: Use pattern matching for advanced searches.")); + content.push(localize('replace.optionSelection', "- Find in Selection: Limit matches to the current selection.")); + content.push(localize('replace.optionPreserve', "- Preserve Case: When replacing, maintains the case of the original match.")); + content.push(''); + + // Settings + content.push(localize('replace.settingsHeader', "Settings You Can Adjust ({0} opens Settings):", '')); + content.push(localize('replace.settingsIntro', "These settings affect how Find and Replace behave or how matches are highlighted.")); + content.push(localize('replace.settingVerbosity', "- `accessibility.verbosity.find`: Controls whether the Find and Replace inputs announce the Accessibility Help hint.")); + content.push(localize('replace.settingFindOnType', "- `editor.find.findOnType`: Runs Find as you type.")); + content.push(localize('replace.settingCursorMove', "- `editor.find.cursorMoveOnType`: Moves the cursor to the best match while you type.")); + content.push(localize('replace.settingSeed', "- `editor.find.seedSearchStringFromSelection`: Controls when selection text is used to seed Find.")); + content.push(localize('replace.settingAutoSelection', "- `editor.find.autoFindInSelection`: Automatically enables Find in Selection based on selection type.")); + content.push(localize('replace.settingLoop', "- `editor.find.loop`: Wraps search at the beginning or end of the file.")); + content.push(localize('replace.settingExtraSpace', "- `editor.find.addExtraSpaceOnTop`: Adds extra scroll space so matches are not hidden behind the Find and Replace dialog.")); + content.push(localize('replace.settingFindHistory', "- `editor.find.history`: Controls whether Find search history is stored.")); + content.push(localize('replace.settingReplaceHistory', "- `editor.find.replaceHistory`: Controls whether Replace history is stored.")); + content.push(localize('replace.settingOccurrences', "- `editor.occurrencesHighlight`: Highlights other occurrences of the current symbol.")); + content.push(localize('replace.settingOccurrencesDelay', "- `editor.occurrencesHighlightDelay`: Controls how soon occurrences are highlighted.")); + content.push(localize('replace.settingSelectionHighlight', "- `editor.selectionHighlight`: Highlights other matches of the current selection.")); + content.push(localize('replace.settingSelectionMaxLength', "- `editor.selectionHighlightMaxLength`: Limits selection highlight length.")); + content.push(localize('replace.settingSelectionMultiline', "- `editor.selectionHighlightMultiline`: Controls whether multi-line selections are highlighted.")); + + // Platform-specific setting + if (isMacintosh) { + content.push(''); + content.push(localize('replace.macSettingHeader', "Platform-Specific Setting (macOS only):")); + content.push(localize('replace.macSetting', "- `editor.find.globalFindClipboard`: Uses the shared macOS Find clipboard when available.")); + } + + content.push(''); + content.push(localize('replace.closingHeader', "Closing:")); + content.push(localize('replace.closingDesc', "Press Escape to close Find and Replace. Focus returns to the editor at the last replacement location, and your search and replace history is preserved.")); + } else { + // ========== FIND-ONLY MODE CONTENT ========== + content.push(localize('find.header', "Accessibility Help: Editor Find")); + content.push(localize('find.context', "You are in the Find dialog for the active editor. This dialog is where you type what you want to locate. The editor is a separate surface that shows each match and its surrounding context.")); + content.push(''); + + // Current Search Status + content.push(localize('find.statusHeader', "Current Search Status:")); + if (searchString) { + content.push(localize('find.searchTerm', "You are searching for: \"{0}\".", searchString)); + if (matchCount !== undefined && matchPosition !== undefined) { + if (matchCount === 0) { + content.push(localize('find.noMatches', "No matches found in the current file. Try adjusting your search text or the options below.")); + } else { + content.push(localize('find.matchStatus', "Match {0} of {1}.", matchPosition, matchCount)); + } + } + } else { + content.push(localize('find.noSearchTerm', "No search text entered yet. Start typing to find matches in the editor.")); + } + content.push(''); + + // Inside the Find Dialog + content.push(localize('find.dialogHeader', "Inside the Find Dialog (What It Does):")); + content.push(localize('find.dialogDesc', "While you are in the Find dialog, your focus stays in the input. You can keep typing, edit your search text, or move through matches without leaving the dialog. When you navigate to a match from here, the editor updates in the background, but your focus remains in the Find dialog.")); + content.push(''); + + // What You Hear + content.push(localize('find.hearHeader', "What You Hear Each Time You Move to a Match:")); + content.push(localize('find.hearDesc', "Each navigation step gives you a complete spoken update so you always know where you are. The order is consistent:")); + content.push(localize('find.hear1', "1) The full line that contains the match is read first, so you get immediate context.")); + content.push(localize('find.hear2', "2) Your position among the matches is announced, so you know how far you are through the results.")); + content.push(localize('find.hear3', "3) The exact line and column are announced, so you know precisely where the match is in the file.")); + content.push(localize('find.hearConclusion', "This sequence happens every time you move forward or backward.")); + content.push(''); + + // Outside the Find Dialog + content.push(localize('find.outsideHeader', "Outside the Find Dialog (Inside the Editor):")); + content.push(localize('find.outsideDesc', "When you are focused in the editor instead of the Find dialog, you can still navigate matches.")); + content.push(localize('find.outsideF3', "- Press {0} to move to the next match.", '')); + content.push(localize('find.outsideShiftF3', "- Press {0} to move to the previous match.", '')); + content.push(localize('find.outsideConclusion', "You hear the same three-step sequence: full line, match position, then line and column.")); + content.push(''); + + // Focus Behavior + content.push(localize('find.focusHeader', "Focus Behavior (Important):")); + content.push(localize('find.focusDesc1', "When you navigate from inside the Find dialog, the editor updates while your focus stays in the input. This is intentional, so you can keep adjusting your search without losing your place.")); + content.push(localize('find.focusDesc2', "If you want to move focus into the editor to edit text or inspect surrounding code, press Escape to close Find. Focus returns to the editor at the most recent match.")); + content.push(''); + + // Keyboard Navigation Summary + content.push(localize('find.keyboardHeader', "Keyboard Navigation Summary:")); + content.push(''); + content.push(localize('find.keyNavFindHeader', "While focused IN the Find input:")); + content.push(localize('find.keyEnter', "- Enter: Move to the next match while staying in the Find dialog.")); + content.push(localize('find.keyShiftEnter', "- Shift+Enter: Move to the previous match while staying in the Find dialog.")); + content.push(''); + content.push(localize('find.keyNavEditorHeader', "While focused IN the editor (not the Find input):")); + content.push(localize('find.keyF3', "- {0}: Move to the next match.", '')); + content.push(localize('find.keyShiftF3', "- {0}: Move to the previous match.", '')); + content.push(''); + content.push(localize('find.keyNavNote', "Note: Don't press Enter or Shift+Enter when focused in the editor - these will insert line breaks instead of navigating.")); + content.push(''); + + // Find Options + content.push(localize('find.optionsHeader', "Find Options in the Dialog:")); + content.push(localize('find.optionCase', "- Match Case: Only exact case matches are included.")); + content.push(localize('find.optionWord', "- Whole Word: Only full words are matched.")); + content.push(localize('find.optionRegex', "- Regular Expression: Use pattern matching for advanced searches.")); + content.push(localize('find.optionSelection', "- Find in Selection: Limit matches to the current selection.")); + content.push(''); + + // Settings + content.push(localize('find.settingsHeader', "Settings You Can Adjust ({0} opens Settings):", '')); + content.push(localize('find.settingsIntro', "These settings affect how Find behaves or how matches are highlighted.")); + content.push(localize('find.settingVerbosity', "- `accessibility.verbosity.find`: Controls whether the Find input announces the Accessibility Help hint.")); + content.push(localize('find.settingFindOnType', "- `editor.find.findOnType`: Runs Find as you type.")); + content.push(localize('find.settingCursorMove', "- `editor.find.cursorMoveOnType`: Moves the cursor to the best match while you type.")); + content.push(localize('find.settingSeed', "- `editor.find.seedSearchStringFromSelection`: Controls when selection text is used to seed Find.")); + content.push(localize('find.settingAutoSelection', "- `editor.find.autoFindInSelection`: Automatically enables Find in Selection based on selection type.")); + content.push(localize('find.settingLoop', "- `editor.find.loop`: Wraps search at the beginning or end of the file.")); + content.push(localize('find.settingExtraSpace', "- `editor.find.addExtraSpaceOnTop`: Adds extra scroll space so matches are not hidden behind the Find dialog.")); + content.push(localize('find.settingHistory', "- `editor.find.history`: Controls whether Find search history is stored.")); + content.push(localize('find.settingOccurrences', "- `editor.occurrencesHighlight`: Highlights other occurrences of the current symbol.")); + content.push(localize('find.settingOccurrencesDelay', "- `editor.occurrencesHighlightDelay`: Controls how soon occurrences are highlighted.")); + content.push(localize('find.settingSelectionHighlight', "- `editor.selectionHighlight`: Highlights other matches of the current selection.")); + content.push(localize('find.settingSelectionMaxLength', "- `editor.selectionHighlightMaxLength`: Limits selection highlight length.")); + content.push(localize('find.settingSelectionMultiline', "- `editor.selectionHighlightMultiline`: Controls whether multi-line selections are highlighted.")); + + // Platform-specific setting + if (isMacintosh) { + content.push(''); + content.push(localize('find.macSettingHeader', "Platform-Specific Setting (macOS only):")); + content.push(localize('find.macSetting', "- `editor.find.globalFindClipboard`: Uses the shared macOS Find clipboard when available.")); + } + + content.push(''); + content.push(localize('find.closingHeader', "Closing:")); + content.push(localize('find.closingDesc', "Press Escape to close Find. Focus returns to the editor at the most recent match, and your search history is preserved.")); + } + + return content.join('\n'); + } +} + +// Register the accessibility help provider +AccessibleViewRegistry.register(new EditorFindAccessibilityHelp()); diff --git a/src/vs/workbench/contrib/debug/browser/replAccessibilityHelp.ts b/src/vs/workbench/contrib/debug/browser/replAccessibilityHelp.ts index 3b994c5e514fb..545ca609110ac 100644 --- a/src/vs/workbench/contrib/debug/browser/replAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/debug/browser/replAccessibilityHelp.ts @@ -12,11 +12,15 @@ import { getReplView, Repl } from './repl.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; import { localize } from '../../../../nls.js'; +import { CONTEXT_IN_DEBUG_REPL } from '../common/debug.js'; export class ReplAccessibilityHelp implements IAccessibleViewImplementation { priority = 120; name = 'replHelp'; - when = ContextKeyExpr.equals('focusedView', 'workbench.panel.repl.view'); + when = ContextKeyExpr.or( + ContextKeyExpr.equals('focusedView', 'workbench.panel.repl.view'), + CONTEXT_IN_DEBUG_REPL + ); type: AccessibleViewType = AccessibleViewType.Help; getProvider(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); @@ -30,32 +34,85 @@ export class ReplAccessibilityHelp implements IAccessibleViewImplementation { class ReplAccessibilityHelpProvider extends Disposable implements IAccessibleViewContentProvider { public readonly id = AccessibleViewProviderId.ReplHelp; - public readonly verbositySettingKey = AccessibilityVerbositySettingId.Debug; + public readonly verbositySettingKey = AccessibilityVerbositySettingId.Find; public readonly options = { type: AccessibleViewType.Help }; - private _treeHadFocus = false; constructor(private readonly _replView: Repl) { super(); - this._treeHadFocus = !!_replView.getFocusedElement(); } public onClose(): void { - if (this._treeHadFocus) { - return this._replView.focusTree(); - } - this._replView.getReplInput().focus(); + this._replView.focusFilter(); } public provideContent(): string { - return [ - localize('repl.help', "The debug console is a Read-Eval-Print-Loop that allows you to evaluate expressions and run commands and can be focused with{0}.", ''), - localize('repl.output', "The debug console output can be navigated to from the input field with the Focus Previous Widget command{0}.", ''), - localize('repl.input', "The debug console input can be navigated to from the output with the Focus Next Widget command{0}.", ''), - localize('repl.history', "The debug console output history can be navigated with the up and down arrow keys."), - localize('repl.accessibleView', "The Open Accessible View command{0} will allow character by character navigation of the console output.", ''), - localize('repl.showRunAndDebug', "The Show Run and Debug view command{0} will open the Run and Debug view and provides more information about debugging.", ''), - localize('repl.clear', "The Debug: Clear Console command{0} will clear the console output.", ''), - localize('repl.lazyVariables', "The setting `debug.expandLazyVariables` controls whether variables are evaluated automatically. This is enabled by default when using a screen reader."), - ].join('\n'); + const content: string[] = []; + + // Header + content.push(localize('repl.header', "Accessibility Help: Debug Console Filter")); + content.push(localize('repl.context', "You are in the Debug Console filter input. This is a filter that instantly hides console messages that do not match your filter, showing only the messages you want to see.")); + content.push(''); + + // Current Filter Status + content.push(localize('repl.statusHeader', "Current Filter Status:")); + content.push(localize('repl.statusDesc', "You are filtering the console output.")); + content.push(''); + + // Inside the Filter Input + content.push(localize('repl.inputHeader', "Inside the Filter Input (What It Does):")); + content.push(localize('repl.inputDesc', "While you are in the filter input, your focus stays in the field. You can type, edit, or adjust your filter without leaving the input. As you type, the console instantly updates to show only messages matching your filter.")); + content.push(''); + + // What Happens When You Filter + content.push(localize('repl.filterHeader', "What Happens When You Filter:")); + content.push(localize('repl.filterDesc', "Each time you change the filter text, the console instantly regenerates to show only matching messages. Your screen reader announces how many messages are now visible. This is live feedback: text searches console output, variable values, and log messages.")); + content.push(''); + + // Focus Behavior + content.push(localize('repl.focusHeader', "Focus Behavior (Important):")); + content.push(localize('repl.focusDesc1', "Your focus stays in the filter input while the console updates in the background. This is intentional, so you can keep typing without losing your place.")); + content.push(localize('repl.focusDesc2', "If you want to review the filtered console output, press Down Arrow to move focus from the filter into the console messages above.")); + content.push(localize('repl.focusDesc3', "Important: The console input area is at the bottom of the console, separate from the filter. To evaluate expressions, navigate to the console input (after the filtered messages) and type your expression.")); + content.push(''); + + // Distinguishing Filter from Console Input + content.push(localize('repl.distinguishHeader', "Distinguishing Filter from Console Input:")); + content.push(localize('repl.distinguishFilter', "The filter input is where you are now. It hides or shows messages without running code.")); + content.push(localize('repl.distinguishConsole', "The console input is at the bottom of the console, after all displayed messages. That is where you type and press Enter to evaluate expressions during debugging.")); + content.push(localize('repl.distinguishSwitch', "To switch to the console input and evaluate an expression, use {0} to focus the console input.", '')); + content.push(''); + + // Filter Syntax + content.push(localize('repl.syntaxHeader', "Filter Syntax and Patterns:")); + content.push(localize('repl.syntaxText', "- Type text: Shows only messages containing that text.")); + content.push(localize('repl.syntaxExclude', "- !text (exclude): Hides messages containing the text, showing all others.")); + content.push(''); + + // Keyboard Navigation Summary + content.push(localize('repl.keyboardHeader', "Keyboard Navigation Summary:")); + content.push(localize('repl.keyDown', "- Down Arrow: Move focus from filter into the console output.")); + content.push(localize('repl.keyTab', "- Tab: Move to other console controls if available.")); + content.push(localize('repl.keyEscape', "- Escape: Clear the filter or close the filter.")); + content.push(localize('repl.keyFocus', "- {0}: Focus the console input to evaluate expressions.", '')); + content.push(''); + + // Settings + content.push(localize('repl.settingsHeader', "Settings You Can Adjust ({0} opens Settings):", '')); + content.push(localize('repl.settingsIntro', "These settings affect the Debug Console.")); + content.push(localize('repl.settingVerbosity', "- `accessibility.verbosity.find`: Controls whether the filter input announces the Accessibility Help hint.")); + content.push(localize('repl.settingCloseOnEnd', "- `debug.console.closeOnEnd`: Automatically close the Debug Console when the debugging session ends.")); + content.push(localize('repl.settingFontSize', "- `debug.console.fontSize`: Font size in the console.")); + content.push(localize('repl.settingFontFamily', "- `debug.console.fontFamily`: Font family in the console.")); + content.push(localize('repl.settingWordWrap', "- `debug.console.wordWrap`: Wrap lines in the console.")); + content.push(localize('repl.settingHistory', "- `debug.console.historySuggestions`: Suggest previously typed input.")); + content.push(localize('repl.settingCollapse', "- `debug.console.collapseIdenticalLines`: Collapse repeated messages with a count.")); + content.push(localize('repl.settingMaxLines', "- `debug.console.maximumLines`: Maximum number of messages to keep in the console.")); + content.push(''); + + // Closing + content.push(localize('repl.closingHeader', "Closing:")); + content.push(localize('repl.closingDesc', "Press Escape to clear the filter, or close the Debug Console. Your filter text is preserved if you reopen the console.")); + + return content.join('\n'); } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatDefaultModel.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatDefaultModel.ts index 05514e5c59412..20286d86cd720 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatDefaultModel.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatDefaultModel.ts @@ -115,6 +115,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis description: localize('inlineChatDefaultModelDescription', "Select the default language model to use for inline chat from the available providers. Model names may include the provider in parentheses, for example 'Claude Haiku 4.5 (copilot)'."), type: 'string', default: '', + order: 1, enum: InlineChatDefaultModel.modelIds, enumItemLabels: InlineChatDefaultModel.modelLabels, markdownEnumDescriptions: InlineChatDefaultModel.modelDescriptions diff --git a/src/vs/workbench/contrib/markers/browser/markers.contribution.ts b/src/vs/workbench/contrib/markers/browser/markers.contribution.ts index b22f84b628adb..8c7055f153479 100644 --- a/src/vs/workbench/contrib/markers/browser/markers.contribution.ts +++ b/src/vs/workbench/contrib/markers/browser/markers.contribution.ts @@ -37,6 +37,8 @@ import { viewFilterSubmenu } from '../../../browser/parts/views/viewFilter.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { problemsConfigurationNodeBase } from '../../../common/configuration.js'; import { MarkerChatContextContribution } from './markersChatContext.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ProblemsAccessibilityHelp } from './markersAccessibilityHelp.js'; KeybindingsRegistry.registerCommandAndKeybindingRule({ id: Markers.MARKER_OPEN_ACTION_ID, @@ -715,3 +717,6 @@ class ActivityUpdater extends Disposable implements IWorkbenchContribution { } workbenchRegistry.registerWorkbenchContribution(ActivityUpdater, LifecyclePhase.Restored); + +// Register Accessible View Help +AccessibleViewRegistry.register(new ProblemsAccessibilityHelp()); diff --git a/src/vs/workbench/contrib/markers/browser/markersAccessibilityHelp.ts b/src/vs/workbench/contrib/markers/browser/markersAccessibilityHelp.ts new file mode 100644 index 0000000000000..a554aa3fa9ca4 --- /dev/null +++ b/src/vs/workbench/contrib/markers/browser/markersAccessibilityHelp.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { AccessibleViewType, AccessibleContentProvider, IAccessibleViewContentProvider, AccessibleViewProviderId } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import * as nls from '../../../../nls.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { MarkersContextKeys } from '../common/markers.js'; + +export class ProblemsAccessibilityHelp implements IAccessibleViewImplementation { + readonly type = AccessibleViewType.Help; + readonly priority = 105; + readonly name = 'problemsFilter'; + readonly when = MarkersContextKeys.MarkerViewFilterFocusContextKey; + + getProvider(accessor: ServicesAccessor): AccessibleContentProvider { + return new ProblemsAccessibilityHelpProvider(accessor.get(IKeybindingService)); + } +} + +class ProblemsAccessibilityHelpProvider extends Disposable implements IAccessibleViewContentProvider { + readonly id = AccessibleViewProviderId.ProblemsFilterHelp; + readonly verbositySettingKey = AccessibilityVerbositySettingId.Find; + readonly options = { type: AccessibleViewType.Help }; + + constructor( + private readonly _keybindingService: IKeybindingService, + ) { + super(); + } + + provideContent(): string { + const lines: string[] = []; + + // Header + lines.push(nls.localize('problems.header', 'Accessibility Help: Problems Panel Filter')); + lines.push(nls.localize('problems.context', 'You are in the Problems panel filter input. This is a filter, not a navigating search. It instantly hides problems that do not match your filter, showing only the problems you want to see.')); + lines.push(''); + + // Current Filter Status + lines.push(nls.localize('problems.statusHeader', 'Current Filter Status:')); + lines.push(nls.localize('problems.statusDesc', 'You are filtering the problems.')); + lines.push(''); + + // Inside the Filter Input + lines.push(nls.localize('problems.inputHeader', 'Inside the Filter Input (What It Does):')); + lines.push(nls.localize('problems.inputDesc', 'While you are in the filter input, your focus stays in the field. You can type, edit, or adjust your filter without leaving the input. As you type, the Problems panel instantly updates to show only problems matching your filter.')); + lines.push(''); + + // What Happens When You Filter + lines.push(nls.localize('problems.filterHeader', 'What Happens When You Filter:')); + lines.push(nls.localize('problems.filterDesc1', 'Each time you change the filter text, the panel instantly regenerates to show only matching problems. Your screen reader announces how many problems are now visible. This is live feedback: as you type or delete characters, the displayed problems update immediately.')); + lines.push(nls.localize('problems.filterDesc2', 'The panel searches problem messages, file names, and error codes, so you can filter by any of these details.')); + lines.push(''); + + // Focus Behavior + lines.push(nls.localize('problems.focusHeader', 'Focus Behavior (Important):')); + lines.push(nls.localize('problems.focusDesc1', 'Your focus stays in the filter input while the panel updates in the background. This is intentional, so you can keep typing without losing your place.')); + lines.push(nls.localize('problems.focusDesc2', 'If you want to navigate the filtered problems, press Down Arrow to move focus from the filter into the problems list below.')); + lines.push(nls.localize('problems.focusDesc3', 'When a problem is focused, press Enter to navigate to that problem in the editor.')); + lines.push(nls.localize('problems.focusDesc4', 'If you want to clear the filter and see all problems, press Escape or delete all filter text.')); + lines.push(''); + + // Filter Syntax + lines.push(nls.localize('problems.syntaxHeader', 'Filter Syntax and Patterns:')); + lines.push(nls.localize('problems.syntaxText', '- Type text: Shows problems whose message, file path, or code contains that text.')); + lines.push(nls.localize('problems.syntaxExclude', '- !text (exclude): Hides problems containing the text, showing all others.')); + lines.push(nls.localize('problems.syntaxExample', 'Example: typing "node_modules" hides all problems in node_modules.')); + lines.push(''); + + // Severity and Scope Filtering + lines.push(nls.localize('problems.severityHeader', 'Severity and Scope Filtering:')); + lines.push(nls.localize('problems.severityIntro', 'Above the filter input are toggle buttons for severity levels and scope:')); + lines.push(nls.localize('problems.severityErrors', '- Errors button: Toggle to show or hide error problems.')); + lines.push(nls.localize('problems.severityWarnings', '- Warnings button: Toggle to show or hide warning problems.')); + lines.push(nls.localize('problems.severityInfo', '- Info button: Toggle to show or hide informational problems.')); + lines.push(nls.localize('problems.severityActiveFile', '- Active File Only button: When enabled, shows only problems in the currently open file.')); + lines.push(nls.localize('problems.severityConclusion', 'These buttons work together with your text filter.')); + lines.push(''); + + // Keyboard Navigation Summary + lines.push(nls.localize('problems.keyboardHeader', 'Keyboard Navigation Summary:')); + lines.push(nls.localize('problems.keyDown', '- Down Arrow: Move focus from filter into the problems list.')); + lines.push(nls.localize('problems.keyTab', '- Tab: Move to severity and scope toggle buttons.')); + lines.push(nls.localize('problems.keyEnter', '- Enter (on a problem): Navigate to that problem in the editor.')); + lines.push(nls.localize('problems.keyF8', '- {0}: Move to the next problem globally from anywhere in the editor.', this._describeCommand('editor.action.marker.nextInFiles') || 'F8')); + lines.push(nls.localize('problems.keyShiftF8', '- {0}: Move to the previous problem globally from anywhere in the editor.', this._describeCommand('editor.action.marker.prevInFiles') || 'Shift+F8')); + lines.push(nls.localize('problems.keyEscape', '- Escape: Clear the filter and return to showing all problems.')); + lines.push(''); + + // Settings + lines.push(nls.localize('problems.settingsHeader', 'Settings You Can Adjust ({0} opens Settings):', this._describeCommand('workbench.action.openSettings') || 'Ctrl+,')); + lines.push(nls.localize('problems.settingsIntro', 'These settings affect the Problems panel.')); + lines.push(nls.localize('problems.settingVerbosity', '- `accessibility.verbosity.find`: Controls whether the filter input announces the Accessibility Help hint.')); + lines.push(nls.localize('problems.settingAutoReveal', '- `problems.autoReveal`: Automatically reveal problems in the editor when you select them.')); + lines.push(nls.localize('problems.settingViewMode', '- `problems.defaultViewMode`: Show problems as a table or tree.')); + lines.push(nls.localize('problems.settingSortOrder', '- `problems.sortOrder`: Sort problems by severity or position.')); + lines.push(nls.localize('problems.settingShowCurrent', '- `problems.showCurrentInStatus`: Show the current problem in the status bar.')); + lines.push(''); + + // Closing + lines.push(nls.localize('problems.closingHeader', 'Closing:')); + lines.push(nls.localize('problems.closingDesc', 'Press Escape to clear the filter and see all problems. Your filter text is preserved if you reopen the panel. Problems are shown from your entire workspace; use Active File Only to focus on a single file.')); + + return lines.join('\n'); + } + + private _describeCommand(commandId: string): string | undefined { + const kb = this._keybindingService.lookupKeybinding(commandId); + return kb?.getAriaLabel() ?? undefined; + } + + onClose(): void { + // No-op + } +} diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts index d0d8260bb51af..0dfb08c73e85d 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -33,6 +33,8 @@ import { McpResourceFilesystem } from '../common/mcpResourceFilesystem.js'; import { McpSamplingService } from '../common/mcpSamplingService.js'; import { McpService } from '../common/mcpService.js'; import { IMcpElicitationService, IMcpSamplingService, IMcpService, IMcpWorkbenchService } from '../common/mcpTypes.js'; +import { IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; +import { BrowserMcpGatewayService } from './mcpGatewayService.js'; import { McpAddContextContribution } from './mcpAddContextContribution.js'; import { AddConfigurationAction, EditStoredInput, InstallFromManifestAction, ListMcpServerCommand, McpBrowseCommand, McpBrowseResourcesCommand, McpConfigureSamplingModels, McpConfirmationServerOptionsCommand, MCPServerActionRendering, McpServerOptionsCommand, McpSkipCurrentAutostartCommand, McpStartPromptingServerCommand, OpenRemoteUserMcpResourceCommand, OpenUserMcpResourceCommand, OpenWorkspaceFolderMcpResourceCommand, OpenWorkspaceMcpResourceCommand, RemoveStoredInput, ResetMcpCachedTools, ResetMcpTrustCommand, RestartServer, ShowConfiguration, ShowInstalledMcpServersCommand, ShowOutput, StartServer, StopServer } from './mcpCommands.js'; import { McpDiscovery } from './mcpDiscovery.js'; @@ -51,6 +53,7 @@ registerSingleton(IMcpWorkbenchService, McpWorkbenchService, InstantiationType.E registerSingleton(IMcpDevModeDebugging, McpDevModeDebugging, InstantiationType.Delayed); registerSingleton(IMcpSamplingService, McpSamplingService, InstantiationType.Delayed); registerSingleton(IMcpElicitationService, McpElicitationService, InstantiationType.Delayed); +registerSingleton(IWorkbenchMcpGatewayService, BrowserMcpGatewayService, InstantiationType.Delayed); mcpDiscoveryRegistry.register(new SyncDescriptor(RemoteNativeMpcDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(InstalledMcpServersDiscovery)); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts new file mode 100644 index 0000000000000..49bb014e5d979 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.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 { URI } from '../../../../base/common/uri.js'; +import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; +import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; +import { IMcpGatewayResult, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; + +/** + * Browser implementation of the MCP Gateway Service. + * + * In browser/serverless web environments without a remote connection, + * there is no Node.js process available to create an HTTP server. + * + * When running with a remote connection, the gateway is created on the + * remote server via IPC. + */ +export class BrowserMcpGatewayService implements IWorkbenchMcpGatewayService { + declare readonly _serviceBrand: undefined; + + constructor( + @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + ) { } + + async createGateway(inRemote: boolean): Promise { + // Browser can only create gateways in remote environment + if (!inRemote) { + return undefined; + } + + const connection = this._remoteAgentService.getConnection(); + if (!connection) { + // Serverless web environment - no gateway available + return undefined; + } + + // Use the remote server's gateway service + return connection.withChannel(McpGatewayChannelName, async channel => { + const service = ProxyChannel.toService(channel); + const info = await service.createGateway(undefined); + + return { + address: URI.revive(info.address), + dispose: () => { + service.disposeGateway(info.gatewayId); + } + }; + }); + } +} diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index fcfc538cf8070..19dd14ad537c1 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -30,7 +30,7 @@ import { IUserDataProfilesService } from '../../../../platform/userDataProfile/c import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { MCP_CONFIGURATION_KEY, WORKSPACE_STANDALONE_CONFIGURATIONS } from '../../../services/configuration/common/configuration.js'; -import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { DidUninstallWorkbenchMcpServerEvent, IWorkbenchLocalMcpServer, IWorkbenchMcpManagementService, IWorkbenchMcpServerInstallResult, IWorkbencMcpServerInstallOptions, LocalMcpServerScope, REMOTE_USER_CONFIG_ID, USER_CONFIG_ID, WORKSPACE_CONFIG_ID, WORKSPACE_FOLDER_CONFIG_ID_PREFIX } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; @@ -721,7 +721,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } async open(extension: IWorkbenchMcpServer, options?: IEditorOptions): Promise { - await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, ACTIVE_GROUP); + await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, MODAL_GROUP); } private getInstallState(extension: McpWorkbenchServer): McpServerInstallState { diff --git a/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts new file mode 100644 index 0000000000000..7376729aa0058 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IWorkbenchMcpGatewayService = createDecorator('IWorkbenchMcpGatewayService'); + +/** + * Result of creating an MCP gateway, which is itself disposable. + */ +export interface IMcpGatewayResult extends IDisposable { + /** + * The address of the HTTP endpoint for this gateway. + */ + readonly address: URI; +} + +/** + * Service that manages MCP gateway HTTP endpoints in the workbench. + * + * The gateway provides an HTTP server that external processes can connect + * to in order to interact with MCP servers known to the editor. The server + * is shared among all gateways and is automatically torn down when the + * last gateway is disposed. + */ +export interface IWorkbenchMcpGatewayService { + readonly _serviceBrand: undefined; + + /** + * Creates a new MCP gateway endpoint. + * + * The gateway is assigned a secure random route ID to make the endpoint + * URL unguessable without authentication. + * + * @param inRemote Whether to create the gateway in the remote environment. + * If true, the gateway is created on the remote server (requires a remote connection). + * If false, the gateway is created locally (requires a local Node process, e.g., desktop). + * @returns A promise that resolves to the gateway result if successful, + * or `undefined` if the requested environment is not available. + */ + createGateway(inRemote: boolean): Promise; +} diff --git a/src/vs/workbench/contrib/mcp/electron-browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/electron-browser/mcp.contribution.ts index 37553b1eac305..701c78f4c08e1 100644 --- a/src/vs/workbench/contrib/mcp/electron-browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/electron-browser/mcp.contribution.ts @@ -6,9 +6,12 @@ import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { mcpDiscoveryRegistry } from '../common/discovery/mcpDiscovery.js'; +import { IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; import { IMcpDevModeDebugging } from '../common/mcpDevMode.js'; import { McpDevModeDebuggingNode } from './mcpDevModeDebuggingNode.js'; import { NativeMcpDiscovery } from './nativeMpcDiscovery.js'; +import { WorkbenchMcpGatewayService } from './mcpGatewayService.js'; mcpDiscoveryRegistry.register(new SyncDescriptor(NativeMcpDiscovery)); registerSingleton(IMcpDevModeDebugging, McpDevModeDebuggingNode, InstantiationType.Delayed); +registerSingleton(IWorkbenchMcpGatewayService, WorkbenchMcpGatewayService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts new file mode 100644 index 0000000000000..2af2c0b4adf0d --- /dev/null +++ b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; +import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; +import { IMcpGatewayResult, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; + +/** + * Electron workbench implementation of the MCP Gateway Service. + * + * This implementation can create gateways either in the main process (local) + * or on a remote server (if connected). + */ +export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { + declare readonly _serviceBrand: undefined; + + private readonly _localPlatformService: IMcpGatewayService; + + constructor( + @IMainProcessService mainProcessService: IMainProcessService, + @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + ) { + this._localPlatformService = ProxyChannel.toService( + mainProcessService.getChannel(McpGatewayChannelName) + ); + } + + async createGateway(inRemote: boolean): Promise { + if (inRemote) { + return this._createRemoteGateway(); + } else { + return this._createLocalGateway(); + } + } + + private async _createLocalGateway(): Promise { + const info = await this._localPlatformService.createGateway(undefined); + + return { + address: URI.revive(info.address), + dispose: () => { + this._localPlatformService.disposeGateway(info.gatewayId); + } + }; + } + + private async _createRemoteGateway(): Promise { + const connection = this._remoteAgentService.getConnection(); + if (!connection) { + // No remote connection - cannot create remote gateway + return undefined; + } + + return connection.withChannel(McpGatewayChannelName, async channel => { + const service = ProxyChannel.toService(channel); + const info = await service.createGateway(undefined); + + return { + address: URI.revive(info.address), + dispose: () => { + service.disposeGateway(info.gatewayId); + } + }; + }); + } +} diff --git a/src/vs/workbench/contrib/output/browser/output.contribution.ts b/src/vs/workbench/contrib/output/browser/output.contribution.ts index daa2516d6ab8c..0ce1bdfa22d0a 100644 --- a/src/vs/workbench/contrib/output/browser/output.contribution.ts +++ b/src/vs/workbench/contrib/output/browser/output.contribution.ts @@ -43,12 +43,17 @@ import { basename } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { hasKey } from '../../../../base/common/types.js'; import { IDefaultLogLevelsService } from '../../../services/log/common/defaultLogLevels.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { OutputAccessibilityHelp } from './outputAccessibilityHelp.js'; const IMPORTED_LOG_ID_PREFIX = 'importedLog.'; // Register Service registerSingleton(IOutputService, OutputService, InstantiationType.Delayed); +// Register Accessibility Help +AccessibleViewRegistry.register(new OutputAccessibilityHelp()); + // Register Output Mode ModesRegistry.registerLanguage({ id: OUTPUT_MODE_ID, diff --git a/src/vs/workbench/contrib/output/browser/outputAccessibilityHelp.ts b/src/vs/workbench/contrib/output/browser/outputAccessibilityHelp.ts new file mode 100644 index 0000000000000..b8e5dedb48e13 --- /dev/null +++ b/src/vs/workbench/contrib/output/browser/outputAccessibilityHelp.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { AccessibleViewType, AccessibleContentProvider, IAccessibleViewContentProvider, AccessibleViewProviderId } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import * as nls from '../../../../nls.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { OUTPUT_FILTER_FOCUS_CONTEXT } from '../../../services/output/common/output.js'; + +export class OutputAccessibilityHelp implements IAccessibleViewImplementation { + readonly type = AccessibleViewType.Help; + readonly priority = 105; + readonly name = 'outputFilter'; + readonly when = OUTPUT_FILTER_FOCUS_CONTEXT; + + getProvider(accessor: ServicesAccessor): AccessibleContentProvider { + return new OutputAccessibilityHelpProvider(accessor.get(IKeybindingService)); + } +} + +class OutputAccessibilityHelpProvider extends Disposable implements IAccessibleViewContentProvider { + readonly id = AccessibleViewProviderId.OutputFindHelp; + readonly verbositySettingKey = AccessibilityVerbositySettingId.Find; + readonly options = { type: AccessibleViewType.Help }; + + constructor( + private readonly _keybindingService: IKeybindingService, + ) { + super(); + } + + provideContent(): string { + const lines: string[] = []; + + // Header + lines.push(nls.localize('output.header', 'Accessibility Help: Output Panel Filter')); + lines.push(nls.localize('output.context', 'You are in the Output panel filter input. This is NOT a navigating search. Instead, it instantly hides lines that do not match your filter, showing only the lines you want to see.')); + lines.push(''); + + // Current Filter Status + lines.push(nls.localize('output.statusHeader', 'Current Filter Status:')); + lines.push(nls.localize('output.statusDesc', 'You are filtering the output.')); + lines.push(''); + + // Inside the Filter Input + lines.push(nls.localize('output.inputHeader', 'Inside the Filter Input (What It Does):')); + lines.push(nls.localize('output.inputDesc', 'While you are in the filter input, your focus stays in the field. You can type, edit, or adjust your filter without leaving the input. As you type, the Output panel instantly updates to show only lines matching your filter.')); + lines.push(''); + + // What Happens When You Filter + lines.push(nls.localize('output.filterHeader', 'What Happens When You Filter:')); + lines.push(nls.localize('output.filterDesc1', 'Each time you change the filter text, the panel instantly regenerates to show only matching lines. Your screen reader announces how many lines are now visible. This is live feedback: as you type or delete characters, the displayed lines update immediately.')); + lines.push(nls.localize('output.filterDesc2', 'New output from your running program is appended to the panel and automatically filtered, so matching new output appears instantly.')); + lines.push(''); + + // Focus Behavior + lines.push(nls.localize('output.focusHeader', 'Focus Behavior (Important):')); + lines.push(nls.localize('output.focusDesc1', 'Your focus stays in the filter input while the panel updates in the background. This is intentional, so you can keep typing without losing your place.')); + lines.push(nls.localize('output.focusDesc2', 'If you want to review the filtered output, press Down Arrow to move focus from the filter into the output content below.')); + lines.push(nls.localize('output.focusDesc3', 'If you want to clear the filter and see all output, press Escape or delete all filter text.')); + lines.push(''); + + // Filter Syntax + lines.push(nls.localize('output.syntaxHeader', 'Filter Syntax and Patterns:')); + lines.push(nls.localize('output.syntaxText', '- Type text: Shows only lines containing that text (case-insensitive by default).')); + lines.push(nls.localize('output.syntaxExclude', '- !text (exclude): Hides lines containing \'text\', showing all other lines.')); + lines.push(nls.localize('output.syntaxEscape', '- \\\\! (escape): Use backslash to search for a literal "!" character.')); + lines.push(nls.localize('output.syntaxMultiple', '- text1, text2 (multiple patterns): Separate patterns with commas to show lines matching ANY pattern.')); + lines.push(nls.localize('output.syntaxExample', 'Example: typing "error, warning" shows lines containing either "error" or "warning".')); + lines.push(''); + + // Keyboard Navigation Summary + lines.push(nls.localize('output.keyboardHeader', 'Keyboard Navigation Summary:')); + lines.push(nls.localize('output.keyDown', '- Down Arrow: Move focus from filter into the output content.')); + lines.push(nls.localize('output.keyTab', '- Tab: Move to log level filter buttons if available.')); + lines.push(nls.localize('output.keyEscape', '- Escape: Clear the filter and return to showing all output.')); + lines.push(''); + + // Settings + lines.push(nls.localize('output.settingsHeader', 'Settings You Can Adjust ({0} opens Settings):', this._describeCommand('workbench.action.openSettings') || 'Ctrl+,')); + lines.push(nls.localize('output.settingsIntro', 'These settings affect how the Output panel works.')); + lines.push(nls.localize('output.settingVerbosity', '- `accessibility.verbosity.find`: Controls whether the filter input announces the Accessibility Help hint.')); + lines.push(nls.localize('output.settingSmartScroll', '- `output.smartScroll.enabled`: Automatically scroll to the latest output when messages arrive.')); + lines.push(''); + + // Closing + lines.push(nls.localize('output.closingHeader', 'Closing:')); + lines.push(nls.localize('output.closingDesc', 'Press Escape to clear the filter and see all output, or close the Output panel. Your filter text is preserved if you reopen the panel.')); + + return lines.join('\n'); + } + + private _describeCommand(commandId: string): string | undefined { + const kb = this._keybindingService.lookupKeybinding(commandId); + return kb?.getAriaLabel() ?? undefined; + } + + onClose(): void { + // No-op + } +} diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index d9e1f30bd111c..5f8db4b627d60 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -879,7 +879,7 @@ export class SettingsEditor2 extends EditorPane { // If the target display category is different than the source's, unfocus the category // so that we can render all found settings again. // Then, the reveal call will correctly find the target setting. - if (this.viewState.filterToCategory && evt.source.displayCategory !== targetElement.displayCategory) { + if (this.viewState.categoryFilter && evt.source.displayCategory !== targetElement.displayCategory) { this.tocTree.setFocus([]); } try { @@ -1054,8 +1054,8 @@ export class SettingsEditor2 extends EditorPane { const scrollBehavior = this.configurationService.getValue<'paginated' | 'continuous'>(SCROLL_BEHAVIOR_KEY); if (this.searchResultModel || scrollBehavior === 'paginated') { // In search mode or paginated mode, filter to show only the selected category - if (this.viewState.filterToCategory !== element) { - this.viewState.filterToCategory = element ?? undefined; + if (this.viewState.categoryFilter !== element) { + this.viewState.categoryFilter = element ?? undefined; // Force render in this case, because // onDidClickSetting relies on the updated view. this.renderTree(undefined, true); @@ -1063,8 +1063,8 @@ export class SettingsEditor2 extends EditorPane { } } else { // In continuous mode, clear any category filter that may have been set in paginated mode - if (this.viewState.filterToCategory) { - this.viewState.filterToCategory = undefined; + if (this.viewState.categoryFilter) { + this.viewState.categoryFilter = undefined; this.renderTree(undefined, true); } if (element && (!e.browserEvent || !(e.browserEvent).fromScroll)) { @@ -1709,7 +1709,7 @@ export class SettingsEditor2 extends EditorPane { if (Array.isArray(rootChildren) && rootChildren.length > 0) { const firstCategory = rootChildren[0]; if (firstCategory instanceof SettingsTreeGroupElement) { - this.viewState.filterToCategory = firstCategory; + this.viewState.categoryFilter = firstCategory; this.tocTree.setFocus([firstCategory]); this.tocTree.setSelection([firstCategory]); } @@ -1918,7 +1918,7 @@ export class SettingsEditor2 extends EditorPane { if (expandResults) { this.tocTree.setFocus([]); - this.viewState.filterToCategory = undefined; + this.viewState.categoryFilter = undefined; } this.tocTreeModel.currentSearchModel = this.searchResultModel; @@ -2040,7 +2040,7 @@ export class SettingsEditor2 extends EditorPane { this.tocTreeModel.currentSearchModel = this.searchResultModel; if (expandResults) { this.tocTree.setFocus([]); - this.viewState.filterToCategory = undefined; + this.viewState.categoryFilter = undefined; this.tocTree.expandAll(); this.settingsTree.scrollTop = 0; } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index e23cf45c10115..a166f49f470ec 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -171,6 +171,112 @@ export const tocData: ITOCEntry = { } ] }, + { + id: 'chat', + label: localize('chat', "Chat"), + settings: ['chat.*'], + children: [ + { + id: 'chat/agent', + label: localize('chatAgent', "Agent"), + settings: [ + 'chat.agent.*', + 'chat.checkpoints.*', + 'chat.editRequests', + 'chat.requestQueuing.*', + 'chat.undoRequests.*', + 'chat.customAgentInSubagent.*', + 'chat.editing.autoAcceptDelay', + 'chat.editing.confirmEditRequest*' + ] + }, + { + id: 'chat/appearance', + label: localize('chatAppearance', "Appearance"), + settings: [ + 'chat.editor.*', + 'chat.fontFamily', + 'chat.fontSize', + 'chat.math.*', + 'chat.agentsControl.*', + 'chat.alternativeToolAction.*', + 'chat.codeBlock.*', + 'chat.editing.explainChanges.enabled', + 'chat.editMode.hidden', + 'chat.editorAssociations', + 'chat.extensionUnification.*', + 'chat.inlineReferences.*', + 'chat.notifyWindow*', + 'chat.statusWidget.*', + 'chat.tips.*', + 'chat.unifiedAgentsBar.*' + ] + }, + { + id: 'chat/sessions', + label: localize('chatSessions', "Sessions"), + settings: [ + 'chat.agentSessionProjection.*', + 'chat.sessions.*', + 'chat.viewProgressBadge.*', + 'chat.viewSessions.*', + 'chat.restoreLastPanelSession', + 'chat.exitAfterDelegation', + 'chat.repoInfo.*' + ] + }, + { + id: 'chat/tools', + label: localize('chatTools', "Tools"), + settings: [ + 'chat.tools.*', + 'chat.extensionTools.*', + 'chat.edits2.enabled' + ] + }, + { + id: 'chat/mcp', + label: localize('chatMcp', "MCP"), + settings: ['mcp', 'chat.mcp.*', 'mcp.*'] + }, + { + id: 'chat/context', + label: localize('chatContext', "Context"), + settings: [ + 'chat.detectParticipant.*', + 'chat.implicitContext.*', + 'chat.promptFilesLocations', + 'chat.instructionsFilesLocations', + 'chat.modeFilesLocations', + 'chat.agentFilesLocations', + 'chat.agentSkillsLocations', + 'chat.hookFilesLocations', + 'chat.promptFilesRecommendations', + 'chat.useAgentsMdFile', + 'chat.useNestedAgentsMdFiles', + 'chat.useAgentSkills', + 'chat.experimental.useSkillAdherencePrompt', + 'chat.useChatHooks', + 'chat.includeApplyingInstructions', + 'chat.includeReferencedInstructions', + 'chat.sendElementsToChat.*' + ] + }, + { + id: 'chat/inlineChat', + label: localize('chatInlineChat', "Inline Chat"), + settings: ['inlineChat.*'] + }, + { + id: 'chat/miscellaneous', + label: localize('chatMiscellaneous', "Miscellaneous"), + settings: [ + 'chat.disableAIFeatures', + 'chat.allowAnonymousAccess' + ] + }, + ] + }, { id: 'features', label: localize('features', "Features"), @@ -260,11 +366,6 @@ export const tocData: ITOCEntry = { label: localize('mergeEditor', 'Merge Editor'), settings: ['mergeEditor.*'] }, - { - id: 'features/chat', - label: localize('chat', 'Chat'), - settings: ['chat.*', 'inlineChat.*', 'mcp'] - }, { id: 'features/issueReporter', label: localize('issueReporter', 'Issue Reporter'), diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts index 28379b18564af..fbd06c80137bb 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts @@ -512,14 +512,6 @@ export async function createTocTreeForExtensionSettings(extensionService: IExten return Promise.all(processPromises).then(() => { const extGroups: ITOCEntry[] = []; for (const extensionRootEntry of extGroupTree.values()) { - for (const child of extensionRootEntry.children!) { - // Sort the individual settings of the child by order. - // Leave the undefined order settings untouched. - child.settings?.sort((a, b) => { - return compareTwoNullableNumbers(a.order, b.order); - }); - } - if (extensionRootEntry.children!.length === 1) { // There is a single category for this extension. // Push a flattened setting. @@ -645,7 +637,31 @@ function getMatchingSettings(allSettings: Set, filter: ITOCFilter): IS } }); - return result.sort((a, b) => a.key.localeCompare(b.key)); + const SETTING_STATUS_NORMAL = 0; + const SETTING_STATUS_PREVIEW = 1; + const SETTING_STATUS_EXPERIMENTAL = 2; + + const getExperimentalStatus = (setting: ISetting) => { + if (setting.tags?.includes('experimental')) { + return SETTING_STATUS_EXPERIMENTAL; + } else if (setting.tags?.includes('preview')) { + return SETTING_STATUS_PREVIEW; + } + return SETTING_STATUS_NORMAL; + }; + + // Sort settings so that preview and experimental settings are deprioritized. + // Within each tier, sort the settings by order, then alphabetically. + return result.sort((a, b) => { + const experimentalStatusA = getExperimentalStatus(a); + const experimentalStatusB = getExperimentalStatus(b); + if (experimentalStatusA !== experimentalStatusB) { + return experimentalStatusA - experimentalStatusB; + } + + const orderComparison = compareTwoNullableNumbers(a.order, b.order); + return orderComparison !== 0 ? orderComparison : a.key.localeCompare(b.key); + }); } const settingPatternCache = new Map(); @@ -2403,14 +2419,14 @@ function escapeInvisibleChars(enumValue: string): string { export class SettingsTreeFilter implements ITreeFilter { constructor( private viewState: ISettingsEditorViewState, - private filterGroups: boolean, + private isFilteringGroups: boolean, @IWorkbenchEnvironmentService private environmentService: IWorkbenchEnvironmentService, ) { } filter(element: SettingsTreeElement, parentVisibility: TreeVisibility): TreeFilterResult { // Filter during search - if (this.viewState.filterToCategory && element instanceof SettingsTreeSettingElement) { - if (!this.settingBelongsToCategory(element, this.viewState.filterToCategory)) { + if (this.viewState.categoryFilter && element instanceof SettingsTreeSettingElement) { + if (!this.settingContainedInGroup(element.setting, this.viewState.categoryFilter)) { return false; } } @@ -2426,8 +2442,8 @@ export class SettingsTreeFilter implements ITreeFilter { // Group with no visible children if (element instanceof SettingsTreeGroupElement) { // When filtering to a specific category, only show that category and its descendants - if (this.filterGroups && this.viewState.filterToCategory) { - if (!this.groupIsRelatedToCategory(element, this.viewState.filterToCategory)) { + if (this.isFilteringGroups && this.viewState.categoryFilter) { + if (!this.groupIsRelatedToCategory(element, this.viewState.categoryFilter)) { return false; } // For groups related to the category, skip the count check and recurse @@ -2444,7 +2460,7 @@ export class SettingsTreeFilter implements ITreeFilter { // Filtered "new extensions" button if (element instanceof SettingsTreeNewExtensionsElement) { - if (this.viewState.tagFilters?.size || this.viewState.filterToCategory) { + if (this.viewState.tagFilters?.size || this.viewState.categoryFilter) { return false; } } @@ -2452,19 +2468,16 @@ export class SettingsTreeFilter implements ITreeFilter { return true; } - /** - * Checks if a setting element belongs to the category or any of its subcategories - * by traversing up the setting's parent chain using IDs. - */ - private settingBelongsToCategory(element: SettingsTreeSettingElement, category: SettingsTreeGroupElement): boolean { - let parent = element.parent; - while (parent) { - if (parent.id === category.id) { - return true; + private settingContainedInGroup(setting: ISetting, group: SettingsTreeGroupElement): boolean { + return group.children.some(child => { + if (child instanceof SettingsTreeGroupElement) { + return this.settingContainedInGroup(setting, child); + } else if (child instanceof SettingsTreeSettingElement) { + return child.setting.key === setting.key; + } else { + return false; } - parent = parent.parent; - } - return false; + }); } /** diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index f85311aa7f653..186468f57adb4 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -34,7 +34,7 @@ export interface ISettingsEditorViewState { featureFilters?: Set; idFilters?: Set; languageFilter?: string; - filterToCategory?: SettingsTreeGroupElement; + categoryFilter?: SettingsTreeGroupElement; } export abstract class SettingsTreeElement extends Disposable { diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index 5d68510bdae15..ec2ed02d53e18 100644 --- a/src/vs/workbench/contrib/search/browser/search.contribution.ts +++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts @@ -44,6 +44,8 @@ import './searchActionsTextQuickAccess.js'; import { TEXT_SEARCH_QUICK_ACCESS_PREFIX, TextSearchQuickAccess } from './quickTextSearch/textSearchQuickAccess.js'; import { Extensions, IConfigurationMigrationRegistry } from '../../../common/configuration.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { SearchAccessibilityHelp } from './searchAccessibilityHelp.js'; registerSingleton(ISearchViewModelWorkbenchService, SearchViewModelWorkbenchService, InstantiationType.Delayed); registerSingleton(ISearchHistoryService, SearchHistoryService, InstantiationType.Delayed); @@ -54,6 +56,8 @@ searchWidgetContributions(); registerWorkbenchContribution2(SearchChatContextContribution.ID, SearchChatContextContribution, WorkbenchPhase.AfterRestored); +AccessibleViewRegistry.register(new SearchAccessibilityHelp()); + const SEARCH_MODE_CONFIG = 'search.mode'; const viewContainer = Registry.as(ViewExtensions.ViewContainersRegistry).registerViewContainer({ diff --git a/src/vs/workbench/contrib/search/browser/searchAccessibilityHelp.ts b/src/vs/workbench/contrib/search/browser/searchAccessibilityHelp.ts new file mode 100644 index 0000000000000..cedfc5ebbee96 --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/searchAccessibilityHelp.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isMacintosh } from '../../../../base/common/platform.js'; +import { localize } from '../../../../nls.js'; +import { AccessibleViewProviderId, AccessibleViewType, AccessibleContentProvider, IAccessibleViewContentProvider, IAccessibleViewOptions } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { SearchContext } from '../common/constants.js'; +import { ISearchViewModelWorkbenchService } from './searchTreeModel/searchViewModelWorkbenchService.js'; +import { IViewsService } from '../../../services/views/common/viewsService.js'; +import { getSearchView } from './searchActionsBase.js'; + +export class SearchAccessibilityHelp implements IAccessibleViewImplementation { + readonly priority = 105; + readonly name = 'search'; + readonly type = AccessibleViewType.Help; + readonly when = SearchContext.SearchInputBoxFocusedKey; + + getProvider(accessor: ServicesAccessor): AccessibleContentProvider | undefined { + const searchViewModelService = accessor.get(ISearchViewModelWorkbenchService); + const viewsService = accessor.get(IViewsService); + + const searchModel = searchViewModelService.searchModel; + if (!searchModel) { + return undefined; + } + + return new SearchAccessibilityHelpProvider(searchModel, viewsService); + } +} + +class SearchAccessibilityHelpProvider extends Disposable implements IAccessibleViewContentProvider { + readonly id = AccessibleViewProviderId.SearchHelp; + readonly verbositySettingKey = AccessibilityVerbositySettingId.Find; + readonly options: IAccessibleViewOptions = { type: AccessibleViewType.Help }; + + constructor( + private readonly _searchModel: { searchResult: { count: () => number }; replaceActive: boolean }, + private readonly _viewsService: IViewsService + ) { + super(); + } + + onClose(): void { + getSearchView(this._viewsService)?.focus(); + } + + provideContent(): string { + const content: string[] = []; + const resultCount = this._searchModel.searchResult.count(); + const isReplaceMode = this._searchModel.replaceActive; + + // Header + content.push(localize('search.header', "Accessibility Help: Search Across Files")); + content.push(localize('search.context', "You are in the Search view. This workspace-wide tool lets you find text or patterns across all files in your workspace.")); + content.push(''); + + // Current Search Status + content.push(localize('search.statusHeader', "Current Search Status:")); + content.push(localize('search.statusIntro', "You are searching across your workspace.")); + if (resultCount !== undefined) { + if (resultCount === 0) { + content.push(localize('search.noResults', "No results found. Check your search term or adjust options below.")); + } else { + content.push(localize('search.resultCount', "{0} results found.", resultCount)); + } + } else { + content.push(localize('search.noSearch', "Type a search term to find results.")); + } + content.push(''); + + // Inside the Search Input + content.push(localize('search.inputHeader', "Inside the Search Input (What It Does):")); + content.push(localize('search.inputDesc', "While you are in the Search input, your focus stays in the field. You can type your search term and navigate the search results list without leaving the input. When you navigate to a result, the editor updates in the background to show the match.")); + content.push(''); + + // What You Hear + content.push(localize('search.hearHeader', "What You Hear Each Time You Move to a Result:")); + content.push(localize('search.hearDesc', "Each navigation step gives you a complete spoken update:")); + content.push(localize('search.hear1', "1) The file name where the result is located is read first, so you know which file contains the match.")); + content.push(localize('search.hear2', "2) The full line that contains the match is read, so you get immediate context.")); + content.push(localize('search.hear3', "3) Your position among the results is announced, so you know how far you are through the results.")); + content.push(localize('search.hear4', "4) The exact line and column are announced, so you know precisely where the match is in the file.")); + content.push(''); + + // Focus Behavior + content.push(localize('search.focusHeader', "Focus Behavior (Important):")); + content.push(localize('search.focusDesc1', "When you navigate from the Search input, the editor updates while your focus stays in the search field. This is intentional, so you can keep refining your search without losing your place.")); + content.push(localize('search.focusDesc2', "If you press Tab, focus moves to the results tree below the input, and you can navigate results and open them. When you press Enter on a result, the match is shown in the editor.")); + content.push(localize('search.focusDesc3', "If you want to focus the editor to edit text at a search result, use {0} to navigate to the result and automatically focus the editor there.", '')); + content.push(''); + + // Keyboard Navigation Summary + content.push(localize('search.keyboardHeader', "Keyboard Navigation Summary:")); + content.push(''); + content.push(localize('search.keyNavSearchHeader', "While focused IN the Search input:")); + content.push(localize('search.keyEnter', "- Enter: Run or refresh the search.")); + content.push(localize('search.keyTab', "- Tab: Move focus to the results tree below.")); + content.push(''); + content.push(localize('search.keyNavResultsHeader', "Navigating search results:")); + content.push(localize('search.keyArrow', "- Down Arrow: Navigate through results in the tree.")); + content.push(localize('search.keyResultEnter', "- Enter (when a result is focused): Navigate to that result in the editor.")); + content.push(''); + content.push(localize('search.keyNavGlobalHeader', "From anywhere (Search input or editor):")); + content.push(localize('search.keyF4', "- {0}: Jump to the next result and focus the editor.", '')); + content.push(localize('search.keyShiftF4', "- {0}: Jump to the previous result and focus the editor.", '')); + content.push(''); + + // Search Options + content.push(localize('search.optionsHeader', "Search Options in the Dialog:")); + content.push(localize('search.optionCase', "- Match Case: Only exact case matches are included.")); + content.push(localize('search.optionWord', "- Whole Word: Only full words are matched.")); + content.push(localize('search.optionRegex', "- Regular Expression: Use pattern matching for advanced searches.")); + content.push(''); + + // Replace Mode + if (isReplaceMode) { + content.push(localize('search.replaceHeader', "Replace Across Files (Replace Mode Active):")); + content.push(localize('search.replaceDesc1', "Tab to the Replace input and type your replacement text.")); + content.push(localize('search.replaceDesc2', "You can replace individual matches or all matches at once.")); + content.push(localize('search.replaceWarning', "Warning: This action affects multiple files. Make sure you have searched for exactly what you want to replace.")); + content.push(''); + } + + // Settings + content.push(localize('search.settingsHeader', "Settings You Can Adjust ({0} opens Settings):", '')); + content.push(localize('search.settingsIntro', "These settings affect how Search across files behaves.")); + content.push(localize('search.settingVerbosity', "- `accessibility.verbosity.find`: Controls whether the Search input announces the Accessibility Help hint.")); + content.push(localize('search.settingSmartCase', "- `search.smartCase`: Use case-insensitive search if your search term is all lowercase.")); + content.push(localize('search.settingSearchOnType', "- `search.searchOnType`: Search all files as you type.")); + content.push(localize('search.settingDebounce', "- `search.searchOnTypeDebouncePeriod`: Wait time in milliseconds before searching as you type.")); + content.push(localize('search.settingMaxResults', "- `search.maxResults`: Maximum number of search results to show.")); + content.push(localize('search.settingCollapse', "- `search.collapseResults`: Expand or collapse results.")); + content.push(localize('search.settingLineNumbers', "- `search.showLineNumbers`: Show line numbers for results.")); + content.push(localize('search.settingSortOrder', "- `search.sortOrder`: Sort results by file name, type, modified time, or match count.")); + content.push(localize('search.settingContextLines', "- `search.searchEditor.defaultNumberOfContextLines`: Number of context lines shown around matches.")); + content.push(localize('search.settingViewMode', "- `search.defaultViewMode`: Show results as list or tree.")); + content.push(localize('search.settingActions', "- `search.actionsPosition`: Position of action buttons.")); + + // Replace-specific setting + if (isReplaceMode) { + content.push(localize('search.settingReplacePreview', "- `search.useReplacePreview`: Open preview when replacing matches.")); + } + + // Platform-specific setting + if (isMacintosh) { + content.push(''); + content.push(localize('search.macSettingHeader', "Platform-Specific Setting (macOS only):")); + content.push(localize('search.macSetting', "- `search.globalFindClipboard`: Uses the shared macOS Find clipboard when available.")); + } + + content.push(''); + content.push(localize('search.closingHeader', "Closing:")); + content.push(localize('search.closingDesc', "Press Escape to close Search. Focus returns to the editor, and your search history is preserved.")); + + return content.join('\n'); + } +} diff --git a/src/vs/workbench/contrib/share/browser/share.contribution.ts b/src/vs/workbench/contrib/share/browser/share.contribution.ts index 66b56f96b27d8..b1345fe64b61b 100644 --- a/src/vs/workbench/contrib/share/browser/share.contribution.ts +++ b/src/vs/workbench/contrib/share/browser/share.contribution.ts @@ -98,7 +98,7 @@ class ShareWorkbenchContribution extends Disposable { primary: KeyMod.Alt | KeyMod.CtrlCmd | KeyCode.KeyS, }, menu: [ - { id: MenuId.CommandCenter, order: 1000 } + { id: MenuId.CommandCenter, order: 3 } ] }); } diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 050202fed8fb4..20f2224f54a7f 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -472,9 +472,10 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { return Promise.resolve({ success: false, task: undefined }); } return new Promise((resolve, reject) => { - this._register(terminal.onDisposed(terminal => { + const onDisposedListener = terminal.onDisposed(terminal => { this._fireTaskEvent(TaskEvent.terminated(task, terminal.instanceId, terminal.exitReason)); - })); + onDisposedListener.dispose(); + }); const onExit = terminal.onExit(() => { const task = activeTerminal.task; try { @@ -1049,7 +1050,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { const startStopProblemMatcher = new StartStopProblemCollector(problemMatchers, this._markerService, this._modelService, ProblemHandlingStrategy.Clean, this._fileService); this._terminalStatusManager.addTerminal(task, terminal, startStopProblemMatcher); this._taskProblemMonitor.addTerminal(terminal, startStopProblemMatcher); - this._register(startStopProblemMatcher.onDidStateChange((event) => { + const problemMatcherListener = startStopProblemMatcher.onDidStateChange((event) => { if (event.kind === ProblemCollectorEventKind.BackgroundProcessingBegins) { this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherStarted, task, terminal?.instanceId)); } else if (event.kind === ProblemCollectorEventKind.BackgroundProcessingEnds) { @@ -1060,7 +1061,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { this._fireTaskEvent(TaskEvent.problemMatcherEnded(task, this._taskHasErrors(task), terminal?.instanceId)); } } - })); + }); let processStartedSignaled = false; terminal.processReady.then(() => { if (!processStartedSignaled) { @@ -1112,6 +1113,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { onData.dispose(); startStopProblemMatcher.done(); startStopProblemMatcher.dispose(); + problemMatcherListener.dispose(); }, 100); if (!processStartedSignaled && terminal) { this._fireTaskEvent(TaskEvent.processStarted(task, terminal.instanceId, terminal.processId!)); @@ -1409,12 +1411,17 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { private async _doCreateTerminal(task: Task, group: string | undefined, launchConfigs: IShellLaunchConfig): Promise { const reconnectedTerminal = await this._reconnectToTerminal(task); - const onDisposed = (terminal: ITerminalInstance) => this._fireTaskEvent(TaskEvent.terminated(task, terminal.instanceId, terminal.exitReason)); + const registerOnDisposed = (terminal: ITerminalInstance) => { + const listener = terminal.onDisposed(() => { + this._fireTaskEvent(TaskEvent.terminated(task, terminal.instanceId, terminal.exitReason)); + listener.dispose(); + }); + }; if (reconnectedTerminal) { if ((CustomTask.is(task) || ContributedTask.is(task)) && task.command.presentation) { reconnectedTerminal.waitOnExit = getWaitOnExitValue(task.command.presentation, task.configurationProperties); } - this._register(reconnectedTerminal.onDisposed(onDisposed)); + registerOnDisposed(reconnectedTerminal); this._logService.trace('reconnected to task and terminal', task._id); return reconnectedTerminal; } @@ -1426,7 +1433,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { this._logService.trace(`Found terminal to split for group ${group}`); const originalInstance = terminal.terminal; const result = await this._terminalService.createTerminal({ location: { parentTerminal: originalInstance }, config: launchConfigs }); - this._register(result.onDisposed(onDisposed)); + registerOnDisposed(result); if (result) { return result; } @@ -1436,7 +1443,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { } // Either no group is used, no terminal with the group exists or splitting an existing terminal failed. const createdTerminal = await this._terminalService.createTerminal({ config: launchConfigs }); - this._register(createdTerminal.onDisposed(onDisposed)); + registerOnDisposed(createdTerminal); return createdTerminal; } @@ -1455,6 +1462,10 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { if (data) { const terminalData = { lastTask: data.lastTask, group: data.group, terminal, shellIntegrationNonce: data.shellIntegrationNonce }; this._terminals[terminal.instanceId] = terminalData; + const listener = terminal.onDisposed(() => { + this._deleteTaskAndTerminal(terminal, terminalData); + listener.dispose(); + }); this._logService.trace('Reconnecting to task terminal', terminalData.lastTask, terminal.instanceId); } } @@ -1577,10 +1588,10 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { } const terminalKey = terminal.instanceId.toString(); const terminalData = { terminal: terminal, lastTask: taskKey, group, shellIntegrationNonce: terminal.shellLaunchConfig.shellIntegrationNonce }; - const onDisposedListener = this._register(terminal.onDisposed(() => { + const onDisposedListener = terminal.onDisposed(() => { this._deleteTaskAndTerminal(terminal, terminalData); onDisposedListener.dispose(); - })); + }); this._terminals[terminalKey] = terminalData; terminal.shellLaunchConfig.tabActions = this._terminalTabActions; return [terminal, undefined]; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 3a1a2c0c388d2..8b316cf49a546 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -16,7 +16,7 @@ import { debounce } from '../../../../base/common/decorators.js'; import { BugIndicatingError, onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; -import { ISeparator, template } from '../../../../base/common/labels.js'; +import { ISeparator, normalizeDriveLetter, template } from '../../../../base/common/labels.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, ImmortalReference, MutableDisposable, dispose, toDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import * as path from '../../../../base/common/path.js'; @@ -1551,10 +1551,10 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // Allow remote and local terminals from remote to be created in untrusted remote workspace if (!trusted && !this.remoteAuthority && !this._workbenchEnvironmentService.remoteAuthority) { this._onProcessExit({ message: nls.localize('workspaceNotTrustedCreateTerminal', "Cannot launch a terminal process in an untrusted workspace") }); - } else if (this._workspaceContextService.getWorkspace().folders.length === 0 && this._cwd && this._userHome && this._cwd !== this._userHome) { + } else if (this._workspaceContextService.getWorkspace().folders.length === 0 && this._cwd && this._userHome && normalizeDriveLetter(this._cwd) !== normalizeDriveLetter(this._userHome)) { // something strange is going on if cwd is not userHome in an empty workspace this._onProcessExit({ - message: nls.localize('workspaceNotTrustedCreateTerminalCwd', "Cannot launch a terminal process in an untrusted workspace with cwd {0} and userHome {1}", this._cwd, this._userHome) + message: nls.localize('workspaceEmptyCreateTerminalCwd', "Cannot launch a terminal process in an empty workspace with cwd {0} different from userHome {1}", this._cwd, this._userHome) }); } // Re-evaluate dimensions if the container has been set since the xterm instance was created diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 18c7354ccd471..8687162369364 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -52,6 +52,33 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private _state: OutputMonitorState = OutputMonitorState.PollingForIdle; get state(): OutputMonitorState { return this._state; } + private _formatLastLineForLog(output: string | undefined): string { + if (!output) { + return ''; + } + const lastLine = output.trimEnd().split(/\r?\n/).pop() ?? ''; + if (!lastLine) { + return ''; + } + // Avoid logging potentially sensitive values from common secret prompts. + if (/(password|passphrase|token|api\s*key|secret)/i.test(lastLine)) { + return ''; + } + // Keep logs bounded. + return lastLine.length > 200 ? lastLine.slice(0, 200) + '…' : lastLine; + } + + private _formatOptionsForLog(options: readonly string[]): string { + if (!options.length) { + return '[]'; + } + // Keep bounded and single-line. + const maxOptions = 12; + const shown = options.slice(0, maxOptions).map(o => o.replace(/\r?\n/g, 'return')); + const suffix = options.length > maxOptions ? `, …(+${options.length - maxOptions})` : ''; + return `[${shown.join(', ')}${suffix}]`; + } + private _lastPromptMarker: XtermMarker | undefined; private _lastPrompt: string | undefined; @@ -121,10 +148,13 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { while (!token.isCancellationRequested) { switch (this._state) { case OutputMonitorState.PollingForIdle: { + this._logService.trace(`OutputMonitor: Entering PollingForIdle (extended=${extended})`); this._state = await this._waitForIdle(this._execution, extended, token); + this._logService.trace(`OutputMonitor: PollingForIdle completed -> state=${OutputMonitorState[this._state]}`); continue; } case OutputMonitorState.Timeout: { + this._logService.trace(`OutputMonitor: Entering Timeout state (extended=${extended})`); const shouldContinuePolling = await this._handleTimeoutState(command, invocationContext, extended, token); if (shouldContinuePolling) { extended = true; @@ -139,11 +169,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { case OutputMonitorState.Cancelled: break; case OutputMonitorState.Idle: { + this._logService.trace('OutputMonitor: Entering Idle handler'); const idleResult = await this._handleIdleState(token); if (idleResult.shouldContinuePollling) { + this._logService.trace('OutputMonitor: Idle handler -> continue polling'); this._state = OutputMonitorState.PollingForIdle; continue; } else { + this._logService.trace(`OutputMonitor: Idle handler -> stop polling (hasResources=${!!idleResult.resources}, hasModelEval=${!!idleResult.modelOutputEvalResponse}, outputLen=${idleResult.output?.length ?? 0})`); resources = idleResult.resources; modelOutputEvalResponse = idleResult.modelOutputEvalResponse; output = idleResult.output; @@ -160,6 +193,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._state = OutputMonitorState.Cancelled; } } finally { + this._logService.trace(`OutputMonitor: Monitoring finished (state=${OutputMonitorState[this._state]}, duration=${Date.now() - pollStartTime}ms)`); this._pollingResult = { state: this._state, output: output ?? this._execution.getOutput(), @@ -185,8 +219,10 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private async _handleIdleState(token: CancellationToken): Promise<{ resources?: ILinkLocation[]; modelOutputEvalResponse?: string; shouldContinuePollling: boolean; output?: string }> { const output = this._execution.getOutput(this._lastPromptMarker); + this._logService.trace(`OutputMonitor: Idle output summary: len=${output.length}, lastLine=${this._formatLastLineForLog(output)}`); if (detectsNonInteractiveHelpPattern(output)) { + this._logService.trace('OutputMonitor: Idle -> non-interactive help pattern detected, stopping'); return { shouldContinuePollling: false, output }; } @@ -196,6 +232,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const isTask = this._execution.task !== undefined; const isTaskInactive = this._execution.isActive ? !(await this._execution.isActive()) : true; if (isTask && isTaskInactive && detectsVSCodeTaskFinishMessage(output)) { + this._logService.trace('OutputMonitor: Idle -> VS Code task finish message detected for inactive task, stopping'); // Task is finished, ignore the "press any key to close" message return { shouldContinuePollling: false, output }; } @@ -203,6 +240,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // Check for generic "press any key" prompts from scripts. // These should be treated as free-form input to let the user press a key. if ((!isTask || !isTaskInactive) && detectsGenericPressAnyKeyPattern(output)) { + this._logService.trace('OutputMonitor: Idle -> generic "press any key" detected, requesting free-form input'); // Register a marker to track this prompt position so we don't re-detect it const currentMarker = this._execution.instance.registerMarker(); if (currentMarker) { @@ -217,23 +255,29 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { detectedRequestForFreeFormInput: true }, true /* acceptAnyKey */); if (receivedTerminalInput) { + this._logService.trace('OutputMonitor: Free-form input received for "press any key", continue polling'); await timeout(200); return { shouldContinuePollling: true }; } else { + this._logService.trace('OutputMonitor: Free-form input declined for "press any key", stopping'); return { shouldContinuePollling: false }; } } // Check if user already inputted since idle was detected (before we even got here) if (this._userInputtedSinceIdleDetected) { + this._logService.trace('OutputMonitor: User input detected since idle; skipping prompt and continuing polling'); this._cleanupIdleInputListener(); return { shouldContinuePollling: true }; } + this._logService.trace('OutputMonitor: Determining user input options via language model'); const confirmationPrompt = await this._determineUserInputOptions(this._execution, token); + this._logService.trace(`OutputMonitor: Input options result: ${confirmationPrompt ? `prompt=${this._formatLastLineForLog(confirmationPrompt.prompt)}, options=${confirmationPrompt.options.length} ${this._formatOptionsForLog(confirmationPrompt.options)}, freeForm=${!!confirmationPrompt.detectedRequestForFreeFormInput}` : 'none'}`); // Check again after the async LLM call - user may have inputted while we were analyzing if (this._userInputtedSinceIdleDetected) { + this._logService.trace('OutputMonitor: User input arrived during input-option analysis; continuing polling'); this._cleanupIdleInputListener(); return { shouldContinuePollling: true }; } @@ -241,26 +285,32 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (confirmationPrompt?.detectedRequestForFreeFormInput) { // Check again right before showing prompt if (this._userInputtedSinceIdleDetected) { + this._logService.trace('OutputMonitor: User input arrived before showing free-form prompt; continuing polling'); this._cleanupIdleInputListener(); return { shouldContinuePollling: true }; } // Clean up the input listener now - the prompt will set up its own this._cleanupIdleInputListener(); this._outputMonitorTelemetryCounters.inputToolFreeFormInputShownCount++; + this._logService.trace('OutputMonitor: Showing free-form input elicitation'); const receivedTerminalInput = await this._requestFreeFormTerminalInput(token, this._execution, confirmationPrompt); if (receivedTerminalInput) { // Small delay to ensure input is processed + this._logService.trace('OutputMonitor: Free-form input received; continuing polling'); await timeout(200); // Continue polling as we sent the input return { shouldContinuePollling: true }; } else { // User declined + this._logService.trace('OutputMonitor: Free-form input declined; stopping'); return { shouldContinuePollling: false }; } } if (confirmationPrompt?.options.length) { + this._logService.trace(`OutputMonitor: Showing option-based input flow (options=${confirmationPrompt.options.length})`); const suggestedOptionResult = await this._selectAndHandleOption(confirmationPrompt, token); + this._logService.trace(`OutputMonitor: Suggested option result: ${suggestedOptionResult?.suggestedOption ? 'hasSuggestion' : 'none'} (autoSent=${!!suggestedOptionResult?.sentToTerminal})`); if (suggestedOptionResult?.sentToTerminal) { // Continue polling as we sent the input this._cleanupIdleInputListener(); @@ -268,17 +318,21 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } // Check again after LLM call - user may have inputted while we were selecting option if (this._userInputtedSinceIdleDetected) { + this._logService.trace('OutputMonitor: User input arrived during option selection; continuing polling'); this._cleanupIdleInputListener(); return { shouldContinuePollling: true }; } // Clean up the input listener now - the prompt will set up its own this._cleanupIdleInputListener(); + this._logService.trace('OutputMonitor: Showing confirmation elicitation for suggested option'); const confirmed = await this._confirmRunInTerminal(token, suggestedOptionResult?.suggestedOption ?? confirmationPrompt.options[0], this._execution, confirmationPrompt); if (confirmed) { // Continue polling as we sent the input + this._logService.trace('OutputMonitor: Option confirmed/sent; continuing polling'); return { shouldContinuePollling: true }; } else { // User declined + this._logService.trace('OutputMonitor: Option declined; stopping'); this._execution.instance.focus(true); return { shouldContinuePollling: false }; } @@ -289,6 +343,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // Let custom poller override if provided const custom = await this._pollFn?.(this._execution, token, this._taskService); + this._logService.trace(`OutputMonitor: Custom poller result: ${custom ? 'provided' : 'none'}`); const resources = custom?.resources; const modelOutputEvalResponse = await this._assessOutputForErrors(this._execution.getOutput(), token); return { resources, modelOutputEvalResponse, shouldContinuePollling: false, output: custom?.output ?? output }; @@ -335,6 +390,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const currentOutput = execution.getOutput(); if (detectsNonInteractiveHelpPattern(currentOutput)) { + this._logService.trace(`OutputMonitor: waitForIdle -> non-interactive help detected (waited=${waited}ms)`); this._state = OutputMonitorState.Idle; this._setupIdleInputListener(); return this._state; @@ -342,6 +398,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const promptResult = detectsInputRequiredPattern(currentOutput); if (promptResult) { + this._logService.trace(`OutputMonitor: waitForIdle -> input-required pattern detected (waited=${waited}ms, lastLine=${this._formatLastLineForLog(currentOutput)})`); this._state = OutputMonitorState.Idle; this._setupIdleInputListener(); return this._state; @@ -358,6 +415,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const isActive = execution.isActive ? await execution.isActive() : undefined; this._logService.trace(`OutputMonitor: waitForIdle check: waited=${waited}ms, recentlyIdle=${recentlyIdle}, isActive=${isActive}`); if (recentlyIdle && isActive !== true) { + this._logService.trace(`OutputMonitor: waitForIdle -> recentlyIdle && !active (waited=${waited}ms, lastLine=${this._formatLastLineForLog(currentOutput)})`); this._state = OutputMonitorState.Idle; this._setupIdleInputListener(); return this._state; @@ -380,10 +438,12 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { */ private _setupIdleInputListener(): void { this._userInputtedSinceIdleDetected = false; + this._logService.trace('OutputMonitor: Setting up idle input listener'); // Set up new listener (MutableDisposable auto-disposes previous) this._userInputListener.value = this._execution.instance.onDidInputData(() => { this._userInputtedSinceIdleDetected = true; + this._logService.trace('OutputMonitor: Detected user terminal input while idle'); }); } @@ -420,13 +480,16 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private async _determineUserInputOptions(execution: IExecution, token: CancellationToken): Promise { if (token.isCancellationRequested) { + this._logService.trace('OutputMonitor: determineUserInputOptions cancelled before start'); return; } const model = await this._getLanguageModel(); if (!model) { + this._logService.trace('OutputMonitor: determineUserInputOptions no language model available'); return undefined; } const lastLines = execution.getOutput(this._lastPromptMarker).trimEnd().split('\n').slice(-15).join('\n'); + this._logService.trace(`OutputMonitor: determineUserInputOptions analyzing lastLines (len=${lastLines.length})`); if (detectsNonInteractiveHelpPattern(lastLines)) { return undefined; @@ -487,6 +550,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { ) { const obj = parsed as { prompt: string; options: unknown; freeFormInput: boolean }; if (this._lastPrompt === obj.prompt) { + this._logService.trace('OutputMonitor: determineUserInputOptions ignoring duplicate prompt'); return; } if (obj.freeFormInput === true) { @@ -505,7 +569,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } } } catch (err) { - console.error('Failed to parse confirmation prompt from language model response:', err); + this._logService.trace('OutputMonitor: Failed to parse confirmation prompt from language model response', err); } return undefined; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 3f9e6678255a9..bb01def5f6030 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -305,7 +305,7 @@ const telemetryIgnoredSequences = [ '\x1b[O', // Focus out ]; -const altBufferMessage = localize('runInTerminalTool.altBufferMessage', "The command opened the alternate buffer."); +const altBufferMessage = '\n' + localize('runInTerminalTool.altBufferMessage', "The command opened the alternate buffer."); export class RunInTerminalTool extends Disposable implements IToolImpl { diff --git a/src/vs/workbench/contrib/terminalContrib/find/browser/terminal.find.contribution.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminal.find.contribution.ts index 40e7294511387..f6c3e79b229bc 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/browser/terminal.find.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminal.find.contribution.ts @@ -252,3 +252,12 @@ registerActiveInstanceAction({ }); // #endregion + +// #region Accessibility Help + +import { AccessibleViewRegistry } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { TerminalFindAccessibilityHelp } from './terminalFindAccessibilityHelp.js'; + +AccessibleViewRegistry.register(new TerminalFindAccessibilityHelp()); + +// #endregion diff --git a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindAccessibilityHelp.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindAccessibilityHelp.ts new file mode 100644 index 0000000000000..3918b6b1855bd --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindAccessibilityHelp.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { AccessibleViewProviderId, AccessibleViewType, AccessibleContentProvider, IAccessibleViewContentProvider, IAccessibleViewOptions } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey.js'; +import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { TerminalFindCommandId } from '../common/terminal.find.js'; + +export class TerminalFindAccessibilityHelp implements IAccessibleViewImplementation { + readonly priority = 105; + readonly name = 'terminal-find'; + readonly type = AccessibleViewType.Help; + readonly when = TerminalContextKeys.findFocus; + + getProvider(accessor: ServicesAccessor): AccessibleContentProvider | undefined { + const commandService = accessor.get(ICommandService); + return new TerminalFindAccessibilityHelpProvider(commandService); + } +} + +class TerminalFindAccessibilityHelpProvider extends Disposable implements IAccessibleViewContentProvider { + readonly id = AccessibleViewProviderId.TerminalFindHelp; + readonly verbositySettingKey = AccessibilityVerbositySettingId.Find; + readonly options: IAccessibleViewOptions = { type: AccessibleViewType.Help }; + + constructor( + private readonly _commandService: ICommandService + ) { + super(); + } + + onClose(): void { + // The Escape key that closes the accessible help will also propagate + // and close the terminal find widget. Re-open the find widget after + // the Escape event has fully propagated through all handlers. + setTimeout(() => { + this._commandService.executeCommand(TerminalFindCommandId.FindFocus); + }, 200); + } + + provideContent(): string { + const content: string[] = []; + + // Header + content.push(localize('terminal.header', "Accessibility Help: Terminal Find")); + content.push(localize('terminal.context', "You are in the Terminal Find input. This searches the entire terminal buffer: both the current output and the scrollback history.")); + content.push(''); + + // Current Search Status + content.push(localize('terminal.statusHeader', "Current Search Status:")); + content.push(localize('terminal.statusDesc', "You are searching the terminal buffer.")); + content.push(''); + + // Inside the Terminal Find Input + content.push(localize('terminal.inputHeader', "Inside the Terminal Find Input (What It Does):")); + content.push(localize('terminal.inputDesc', "While you are in the Terminal Find input, your focus stays in the field. You can type, edit your search term, or navigate matches without leaving the input. When you navigate to a match, the terminal scrolls to show it, but your focus remains in the Find input.")); + content.push(''); + + // What You Hear + content.push(localize('terminal.hearHeader', "What You Hear Each Time You Move to a Match:")); + content.push(localize('terminal.hearDesc', "Each navigation step gives you a complete spoken update:")); + content.push(localize('terminal.hear1', "1) The full line that contains the match is read first, so you get immediate context.")); + content.push(localize('terminal.hear2', "2) Your position among the matches is announced, so you know how far you are through the results.")); + content.push(localize('terminal.hear3', "3) The exact line and column are announced, so you know precisely where the match is in the buffer.")); + content.push(''); + + // Focus Behavior + content.push(localize('terminal.focusHeader', "Focus Behavior (Important):")); + content.push(localize('terminal.focusDesc1', "When you navigate from the Terminal Find input, the terminal buffer updates in the background while your focus stays in the input. This is intentional, so you can keep refining your search without losing your place.")); + content.push(localize('terminal.focusDesc2', "The terminal automatically scrolls to show the match you navigate to.")); + content.push(localize('terminal.focusDesc3', "If you want to close Find and return focus to the terminal command line, press Escape. Focus moves to the command input at the bottom of the terminal.")); + content.push(''); + + // Keyboard Navigation Summary + content.push(localize('terminal.keyboardHeader', "Keyboard Navigation Summary:")); + content.push(''); + content.push(localize('terminal.keyNavHeader', "While focused IN the Find input:")); + content.push(localize('terminal.keyEnter', "- Enter: Move to the next match while staying in the Find input.")); + content.push(localize('terminal.keyShiftEnter', "- Shift+Enter: Move to the previous match while staying in the Find input.")); + content.push(''); + content.push(localize('terminal.keyNavNote', "Note: Terminal Find keeps focus in the Find input. If you need to return to the terminal command line, press Escape to close Find.")); + content.push(''); + + // Find Options + content.push(localize('terminal.optionsHeader', "Find Options:")); + content.push(localize('terminal.optionCase', "- Match Case: Only exact case matches are included.")); + content.push(localize('terminal.optionWord', "- Whole Word: Only full words are matched.")); + content.push(localize('terminal.optionRegex', "- Regular Expression: Use pattern matching for advanced searches.")); + content.push(''); + + // Settings + content.push(localize('terminal.settingsHeader', "Settings You Can Adjust ({0} opens Settings):", '')); + content.push(localize('terminal.settingsDesc', "Terminal Find has limited configuration options. Most behavior is controlled by the terminal itself.")); + content.push(localize('terminal.settingVerbosity', "- `accessibility.verbosity.find`: Controls whether the Terminal Find input announces the Accessibility Help hint.")); + content.push(''); + + // Closing + content.push(localize('terminal.closingHeader', "Closing:")); + content.push(localize('terminal.closingDesc', "Press Escape to close Terminal Find. Focus moves to the terminal command line, and your search history is available on next Find.")); + + return content.join('\n'); + } +} diff --git a/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css b/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css index b80d8e50ea747..a76868c19b28f 100644 --- a/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css +++ b/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css @@ -135,14 +135,3 @@ color: var(--vscode-descriptionForeground); font-size: var(--vscode-bodyFontSize-small); } - -/* Action button */ -.update-status-tooltip .action-button-container { - display: flex; - justify-content: flex-end; - margin-top: 8px; -} - -.update-status-tooltip .action-button-container .monaco-button { - padding: 4px 14px; -} diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts index eab82efe7fcdf..d2041e6678405 100644 --- a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -5,7 +5,6 @@ import * as dom from '../../../../base/browser/dom.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; import { toAction } from '../../../../base/common/actions.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -17,7 +16,6 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { Downloading, IUpdate, IUpdateService, Overwriting, StateType, State as UpdateState } from '../../../../platform/update/common/update.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, TooltipContent } from '../../../services/statusbar/browser/statusbar.js'; @@ -198,10 +196,6 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor this.appendProductInfo(container, update); this.appendWhatsIncluded(container); - this.appendActionButton(container, nls.localize('updateStatus.downloadButton', "Download"), store, () => { - this.runCommandAndClose('update.downloadNow'); - }); - return container; } }; @@ -274,10 +268,6 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor this.appendProductInfo(container, update); this.appendWhatsIncluded(container); - this.appendActionButton(container, nls.localize('updateStatus.installButton', "Install"), store, () => { - this.runCommandAndClose('update.install'); - }); - return container; } }; @@ -293,10 +283,6 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor this.appendProductInfo(container, update); this.appendWhatsIncluded(container); - this.appendActionButton(container, nls.localize('updateStatus.restartButton', "Restart"), store, () => { - this.runCommandAndClose('update.restart'); - }); - return container; } }; @@ -403,7 +389,8 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor } } - private appendWhatsIncluded(container: HTMLElement): void { + private appendWhatsIncluded(container: HTMLElement) { + /* const whatsIncluded = dom.append(container, dom.$('.whats-included')); const sectionTitle = dom.append(whatsIncluded, dom.$('.section-title')); @@ -421,13 +408,7 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor const li = dom.append(list, dom.$('li')); li.textContent = item; } - } - - private appendActionButton(container: HTMLElement, label: string, store: DisposableStore, onClick: () => void): void { - const buttonContainer = dom.append(container, dom.$('.action-button-container')); - const button = store.add(new Button(buttonContainer, { ...defaultButtonStyles, secondary: true, hoverDelegate: nativeHoverDelegate })); - button.label = label; - store.add(button.onDidClick(onClick)); + */ } } diff --git a/src/vs/workbench/contrib/webview/browser/webview.contribution.ts b/src/vs/workbench/contrib/webview/browser/webview.contribution.ts index 85c3cc4a2bce7..5fcdafe40b9cc 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.contribution.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.contribution.ts @@ -9,9 +9,11 @@ import { CopyAction, CutAction, PasteAction } from '../../../../editor/contrib/c import * as nls from '../../../../nls.js'; import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { IWebviewService, IWebview } from './webview.js'; import { WebviewInput } from '../../webviewPanel/browser/webviewEditorInput.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { WebviewFindAccessibilityHelp } from './webviewFindAccessibilityHelp.js'; const PRIORITY = 100; @@ -83,3 +85,6 @@ if (PasteAction) { when: ContextKeyExpr.not(PreventDefaultContextMenuItemsContextKeyName), }); } + +// Register webview find accessibility help +AccessibleViewRegistry.register(new WebviewFindAccessibilityHelp()); diff --git a/src/vs/workbench/contrib/webview/browser/webviewFindAccessibilityHelp.ts b/src/vs/workbench/contrib/webview/browser/webviewFindAccessibilityHelp.ts new file mode 100644 index 0000000000000..19cfa10c7146c --- /dev/null +++ b/src/vs/workbench/contrib/webview/browser/webviewFindAccessibilityHelp.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { AccessibleViewProviderId, AccessibleViewType, AccessibleContentProvider, IAccessibleViewContentProvider, IAccessibleViewOptions } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_FOCUSED } from './webview.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; + +export class WebviewFindAccessibilityHelp implements IAccessibleViewImplementation { + readonly priority = 105; + readonly name = 'webview-find'; + readonly type = AccessibleViewType.Help; + readonly when = KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_FOCUSED; + + getProvider(accessor: ServicesAccessor): AccessibleContentProvider | undefined { + return new WebviewFindAccessibilityHelpProvider(); + } +} + +class WebviewFindAccessibilityHelpProvider extends Disposable implements IAccessibleViewContentProvider { + readonly id = AccessibleViewProviderId.WebviewFindHelp; + readonly verbositySettingKey = AccessibilityVerbositySettingId.Find; + readonly options: IAccessibleViewOptions = { type: AccessibleViewType.Help }; + + onClose(): void { + // Focus will remain on webview + } + + provideContent(): string { + const content: string[] = []; + + // Header + content.push(localize('webview.header', "Accessibility Help: Webview Find")); + content.push(localize('webview.context', "You are in the Find input for embedded web content. This could be a Markdown preview, a documentation viewer, or a web-based extension interface.")); + content.push(''); + + // Current Search Status + content.push(localize('webview.statusHeader', "Current Search Status:")); + content.push(localize('webview.statusDesc', "You are searching the web content.")); + content.push(''); + + // Inside the Webview Find Input + content.push(localize('webview.inputHeader', "Inside the Webview Find Input (What It Does):")); + content.push(localize('webview.inputDesc', "While you are in the Find input, your focus stays in the field. You can type, edit your search term, or navigate matches without leaving the input. When you navigate to a match, the webview updates to show it, but your focus remains in the Find input.")); + content.push(''); + + // What You Hear + content.push(localize('webview.hearHeader', "What You Hear Each Time You Move to a Match:")); + content.push(localize('webview.hearDesc', "Each navigation step gives you a complete spoken update:")); + content.push(localize('webview.hear1', "1) The content containing the match is read first, so you get immediate context.")); + content.push(localize('webview.hear2', "2) Your position among the matches is announced, so you know how far you are through the results.")); + content.push(localize('webview.hear3', "3) The exact location information is announced so you know where the match is.")); + content.push(''); + + // Focus Behavior + content.push(localize('webview.focusHeader', "Focus Behavior (Important):")); + content.push(localize('webview.focusDesc1', "When you navigate from the Webview Find input, the content updates in the background while your focus stays in the input. This is intentional, so you can keep refining your search without losing your place.")); + content.push(localize('webview.focusDesc2', "The webview may scroll to show the match, depending on how it is designed.")); + content.push(localize('webview.focusDesc3', "If you want to close Find and return focus to the webview content, press Escape. Focus moves back into the webview.")); + content.push(''); + + // Keyboard Navigation Summary + content.push(localize('webview.keyboardHeader', "Keyboard Navigation Summary:")); + content.push(''); + content.push(localize('webview.keyNavHeader', "While focused IN the Find input:")); + content.push(localize('webview.keyEnter', "- Enter: Move to the next match while staying in the Find input.")); + content.push(localize('webview.keyShiftEnter', "- Shift+Enter: Move to the previous match while staying in the Find input.")); + content.push(''); + + // Find Options + content.push(localize('webview.optionsHeader', "Find Options:")); + content.push(localize('webview.optionCase', "- Match Case: Only exact case matches are included.")); + content.push(localize('webview.optionWord', "- Whole Word: Only full words are matched.")); + content.push(localize('webview.optionRegex', "- Regular Expression: Use pattern matching for advanced searches.")); + content.push(''); + + // Important About Webviews + content.push(localize('webview.importantHeader', "Important About Webviews:")); + content.push(localize('webview.importantDesc', "Some webviews intercept keyboard input before VS Code's Find can use it. If Enter or Shift+Enter do not navigate matches, the webview may be handling those keys. Try clicking or tabbing into the webview content first to ensure the webview has focus, then reopen Find and try navigation again.")); + content.push(''); + + // Settings + content.push(localize('webview.settingsHeader', "Settings You Can Adjust ({0} opens Settings):", '')); + content.push(localize('webview.settingsDesc', "Webview Find has minimal configuration. Most behavior depends on the webview itself.")); + content.push(localize('webview.settingVerbosity', "- `accessibility.verbosity.find`: Controls whether the Webview Find input announces the Accessibility Help hint.")); + content.push(''); + + // Closing + content.push(localize('webview.closingHeader', "Closing:")); + content.push(localize('webview.closingDesc', "Press Escape to close Webview Find. Focus moves back into the webview content, and your search history is available on next Find.")); + + return content.join('\n'); + } +} diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index a0185e64ef769..a67469b86090d 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -495,7 +495,8 @@ export interface IAuxiliaryEditorPart extends IEditorPart { /** * Close this auxiliary editor part after moving all - * editors of all groups back to the main editor part. + * dirty editors of all groups back to the main editor + * part. * * @returns `false` if an editor could not be moved back. */ @@ -511,11 +512,13 @@ export interface IModalEditorPart extends IEditorPart { /** * Close this modal editor part after moving all - * editors of all groups back to the main editor part. + * editors of all groups back to the main editor part + * if the related option is set. Dirty editors are + * always moved back to the main part and thus not closed. * * @returns `false` if an editor could not be moved back. */ - close(): boolean; + close(options?: { mergeAllEditorsToMainPart?: boolean }): boolean; } export interface IEditorWorkingSet { diff --git a/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts b/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts index c0d4dc2b70244..5fa6524edda65 100644 --- a/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts +++ b/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts @@ -7,6 +7,19 @@ declare module 'vscode' { // https://github.com/microsoft/vscode/issues/288777 @DonJayamanne + /** + * Represents an MCP gateway that exposes MCP servers via HTTP. + * The gateway provides an HTTP endpoint that external processes can connect + * to in order to interact with MCP servers known to the editor. + */ + export interface McpGateway extends Disposable { + /** + * The address of the HTTP MCP server endpoint. + * External processes can connect to this URI to interact with MCP servers. + */ + readonly address: Uri; + } + /** * Namespace for language model related functionality. */ @@ -27,5 +40,20 @@ declare module 'vscode' { * definitions from any source. */ export const onDidChangeMcpServerDefinitions: Event; + + /** + * Starts an MCP gateway that exposes MCP servers via an HTTP endpoint. + * + * The gateway creates a localhost HTTP server that external processes (such as + * CLI-based agent loops) can connect to in order to interact with MCP servers + * that the editor knows about. + * + * The HTTP server is shared among all gateways and is automatically torn down + * when the last gateway is disposed. + * + * @returns A promise that resolves to an {@link McpGateway} if successful, + * or `undefined` if no Node process is available (e.g., in serverless web environments). + */ + export function startMcpGateway(): Thenable; } } diff --git a/test/smoke/extensions/vscode-smoketest-ext-host/extension.js b/test/smoke/extensions/vscode-smoketest-ext-host/extension.js new file mode 100644 index 0000000000000..e69899a6b29e5 --- /dev/null +++ b/test/smoke/extensions/vscode-smoketest-ext-host/extension.js @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check +const vscode = require('vscode'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +/** @type {string | undefined} */ +let deactivateMarkerFile; + +/** + * @param {vscode.ExtensionContext} context + */ +function activate(context) { + // This is used to verify that the extension host process is properly killed + // when window reloads even if the extension host is blocked + // Refs: https://github.com/microsoft/vscode/issues/291346 + context.subscriptions.push( + vscode.commands.registerCommand('smoketest.getExtensionHostPidAndBlock', (delayMs = 100, durationMs = 60000) => { + const pid = process.pid; + + // Write PID file to workspace folder if available, otherwise temp dir + // Note: filename must match name in extension-host-restart.test.ts + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const pidFile = workspaceFolder + ? path.join(workspaceFolder, 'vscode-ext-host-pid.txt') + : path.join(os.tmpdir(), 'vscode-ext-host-pid.txt'); + setTimeout(() => { + fs.writeFileSync(pidFile, String(pid), 'utf-8'); + + // Block the extension host without busy-spinning to avoid pegging a CPU core. + // Prefer Atomics.wait on a SharedArrayBuffer when available; otherwise, fall back + // to the original busy loop to preserve behavior in older environments. + if (typeof SharedArrayBuffer === 'function' && typeof Atomics !== 'undefined' && typeof Atomics.wait === 'function') { + const sab = new SharedArrayBuffer(4); + const blocker = new Int32Array(sab); + // Wait up to durationMs milliseconds. This blocks the thread without consuming CPU. + Atomics.wait(blocker, 0, 0, durationMs); + } else { + const start = Date.now(); + while (Date.now() - start < durationMs) { + // Busy loop (fallback) + } + } + }, delayMs); + + return pid; + }) + ); + + // This command sets up a marker file path that will be written during deactivation. + // It allows the smoke test to verify that extensions get a chance to deactivate. + context.subscriptions.push( + vscode.commands.registerCommand('smoketest.setupGracefulDeactivation', () => { + const pid = process.pid; + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const pidFile = workspaceFolder + ? path.join(workspaceFolder, 'vscode-ext-host-pid-graceful.txt') + : path.join(os.tmpdir(), 'vscode-ext-host-pid-graceful.txt'); + deactivateMarkerFile = workspaceFolder + ? path.join(workspaceFolder, 'vscode-ext-host-deactivated.txt') + : path.join(os.tmpdir(), 'vscode-ext-host-deactivated.txt'); + + // Write PID file immediately so test knows the extension is ready + fs.writeFileSync(pidFile, String(pid), 'utf-8'); + + return { pid, markerFile: deactivateMarkerFile }; + }) + ); +} + +function deactivate() { + // Write marker file to indicate deactivation was called + if (deactivateMarkerFile) { + try { + fs.writeFileSync(deactivateMarkerFile, `deactivated at ${Date.now()}`, 'utf-8'); + } catch { + // Ignore errors (e.g., folder not accessible) + } + } +} + +module.exports = { + activate, + deactivate +}; diff --git a/test/smoke/extensions/vscode-smoketest-ext-host/package.json b/test/smoke/extensions/vscode-smoketest-ext-host/package.json new file mode 100644 index 0000000000000..4b517b57d520e --- /dev/null +++ b/test/smoke/extensions/vscode-smoketest-ext-host/package.json @@ -0,0 +1,29 @@ +{ + "name": "vscode-smoketest-ext-host", + "displayName": "Smoke Test Extension Host", + "description": "Extension for smoke testing extension host lifecycle", + "version": "0.0.1", + "publisher": "vscode", + "license": "MIT", + "private": true, + "engines": { + "vscode": "^1.55.0" + }, + "activationEvents": [ + "onStartupFinished" + ], + "main": "./extension.js", + "extensionKind": ["ui"], + "contributes": { + "commands": [ + { + "command": "smoketest.getExtensionHostPidAndBlock", + "title": "Smoke Test: Get Extension Host PID and Block" + }, + { + "command": "smoketest.setupGracefulDeactivation", + "title": "Smoke Test: Setup Graceful Deactivation" + } + ] + } +} diff --git a/test/smoke/src/areas/extensions/extension-host-restart.test.ts b/test/smoke/src/areas/extensions/extension-host-restart.test.ts new file mode 100644 index 0000000000000..6528e61abe327 --- /dev/null +++ b/test/smoke/src/areas/extensions/extension-host-restart.test.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; +import { Application, Logger } from '../../../../automation'; +import { installAllHandlers, timeout } from '../../utils'; + +/** + * Verifies that window reload kills the extension host even when blocked. + * + */ +export function setup(logger: Logger) { + describe('Extension Host Restart', () => { + + installAllHandlers(logger, opts => opts); + + function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + it('kills blocked extension host on window reload (windowLifecycleBound)', async function () { + this.timeout(60_000); + + const app = this.app as Application; + const pidFile = path.join(app.workspacePathOrFolder, 'vscode-ext-host-pid.txt'); + + if (fs.existsSync(pidFile)) { + fs.unlinkSync(pidFile); + } + + await app.workbench.quickaccess.runCommand('smoketest.getExtensionHostPidAndBlock'); + + // Wait for PID file to be created + let retries = 0; + while (!fs.existsSync(pidFile) && retries < 20) { + await timeout(500); + retries++; + } + + if (!fs.existsSync(pidFile)) { + throw new Error('PID file was not created - extension may not have activated'); + } + + const pid = parseInt(fs.readFileSync(pidFile, 'utf-8'), 10); + logger.log(`Old extension host PID: ${pid}`); + + // Reload window while extension host is blocked + await app.workbench.quickaccess.runCommand('Developer: Reload Window', { keepOpen: true }); + await app.code.whenWorkbenchRestored(); + logger.log('Window reloaded'); + + // Verify old process is gone, allowing for slower teardown on busy machines + const maxWaitMs = 10_000; + const pollIntervalMs = 500; + let waitedMs = 0; + while (processExists(pid) && waitedMs < maxWaitMs) { + await timeout(pollIntervalMs); + waitedMs += pollIntervalMs; + } + + const stillExists = processExists(pid); + if (stillExists) { + throw new Error(`Extension host ${pid} still running after reload (waited ${maxWaitMs}ms)`); + } + + logger.log('Extension host was properly killed on reload'); + }); + + it('allows extensions to gracefully deactivate on window reload (windowLifecycleGraceTime)', async function () { + this.timeout(60_000); + + const app = this.app as Application; + const pidFile = path.join(app.workspacePathOrFolder, 'vscode-ext-host-pid-graceful.txt'); + const markerFile = path.join(app.workspacePathOrFolder, 'vscode-ext-host-deactivated.txt'); + + // Clean up any existing files + if (fs.existsSync(pidFile)) { + fs.unlinkSync(pidFile); + } + if (fs.existsSync(markerFile)) { + fs.unlinkSync(markerFile); + } + + // Setup the extension to write a marker file on deactivation + await app.workbench.quickaccess.runCommand('smoketest.setupGracefulDeactivation'); + + // Wait for PID file to be created (confirms extension is ready) + let retries = 0; + while (!fs.existsSync(pidFile) && retries < 20) { + await timeout(500); + retries++; + } + + if (!fs.existsSync(pidFile)) { + throw new Error('PID file was not created - extension may not have activated'); + } + + const pid = parseInt(fs.readFileSync(pidFile, 'utf-8'), 10); + logger.log(`Extension host PID for graceful deactivation test: ${pid}`); + + // Reload window - this should trigger graceful deactivation + await app.workbench.quickaccess.runCommand('Developer: Reload Window', { keepOpen: true }); + await app.code.whenWorkbenchRestored(); + logger.log('Window reloaded'); + + // Wait for the process to exit and marker file to be written + const maxWaitMs = 10_000; + const pollIntervalMs = 500; + let waitedMs = 0; + while (!fs.existsSync(markerFile) && waitedMs < maxWaitMs) { + await timeout(pollIntervalMs); + waitedMs += pollIntervalMs; + } + + if (!fs.existsSync(markerFile)) { + throw new Error(`Deactivation marker file was not created within ${maxWaitMs}ms - extension may not have been given time to deactivate gracefully`); + } + + logger.log('Extension was given time to gracefully deactivate on reload'); + + // Also verify the old process is gone + waitedMs = 0; + while (processExists(pid) && waitedMs < maxWaitMs) { + await timeout(pollIntervalMs); + waitedMs += pollIntervalMs; + } + + if (processExists(pid)) { + throw new Error(`Extension host ${pid} still running after reload (waited ${maxWaitMs}ms)`); + } + + logger.log('Extension host was properly terminated after graceful deactivation'); + }); + }); +} diff --git a/test/smoke/src/main.ts b/test/smoke/src/main.ts index fc8b4f8800f96..c57fbc25ecb03 100644 --- a/test/smoke/src/main.ts +++ b/test/smoke/src/main.ts @@ -21,6 +21,7 @@ import { setup as setupNotebookTests } from './areas/notebook/notebook.test'; import { setup as setupLanguagesTests } from './areas/languages/languages.test'; import { setup as setupStatusbarTests } from './areas/statusbar/statusbar.test'; import { setup as setupExtensionTests } from './areas/extensions/extensions.test'; +import { setup as setupExtensionHostRestartTests } from './areas/extensions/extension-host-restart.test'; import { setup as setupMultirootTests } from './areas/multiroot/multiroot.test'; import { setup as setupLocalizationTests } from './areas/workbench/localization.test'; import { setup as setupLaunchTests } from './areas/workbench/launch.test'; @@ -351,6 +352,16 @@ async function setup(): Promise { } await measureAndLog(() => setupRepository(), 'setupRepository', logger); + // Copy smoke test extension for extension host restart test + if (!opts.web && !opts.remote) { + const smokeExtPath = path.join(rootPath, 'test', 'smoke', 'extensions', 'vscode-smoketest-ext-host'); + const dest = path.join(extensionsPath, 'vscode-smoketest-ext-host'); + if (fs.existsSync(dest)) { + fs.rmSync(dest, { recursive: true, force: true }); + } + fs.cpSync(smokeExtPath, dest, { recursive: true }); + } + logger.log('Smoketest setup done!\n'); } @@ -403,6 +414,7 @@ describe(`VSCode Smoke Tests (${opts.web ? 'Web' : 'Electron'})`, () => { setupTaskTests(logger); setupStatusbarTests(logger); if (quality !== Quality.Dev && quality !== Quality.OSS) { setupExtensionTests(logger); } + if (!opts.web && !opts.remote) { setupExtensionHostRestartTests(logger); } setupMultirootTests(logger); if (!opts.web && !opts.remote && quality !== Quality.Dev && quality !== Quality.OSS) { setupLocalizationTests(logger); } if (!opts.web && !opts.remote) { setupLaunchTests(logger); }